diff --git a/WEBSITE/script.js b/WEBSITE/script.js index 2ece5e06bb..6829fb68ac 100644 --- a/WEBSITE/script.js +++ b/WEBSITE/script.js @@ -123,8 +123,9 @@ const MENU_X = 120; const CLOCK_W = 4 * CW - 36; const DEFAULT_WEIGHT = 300; - /** The story runs from 0 to 9: chapters 1 to 7 take one unit each, chapter 8 two. */ - const STORY_END = 9; + /** The story runs from 0 to 10: chapters 1 to 7 take one unit each, chapter 8 three. */ + const STORY_END = 10; + const LONG = 3; /** Icon shapes, as border radii for icons and for the picture widget. */ const SHAPES = { squircle: { icon: '30%', widget: '30px' }, @@ -529,13 +530,13 @@ const s = blank(); P = clamp(P, 0, STORY_END); if (P >= 7) { - // Chapter 8, two units long: the list closes and the phone is home again. From there on the + // Chapter 8, three units long: the list closes and the phone is home again. From there on the // layered phone (setupAnatomy) takes its place and comes apart; this one is hidden. - const t = (P - 7) / 2; - s.drawer = 1 - E(t, 0, 0.1); + const t = (P - 7) / LONG; + s.drawer = 1 - E(t, 0, 0.06); s.list = ph.scrollFor(L('P')); s.hidden = { Podcasts: 1 }; - s.snack = 1 - E(t, 0, 0.08); + s.snack = 1 - E(t, 0, 0.05); return s; } const ch = Math.min(6, Math.floor(P)); @@ -648,9 +649,16 @@ return s; } - /** Chapter 8, from its start (0) to its end (1): when the phones swap, and how far apart the layers are. */ - const SWAP_AT = 0.12; - const layersApart = (t) => smooth(seg(t, 0.14, 0.42)) * (1 - smooth(seg(t, 0.74, 0.94))); + /** + * Chapter 8, from its start (0) to its end (1): when the phones swap, how far apart the layers + * are, and which layer is looked at (0 the glass to 4 the wallpaper) while the stack is open. + */ + const SWAP_AT = 0.08; + const layersApart = (t) => smooth(seg(t, 0.09, 0.26)) * (1 - smooth(seg(t, 0.85, 0.97))); + const lookingAt = (t) => ({ + amount: smooth(seg(t, 0.27, 0.32)) * (1 - smooth(seg(t, 0.78, 0.83))), + at: seg(t, 0.33, 0.77) * 4, + }); function setupStory(ph, layered) { const box = document.querySelector('.story__steps'); @@ -667,14 +675,13 @@ const P = clamp((-box.getBoundingClientRect().top + vh / 2) / unit, 0, STORY_END); const ch = P >= 7 ? 7 : Math.floor(P); // Without motion, each chapter shows its finished picture (chapter 8: pulled apart). - const shown = reduced ? (ch === 7 ? 7.98 : ch + 0.985) : P; + const shown = reduced ? (ch === 7 ? 7 + LONG * 0.2 : ch + 0.985) : P; if (force || Math.abs(shown - lastP) > 0.0002) { lastP = shown; ph.render(storyState(ph, shown)); if (layered) { - const t = shown >= 7 ? (shown - 7) / 2 : 0; - const swapped = shown >= 7 && t >= SWAP_AT; - layered.set(swapped ? layersApart(t) : 0, swapped, force); + const t = shown >= 7 ? (shown - 7) / LONG : 0; + layered.set(t, shown >= 7 && t >= SWAP_AT, force); } } if (ch !== lastChapter) { @@ -1610,21 +1617,19 @@ svg.append(path, dot); return { path, dot }; }); + const LABEL_W = 214; + const GAP = 165; // between layers, in the phone's pixels let scale = 1; - let lastE = -1; + let lastT = -1; let swapped = null; /** Same picture as the story phone shows, so the swap is not seen. */ function syncPictures() { pics.forEach((p, i) => p.classList.toggle('on', i === storyPhone.picIndex)); } - /** Puts the layers where e (0 closed, 1 fully apart) wants them. */ - function arrange(e) { - const GAP = 150; // between layers, in the phone's pixels + const wide = () => window.innerWidth > 900; + function place(e, shift) { const tilt = 56 * e; - const sc = scale * (1 - 0.36 * e); - const wide = window.innerWidth > 900; - // Opening, the stack moves left to leave room for its labels. - const shift = wide ? -e * Math.min(140, stage.clientWidth * 0.17) : 0; + const sc = scale * (1 - 0.44 * e); const ox = 207 * (scale - sc) + shift; // The layers rise as they separate; lower the whole stack by half of that, to keep it centred. const oy = 434 * (scale - sc) + e * 2.5 * GAP * Math.sin((tilt * Math.PI) / 180) * sc; @@ -1633,66 +1638,97 @@ layers.forEach((l, i) => { l.style.transform = i ? `translateZ(${(i * GAP * e).toFixed(1)}px)` : ''; }); - // Flat, it is just a home screen: the app list layer only shows once the stack opens. - layers[4].style.opacity = smooth(seg(e, 0.15, 0.6)).toFixed(3); } - /** The labels: one column beside the stack, evenly spaced, each joined to its layer's edge. */ - function label(e) { + /** + * Puts the layers where e (0 closed, 1 fully apart) wants them. The pins sit on each layer's + * outer corner, its rightmost point; if the labels beside them would not fit, the stack moves + * left by just enough. + */ + function arrange(e) { + place(e, 0); + if (!wide() || e <= 0) return; + const room = stage.getBoundingClientRect().right - 12 - LABEL_W - 34; + let corner = -Infinity; + for (const pin of pins) corner = Math.max(corner, pin.getBoundingClientRect().left); + if (corner > room) place(e, room - corner); + } + /** Each label straight across from its layer's corner, on a short line that crosses nothing. */ + function label(e, focus, weights) { const shown = seg(e, 0.55, 0.95); - svg.style.opacity = shown.toFixed(3); - labels.forEach((li) => { li.style.opacity = shown.toFixed(3); }); - if (window.innerWidth <= 900 || shown <= 0) return; + svg.style.opacity = shown > 0 ? '1' : '0'; + if (!wide() || shown <= 0) { + labels.forEach((li) => { li.style.opacity = '0'; }); + return; + } const base = stage.getBoundingClientRect(); - let right = -Infinity; - for (const l of layers) right = Math.max(right, l.getBoundingClientRect().right); - const LABEL_W = 214; - const colX = clamp(right - base.left + 34, 0, base.width - LABEL_W - 8); const pts = pins.map((pin) => { const r = pin.getBoundingClientRect(); return { x: r.left - base.left, y: r.top - base.top }; }); + const colX = Math.min(Math.max(...pts.map((pt) => pt.x)) + 34, base.width - LABEL_W - 12); const ys = []; let prev = -Infinity; for (const pt of pts) { - const y = Math.max(pt.y, prev + 76); // never closer than one tag's height + const y = Math.max(pt.y, prev + 74); // never closer than one tag's height ys.push(y); prev = y; } - const spill = ys[ys.length - 1] + 44 - base.height; + const spill = ys[ys.length - 1] + 40 - base.height; if (spill > 0) for (let k = 0; k < ys.length; k++) ys[k] -= spill; labels.forEach((li, k) => { + const lit = 1 - focus * (1 - Math.max(0.35, weights[k])); + li.style.opacity = (shown * lit).toFixed(3); + li.classList.toggle('is-on', focus > 0.5 && weights[k] > 0.5); li.style.transform = `translate(${colX.toFixed(1)}px, ${ys[k].toFixed(1)}px)`; const { x, y } = pts[k]; - const bend = Math.max(x + 16, colX - 26); - lines[k].path.setAttribute('d', `M${x.toFixed(1)} ${y.toFixed(1)} L${bend.toFixed(1)} ${ys[k].toFixed(1)} L${colX.toFixed(1)} ${ys[k].toFixed(1)}`); + const d = Math.abs(ys[k] - y) < 0.5 + ? `M${x.toFixed(1)} ${y.toFixed(1)} H${colX.toFixed(1)}` + : `M${x.toFixed(1)} ${y.toFixed(1)} H${(x + 12).toFixed(1)} L${(colX - 12).toFixed(1)} ${ys[k].toFixed(1)} H${colX.toFixed(1)}`; + lines[k].path.setAttribute('d', d); + lines[k].path.style.opacity = (shown * lit).toFixed(3); lines[k].dot.setAttribute('cx', x.toFixed(1)); lines[k].dot.setAttribute('cy', y.toFixed(1)); + lines[k].dot.style.opacity = (shown * lit).toFixed(3); }); } const fit = () => { scale = host.clientWidth / 414; - arrange(Math.max(0, lastE)); // scaled from the start: at full size it would widen the page + place(0, 0); // scaled from the start: at full size it would widen the page + lastT = -1; }; fit(); if ('ResizeObserver' in window) new ResizeObserver(fit).observe(host); - function set(e, on, force) { + /** t: where chapter 8 is, from 0 to 1. on: whether this phone is the one shown. */ + function set(t, on, force) { if (on !== swapped) { swapped = on; host.style.visibility = on ? 'visible' : 'hidden'; storyPhone.host.style.visibility = on ? 'hidden' : ''; if (on) syncPictures(); - lastE = -1; + lastT = -1; } if (!on) { - label(0); + label(0, 0, []); rail.style.opacity = ''; return; } - if (!force && Math.abs(e - lastE) < 0.0005) return; - lastE = e; + if (!force && Math.abs(t - lastT) < 0.0002) return; + lastT = t; + const e = layersApart(t); arrange(e); rail.style.opacity = (1 - e).toFixed(3); - label(e); + // One layer at a time: the one looked at stays bright and its label lights up, the others + // dim. Only where the labels are shown. + const look = lookingAt(t); + const focus = wide() ? look.amount : 0; + const weights = labels.map((_, k) => clamp(1 - Math.abs(k - look.at))); + layers.forEach((l, i) => { + const k = 5 - i; // the labels run from the glass (layer 5) down + const w = i === 0 ? 0 : weights[k]; + const base = i === 4 ? smooth(seg(e, 0.15, 0.6)) : 1; // flat, the list layer is not there + l.style.opacity = (base * (1 - focus * (1 - Math.max(0.16, w)))).toFixed(3); + }); + label(e, focus, weights); } return { set, syncPictures, element: xp }; } diff --git a/WEBSITE/styles.css b/WEBSITE/styles.css index 38859d85ab..ee3bce507d 100644 --- a/WEBSITE/styles.css +++ b/WEBSITE/styles.css @@ -293,7 +293,7 @@ section { position: relative; } .story__phones .phone-host, .story__phones .xhost { position: absolute; inset: 0; height: auto; } .story__phones .xhost { visibility: hidden; } /* Chapter 8 is two screens long; its text stays put while the phone comes apart. */ -.step.step--long { min-height: 200vh; min-height: 200svh; align-items: flex-start; } +.step.step--long { min-height: 300vh; min-height: 300svh; align-items: flex-start; } .step.step--long .step__card { position: sticky; top: max(96px, calc(50vh - 170px)); } .story__rail { position: absolute; right: 0; top: 50%; translate: 0 -50%; display: flex; flex-direction: column; gap: 10px; margin: 0; padding: 0; list-style: none; } .story__rail li { width: 12px; height: 34px; border: var(--b) solid var(--fg); transition: background .3s, height .3s; } @@ -786,17 +786,20 @@ section { position: relative; } .xl--list .list { top: 46px; } .xl--list .alpha { bottom: 112px; } .xl--glass::before { content: ""; position: absolute; inset: 0; opacity: var(--e); background: linear-gradient(125deg, rgba(255, 255, 255, .2), rgba(255, 255, 255, 0) 38%, rgba(255, 255, 255, .07) 68%, rgba(255, 255, 255, 0)); } -.xl__pin { position: absolute; right: 0; top: 30%; width: 1px; height: 1px; } +.xl__pin { position: absolute; right: 14px; bottom: 14px; width: 1px; height: 1px; } .anatomy__lines { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; opacity: 0; } .anatomy__lines path { fill: none; stroke: var(--cyan); stroke-width: 2; } .anatomy__lines circle { fill: var(--bg); stroke: var(--cyan); stroke-width: 2.5; } -.anatomy__labels { list-style: none; margin: 0; padding: 0; } +.anatomy__labels { position: absolute; inset: 0; list-style: none; margin: 0; padding: 0; pointer-events: none; } /* out of the flow, or the stage's grid would split its height and the phone sit high */ .anatomy__labels li { position: absolute; left: 0; top: 0; z-index: 3; width: 214px; translate: 0 -50%; padding: 9px 12px 10px; background: rgba(7, 7, 13, .94); border: var(--b) solid var(--fg); box-shadow: 4px 4px 0 var(--violet); opacity: 0; pointer-events: none; will-change: transform; } +.anatomy__labels li { transition: border-color .25s, box-shadow .25s; } +.anatomy__labels li.is-on { border-color: var(--cyan); box-shadow: 4px 4px 0 var(--cyan); } +.anatomy__labels li.is-on b { color: var(--cyan); } .anatomy__labels i { margin-right: 8px; font: 700 11px/1 var(--mono); font-style: normal; color: var(--cyan); } .anatomy__labels b { font: 900 13px/1.15 var(--display); text-transform: uppercase; letter-spacing: -.01em; } .anatomy__labels span { display: block; margin-top: 6px; font: 500 12px/1.3 var(--mono); color: var(--dim); white-space: nowrap; }