/** * Interactive Flower Cards — works with existing Webflow layout * * Setup: * 1. Keep your existing .never_item structure (text labels are matched automatically). * 2. Load Matter.js, then this script, in Page Settings → Footer. * 3. Flower CDN URLs are baked into this file. */ (function () { "use strict"; const CARD_ID_ALIASES = { "on demand": "on-demand", "on-demand": "on-demand", accountable: "accountable", "trusted partners": "trusted-partners", "trusted-partners": "trusted-partners", }; function resolveCardId(element) { const attr = (element.dataset.cardId || "").trim().toLowerCase(); if (attr && CARD_MODES[attr]) return attr; if (attr && CARD_ID_ALIASES[attr]) return CARD_ID_ALIASES[attr]; const text = (element.textContent || "").replace(/\s+/g, " ").trim().toLowerCase(); for (const [alias, id] of Object.entries(CARD_ID_ALIASES)) { if (text.includes(alias)) return id; } return null; } function ensureFlowerStyles() { if (document.getElementById("fe-flower-styles")) return; const style = document.createElement("style"); style.id = "fe-flower-styles"; style.textContent = ` .never_item, .fe-flower-card { position: relative; overflow: hidden; } .fe-flower-layer { position: absolute; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; } .fe-flower-layer img { position: absolute; top: 0; left: 0; transform-origin: center center; will-change: transform; pointer-events: none; user-select: none; } .never_item > :not(.fe-flower-layer) { position: relative; z-index: 2; } `; document.head.appendChild(style); } function ensureFlowerLayer(element) { let layer = element.querySelector(".fe-flower-layer"); if (layer) return layer; layer = document.createElement("div"); layer.className = "fe-flower-layer"; layer.setAttribute("aria-hidden", "true"); element.prepend(layer); return layer; } const FLOWER_CONFIG = { cardWidth: 308, cardHeight: 308, wallThickness: 40, spawnBleed: 55, spawnInterval: 120, throwSpeedX: 16, throwSpeedY: 10, throwSpeedYMin: -0.5, throwSpeedYMax: 0.5, rotationMin: -12, rotationMax: 12, gravity: 0.5, drainGravity: 1.2, swayTorque: 0.003, swayForceX: 0.00003, swayForceY: 0.00003, swaySpeedMin: 0.8, swaySpeedMax: 1.6, friction: 0.45, frictionStatic: 0.55, frictionAir: 0.08, restitution: 0.05, flowerMass: 0.00012, positionIterations: 8, velocityIterations: 6, fillThreshold: 0.85, densityGridSize: 16, maxFlowers: 200, maxSpeed: 100, drainCleanupMargin: 30, }; const CARD_MODES = { "on-demand": { mode: "fourEdge", spawnInterval: 120, throwSpeedX: 14, throwSpeedY: 14, wallBleed: 55, }, accountable: { mode: "topRain", spawnInterval: 80, throwSpeedY: 16 }, "trusted-partners": { mode: "rightWind", spawnInterval: 50, throwSpeedX: 26, frictionAir: 0.06, swayPosition: false, }, }; const FLOWER_ASSETS = [ { src: "https://cdn.prod.website-files.com/697a05c3e5cc94cfc4a88e7e/6a6a20d13a548317d80e3b22_blue_flower.svg", width: 48, height: 34, colliderRadius: 13, anchorY: 17 }, { src: "https://cdn.prod.website-files.com/697a05c3e5cc94cfc4a88e7e/6a6a20d2617c9d5795bad449_Lightblue_flower.svg", width: 45, height: 43, colliderRadius: 14, anchorY: 21 }, { src: "https://cdn.prod.website-files.com/697a05c3e5cc94cfc4a88e7e/6a6a20d10c9fefb41768e4d6_Blueish_flower.svg", width: 87, height: 75, colliderRadius: 17, anchorY: 28 }, { src: "https://cdn.prod.website-files.com/697a05c3e5cc94cfc4a88e7e/6a6a20d18ccd3414baeb69e9_Pink_flower.svg", width: 109, height: 102, colliderRadius: 22, anchorY: 38 }, { src: "https://cdn.prod.website-files.com/697a05c3e5cc94cfc4a88e7e/6a6a20d169ae57fec6be81a8_Pinkish_flower.svg", width: 42, height: 56, colliderRadius: 12, anchorY: 22 }, { src: "https://cdn.prod.website-files.com/697a05c3e5cc94cfc4a88e7e/6a6a20d100b433675624575f_Red_flower.svg", width: 100, height: 96, colliderRadius: 21, anchorY: 36 }, { src: "https://cdn.prod.website-files.com/697a05c3e5cc94cfc4a88e7e/6a6a20d21dc5c690630198df_Yellowish_flower.svg", width: 56, height: 51, colliderRadius: 15, anchorY: 25 }, ]; const { Engine, World, Bodies, Body, Composite, Sleeping } = Matter; function randomBetween(min, max) { return min + Math.random() * (max - min); } function randomInt(min, max) { return Math.floor(randomBetween(min, max + 1)); } function pickRandomAsset() { return FLOWER_ASSETS[randomInt(0, FLOWER_ASSETS.length - 1)]; } function getColliderRadius(asset) { return Math.max(3, asset.colliderRadius); } class FlowerCard { constructor(element, modeConfig, config) { this.element = element; this.layer = ensureFlowerLayer(element); this.config = { ...config }; this.mode = modeConfig.mode; this.modeConfig = modeConfig; this.spawnInterval = modeConfig.spawnInterval ?? config.spawnInterval; this.flowers = []; this.spawnTimer = null; this.isHovering = false; this.isDraining = false; this.isFull = false; this.rafId = null; this.hasFloor = false; this.zIndexCounter = 10000; // Match physics box to the actual card size in the layout const rect = element.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { this.config.cardWidth = Math.round(rect.width); this.config.cardHeight = Math.round(rect.height); } this.handleMouseEnter = this.handleMouseEnter.bind(this); this.handleMouseLeave = this.handleMouseLeave.bind(this); this.initPhysics(); this.element.addEventListener("mouseenter", this.handleMouseEnter); this.element.addEventListener("mouseleave", this.handleMouseLeave); } initPhysics() { const { cardWidth, cardHeight, wallThickness, gravity, positionIterations, velocityIterations } = this.config; this.engine = Engine.create({ gravity: { x: 0, y: gravity }, enableSleeping: true, positionIterations, velocityIterations, }); this.world = this.engine.world; const wallOpts = { isStatic: true, friction: this.config.friction, frictionStatic: this.config.frictionStatic, restitution: 0, }; const bleed = this.getWallBleed(); const spanW = cardWidth + bleed * 2; const spanH = cardHeight + bleed * 2; this.leftWall = Bodies.rectangle( -bleed - wallThickness / 2, cardHeight / 2, wallThickness, spanH + wallThickness, wallOpts ); this.rightWall = Bodies.rectangle( cardWidth + bleed + wallThickness / 2, cardHeight / 2, wallThickness, spanH + wallThickness, wallOpts ); this.topWall = Bodies.rectangle( cardWidth / 2, -bleed - wallThickness / 2, spanW + wallThickness, wallThickness, wallOpts ); this.bottomWall = Bodies.rectangle( cardWidth / 2, cardHeight + bleed + wallThickness / 2, spanW + wallThickness, wallThickness, wallOpts ); this.wallBodies = { left: this.leftWall, right: this.rightWall, top: this.topWall, bottom: this.bottomWall, }; this.activeWalls = new Set(); this.setHoverWalls(); } setGravity(y) { this.engine.gravity.y = y; } getHoverGravity() { return this.config.gravity; } getDrainGravity() { return this.modeConfig.drainGravity ?? this.config.drainGravity; } getWallBleed() { if (this.mode !== "fourEdge") return 0; return this.modeConfig.wallBleed ?? this.config.spawnBleed; } getActiveWalls() { switch (this.mode) { case "fourEdge": return ["left", "right", "top", "bottom"]; case "topRain": return ["left", "right", "bottom"]; case "rightWind": return ["left", "top", "bottom"]; default: return ["left", "right", "bottom"]; } } setHoverWalls() { const target = new Set(this.getActiveWalls()); for (const name of this.activeWalls) { if (!target.has(name)) { Composite.remove(this.world, this.wallBodies[name]); } } for (const name of target) { if (!this.activeWalls.has(name)) { World.add(this.world, this.wallBodies[name]); } } this.activeWalls = target; this.hasFloor = target.has("bottom"); } removeFloor() { if (!this.hasFloor) return; if (this.activeWalls.has("bottom")) { Composite.remove(this.world, this.bottomWall); this.activeWalls.delete("bottom"); } this.hasFloor = false; } handleMouseEnter() { this.isHovering = true; this.isDraining = false; this.isFull = false; this.setGravity(this.getHoverGravity()); this.setHoverWalls(); this.startSpawning(); this.startLoop(); } handleMouseLeave() { this.isHovering = false; this.stopSpawning(); this.removeFloor(); this.isDraining = this.flowers.length > 0; if (this.isDraining) { this.setGravity(this.getDrainGravity()); } for (const flower of this.flowers) { Sleeping.set(flower.body, false); } this.startLoop(); } startSpawning() { if (this.spawnTimer) return; this.trySpawn(); this.spawnTimer = window.setInterval(() => this.trySpawn(), this.spawnInterval); } stopSpawning() { if (!this.spawnTimer) return; window.clearInterval(this.spawnTimer); this.spawnTimer = null; } markCardFull() { if (this.isFull) return; this.isFull = true; this.stopSpawning(); } trySpawn() { if (!this.isHovering || this.isFull) return; if (this.flowers.length >= this.config.maxFlowers) { this.markCardFull(); return; } if (this.isCardFull()) { this.markCardFull(); return; } switch (this.mode) { case "fourEdge": this.spawnFromEdge("top"); this.spawnFromEdge("left"); this.spawnFromEdge("right"); this.spawnFromEdge("bottom"); break; case "topRain": this.spawnFromTop(); break; case "rightWind": this.spawnFromRight(); break; default: break; } } createFlower(asset, x, y, velocity, angleRad) { const { width, height, anchorY = height / 2 } = asset; const anchorX = width / 2; const radius = getColliderRadius(asset); const { friction, frictionStatic, restitution, flowerMass } = this.config; const frictionAir = this.modeConfig.frictionAir ?? this.config.frictionAir; const body = Bodies.circle(x, y, radius, { friction, frictionAir, frictionStatic, restitution, density: flowerMass, sleepThreshold: Infinity, }); Body.setAngle(body, angleRad); Body.setVelocity(body, velocity); const img = document.createElement("img"); img.src = asset.src; img.alt = ""; img.draggable = false; img.style.width = `${width}px`; img.style.height = `${height}px`; const zIndex = this.zIndexCounter; this.zIndexCounter -= 1; img.style.zIndex = String(zIndex); this.layer.insertBefore(img, this.layer.firstChild); World.add(this.world, body); this.flowers.push({ body, element: img, width, height, anchorX, anchorY, colliderRadius: radius, zIndex, src: asset.src, swayPhase: randomBetween(0, Math.PI * 2), swaySpeed: randomBetween(this.config.swaySpeedMin, this.config.swaySpeedMax), }); } spawnFromEdge(edge) { if (this.flowers.length >= this.config.maxFlowers) return; const asset = pickRandomAsset(); const radius = getColliderRadius(asset); const { cardWidth, cardHeight, spawnBleed, throwSpeedX, throwSpeedY, rotationMin, rotationMax } = this.config; const angleRad = randomBetween(rotationMin, rotationMax) * (Math.PI / 180); const speedX = this.modeConfig.throwSpeedX ?? throwSpeedX; const speedY = this.modeConfig.throwSpeedY ?? throwSpeedY; const inset = radius + 2; const gutter = spawnBleed; let x = 0; let y = 0; let velocity = { x: 0, y: 0 }; switch (edge) { case "top": x = randomBetween(inset, cardWidth - inset); y = -randomBetween(gutter * 0.4, gutter - radius); velocity = { x: 0, y: speedY }; break; case "bottom": x = randomBetween(inset, cardWidth - inset); y = cardHeight + randomBetween(gutter * 0.4, gutter - radius); velocity = { x: 0, y: -speedY }; break; case "left": x = -randomBetween(gutter * 0.4, gutter - radius); y = randomBetween(inset, cardHeight - inset); velocity = { x: speedX, y: 0 }; break; case "right": x = cardWidth + randomBetween(gutter * 0.4, gutter - radius); y = randomBetween(inset, cardHeight - inset); velocity = { x: -speedX, y: 0 }; break; default: return; } this.createFlower(asset, x, y, velocity, angleRad); } spawnFromTop() { const asset = pickRandomAsset(); const radius = getColliderRadius(asset); const { cardWidth, spawnBleed, throwSpeedY, rotationMin, rotationMax } = this.config; const angleRad = randomBetween(rotationMin, rotationMax) * (Math.PI / 180); const x = randomBetween(radius, cardWidth - radius); const y = -(spawnBleed + radius); const speedY = this.modeConfig.throwSpeedY ?? throwSpeedY; this.createFlower(asset, x, y, { x: 0, y: speedY }, angleRad); } spawnFromRight() { const asset = pickRandomAsset(); const radius = getColliderRadius(asset); const { cardWidth, cardHeight, spawnBleed, throwSpeedX, throwSpeedYMin, throwSpeedYMax, rotationMin, rotationMax } = this.config; const angleRad = randomBetween(rotationMin, rotationMax) * (Math.PI / 180); const x = cardWidth + spawnBleed + radius; const y = randomBetween(radius, cardHeight - radius); const speedX = this.modeConfig.throwSpeedX ?? throwSpeedX; const speedY = randomBetween( this.modeConfig.throwSpeedYMin ?? throwSpeedYMin, this.modeConfig.throwSpeedYMax ?? throwSpeedYMax ); this.createFlower(asset, x, y, { x: -speedX, y: speedY }, angleRad); } isCardFull() { const { cardWidth, cardHeight, densityGridSize, fillThreshold } = this.config; const cellW = cardWidth / densityGridSize; const cellH = cardHeight / densityGridSize; let covered = 0; const total = densityGridSize * densityGridSize; for (let row = 0; row < densityGridSize; row += 1) { for (let col = 0; col < densityGridSize; col += 1) { const cx = col * cellW + cellW / 2; const cy = row * cellH + cellH / 2; for (const flower of this.flowers) { const { x, y } = flower.body.position; const dx = cx - x; const dy = cy - y; const r = flower.colliderRadius; if (dx * dx + dy * dy <= r * r) { covered += 1; break; } } } } return covered / total >= fillThreshold; } applyFlowerSway(now) { const { swayTorque, swayForceX, swayForceY } = this.config; const time = now * 0.001; const maxAngularSpeed = 0.08; const angularDrag = 0.92; for (const flower of this.flowers) { const { body, swayPhase, swaySpeed } = flower; if (body.isStatic) continue; const t = time * swaySpeed + swayPhase; const swing = Math.sin(t); if (this.modeConfig.swayPosition !== false) { const driftX = Math.sin(t); const driftY = Math.cos(t * 0.85 + swayPhase * 0.5); Body.applyForce(body, body.position, { x: driftX * swayForceX, y: driftY * swayForceY, }); } let angularVelocity = (body.angularVelocity + swing * swayTorque) * angularDrag; angularVelocity = Math.max(-maxAngularSpeed, Math.min(maxAngularSpeed, angularVelocity)); Body.setAngularVelocity(body, angularVelocity); } } clampFlowerVelocities() { const maxSpeed = this.modeConfig.maxSpeed ?? this.config.maxSpeed; const maxSpeedSq = maxSpeed * maxSpeed; for (const flower of this.flowers) { const { body } = flower; const { x, y } = body.velocity; const speedSq = x * x + y * y; if (speedSq <= maxSpeedSq) continue; const scale = maxSpeed / Math.sqrt(speedSq); Body.setVelocity(body, { x: x * scale, y: y * scale }); } } removeEscapedFlowers() { if (this.isDraining) return; const margin = this.config.spawnBleed + 80; const { cardWidth, cardHeight } = this.config; const remaining = []; for (const flower of this.flowers) { const { x, y } = flower.body.position; const outOfBounds = x < -margin || x > cardWidth + margin || y < -margin || y > cardHeight + margin * 2; if (outOfBounds) { Composite.remove(this.world, flower.body); flower.element.remove(); } else { remaining.push(flower); } } this.flowers = remaining; } startLoop() { if (this.rafId) return; this.lastTime = performance.now(); this.rafId = requestAnimationFrame((time) => this.tick(time)); } stopLoop() { if (!this.rafId) return; cancelAnimationFrame(this.rafId); this.rafId = null; this.lastTime = null; } tick(now) { const delta = Math.min(now - (this.lastTime ?? now), 33.33); this.lastTime = now; this.applyFlowerSway(now); Engine.update(this.engine, delta); this.clampFlowerVelocities(); this.removeEscapedFlowers(); this.syncFlowers(); this.cleanupFallenFlowers(); const shouldRun = this.isHovering || this.isDraining || this.flowers.length > 0; if (shouldRun) { this.rafId = requestAnimationFrame((time) => this.tick(time)); } else { this.stopLoop(); } } syncFlowers() { for (const flower of this.flowers) { const { body, element, anchorX, anchorY } = flower; const x = body.position.x - anchorX; const y = body.position.y - anchorY; element.style.transform = `translate(${x}px, ${y}px) rotate(${body.angle}rad)`; } } cleanupFallenFlowers() { if (!this.isDraining) return; const threshold = this.config.cardHeight + this.config.drainCleanupMargin; const remaining = []; for (const flower of this.flowers) { const top = flower.body.position.y - flower.anchorY; if (top > threshold) { Composite.remove(this.world, flower.body); flower.element.remove(); } else { remaining.push(flower); } } this.flowers = remaining; if (this.flowers.length === 0) { this.isDraining = false; this.isFull = false; this.setGravity(this.getHoverGravity()); this.zIndexCounter = 10000; } } } function initFlowerCards() { if (typeof Matter === "undefined") { console.error("Matter.js must load before flower-cards.js"); return; } ensureFlowerStyles(); const cards = new Set([ ...document.querySelectorAll(".never_item"), ...document.querySelectorAll(".fe-flower-card"), ]); cards.forEach((element) => { if (element.dataset.feFlowerReady === "1") return; const cardId = resolveCardId(element); if (!cardId) return; const modeConfig = CARD_MODES[cardId]; if (!modeConfig) { console.warn("Unknown flower card id:", cardId); return; } element.dataset.feFlowerReady = "1"; element.dataset.cardId = cardId; new FlowerCard(element, modeConfig, FLOWER_CONFIG); }); } window.FlowerCardsWebflow = { init: initFlowerCards, CARD_MODES, FLOWER_CONFIG }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initFlowerCards); } else { initFlowerCards(); } })();