|
| 1 | +// Injects a fixed full-page background scene (planets, graph, code snippets) |
| 2 | +// directly into document.body. Color switches to white when a dark section |
| 3 | +// is in the centre of the viewport, detected via a scroll listener. |
| 4 | + |
| 5 | +// ─── SVG data ──────────────────────────────────────────────────────────────── |
| 6 | + |
| 7 | +const NODES: [number, number][] = [ |
| 8 | + [110, 95], [360, 60], [680, 85], [990, 50], [1230, 105], |
| 9 | + [1385, 190], [1350, 410], [1160, 330], [1030, 190], [870, 295], |
| 10 | + [760, 470], [1210, 590], [1030, 740], [800, 830], [570, 775], |
| 11 | + [340, 845], [140, 755], [62, 510], |
| 12 | +]; |
| 13 | + |
| 14 | +const EDGES: [number, number][] = [ |
| 15 | + [0,1],[1,2],[2,3],[3,4],[4,5],[5,6],[4,8],[8,9],[9,10], |
| 16 | + [7,6],[6,11],[11,12],[12,13],[13,14],[14,15],[15,16],[16,17], |
| 17 | + [2,9],[10,14],[7,11],[3,8],[0,17], |
| 18 | +]; |
| 19 | + |
| 20 | +const SNIPPETS = [ |
| 21 | + { text: "mamba install xtensor", x: 770, y: 765, angle: -4 }, |
| 22 | + { text: "import ipywidgets as w", x: 1075, y: 540, angle: -6 }, |
| 23 | + { text: "voila dashboard.ipynb", x: 1295, y: 435, angle: 5 }, |
| 24 | + { text: "xt::arange<double>(10)", x: 305, y: 855, angle: 7 }, |
| 25 | + { text: "jupyter lite build", x: 920, y: 115, angle: -3 }, |
| 26 | + { text: "import pyarrow as pa", x: 180, y: 380, angle: 4 }, |
| 27 | +]; |
| 28 | + |
| 29 | +function buildSVG(): string { |
| 30 | + const edges = EDGES.map(([a, b]) => |
| 31 | + `<line x1="${NODES[a][0]}" y1="${NODES[a][1]}" x2="${NODES[b][0]}" y2="${NODES[b][1]}" stroke="currentColor" stroke-width="1"/>`, |
| 32 | + ).join(""); |
| 33 | + |
| 34 | + const nodes = NODES.map(([x, y]) => |
| 35 | + `<circle cx="${x}" cy="${y}" r="4" fill="currentColor"/>`, |
| 36 | + ).join(""); |
| 37 | + |
| 38 | + const snippets = SNIPPETS.map(({ text, x, y, angle }) => |
| 39 | + `<text transform="translate(${x},${y}) rotate(${angle})">${text}</text>`, |
| 40 | + ).join(""); |
| 41 | + |
| 42 | + return `<svg viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" |
| 43 | + width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"> |
| 44 | + <style> |
| 45 | + #_pbg_p { animation: pbg-p 58s ease-in-out infinite; } |
| 46 | + #_pbg_g { animation: pbg-g 72s ease-in-out infinite; animation-delay:-18s; } |
| 47 | + #_pbg_s { animation: pbg-s 44s ease-in-out infinite; animation-delay:-9s; } |
| 48 | + @keyframes pbg-p { |
| 49 | + 0%,100%{transform:translate(0,0)} 33%{transform:translate(11px,-16px)} 66%{transform:translate(-8px,10px)} |
| 50 | + } |
| 51 | + @keyframes pbg-g { |
| 52 | + 0%,100%{transform:translate(0,0)} 50%{transform:translate(-13px,8px)} |
| 53 | + } |
| 54 | + @keyframes pbg-s { |
| 55 | + 0%,100%{transform:translate(0,0)} 40%{transform:translate(7px,-11px)} 75%{transform:translate(-5px,13px)} |
| 56 | + } |
| 57 | + </style> |
| 58 | + <g id="_pbg_p" opacity="0.09"> |
| 59 | + <circle cx="-40" cy="875" r="178" fill="currentColor"/> |
| 60 | + <ellipse cx="-40" cy="875" rx="285" ry="46" fill="none" stroke="currentColor" stroke-width="2.5" transform="rotate(-22,-40,875)"/> |
| 61 | + <circle cx="1382" cy="72" r="64" fill="currentColor"/> |
| 62 | + <ellipse cx="1382" cy="72" rx="102" ry="21" fill="none" stroke="currentColor" stroke-width="1.8" transform="rotate(-18,1382,72)"/> |
| 63 | + <circle cx="428" cy="138" r="32" fill="currentColor"/> |
| 64 | + <ellipse cx="428" cy="138" rx="54" ry="12" fill="none" stroke="currentColor" stroke-width="1.4" transform="rotate(-28,428,138)"/> |
| 65 | + <circle cx="82" cy="642" r="26" fill="currentColor"/> |
| 66 | + <circle cx="215" cy="48" r="21" fill="currentColor"/> |
| 67 | + <circle cx="648" cy="36" r="11" fill="currentColor"/> |
| 68 | + <circle cx="1052" cy="828" r="9" fill="currentColor"/> |
| 69 | + <circle cx="582" cy="882" r="14" fill="currentColor"/> |
| 70 | + </g> |
| 71 | + <g id="_pbg_g" opacity="0.14">${edges}${nodes}</g> |
| 72 | + <g id="_pbg_s" opacity="0.11" fill="currentColor" |
| 73 | + font-family="'Roboto Mono','Courier New',monospace" font-size="13">${snippets}</g> |
| 74 | +</svg>`; |
| 75 | +} |
| 76 | + |
| 77 | +// ─── DOM element ────────────────────────────────────────────────────────────── |
| 78 | + |
| 79 | +let bgEl: HTMLDivElement | null = null; |
| 80 | + |
| 81 | +function mount(): void { |
| 82 | + // Recover existing element after HMR resets the module variable |
| 83 | + if (!bgEl) { |
| 84 | + bgEl = document.getElementById("page-background-scene") as HTMLDivElement | null; |
| 85 | + } |
| 86 | + if (bgEl) return; |
| 87 | + |
| 88 | + bgEl = document.createElement("div"); |
| 89 | + bgEl.setAttribute("aria-hidden", "true"); |
| 90 | + bgEl.setAttribute("id", "page-background-scene"); |
| 91 | + Object.assign(bgEl.style, { |
| 92 | + position: "fixed", |
| 93 | + inset: "0", |
| 94 | + zIndex: "1", |
| 95 | + pointerEvents: "none", |
| 96 | + userSelect: "none", |
| 97 | + overflow: "hidden", |
| 98 | + color: "#1d1d1b", |
| 99 | + transition: "color 0.6s ease", |
| 100 | + }); |
| 101 | + bgEl.innerHTML = buildSVG(); |
| 102 | + document.body.appendChild(bgEl); |
| 103 | +} |
| 104 | + |
| 105 | +function applyDark(dark: boolean): void { |
| 106 | + if (!bgEl) return; |
| 107 | + bgEl.style.color = dark ? "#ffffff" : "#1d1d1b"; |
| 108 | + const p = bgEl.querySelector<SVGGElement>("#_pbg_p"); |
| 109 | + const g = bgEl.querySelector<SVGGElement>("#_pbg_g"); |
| 110 | + const s = bgEl.querySelector<SVGGElement>("#_pbg_s"); |
| 111 | + if (p) p.setAttribute("opacity", dark ? "0.12" : "0.09"); |
| 112 | + if (g) g.setAttribute("opacity", dark ? "0.18" : "0.14"); |
| 113 | + if (s) s.setAttribute("opacity", dark ? "0.15" : "0.11"); |
| 114 | +} |
| 115 | + |
| 116 | +// ─── Scroll-based dark detection ────────────────────────────────────────────── |
| 117 | +// Re-queries the DOM on every scroll so it always reflects the current page. |
| 118 | + |
| 119 | +function updateColor(): void { |
| 120 | + const vh = window.innerHeight; |
| 121 | + const darkEls = document.querySelectorAll<HTMLElement>("[data-section-bg='dark']"); |
| 122 | + let anyDark = false; |
| 123 | + darkEls.forEach((el) => { |
| 124 | + const { top, bottom } = el.getBoundingClientRect(); |
| 125 | + // Trigger when the dark section covers the middle 60 % of the viewport |
| 126 | + if (top < vh * 0.8 && bottom > vh * 0.2) anyDark = true; |
| 127 | + }); |
| 128 | + applyDark(anyDark); |
| 129 | +} |
| 130 | + |
| 131 | +let scrollListenerAdded = false; |
| 132 | + |
| 133 | +// ─── Route lifecycle ────────────────────────────────────────────────────────── |
| 134 | + |
| 135 | +export function onRouteDidUpdate(): void { |
| 136 | + mount(); |
| 137 | + |
| 138 | + // Add the scroll listener once (persists across SPA navigations) |
| 139 | + if (!scrollListenerAdded) { |
| 140 | + window.addEventListener("scroll", updateColor, { passive: true }); |
| 141 | + scrollListenerAdded = true; |
| 142 | + } |
| 143 | + |
| 144 | + // Always reset to light first, then re-check after the DOM settles |
| 145 | + applyDark(false); |
| 146 | + requestAnimationFrame(updateColor); |
| 147 | +} |
0 commit comments