(() => { 'use strict'; const divider = document.querySelector('#v287-packaging-title .v7113-packaging-title__separator'); const title = document.getElementById('v287-packaging-title'); const section = document.getElementById('bioten'); if (!divider || !title || !section) return; const reducedMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false }; const lowPower = (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 2) || (navigator.deviceMemory && navigator.deviceMemory <= 2); const root = document.documentElement; const targetColor = [17, 128, 93]; const sourcePalette = [ [0, 121, 58], [10, 143, 103], [13, 91, 156], [29, 111, 184] ]; const cycleDuration = 6400; const phaseStops = { scattered: .11, formed: .32, barrier: .72, dispersed: .84 }; let canvas = null; let context = null; let particles = []; let frameId = 0; let cycleStartedAt = 0; let running = false; let sectionObserver = null; let sizeObserver = null; let languageObserver = null; let sectionIsVisible = false; let lastFormation = 1; function isEnglish() { return root.lang.toLowerCase().startsWith('en') || root.dataset.lang === 'en'; } function canEnhance() { return window.innerWidth >= 761 && !isEnglish() && !reducedMotion.matches && !lowPower && 'IntersectionObserver' in window && 'ResizeObserver' in window; } function seeded(index, salt) { const value = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453; return value - Math.floor(value); } function clamp(value, minimum = 0, maximum = 1) { return Math.max(minimum, Math.min(maximum, value)); } function easeOutCubic(value) { return 1 - Math.pow(1 - clamp(value), 3); } function easeInOutCubic(value) { const progress = clamp(value); return progress < .5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2; } function mix(from, to, progress, alpha) { const r = Math.round(from[0] + (to[0] - from[0]) * progress); const g = Math.round(from[1] + (to[1] - from[1]) * progress); const b = Math.round(from[2] + (to[2] - from[2]) * progress); return `rgba(${r}, ${g}, ${b}, ${alpha.toFixed(3)})`; } function setPhase(phase) { divider.dataset.v7118Phase = phase; divider.classList.toggle('is-v7118-forming', phase === 'forming'); divider.classList.toggle('is-v7118-barrier', phase === 'barrier'); divider.classList.toggle('is-v7118-dispersing', phase === 'dispersing'); divider.classList.toggle('is-v7118-scattered', phase === 'scattered'); } function buildParticles(width, height, lineHeight) { const count = Math.max(46, Math.min(60, Math.round(lineHeight * 1.65))); const centerX = width / 2; const centerY = height / 2; const targetTop = (height - lineHeight) / 2; particles = Array.from({ length: count }, (_, index) => { const targetY = targetTop + (lineHeight * index) / Math.max(1, count - 1); const angle = seeded(index, 1) * Math.PI * 2; const radiusX = 19 + seeded(index, 2) * 12; const radiusY = lineHeight * .72 + seeded(index, 3) * 14; const burstForce = 5 + seeded(index, 7) * 8; return { startX: clamp(centerX + Math.cos(angle) * radiusX + (seeded(index, 8) - .5) * 5, 2, width - 2), startY: clamp(centerY + Math.sin(angle) * radiusY, 2, height - 2), targetX: centerX + (seeded(index, 4) - .5) * .82, targetY: targetY + (seeded(index, 9) - .5) * .28, burstX: Math.cos(angle) * burstForce, burstY: Math.sin(angle) * burstForce, phase: seeded(index, 5) * Math.PI * 2, size: .56 + seeded(index, 6) * 1.02, drift: .76 + seeded(index, 10) * .7, spark: index % 3 === 0, sourceColor: sourcePalette[index % sourcePalette.length] }; }); } function draw(formation, phaseProgress = 0, phase = 'barrier') { if (!canvas || !context) return; const width = Number.parseFloat(canvas.style.width) || 72; const height = Number.parseFloat(canvas.style.height) || 76; const progress = clamp(formation); const eased = easeOutCubic(progress); const colorSettle = progress * progress * (3 - 2 * progress); const burstEnvelope = phase === 'dispersing' ? Math.sin(Math.PI * clamp(phaseProgress)) : 0; const lockPulse = phase === 'barrier' ? Math.sin(Math.PI * clamp(phaseProgress / .18)) : 0; context.clearRect(0, 0, width, height); particles.forEach((particle) => { const waveEnvelope = Math.sin(Math.PI * progress) * (1 - progress * .28); const wave = Math.sin(particle.phase + phaseProgress * Math.PI * 2) * 4.8 * particle.drift * waveEnvelope; const x = particle.startX + (particle.targetX - particle.startX) * eased + wave + particle.burstX * burstEnvelope; const y = particle.startY + (particle.targetY - particle.startY) * eased + particle.burstY * burstEnvelope; const alpha = Math.min(1, .28 + progress * .78 + burstEnvelope * .1 + lockPulse * .08); if (burstEnvelope > .06 && particle.spark) { context.beginPath(); context.strokeStyle = mix( particle.sourceColor, targetColor, colorSettle * .65, .08 + burstEnvelope * .17 ); context.lineWidth = .48 + burstEnvelope * .28; context.moveTo( x - particle.burstX * (.12 + burstEnvelope * .12), y - particle.burstY * (.12 + burstEnvelope * .12) ); context.lineTo(x, y); context.stroke(); } if ((burstEnvelope > .08 || lockPulse > .08) && particle.spark) { context.beginPath(); context.fillStyle = mix( particle.sourceColor, targetColor, colorSettle, .04 + Math.max(burstEnvelope, lockPulse) * .12 ); context.arc( x, y, particle.size * (2.2 + Math.max(burstEnvelope, lockPulse) * 1.3), 0, Math.PI * 2 ); context.fill(); } context.beginPath(); context.fillStyle = mix(particle.sourceColor, targetColor, colorSettle, alpha); context.arc( x, y, particle.size * (1 + burstEnvelope * .26 + lockPulse * .16), 0, Math.PI * 2 ); context.fill(); }); lastFormation = progress; } function resizeCanvas() { if (!canvas || !context) return; const fontSize = Number.parseFloat(getComputedStyle(title).fontSize) || 40; const cssWidth = 72; const cssHeight = Math.max(72, fontSize * 1.9); const lineHeight = fontSize * .72; const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.style.width = `${cssWidth}px`; canvas.style.height = `${cssHeight}px`; canvas.width = Math.round(cssWidth * dpr); canvas.height = Math.round(cssHeight * dpr); context.setTransform(dpr, 0, 0, dpr, 0, 0); buildParticles(cssWidth, cssHeight, lineHeight); draw(lastFormation, 0, lastFormation >= .98 ? 'barrier' : 'scattered'); } function formationAt(cycleProgress) { if (cycleProgress < phaseStops.scattered) { return { phase: 'scattered', formation: 0, local: cycleProgress / phaseStops.scattered }; } if (cycleProgress < phaseStops.formed) { const local = (cycleProgress - phaseStops.scattered) / (phaseStops.formed - phaseStops.scattered); return { phase: 'forming', formation: easeOutCubic(local), local }; } if (cycleProgress < phaseStops.barrier) { const local = (cycleProgress - phaseStops.formed) / (phaseStops.barrier - phaseStops.formed); return { phase: 'barrier', formation: 1, local }; } if (cycleProgress < phaseStops.dispersed) { const local = (cycleProgress - phaseStops.barrier) / (phaseStops.dispersed - phaseStops.barrier); return { phase: 'dispersing', formation: 1 - easeInOutCubic(local), local }; } return { phase: 'scattered', formation: 0, local: (cycleProgress - phaseStops.dispersed) / (1 - phaseStops.dispersed) }; } function stopLoop({ settle = true } = {}) { running = false; if (frameId) cancelAnimationFrame(frameId); frameId = 0; if (settle && canvas) { draw(1, 1, 'barrier'); setPhase('barrier'); } } function startLoop() { if (running || !canvas || !canEnhance() || !sectionIsVisible || document.hidden) return; running = true; cycleStartedAt = performance.now(); setPhase('scattered'); draw(0, 0, 'scattered'); const step = (now) => { if (!running || !canvas || !canEnhance() || !sectionIsVisible || document.hidden) { stopLoop({ settle: true }); return; } const elapsed = (now - cycleStartedAt) % cycleDuration; const cycleProgress = elapsed / cycleDuration; const state = formationAt(cycleProgress); setPhase(state.phase); draw(state.formation, state.local, state.phase); frameId = requestAnimationFrame(step); }; frameId = requestAnimationFrame(step); } function createCanvas() { if (canvas || !canEnhance()) return; canvas = document.createElement('canvas'); canvas.className = 'v7118-particle-divider__canvas'; canvas.setAttribute('aria-hidden', 'true'); context = canvas.getContext('2d', { alpha: true }); if (!context) { canvas = null; return; } divider.appendChild(canvas); divider.classList.add('is-v7118-particle-divider'); lastFormation = sectionIsVisible ? 0 : 1; resizeCanvas(); if (sectionIsVisible) startLoop(); } function removeCanvas() { stopLoop({ settle: false }); if (canvas) canvas.remove(); canvas = null; context = null; particles = []; lastFormation = 1; delete divider.dataset.v7118Phase; divider.classList.remove( 'is-v7118-particle-divider', 'is-v7118-forming', 'is-v7118-barrier', 'is-v7118-dispersing', 'is-v7118-scattered' ); } function syncLanguageAndViewport() { if (canEnhance()) { createCanvas(); if (sectionIsVisible) startLoop(); } else { removeCanvas(); } } sectionObserver = new IntersectionObserver((entries) => { const entry = entries.find((item) => item.target === section); if (!entry) return; sectionIsVisible = entry.isIntersecting && entry.intersectionRatio >= .18; if (sectionIsVisible) { startLoop(); } else { stopLoop({ settle: true }); } }, { threshold: [0, .18, .35] }); sectionObserver.observe(section); sizeObserver = new ResizeObserver(() => { if (canvas) resizeCanvas(); syncLanguageAndViewport(); }); sizeObserver.observe(title); languageObserver = new MutationObserver(syncLanguageAndViewport); languageObserver.observe(root, { attributes: true, attributeFilter: ['lang', 'data-lang'] }); const handleVisibility = () => { if (document.hidden) { stopLoop({ settle: true }); } else if (sectionIsVisible) { startLoop(); } }; const handleResize = () => syncLanguageAndViewport(); document.addEventListener('visibilitychange', handleVisibility, { passive: true }); window.addEventListener('resize', handleResize, { passive: true }); reducedMotion.addEventListener?.('change', syncLanguageAndViewport); window.addEventListener('pageshow', syncLanguageAndViewport, { passive: true }); window.addEventListener('pagehide', () => { stopLoop({ settle: false }); sectionObserver?.disconnect(); sizeObserver?.disconnect(); languageObserver?.disconnect(); document.removeEventListener('visibilitychange', handleVisibility); window.removeEventListener('resize', handleResize); reducedMotion.removeEventListener?.('change', syncLanguageAndViewport); }, { once: true }); syncLanguageAndViewport(); })();