Something went wrong. Try again.
Monorepo for Tangled forked from tangled.org/core
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513<script lang="ts"> import { onMount, untrack } from "svelte"; import { sineInOut } from "svelte/easing"; import { prefersReducedMotion, Spring, Tween } from "svelte/motion"; import Container from "#lib/components/landing/ui/Container.svelte"; import Knot from "#lib/components/landing/thread/Knot.svelte"; import Ribbon from "#lib/components/landing/thread/Ribbon.svelte"; import { join, lengthAtY, sample, straight, type Line, type Pt } from "#lib/components/landing/thread/geometry.ts"; import { getThread, PEN, type KnotAnchor } from "#lib/components/landing/thread/thread.svelte.ts";
/* * Draws the thread from under the hero's stack to the CTA (see thread.svelte.ts). Laid * over the whole landing page, out of the way of the pointer. */
const thread = getThread();
/** The hero's bottom thread (86.57 × 252.13), traced down * its centre: out from under the stack, a curl, and back under itself. */ const CURL_W = 86.5686; const CURL: Pt[] = [ [39.5, 1], [39.1, 24], [38.1, 48], [35.3, 60], [29.6, 72], [19.4, 84], [12.4, 90], [6.1, 96], [2, 102], [0.9, 108], [4.1, 114], [12.1, 120], [23, 126], [35, 132], [47.4, 138], [58.5, 144], [67, 150], [73.4, 156], [78, 162], [81.5, 168], [83.6, 174], [85.5, 186], [84.1, 198], [79.9, 210], [73, 222], [62.9, 234], [48.6, 246], [41.6, 251] ]; /** * Desktop: the swing from the curl's end over to the rail (291.96 × 160.32), * traced down its centre: a long, shallow S from the top right that * lands heading straight down at the bottom left. It starts where it runs at the curl's * slope (the drawn line's first few px drop steeply to meet its own curl, not ours, and * would kink). Fitted between the two, so only its proportions matter. */ const SWING: Pt[] = [ [266, 28.6], [257.6, 34], [247.7, 39.2], [238.6, 43.4], [229.3, 47.2], [218.8, 51], [208.2, 54.8], [197.4, 58], [187.8, 60.8], [177, 63.8], [166.2, 66.6], [156.4, 69.1], [146.7, 71.5], [137, 74], [126.2, 76.8], [116.5, 79.3], [105.7, 82.3], [94.8, 85.4], [84.1, 88.6], [74.6, 91.8], [65.2, 95.2], [55.9, 98.8], [45.7, 103.5], [36.8, 108.1], [27.4, 114.2], [18.6, 121.2], [11, 129.5], [5.7, 137.9], [1.8, 148.4], [1, 160] ]; /** * Below desktop, the line off the knot, * traced down its centre from the knot: it drops, kinks left, flicks out right and turns * hard back on itself — the hook, `HOOK`, by the knot at its drawn size — then runs * back left and eases down onto the rail — the `RUN`, by its distance left of the hook's * tip (`TIP`) and below the knot. The run is stretched or squeezed to the room there is; * its last 110px, the bend down, keeps its shape where there's room for it. * * It leaves the knot straight down, as the knot's lower strand does, down to where that * strand ends (12px under the knot's centre), and only then eases into the drawing's * lean left; leaning from the knot's centre, it met the strand a step to its left. */ const HOOK: Pt[] = [ [0, 0], [0, 5.2], [0, 12], [-0.8, 17.7], [-2.5, 23.8], [-4.7, 29.8], [-7.1, 35.7], [-9.5, 41.5], [-12.3, 47], [-16.1, 51.9], [-20.8, 56.1], [-25.1, 60.5], [-28.6, 65.7], [-28.5, 71.7], [-24.1, 76], [-18.9, 79.5], [-13.4, 82.5], [-7.8, 85.3], [-2.2, 88.1], [3.4, 90.8], [8.9, 93.7], [11, 95.4] ]; const TIP = 11; const RUN: Pt[] = [ [9.2, 97.2], [15.5, 96.9], [27.9, 96.2], [40.4, 95.8], [52.9, 95.6], [65.5, 95.6], [71.7, 95.8], [78, 96.2], [84.2, 96.7], [90.4, 97.4], [96.7, 98.2], [102.8, 99.2], [109, 100.4], [115.1, 101.8], [121.1, 103.4], [127, 105.2], [132.9, 107.2], [138.6, 109.8], [144.1, 112.7], [149.4, 116], [154.6, 119.4], [159.5, 123.4], [164, 127.7], [167.9, 132.6], [171.1, 137.9], [174.2, 143.3], [176.7, 149.1], [178.8, 154.9], [180.3, 161], [181.5, 167.1], [182, 175] ]; /** The hook's tip to the rail as drawn, and how much of that is the bend down. */ const RUN_W = 182; const BEND_W = 110;
/** * At the top of the page the pen sits lower (a share of the screen), so the line off the * hero's stack runs on a good way down on load. */ const PEN_TOP = 0.97;
let probe: HTMLElement | undefined = $state();
/** Measured layout, relative to the landing root. */ let layout = $state.raw<{ /** The line, from under the hero's stack down beside every section. */ line: Line; /** Where the line ends and the CTA's lead begins. */ end: number; /** Where the line starts, under the hero's stack. */ start: number; /** The furthest the page scrolls. */ bottom: number; x: number; /** * Each active knot: its anchor, its page y and how far along the line that is. The * anchor is kept with it: across the breakpoint the active anchors change before the * layout is measured again. */ knots: { anchor: KnotAnchor; y: number; len: number }[]; } | null>(null);
/** The page's scroll, relative to the landing root, and the viewport height. */ let s = $state(0); let vh = $state(800); let rootTop = 0;
const desktop = $derived(thread.desktop.current); /** * The line drawn all the way, every knot tied: once it has run into the CTA, and from the * start under reduced motion, where it doesn't draw on with the scroll but simply stands * from the hero to the CTA. */ const whole = $derived(thread.finished || prefersReducedMotion.current);
function rect(el: HTMLElement | undefined) { const root = thread.els.root; if (!el || !root) return null; const r = el.getBoundingClientRect(); const o = root.getBoundingClientRect(); if (!r.width && !r.height) return null; return { left: r.left - o.left, top: r.top - o.top, width: r.width, height: r.height }; }
function measure() { const root = thread.els.root; if (!root || !probe) return; vh = innerHeight; rootTop = root.getBoundingClientRect().top + scrollY; s = scrollY - rootTop;
const container = rect(probe.parentElement ?? undefined); const railOffset = parseFloat(getComputedStyle(probe).getPropertyValue("--rail-x")) || 0; const x = (container?.left ?? 0) + railOffset; thread.railX = x;
const start = rect(thread.els.start); const body = rect(thread.els.body); const end = rect(thread.els.end); if (!start || !body || !end) return; const knot: Pt = [start.left + start.width / 2, start.top + start.height / 2];
// Desktop: out from under the stack through the curl, then the long S over to the rail. // Narrow: the hook off the knot, then the run back across onto the rail. Either way it // then runs straight on down the rail to the CTA. const curl = desktop ? rect(thread.els.curl) : null; let pts: Pt[]; let landing: number; if (curl) { const k = curl.width / CURL_W; // Its last point flattens off to meet the original swing; ours takes over a point earlier. // It leaves the knot straight down to where the knot's lower strand ends, as the hook // does (HOOK), and only then leans into the curl: leaning from the knot's centre, it // met the strand a step to its left. The curl's next point is dropped, too close // under that to ease into. const out = CURL.slice(2, -1).map(([cx, cy]): Pt => [ curl.left + cx * k, curl.top + cy * k ]); out.unshift(knot, [knot[0], knot[1] + 5.2], [knot[0], knot[1] + 12]); const [ex, ey] = out[out.length - 1]; // Fitted across the room there is; the drop eases with it, so a narrower swing is a // little shorter rather than steeper. const [sx, sy] = SWING[0]; const [lx, ly] = SWING[SWING.length - 1]; const w = Math.max(40, ex - x); const h = Math.max(110, Math.min(190, (ly - sy) * Math.sqrt(w / (sx - lx)))); const swing = SWING.map(([fx, fy]): Pt => [ x + ((fx - lx) / (sx - lx)) * w, ey + ((fy - sy) / (ly - sy)) * h ]); pts = [...out, ...swing.slice(1)]; landing = ey + h; } else { const hook = HOOK.map(([hx, hy]): Pt => [knot[0] + hx, knot[1] + hy]); const tip = knot[0] + TIP; const room = Math.max(40, tip - x); // Where the run's distance from the tip lands: its straight part takes up any // difference in room, and if there isn't room for the bend it's squeezed with it. const along = (d: number) => { if (room < RUN_W) return d * (room / RUN_W); const straightFig = RUN_W - BEND_W; const straightNow = room - BEND_W; return d <= straightFig ? d * (straightNow / straightFig) : straightNow + (d - straightFig); }; const run = RUN.map(([d, ry]): Pt => [tip - along(d), knot[1] + ry]); pts = [...hook, ...run]; landing = run[run.length - 1][1]; } // It lands heading straight down: left to its neighbours, the last point takes the // swing's slant and overshoots the rail before hooking back onto it. const line = join( sample(pts, 2, [...pts.slice(1).map(() => undefined), [0, 1]]), straight([x, landing], [x, end.top]) );
const knots = thread.active.map((a) => { const r = rect(a.el); const y = r ? r.top + Math.min(r.height, 32) / 2 : 0; return { anchor: a, y, len: lengthAtY(line, y) }; });
const bottom = document.documentElement.scrollHeight - innerHeight - rootTop; layout = { line, end: end.top, start: knot[1], bottom, x, knots }; }
function onScroll() { s = scrollY - rootTop; }
/** Where the pen is on the page at a scroll: PEN down the screen, or PEN_TOP near the top. */ const penAt = (scroll: number) => scroll + (PEN_TOP - (PEN_TOP - PEN) * Math.min(1, Math.max(0, scroll) / vh)) * vh;
/** The pen on the page, and how far the line is drawn by the scroll. */ const penY = $derived(penAt(s)); const atBottom = $derived(!!layout && s >= layout.bottom - 1); const scrolled = $derived(layout ? lengthAtY(layout.line, penY) : 0);
/* * The intro: once the stack is in, the line goes to the scroll, and the ink below draws * it out from under the stack. Loaded further down the page (a reload, a link to a * section) or scrolled past the hero before the intro's done, it's let go there and then. */ let released = $state(false); let drawingIn = $state(false); let drawInLag = 0; const heroGone = $derived(!!layout && layout.knots.length > 0 && s > layout.start); $effect(() => { if (released || !heroGone) return; released = true; if (untrack(() => thread.intro) === "bottom") thread.intro = "done"; }); $effect(() => { if (thread.intro !== "bottom" || !layout) return; released = true; drawingIn = true; drawInLag = untrack(() => scrolled - ink.current); thread.intro = "done"; }); const target = $derived( (whole || thread.reached) && layout ? layout.line.total : released ? scrolled : 0 );
/* * The line doesn't jump to the scroll — the ink chases it on a spring, drawing on (or * back) like a pen however fast the page moves. A jump of more than a screen and a half * in one go is the page landing somewhere (a reload, a link to a section), not the reader * scrolling, so the line is just there. * * The ink runs on past the line's end by as far as the pen is past it, and that overrun * is the CTA's lead (the spill below), so the lead only starts once the line has got there. * * The spring trails the pen by about nine times the scroll's speed per frame, so a phone's * flick would leave the line's end, and each knot it ties, up near the top of the screen * or off it. It never trails by more than `INK_LAG` of the screen: scrolling fast, its end * holds there like a pen, a little above the middle, so every knot ties in view. */ const INK_LAG = 0.2; const ink = new Spring(0, { stiffness: 0.09, damping: 0.85 }); const overrun = $derived.by(() => { if (!layout || !released) return 0; const past = Math.max(0, penY - layout.end); // Once the CTA is in view, or at the foot of the page where there's no more scroll to // draw it on with, it runs the rest of the way into the CTA itself. return atBottom || thread.reached ? Math.max(past, thread.lead + 1) : past; });
/* * Once the CTA is in view the line runs the rest of the way into it on its own, however * far that is: one stroke at a steady pen's pace, easing in and out, rather than the * spring's catch-up (which snaps to within `INK_LAG` and then whips off). */ const PACE = 1.2; const run = new Tween(0, { easing: sineInOut }); let running = $state(false); let runTo = 0; const inked = $derived(running ? run.current : ink.current);
$effect(() => { const to = target + overrun; if (thread.reached && !prefersReducedMotion.current) { runTo = to; if (untrack(() => running)) { if (untrack(() => run.current === run.target)) void run.set(to, { duration: 0 }); return; } const from = untrack(() => ink.current); running = true; void run.set(from, { duration: 0 }); const duration = Math.min(1400, Math.max(450, Math.abs(to - from) / PACE)); void run.set(to, { duration }).then(() => run.set(runTo, { duration: 0 })); return; } const lag = to - untrack(() => ink.current); const h = untrack(() => vh); if (prefersReducedMotion.current || Math.abs(lag) > 1.5 * h) { void ink.set(to, { instant: true }); return; } const max = untrack(() => drawingIn) ? Math.max(INK_LAG * h, drawInLag) : INK_LAG * h; if (Math.abs(lag) > max) { void ink.set(to - Math.sign(lag) * max, { instant: true }); } ink.target = to; });
$effect(() => { if (drawingIn && Math.abs(ink.target - ink.current) <= INK_LAG * vh) drawingIn = false; });
const drawn = $derived(Math.min(inked, layout?.line.total ?? 0));
/* How far the line has run on into the CTA. */ $effect(() => { if (!layout || !released) { thread.spill = 0; return; } const spill = Math.max(0, inked - layout.line.total); // Once it's run all the way in, it stays run in. thread.spill = thread.finished ? Math.max( spill, untrack(() => thread.spill) ) : spill; });
/** Each knot is tied when the line itself gets there. */ const knots = $derived.by(() => { if (!layout) return []; return layout.knots.map(({ anchor, y, len }) => { const at = released && drawn >= len - 0.5; return { anchor, y, tied: at || whole }; }); });
// Re-measure when the page's layout moves: sizes, fonts, the breakpoint, the anchors. $effect(() => { void desktop; void thread.anchors.length; void thread.els; measure(); // And again once everything attached alongside has registered and laid out too. const raf = requestAnimationFrame(() => measure()); return () => cancelAnimationFrame(raf); });
// Anything that moves the page's height under the line — content loading in, a section // growing — moves the CTA, so watch the landing root for it. The root registers itself // after the rail mounts, so this waits for it rather than looking once on mount. $effect(() => { const root = thread.els.root; if (!root) return; const ro = new ResizeObserver(() => measure()); ro.observe(root); return () => ro.disconnect(); });
onMount(() => { document.fonts?.ready.then(measure); });</script>
<svelte:window onscroll={onScroll} onresize={measure} />
<div class="rail" aria-hidden="true"> <!-- Where the rail runs: a rail container, read for its edge and --rail-x. --> <Container width="rail" class="probe" aria-hidden="true"> <div bind:this={probe}></div> </Container>
{#if layout} <svg class="page"> <Ribbon line={layout.line} {drawn} seed={7} taper={[0, 0]} tail={120} /> </svg>
{#each knots as k (k.anchor.id)} <Knot x={layout.x} y={k.y} tied={k.tied} /> {/each} {/if}</div>
<style> .rail { position: absolute; inset: 0; z-index: 2; pointer-events: none; } .rail :global(.probe) { height: 0; visibility: hidden; } svg { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; }</style>