Skiply.ai/skiply/skiply-connected-systems.js

167 lines
6.7 KiB
JavaScript

/* =============================================
Skiply.ai — Connected Systems section
Scan-the-shoe → distribute-to-tree motion sequence
============================================= */
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const section = document.getElementById('connected-systems');
const shoeStage = document.getElementById('shoeStage');
const horizon = document.getElementById('scanHorizon');
const flowLine = document.querySelector('.connected__flow-line');
const flowDot = document.querySelector('.connected__flow-dot');
const mesh = document.getElementById('shoeMesh');
const meshClip = document.getElementById('meshClip');
const particlesHost = document.getElementById('scanParticles');
const labels = Array.from(document.querySelectorAll('.scan-label'));
const treeSvg = document.getElementById('treeSvg');
const branchGroups = Array.from(document.querySelectorAll('.connected__branch-group'));
const nodes = Array.from(document.querySelectorAll('.connected__node'));
const routes = Array.from(document.querySelectorAll('.route'));
if (!section || !shoeStage || typeof gsap === 'undefined') return;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* ── Build scan particles ───────────────────── */
const PARTICLE_COUNT = 14;
const particles = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = document.createElement('span');
p.className = 'scan-particle';
const left = 8 + Math.random() * 84;
const top = 6 + Math.random() * 88;
const size = 2 + Math.random() * 3;
p.style.left = left + '%';
p.style.top = top + '%';
p.style.width = size + 'px';
p.style.height = size + 'px';
particlesHost.appendChild(p);
particles.push({ el: p, left });
}
/* ── Ambient dust: persistent, independent of the scroll timeline ── */
const ambientHost = document.getElementById('ambientParticles');
const AMBIENT_COUNT = 38;
for (let i = 0; i < AMBIENT_COUNT; i++) {
const p = document.createElement('span');
p.className = 'ambient-particle';
const size = 1.5 + Math.random() * 2;
p.style.left = Math.random() * 100 + '%';
p.style.top = Math.random() * 100 + '%';
p.style.width = size + 'px';
p.style.height = size + 'px';
p.style.setProperty('--dur', (2.5 + Math.random() * 3).toFixed(2) + 's');
p.style.setProperty('--delay', (Math.random() * 4).toFixed(2) + 's');
p.style.setProperty('--dx', (Math.random() * 20 - 10).toFixed(1) + 'px');
p.style.setProperty('--dy', (-6 - Math.random() * 16).toFixed(1) + 'px');
ambientHost.appendChild(p);
}
if (reduceMotion) {
gsap.set(shoeStage, { opacity: 1 });
gsap.set(mesh, { opacity: 0.75 });
gsap.set(meshClip, { '--reveal': '115%' });
gsap.set(labels, { opacity: 1, y: 0, scale: 1 });
gsap.set(particles.map((p) => p.el), { opacity: 0.6 });
gsap.set(horizon, { opacity: 0.85, scaleX: 1 });
gsap.set(flowLine, { opacity: 0.7, scaleY: 1 });
gsap.set(treeSvg, { opacity: 1 });
gsap.set(branchGroups, { clipPath: 'inset(0% 0% 0% 0%)' });
gsap.set(nodes, { opacity: 1, scale: 1 });
gsap.set(routes, { opacity: 1, y: -4 });
routes.forEach((el) => el.classList.add('is-lit'));
return;
}
gsap.set(shoeStage, { opacity: 0 });
gsap.set(labels, { opacity: 0, y: 10, scale: 0.9, transformOrigin: '50% 50%' });
gsap.set(routes, { opacity: 0, y: 20 });
function playIntro() {
const tl = gsap.timeline({ defaults: { ease: 'power2.out' } });
// 1. Stage fades up, ambient glow already pulsing via CSS
tl.to(shoeStage, { opacity: 1, duration: 0.5 }, 0);
// 2. The horizon line is the whole scan effect: it lights up and
// grows outward from center, passing through the shoe and
// extending past its silhouette on both sides. The wireframe
// mesh reveals in step with it.
tl.to(horizon, { opacity: 0.85, duration: 0.2 }, 0.3)
.to(horizon, { scaleX: 1, duration: 0.6, ease: 'power2.out' }, 0.3)
.to(mesh, { opacity: 0.75, duration: 0.2 }, 0.3)
.to(meshClip, { '--reveal': '115%', duration: 0.6, ease: 'power2.out' }, 0.3);
// 3. Particles twinkle in across the same window the horizon grows
particles.forEach(({ el, left }) => {
const t = 0.3 + (left / 100) * 0.6;
tl.to(el, { opacity: 1, duration: 0.25 }, t)
.to(el, { opacity: 0.45, y: -4, duration: 0.5 }, t + 0.25);
});
// 4. Annotation labels appear one at a time, each a distinct beat
labels.forEach((label, i) => {
tl.to(label, { opacity: 1, y: 0, scale: 1, duration: 0.4, ease: 'back.out(1.7)' }, 0.45 + i * 0.32);
});
// 5. The horizon line hands off into a connector that carries the
// light down from the shoe stage into the tree's attachment point.
tl.to(flowLine, { opacity: 0.7, scaleY: 1, duration: 0.4, ease: 'power1.out' }, 1.1)
.fromTo(flowDot, { opacity: 0, top: '0%' }, { opacity: 1, top: '100%', duration: 0.4, ease: 'power1.in' }, 1.1)
.to(flowDot, { opacity: 0, duration: 0.15 }, 1.5);
// 6. The tree becomes visible, then each branch draws itself in
// center-out: the middle line first, then the two adjacent, then
// the two outer ones — each followed by its node simply appearing
// and its pill lighting up as the light "arrives".
const centerIndex = Math.floor(branchGroups.length / 2);
const STEP = 0.22;
const BRANCH_DUR = 0.9;
tl.addLabel('branches', 1.5)
.to(treeSvg, { opacity: 1, duration: 0.3 }, 'branches');
branchGroups.forEach((group, i) => {
const pos = `branches+=${(Math.abs(i - centerIndex) * STEP).toFixed(2)}`;
tl.fromTo(group,
{ clipPath: 'inset(0% 0% 100% 0%)' },
{
clipPath: 'inset(0% 0% 0% 0%)',
duration: BRANCH_DUR,
ease: 'power2.out',
// Fires exactly when this branch's line finishes drawing, so
// the node and its route card never appear before the line
// has actually reached them.
onComplete: () => {
gsap.to(nodes[i], { opacity: 1, duration: 0.12 });
gsap.to(routes[i], {
opacity: 1,
y: -4, // matches .is-lit's lift so there's no transform conflict
duration: 0.4,
ease: 'power2.out',
onComplete: () => routes[i].classList.add('is-lit'),
});
},
},
pos
);
});
return tl;
}
if (typeof ScrollTrigger !== 'undefined') {
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.create({
trigger: section,
start: 'top 70%',
once: true,
onEnter: playIntro,
});
} else {
playIntro();
}
});