From b7f423d08c7f8581c019cf69a66c8e55461b479c Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 23 Oct 2026 18:57:10 +0200 Subject: [PATCH] feat(mockups): 06 constellation (three.js) Songs as stars along a time-proportional path through deep space, saga nebulae, motif constellation arcs, fly-through camera, WebGL fallback. Co-Authored-By: Claude Opus 5.5 --- src/mockups/06-constellation/index.html | 20 +- src/mockups/06-constellation/layout.ts | 80 +++ src/mockups/06-constellation/main.ts | 384 ++++++++++++- src/mockups/06-constellation/scene.ts | 728 ++++++++++++++++++++++++ src/mockups/06-constellation/style.css | 399 ++++++++++++- 5 files changed, 1605 insertions(+), 6 deletions(-) create mode 100644 src/mockups/06-constellation/layout.ts create mode 100644 src/mockups/06-constellation/scene.ts diff --git a/src/mockups/06-constellation/index.html b/src/mockups/06-constellation/index.html index dd7638d..544a236 100644 --- a/src/mockups/06-constellation/index.html +++ b/src/mockups/06-constellation/index.html @@ -3,12 +3,30 @@ + Constellation (3D) — EPIC Timeline mockup -
+
+
+
+
+ +
+

EPIC, charted

+

Every song is a star on the voyage home. Every recurring theme is a constellation.

+
+ + + +
+ + + + +
diff --git a/src/mockups/06-constellation/layout.ts b/src/mockups/06-constellation/layout.ts new file mode 100644 index 0000000..2c26b83 --- /dev/null +++ b/src/mockups/06-constellation/layout.ts @@ -0,0 +1,80 @@ +// Pure layout for the constellation: where each song-star sits along the voyage path. +// Path distance is proportional to in-story time (YEAR units per year after Troy). Songs that share +// a year are staggered by STEP along the path in track order so they stay distinct but clustered. +import { songs, sagas } from "../../data/sample"; +import { motifsInSong } from "../../data/query"; +import type { Song } from "../../data/types"; + +export const YEAR = 64; // path units per in-story year +export const STEP = 9; // stagger between songs in the same year +export const START = 36; // path units before the first song (room for the intro flight) +export const TAIL = 48; // path units after the last song + +export interface StarSpec { + song: Song; + index: number; + dist: number; // distance along the path + lateral: [number, number]; // offset from the path in (normal, binormal) units + magnitude: number; // 1..n = number of distinct motifs (brightness) +} + +export function buildStars(): StarSpec[] { + const ordered = [...songs].sort((a, b) => a.track - b.track); + const t0 = ordered[0]?.storyTime.yearsAfterTroy ?? 0; + const seen = new Map(); + return ordered.map((song, index) => { + const y = song.storyTime.yearsAfterTroy; + const k = seen.get(y) ?? 0; + seen.set(y, k + 1); + const angle = index * 2.399963; // golden-angle scatter around the path + const r = 3.2 + (index % 3) * 1.6; + return { + song, + index, + dist: START + (y - t0) * YEAR + k * STEP, + lateral: [Math.cos(angle) * r, Math.sin(angle) * r * 0.7], + magnitude: motifsInSong(song).length, + }; + }); +} + +export function pathLength(stars: StarSpec[]): number { + const last = stars[stars.length - 1]; + return (last?.dist ?? 0) + TAIL; +} + +/** Integer in-story years spanned by the songs, with their path distance. */ +export function yearMarks(stars: StarSpec[]): { year: number; dist: number }[] { + const first = stars[0]; + const last = stars[stars.length - 1]; + if (!first || !last) return []; + const y0 = first.song.storyTime.yearsAfterTroy; + const y1 = last.song.storyTime.yearsAfterTroy; + const out: { year: number; dist: number }[] = []; + for (let y = y0; y <= y1; y++) out.push({ year: y, dist: START + (y - y0) * YEAR }); + return out; +} + +/** Gaps of more than one year between consecutive songs, labelled in the scene. */ +export function longGaps(stars: StarSpec[]): { years: number; from: number; to: number }[] { + const out: { years: number; from: number; to: number }[] = []; + for (let i = 1; i < stars.length; i++) { + const a = stars[i - 1]!; + const b = stars[i]!; + const years = b.song.storyTime.yearsAfterTroy - a.song.storyTime.yearsAfterTroy; + if (years >= 1) out.push({ years, from: a.dist, to: b.dist }); + } + return out; +} + +export function sagaRanges(stars: StarSpec[]) { + return [...sagas] + .sort((a, b) => a.order - b.order) + .map((saga) => { + const own = stars.filter((s) => s.song.sagaId === saga.id); + const from = Math.min(...own.map((s) => s.dist)); + const to = Math.max(...own.map((s) => s.dist)); + return { saga, from, to, stars: own }; + }) + .filter((r) => r.stars.length > 0); +} diff --git a/src/mockups/06-constellation/main.ts b/src/mockups/06-constellation/main.ts index 856977a..5750496 100644 --- a/src/mockups/06-constellation/main.ts +++ b/src/mockups/06-constellation/main.ts @@ -1,5 +1,383 @@ +import { motifs } from "../../data/sample"; +import { motifsInSong, sagaOf, songsWithMotif } from "../../data/query"; +import type { Motif, Song } from "../../data/types"; import { mountCredits } from "../../shared/credits"; +import { createAudioSlot } from "../../shared/audio-slot"; +import { createVideoCards } from "../../shared/video-cards"; +import { buildStars, longGaps, pathLength, yearMarks, type StarSpec } from "./layout"; +import { ConstellationScene, webglAvailable } from "./scene"; -mountCredits(); -const app = document.getElementById("app")!; -app.innerHTML = `

Constellation (3D)

stub

`; +const credits = mountCredits(); +const $ = (id: string) => document.getElementById(id) as T; +const sky = $("sky"); +const labelLayer = $("sky-labels"); +const legend = $("legend"); +const ruler = $("ruler"); +const panel = $("panel"); +const fallback = $("fallback"); + +const stars = buildStars(); +const reducedMq = window.matchMedia("(prefers-reduced-motion: reduce)"); +const narrowMq = window.matchMedia("(max-width: 760px)"); +const params = new URLSearchParams(location.search); + +let openIdx: number | null = null; +let motifId: string | null = null; +let nearestIdx = 0; +let scene: ConstellationScene | null = null; + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); +const motifById = (id: string) => motifs.find((m) => m.id === id); +const yearsText = (y: number) => (y === 0 ? "Year of the fall of Troy" : y === 1 ? "1 year after Troy" : `${y} years after Troy`); + +// Keep panels clear of the credits bar, whatever its wrapped height. +new ResizeObserver(() => { + document.documentElement.style.setProperty("--credits-h", `${credits.offsetHeight}px`); +}).observe(credits); + +// ------------------------------------------------------------------ legend + +function lineGlyph(m: Motif) { + const dash = m.kind === "character" ? ` stroke-dasharray="4 3"` : ""; + return ``; +} + +function renderLegend() { + const groups: [string, Motif[]][] = [ + ["Musical motifs", motifs.filter((m) => m.kind === "motif")], + ["Character themes", motifs.filter((m) => m.kind === "character")], + ]; + const sel = motifId ? motifById(motifId) : undefined; + legend.innerHTML = ` +

Constellations

+ ${groups.map(([name, list]) => ` +

${name}

+
    + ${list.map((m) => `
  • `).join("")} +
`).join("")} + ${sel ? `
+

${esc(sel.description)}

+

Heard in ${songsWithMotif(sel.id).length} songs:

+
    ${songsWithMotif(sel.id).map((s) => { + const parts = s.parts.filter((p) => p.motifIds.includes(sel.id)).map((p) => p.name).join(", "); + return `
  1. `; + }).join("")}
+ +
` + : `

Choose a constellation to trace a theme through the voyage. Brighter stars carry more themes.

`} + `; +} + +legend.addEventListener("click", (e) => { + const t = (e.target as HTMLElement).closest("[data-motif],[data-goto]"); + if (!t) return; + if (t.dataset.goto) { + const i = stars.findIndex((s) => s.song.id === t.dataset.goto); + if (i >= 0) openSong(i); + return; + } + selectMotif(t.dataset.motif && t.dataset.motif !== motifId ? t.dataset.motif : null); +}); + +function selectMotif(id: string | null) { + motifId = id; + renderLegend(); + scene?.setMotif(id); + // label each lit star with the parts that carry the motif + const m = id ? motifById(id) : undefined; + (scene?.starLabels ?? []).forEach((el, i) => { + const parts = m ? stars[i]!.song.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name) : []; + el.querySelector(".sky-label__parts")!.textContent = parts.join(", "); + el.style.setProperty("--c", m?.color ?? ""); + }); + for (const el of document.querySelectorAll(".catalogue__star")) { + const s = stars.find((x) => x.song.id === el.dataset.song)!; + const has = !!m && s.song.parts.some((p) => p.motifIds.includes(m.id)); + el.classList.toggle("is-lit", has); + el.classList.toggle("is-dim", !!m && !has); + el.style.setProperty("--c", m?.color ?? ""); + } + syncPanelMotif(); + renderRuler(); +} + +// ------------------------------------------------------------------ ruler (time scale) + +function renderRuler() { + const len = pathLength(stars); + const first = stars[0]!.dist - 10; + const pct = (d: number) => (((d - first) / (len - 6 - first)) * 100).toFixed(2); + const m = motifId ? motifById(motifId) : undefined; + ruler.innerHTML = ` +
+ ${yearMarks(stars).map((y) => `${y.year === 0 ? "Troy falls" : `Year ${y.year}`}`).join("")} + ${longGaps(stars).filter((g) => g.years > 1).map((g) => `${g.years} years pass`).join("")} + ${stars.map((s, i) => { + const lit = !!m && s.song.parts.some((p) => p.motifIds.includes(m.id)); + return ``; + }).join("")} + +
+

Scroll or drag to travelArrow keys hop star to starClick a star to open its song

`; + updateCursor(scene?.progress ?? stars[0]!.dist); +} + +function updateCursor(p: number) { + const cur = document.getElementById("ruler-cursor"); + if (!cur) return; + const len = pathLength(stars); + const first = stars[0]!.dist - 10; + cur.style.left = `${Math.max(0, Math.min(100, ((p + 4 - first) / (len - 6 - first)) * 100))}%`; +} + +// keyboard focus on a ruler star shows the same hover label as the pointer does +ruler.addEventListener("focusin", (e) => { + const b = (e.target as HTMLElement).closest("[data-i]"); + if (b) scene?.setHover(Number(b.dataset.i)); +}); +ruler.addEventListener("focusout", () => scene?.setHover(null)); + +ruler.addEventListener("click", (e) => { + const b = (e.target as HTMLElement).closest("[data-i]"); + if (b) { openSong(Number(b.dataset.i)); return; } + const track = ruler.querySelector(".ruler__track")!.getBoundingClientRect(); + if (e.clientY < track.top - 8 || e.clientY > track.bottom + 8) return; + const len = pathLength(stars); + const first = stars[0]!.dist - 10; + const f = (e.clientX - track.left) / track.width; + scene?.setProgress(first + f * (len - 6 - first) - 4); +}); + +// ------------------------------------------------------------------ song panel + +function partHtml(song: Song) { + return song.parts.map((p) => { + const chips = p.motifIds.map((id) => motifById(id)).filter((m): m is Motif => !!m) + .map((m) => ``).join(""); + return `
  • +
    ${esc(p.name)}${esc(p.singers.join(" and "))}
    +
    ${p.lines.map((l) => `

    ${esc(l)}

    `).join("")}
    +
    ${chips}
    +
  • `; + }).join(""); +} + +function renderPanel(i: number) { + const s = stars[i]!; + const song = s.song; + const saga = sagaOf(song); + const prev = stars[i - 1], next = stars[i + 1]; + const ref = song.odyssey + ? `Book ${song.odyssey.book}, lines ${esc(song.odyssey.lines.replace("-", "–"))}` + : `No direct counterpart in Homer`; + panel.innerHTML = ` +
    +
    +

    Star ${song.track} of ${stars.length}, in ${esc(saga.name)}

    +

    ${esc(song.title)}

    + +
    +
    +
    In the story
    ${esc(song.storyTime.label)}${yearsText(song.storyTime.yearsAfterTroy)}
    +
    In the Odyssey
    ${ref}
    +
    Themes
    ${motifsInSong(song).map((m) => ``).join("")}${motifsInSong(song).length} recurring
    +
    +
    +

    Parts

    +
      ${partHtml(song)}
    +
    +
    +

    A fact

    + ${song.facts.map((f) => `

    ${esc(f)}

    `).join("")} +
    +
    +

    Listen

    +
    +
    +
    +

    Watch

    +
    +
    +
    + `; + panel.setAttribute("aria-labelledby", "panel-title"); + panel.querySelector('[data-slot="audio"]')!.replaceWith(createAudioSlot(song)); + panel.querySelector('[data-slot="video"]')!.replaceWith(createVideoCards(song)); + syncPanelMotif(); +} + +function syncPanelMotif() { + for (const li of panel.querySelectorAll(".part")) { + const has = !!motifId && li.dataset.motifs!.split(" ").includes(motifId); + li.classList.toggle("is-lit", has); + li.classList.toggle("is-dim", !!motifId && !has); + li.style.setProperty("--c", motifId ? motifById(motifId)!.color : ""); + } + for (const c of panel.querySelectorAll("[data-chip]")) c.setAttribute("aria-pressed", String(c.dataset.chip === motifId)); +} + +panel.addEventListener("click", (e) => { + const t = e.target as HTMLElement; + if (t.closest("[data-close]")) { closeSong(); return; } + const chip = t.closest("[data-chip]"); + if (chip) { selectMotif(chip.dataset.chip === motifId ? null : chip.dataset.chip!); return; } + const step = t.closest("[data-step]"); + if (step) openSong(Number(step.dataset.step)); +}); + +function panelShift(): [number, number] { + if (panel.hidden) return [0, 0]; + if (narrowMq.matches) return [0, panel.offsetHeight / 2]; + return [panel.offsetWidth / 2, 0]; +} + +function openSong(i: number, instant = false) { + if (!stars[i]) return; + const wasOpen = openIdx !== null; + openIdx = i; + renderPanel(i); + panel.hidden = false; + document.body.classList.add("has-panel"); + scene?.setOpen(i); + scene?.flyTo(i, true, instant); + const [x, y] = panelShift(); + scene?.setViewShift(x, y, instant); + renderRuler(); + highlightCatalogue(); + if (!wasOpen) (panel.querySelector(".panel__close") as HTMLElement | null)?.focus({ preventScroll: true }); +} + +function closeSong() { + if (openIdx === null) return; + const i = openIdx; + openIdx = null; + panel.hidden = true; + document.body.classList.remove("has-panel"); + scene?.setOpen(null); + scene?.setViewShift(0, 0); + scene?.flyTo(i, false); + renderRuler(); + highlightCatalogue(); +} + +// ------------------------------------------------------------------ keyboard + +document.addEventListener("keydown", (e) => { + const target = e.target as HTMLElement; + if (target.closest("input, textarea, select")) return; + const inUi = !!target.closest(".panel, .legend, .ruler"); + const cur = openIdx ?? nearestIdx; + switch (e.key) { + case "ArrowRight": case "ArrowDown": case "PageDown": + if (inUi && e.key === "ArrowDown") return; + e.preventDefault(); + openIdx !== null ? openSong(Math.min(stars.length - 1, cur + 1)) : scene?.flyTo(Math.min(stars.length - 1, cur + 1), false); + break; + case "ArrowLeft": case "ArrowUp": case "PageUp": + if (inUi && e.key === "ArrowUp") return; + e.preventDefault(); + openIdx !== null ? openSong(Math.max(0, cur - 1)) : scene?.flyTo(Math.max(0, cur - 1), false); + break; + case "Home": e.preventDefault(); scene?.flyTo(0, false); break; + case "End": e.preventDefault(); scene?.flyTo(stars.length - 1, false); break; + case "Enter": + if (inUi) return; + openSong(cur); + break; + case "Escape": + if (openIdx !== null) closeSong(); + else if (motifId) selectMotif(null); + break; + } +}); + +// ------------------------------------------------------------------ WebGL fallback: a printed star catalogue + +function highlightCatalogue() { + for (const el of document.querySelectorAll(".catalogue__star")) { + el.setAttribute("aria-current", String(stars[openIdx ?? -1]?.song.id === el.dataset.song)); + } +} + +function renderFallback(list: StarSpec[]) { + document.body.classList.add("no-webgl"); + fallback.hidden = false; + const years = [...new Set(list.map((s) => s.song.storyTime.yearsAfterTroy))]; + fallback.innerHTML = ` +
    +

    The 3D star chart can’t start here

    +

    Your browser didn’t give this page WebGL, which the flight through the constellation needs. Turn on hardware acceleration in your browser settings, or open the page in another browser.

    +

    Meanwhile, here is the same chart as a flat star catalogue. Every song and theme still works.

    +
    +
      ${years.map((y) => ` +
    1. ${yearsText(y)}

      +
        ${list.filter((s) => s.song.storyTime.yearsAfterTroy === y).map((s) => ` +
      1. `).join("")} +
    2. `).join("")} +
    `; + fallback.addEventListener("click", (e) => { + const b = (e.target as HTMLElement).closest("[data-i]"); + if (b) openSong(Number(b.dataset.i)); + }); +} + +// ------------------------------------------------------------------ boot + +const intro = !params.has("song") && !params.has("hover") && !params.has("star"); +if (webglAvailable()) { + try { + scene = new ConstellationScene(sky, labelLayer, stars, { + onHover: () => {}, + onPick: (i) => openSong(i), + onProgress: (p, nearest) => { + nearestIdx = nearest; + updateCursor(p); + }, + }, { reducedMotion: reducedMq.matches, intro }); + // enrich star labels with the hover details + scene.starLabels.forEach((el, i) => { + const s = stars[i]!.song; + const meta = document.createElement("span"); + meta.className = "sky-label__meta"; + meta.innerHTML = `${esc(s.storyTime.label)}${esc(sagaOf(s).name)}Click to open`; + el.append(meta); + }); + } catch (err) { + console.warn("WebGL scene failed, showing the catalogue instead", err); + scene?.dispose(); + scene = null; + } +} +if (!scene) renderFallback(stars); + +renderLegend(); +renderRuler(); + +window.addEventListener("resize", () => { + if (openIdx === null) return; + const [x, y] = panelShift(); + scene?.setViewShift(x, y, true); +}); +window.addEventListener("pagehide", () => { scene?.dispose(); scene = null; }); + +// Screenshot / demo states: ?star= flies there, ?hover= shows a hovered star, +// ?motif= lights a constellation, ?song= opens a song. +const findIdx = (id: string | null) => stars.findIndex((s) => s.song.id === id); +const starAt = findIdx(params.get("star") ?? params.get("hover")); +if (starAt >= 0) scene?.flyTo(starAt, false, true); +const hoverAt = findIdx(params.get("hover")); +if (hoverAt >= 0) scene?.setHover(hoverAt); +const pm = params.get("motif"); +if (pm && motifById(pm)) selectMotif(pm); +const songAt = findIdx(params.get("song")); +if (songAt >= 0) openSong(songAt, true); diff --git a/src/mockups/06-constellation/scene.ts b/src/mockups/06-constellation/scene.ts new file mode 100644 index 0000000..0e39f40 --- /dev/null +++ b/src/mockups/06-constellation/scene.ts @@ -0,0 +1,728 @@ +// three.js star chart: the voyage path through deep space, song-stars, saga nebulae and motif +// constellation arcs. All DOM outside the canvas (panel, legend, ruler) lives in main.ts. +import * as THREE from "three"; +import { LineSegments2 } from "three/addons/lines/LineSegments2.js"; +import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js"; +import { LineMaterial } from "three/addons/lines/LineMaterial.js"; +import { motifs } from "../../data/sample"; +import type { Motif } from "../../data/types"; +import { longGaps, pathLength, sagaRanges, yearMarks, type StarSpec } from "./layout"; + +const BACK = 12; // camera trails the progress point by this much path distance +const AHEAD = 12; // and looks this far ahead +const HEIGHT = 8; // above the path +const SIDE = 17; // and off to its side, so the voyage reads left to right like a timeline +const PRE = 60; // curve extends this far before path distance 0 + +const SAGA_HUES: Record = { + troy: ["#c4893f", "#e7c27a"], + cyclops: ["#7c2d5c", "#c0486f"], + ocean: ["#25698a", "#47a9ac"], +}; +const FALLBACK_HUES: [string, string] = ["#6a6fa8", "#a7acd8"]; + +export interface SceneCallbacks { + onHover(index: number | null): void; + onPick(index: number): void; + onProgress(progress: number, nearest: number): void; +} + +/** A DOM label pinned to a world position. Lower rank wins when labels collide on screen. */ +export interface SceneLabel { + el: HTMLElement; pos: THREE.Vector3; fade: number; rank: number; star: boolean; + box?: { ox: number; oy: number; w: number; h: number }; +} + +/** Returns null when WebGL is unavailable so the caller can render the fallback. */ +export function webglAvailable(): boolean { + if (new URLSearchParams(location.search).has("nowebgl")) return false; + try { + const c = document.createElement("canvas"); + return !!(c.getContext("webgl2") ?? c.getContext("webgl")); + } catch { + return false; + } +} + +// Seeded RNG so the sky is the same on every load (and in every screenshot). +function rng(seed: number) { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const starVert = /* glsl */ ` + attribute float aSize; attribute vec3 aColor; attribute float aState; attribute vec4 aRing; + uniform float uScale; uniform float uTime; uniform float uPixelRatio; + varying vec3 vColor; varying float vState; varying vec4 vRing; + void main() { + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * mv; + float tw = 1.0 + 0.07 * sin(uTime * 1.3 + position.x * 2.7 + position.z); + float grow = aState > 1.5 ? 1.35 : 1.0; + gl_PointSize = clamp(aSize * grow * tw * uScale / -mv.z, 3.0, 110.0 * uPixelRatio); + vColor = aColor; vState = aState; vRing = aRing; + }`; + +const starFrag = /* glsl */ ` + varying vec3 vColor; varying float vState; varying vec4 vRing; + void main() { + vec2 p = gl_PointCoord * 2.0 - 1.0; + float r = length(p); + float core = smoothstep(0.13, 0.0, r); + float halo = exp(-r * r * 9.0) * 0.6; + float spikes = (exp(-abs(p.x) * 30.0) * exp(-abs(p.y) * 3.2) + exp(-abs(p.y) * 30.0) * exp(-abs(p.x) * 3.2)) * 0.5; + float k = vState < 0.5 ? 0.22 : (vState < 1.5 ? 0.85 : 1.25); + float a = (core + halo + spikes) * k; + float ring = (1.0 - smoothstep(0.0, 0.035, abs(r - 0.6))) * vRing.a; + a *= smoothstep(1.0, 0.8, r); + vec3 c = mix(vColor, vec3(1.0), core * 0.85) * a + vRing.rgb * ring; + gl_FragColor = vec4(c, clamp(a + ring, 0.0, 1.0)); + }`; + +const dustVert = /* glsl */ ` + attribute float aSize; attribute vec3 aColor; attribute float aAlpha; + uniform float uScale; uniform float uAttenuate; uniform float uPixelRatio; + varying vec3 vColor; varying float vAlpha; + void main() { + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * mv; + float s = uAttenuate > 0.5 ? aSize * uScale / -mv.z : aSize * uPixelRatio; + gl_PointSize = clamp(s, 1.0, 220.0 * uPixelRatio); + vColor = aColor; vAlpha = aAlpha; + }`; + +const dustFrag = /* glsl */ ` + uniform float uBoost; + varying vec3 vColor; varying float vAlpha; + void main() { + vec2 p = gl_PointCoord * 2.0 - 1.0; + float r2 = dot(p, p); + if (r2 > 1.0) discard; + float a = exp(-r2 * 4.0) * vAlpha * uBoost; + gl_FragColor = vec4(vColor * a, a); + }`; + +export class ConstellationScene { + readonly stars: StarSpec[]; + readonly starPos: THREE.Vector3[] = []; + readonly starLabels: HTMLElement[] = []; + + private renderer: THREE.WebGLRenderer; + private scene = new THREE.Scene(); + private camera = new THREE.PerspectiveCamera(52, 1, 0.1, 2400); + private curve: THREE.CatmullRomCurve3; + private curveLen: number; + private starGeo = new THREE.BufferGeometry(); + private starMat: THREE.ShaderMaterial; + private sizeUniforms: { value: number }[] = []; + private nebulae = new Map(); + private motifLines = new Map(); + private labels: SceneLabel[] = []; + private ac = new AbortController(); + private ro: ResizeObserver; + private raf = 0; + private clock = new THREE.Clock(); + private reduced: boolean; + private dirty = true; + + // camera rig state + private p = 0; + private pT = 0; + private yaw = 0; private yawT = 0; + private pitch = 0; private pitchT = 0; + private focusW = 0; private focusT = 0; + private focusPos = new THREE.Vector3(); + private shift = new THREE.Vector2(); private shiftT = new THREE.Vector2(); + private size = new THREE.Vector2(1, 1); + private lastNearest = -1; + + // interaction state + private hover: number | null = null; + private open: number | null = null; + private motif: string | null = null; + private pointer = { down: false, x: 0, y: 0, sx: 0, sy: 0, moved: false }; + + constructor( + private host: HTMLElement, + private labelLayer: HTMLElement, + stars: StarSpec[], + private cb: SceneCallbacks, + opts: { reducedMotion: boolean; intro: boolean }, + ) { + this.stars = stars; + this.reduced = opts.reducedMotion; + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: "high-performance" }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75)); + this.renderer.setClearColor(0x050816, 1); + this.renderer.domElement.className = "sky__canvas"; + this.renderer.domElement.setAttribute("aria-hidden", "true"); + host.prepend(this.renderer.domElement); + this.scene.fog = new THREE.FogExp2(0x050816, 0.0016); + + this.curve = this.buildCurve(pathLength(stars)); + this.curveLen = this.curve.getLength(); + stars.forEach((s) => this.starPos.push(this.starWorld(s))); + + this.starMat = new THREE.ShaderMaterial({ + vertexShader: starVert, + fragmentShader: starFrag, + uniforms: { uScale: { value: 1 }, uTime: { value: 0 }, uPixelRatio: { value: this.renderer.getPixelRatio() } }, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + this.sizeUniforms.push(this.starMat.uniforms.uScale!); + + this.buildBackdrop(); + this.buildPath(); + this.buildNebulae(); + this.buildConstellations(); + this.buildStars(); + this.buildLabels(); + + this.ro = new ResizeObserver(() => this.resize()); + this.ro.observe(host); + this.resize(); + this.bindInput(); + + const first = stars[0]?.dist ?? 0; + this.pT = first - 4; + if (opts.intro && !this.reduced) { + this.p = this.pT - 34; + this.pitch = 0.42; + this.yaw = -0.5; + } else { + this.p = this.pT; + } + document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()), { signal: this.ac.signal }); + this.start(); + } + + // ---------------------------------------------------------------- construction + + private buildCurve(len: number) { + const pts: THREE.Vector3[] = []; + for (let d = -PRE; d <= len + 70; d += 22) { + pts.push(new THREE.Vector3( + Math.sin(d * 0.012) * 16 + Math.sin(d * 0.005 + 1.2) * 12, + Math.sin(d * 0.011 + 0.7) * 8, + -d, + )); + } + return new THREE.CatmullRomCurve3(pts, false, "centripetal"); + } + + private u(d: number) { + return THREE.MathUtils.clamp((d + PRE) / this.curveLen, 0, 1); + } + pathPoint(d: number, out = new THREE.Vector3()) { + return this.curve.getPointAt(this.u(d), out); + } + private frame(d: number) { + const t = this.curve.getTangentAt(this.u(d)).normalize(); + const n = new THREE.Vector3().crossVectors(t, new THREE.Vector3(0, 1, 0)).normalize(); + const b = new THREE.Vector3().crossVectors(n, t).normalize(); + return { t, n, b }; + } + private starWorld(s: StarSpec) { + const { n, b } = this.frame(s.dist); + return this.pathPoint(s.dist).addScaledVector(n, s.lateral[0]).addScaledVector(b, s.lateral[1]); + } + + private dust(count: number, fill: (i: number, pos: Float32Array, col: Float32Array, size: Float32Array, alpha: Float32Array) => void, attenuate: boolean) { + const pos = new Float32Array(count * 3); + const col = new Float32Array(count * 3); + const size = new Float32Array(count); + const alpha = new Float32Array(count); + for (let i = 0; i < count; i++) fill(i, pos, col, size, alpha); + const geo = new THREE.BufferGeometry(); + geo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + geo.setAttribute("aColor", new THREE.BufferAttribute(col, 3)); + geo.setAttribute("aSize", new THREE.BufferAttribute(size, 1)); + geo.setAttribute("aAlpha", new THREE.BufferAttribute(alpha, 1)); + const mat = new THREE.ShaderMaterial({ + vertexShader: dustVert, + fragmentShader: dustFrag, + uniforms: { + uScale: { value: 1 }, uAttenuate: { value: attenuate ? 1 : 0 }, + uPixelRatio: { value: this.renderer.getPixelRatio() }, uBoost: { value: 1 }, + }, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + this.sizeUniforms.push(mat.uniforms.uScale!); + const pts = new THREE.Points(geo, mat); + pts.frustumCulled = false; + this.scene.add(pts); + return mat; + } + + private buildBackdrop() { + const r = rng(7); + const tints = [new THREE.Color("#cfd8ff"), new THREE.Color("#fff1d6"), new THREE.Color("#ffffff"), new THREE.Color("#ffd2b8")]; + const mid = this.pathPoint(pathLength(this.stars) / 2); + this.dust(3600, (i, pos, col, size, alpha) => { + const u = r() * 2 - 1, th = r() * Math.PI * 2, rad = 420 + r() * 700; + const s = Math.sqrt(1 - u * u); + pos.set([mid.x + rad * s * Math.cos(th), mid.y + rad * u * 0.75, mid.z + rad * s * Math.sin(th)], i * 3); + const c = tints[i % tints.length]!; + col.set([c.r, c.g, c.b], i * 3); + const big = r() > 0.97; + size[i] = big ? 4.5 + r() * 3 : 1.6 + r() * 2.2; + alpha[i] = big ? 0.9 : 0.25 + r() * 0.55; + }, false); + // near dust that drifts past the camera, for parallax while flying + const len = pathLength(this.stars); + this.dust(900, (i, pos, col, size, alpha) => { + const p = this.pathPoint(r() * (len + 40) - 20); + pos.set([p.x + (r() - 0.5) * 120, p.y + (r() - 0.5) * 70, p.z + (r() - 0.5) * 60], i * 3); + col.set([0.75, 0.8, 1], i * 3); + size[i] = 0.35 + r() * 0.5; + alpha[i] = 0.35 + r() * 0.4; + }, true); + } + + private buildPath() { + // The voyage track: an engraved dotted line, plus year meridians and gap captions. + const len = pathLength(this.stars); + const pts: number[] = []; + for (let d = 2; d < len; d += 1.1) { + const p = this.pathPoint(d); + pts.push(p.x, p.y, p.z); + } + const chart = new THREE.Color("#8d97c8"); + this.dust(pts.length / 3, (i, pos, col, size, alpha) => { + pos.set([pts[i * 3]!, pts[i * 3 + 1]!, pts[i * 3 + 2]!], i * 3); + col.set([chart.r, chart.g, chart.b], i * 3); + size[i] = 0.42; + alpha[i] = 0.9; + }, true); + + for (const mark of yearMarks(this.stars)) { + const { t, n, b } = this.frame(mark.dist - 5); + const c = this.pathPoint(mark.dist - 5); + const ring: number[] = []; + for (let i = 0; i <= 96; i++) { + const a = (i / 96) * Math.PI * 2; + const v = c.clone().addScaledVector(n, Math.cos(a) * 7).addScaledVector(b, Math.sin(a) * 7); + ring.push(v.x, v.y, v.z); + } + const rg = new THREE.BufferGeometry(); + rg.setAttribute("position", new THREE.Float32BufferAttribute(ring, 3)); + const line = new THREE.Line(rg, new THREE.LineDashedMaterial({ color: 0x8d97c8, dashSize: 0.6, gapSize: 0.9, transparent: true, opacity: 0.3 })); + line.computeLineDistances(); + this.scene.add(line); + void t; + const el = document.createElement("div"); + el.className = "sky-label sky-label--year"; + el.textContent = mark.year === 0 ? "The fall of Troy" : `Year ${mark.year} after Troy`; + this.addLabel(el, c.clone().addScaledVector(b, 7.8), 120, 4); + } + for (const gap of longGaps(this.stars)) { + const el = document.createElement("div"); + el.className = "sky-label sky-label--gap"; + const words = ["", "a year", "two years", "three years", "four years"]; + el.textContent = gap.years === 1 ? "a year passes" : `${words[gap.years] ?? `${gap.years} years`} pass`; + const d = (gap.from + gap.to) / 2; + const { b } = this.frame(d); + this.addLabel(el, this.pathPoint(d).addScaledVector(b, -2.2), 150, 2); + } + } + + private buildNebulae() { + for (const range of sagaRanges(this.stars)) { + const [a, b] = SAGA_HUES[range.saga.id] ?? FALLBACK_HUES; + const ca = new THREE.Color(a), cb = new THREE.Color(b); + const r = rng(range.saga.order * 101); + const from = range.from - 12, to = range.to + 12; + const tmp = new THREE.Color(); + const mat = this.dust(700, (i, pos, col, size, alpha) => { + const d = from + r() * (to - from); + const { n, b: bn } = this.frame(d); + const g = () => (r() + r() + r() - 1.5) / 1.5; // ~gaussian + const rad = 15 + r() * 6; + const p = this.pathPoint(d).addScaledVector(n, g() * rad * 1.3).addScaledVector(bn, g() * rad * 0.8); + p.z += g() * 8; + pos.set([p.x, p.y, p.z], i * 3); + tmp.copy(ca).lerp(cb, r() * r()); + col.set([tmp.r, tmp.g, tmp.b], i * 3); + const cloud = i % 3 === 0; + size[i] = cloud ? 40 + r() * 55 : 0.4 + r() * 0.6; + alpha[i] = cloud ? 0.028 + r() * 0.04 : 0.5 + r() * 0.45; + }, true); + this.nebulae.set(range.saga.id, mat); + + const el = document.createElement("div"); + el.className = "sky-label sky-label--saga"; + el.style.setProperty("--hue", b); + el.textContent = range.saga.name; + const mid = (range.from + range.to) / 2; + const { b: up } = this.frame(mid); + this.addLabel(el, this.pathPoint(mid).addScaledVector(up, 18), 170, 3); + } + } + + private buildConstellations() { + motifs.forEach((m, mi) => { + const lit = this.stars.filter((s) => s.song.parts.some((p) => p.motifIds.includes(m.id))); + if (lit.length < 2) return; + const theta = (mi / motifs.length) * Math.PI * 2; + const seg: number[] = []; + for (let i = 1; i < lit.length; i++) { + const A = this.starPos[lit[i - 1]!.index]!, B = this.starPos[lit[i]!.index]!; + const mid = A.clone().add(B).multiplyScalar(0.5); + const { n, b } = this.frame((lit[i - 1]!.dist + lit[i]!.dist) / 2); + const bow = 1.6 + A.distanceTo(B) * 0.07; + const ctrl = mid.addScaledVector(n, Math.cos(theta) * bow).addScaledVector(b, Math.sin(theta) * bow); + const curve = new THREE.QuadraticBezierCurve3(A, ctrl, B); + const pts = curve.getPoints(24); + // stop short of the stars so the arcs read as chart lines, not skewers + for (let k = 1; k < pts.length - 2; k++) seg.push(...pts[k]!.toArray(), ...pts[k + 1]!.toArray()); + } + const geo = new LineSegmentsGeometry(); + geo.setPositions(seg); + const mat = new LineMaterial({ + color: new THREE.Color(m.color).getHex(), + linewidth: 1.2, + transparent: true, + opacity: 0.22, + depthWrite: false, + dashed: m.kind === "character", + dashSize: 0.9, + gapSize: 0.7, + worldUnits: false, + }); + const line = new LineSegments2(geo, mat); + if (m.kind === "character") line.computeLineDistances(); + line.frustumCulled = false; + this.scene.add(line); + this.motifLines.set(m.id, { line, mat, motif: m }); + }); + } + + private buildStars() { + const n = this.stars.length; + const pos = new Float32Array(n * 3), col = new Float32Array(n * 3), size = new Float32Array(n); + const state = new Float32Array(n).fill(1), ring = new Float32Array(n * 4); + const warm = new THREE.Color("#fff3d8"); + this.stars.forEach((s, i) => { + pos.set(this.starPos[i]!.toArray(), i * 3); + const hue = new THREE.Color((SAGA_HUES[s.song.sagaId] ?? FALLBACK_HUES)[1]); + const c = warm.clone().lerp(hue, 0.45); + col.set([c.r, c.g, c.b], i * 3); + size[i] = 3.4 + s.magnitude * 1.05; + }); + this.starGeo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + this.starGeo.setAttribute("aColor", new THREE.BufferAttribute(col, 3)); + this.starGeo.setAttribute("aSize", new THREE.BufferAttribute(size, 1)); + this.starGeo.setAttribute("aState", new THREE.BufferAttribute(state, 1)); + this.starGeo.setAttribute("aRing", new THREE.BufferAttribute(ring, 4)); + const pts = new THREE.Points(this.starGeo, this.starMat); + pts.frustumCulled = false; + pts.renderOrder = 2; + this.scene.add(pts); + } + + private buildLabels() { + this.stars.forEach((s, i) => { + const el = document.createElement("div"); + el.className = "sky-label sky-label--star"; + el.dataset.song = s.song.id; + el.innerHTML = `${s.song.track}`; + el.querySelector(".sky-label__name")!.textContent = s.song.title; + this.starLabels.push(el); + this.addLabel(el, this.starPos[i]!, 150, 1, true); + }); + } + + private addLabel(el: HTMLElement, pos: THREE.Vector3, fade: number, rank: number, star = false) { + this.labelLayer.append(el); + this.labels.push({ el, pos, fade, rank, star }); + } + + // ---------------------------------------------------------------- public API + + get progressRange(): [number, number] { + return [(this.stars[0]?.dist ?? 0) - 10, (this.stars[this.stars.length - 1]?.dist ?? 0) + 4]; + } + + setProgress(p: number, instant = false) { + const [lo, hi] = this.progressRange; + this.pT = THREE.MathUtils.clamp(p, lo, hi); + if (this.open === null) this.focusT = 0; + if (instant || this.reduced) this.p = this.pT; + this.dirty = true; + } + nudge(delta: number) { + this.setProgress(this.pT + delta); + } + get progress() { return this.pT; } + + /** Fly to a star; `focus` pins the look-at on it (used while the song is open). */ + flyTo(index: number, focus: boolean, instant = false) { + const s = this.stars[index]; + if (!s) return; + this.focusPos.copy(this.starPos[index]!); + this.focusT = focus ? 1 : 0; + this.yawT = 0; this.pitchT = 0; + this.setProgress(s.dist - (focus ? 3 : 4), instant); + if (instant || this.reduced) { this.focusW = this.focusT; this.yaw = 0; this.pitch = 0; } + } + + /** Shift the projection centre (px) so the focused star clears an overlay panel. */ + setViewShift(x: number, y: number, instant = false) { + this.shiftT.set(x, y); + if (instant || this.reduced) this.shift.copy(this.shiftT); + this.dirty = true; + } + + setOpen(index: number | null) { + this.open = index; + if (index === null) this.focusT = 0; + this.applyStates(); + } + setHover(index: number | null) { + if (this.hover === index) return; + this.hover = index; + this.applyStates(); + this.cb.onHover(index); + } + setMotif(id: string | null) { + this.motif = id; + for (const [mid, { mat }] of this.motifLines) { + const on = id === mid; + mat.opacity = id === null ? 0.22 : on ? 1 : 0.05; + mat.linewidth = on ? 2.6 : 1.2; + } + const neb = id === null ? 1 : 0.55; + for (const mat of this.nebulae.values()) mat.uniforms.uBoost!.value = neb; + this.applyStates(); + } + + projectStar(index: number): { x: number; y: number; visible: boolean } { + const v = this.starPos[index]!.clone().project(this.camera); + return { x: (v.x * 0.5 + 0.5) * this.size.x, y: (-v.y * 0.5 + 0.5) * this.size.y, visible: v.z < 1 }; + } + + private applyStates() { + const state = this.starGeo.getAttribute("aState") as THREE.BufferAttribute; + const ring = this.starGeo.getAttribute("aRing") as THREE.BufferAttribute; + const m = this.motif ? motifs.find((x) => x.id === this.motif) : undefined; + const mc = m ? new THREE.Color(m.color) : null; + this.stars.forEach((s, i) => { + const has = m ? s.song.parts.some((p) => p.motifIds.includes(m.id)) : false; + let st = m ? (has ? 1.6 : 0) : 1; + if (i === this.hover || i === this.open) st = 2; + state.setX(i, st); + if (mc && has) ring.setXYZW(i, mc.r, mc.g, mc.b, 0.7); + else if (i === this.open) ring.setXYZW(i, 0.95, 0.92, 0.85, 0.55); + else ring.setXYZW(i, 0, 0, 0, 0); + this.starLabels[i]!.classList.toggle("is-hover", i === this.hover); + this.starLabels[i]!.classList.toggle("is-open", i === this.open); + this.starLabels[i]!.classList.toggle("is-dim", !!m && !has); + this.starLabels[i]!.classList.toggle("is-lit", !!m && has); + }); + state.needsUpdate = true; + ring.needsUpdate = true; + for (const l of this.labels) l.box = undefined; + this.dirty = true; + } + + // ---------------------------------------------------------------- input + + private pick(x: number, y: number): number | null { + let best: number | null = null; + let bestD = 26; + this.stars.forEach((_, i) => { + const p = this.projectStar(i); + if (!p.visible) return; + const d = Math.hypot(p.x - x, p.y - y); + if (d < bestD) { bestD = d; best = i; } + }); + return best; + } + + private bindInput() { + const el = this.renderer.domElement; + const sig = { signal: this.ac.signal }; + el.addEventListener("pointerdown", (e) => { + this.pointer = { down: true, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: false }; + el.setPointerCapture(e.pointerId); + }, sig); + el.addEventListener("pointermove", (e) => { + const rect = el.getBoundingClientRect(); + if (this.pointer.down) { + const dx = e.clientX - this.pointer.x, dy = e.clientY - this.pointer.y; + this.pointer.x = e.clientX; this.pointer.y = e.clientY; + if (Math.hypot(e.clientX - this.pointer.sx, e.clientY - this.pointer.sy) > 5) this.pointer.moved = true; + if (this.pointer.moved) { + // horizontal drag travels along the voyage, vertical drag tilts the view + this.nudge(-dx * 0.09); + this.yawT = THREE.MathUtils.clamp(this.yawT - dx * 0.0016, -0.6, 0.6); + this.pitchT = THREE.MathUtils.clamp(this.pitchT + dy * 0.004, -0.35, 0.7); + if (this.reduced) { this.yaw = this.yawT; this.pitch = this.pitchT; } + el.classList.add("is-dragging"); + } + return; + } + const hit = this.pick(e.clientX - rect.left, e.clientY - rect.top); + this.setHover(hit); + el.style.cursor = hit === null ? "grab" : "pointer"; + }, sig); + const end = (e: PointerEvent) => { + if (!this.pointer.down) return; + this.pointer.down = false; + el.classList.remove("is-dragging"); + if (!this.pointer.moved) { + const rect = el.getBoundingClientRect(); + const hit = this.pick(e.clientX - rect.left, e.clientY - rect.top); + if (hit !== null) this.cb.onPick(hit); + } + }; + el.addEventListener("pointerup", end, sig); + el.addEventListener("pointercancel", end, sig); + el.addEventListener("pointerleave", () => { if (!this.pointer.down) this.setHover(null); }, sig); + el.addEventListener("wheel", (e) => { + e.preventDefault(); + const d = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; + this.nudge(d * (e.deltaMode === 1 ? 1.2 : 0.045)); + }, { ...sig, passive: false }); + } + + // ---------------------------------------------------------------- frame loop + + private resize() { + const w = this.host.clientWidth || 1, h = this.host.clientHeight || 1; + this.size.set(w, h); + this.renderer.setSize(w, h, false); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + const scale = (h * this.renderer.getPixelRatio()) / (2 * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2))); + for (const u of this.sizeUniforms) u.value = scale; + for (const { mat } of this.motifLines.values()) mat.resolution.set(w, h); + this.dirty = true; + this.renderFrame(0); + } + + private start() { + if (this.raf || document.hidden) return; + this.clock.getDelta(); + const loop = () => { + this.raf = requestAnimationFrame(loop); + this.renderFrame(Math.min(this.clock.getDelta(), 0.1)); + }; + this.raf = requestAnimationFrame(loop); + } + private stop() { + cancelAnimationFrame(this.raf); + this.raf = 0; + } + + private tmpA = new THREE.Vector3(); + private tmpB = new THREE.Vector3(); + + private renderFrame(dt: number) { + const k = this.reduced ? 1 : 1 - Math.exp(-dt * 3.4); + const moving = + Math.abs(this.pT - this.p) > 0.002 || Math.abs(this.focusT - this.focusW) > 0.001 || + Math.abs(this.yawT - this.yaw) > 0.0005 || Math.abs(this.pitchT - this.pitch) > 0.0005 || + this.shift.distanceTo(this.shiftT) > 0.3; + if (!moving && !this.dirty && this.reduced) return; // reduced motion: render only on change + + this.p += (this.pT - this.p) * k; + this.focusW += (this.focusT - this.focusW) * k; + this.yaw += (this.yawT - this.yaw) * k; + this.pitch += (this.pitchT - this.pitch) * k; + this.shift.lerp(this.shiftT, k); + if (!this.pointer.down && this.open === null && !this.reduced) { + // drift back to the chart's natural heading after a drag + this.yawT *= 1 - dt * 0.6; + this.pitchT *= 1 - dt * 0.6; + } + if (!this.reduced) this.starMat.uniforms.uTime!.value += dt; + + const { n, b } = this.frame(this.p - BACK); + const base = this.pathPoint(this.p - BACK, this.tmpA).addScaledVector(b, HEIGHT).addScaledVector(n, SIDE); + const look = this.pathPoint(this.p + AHEAD, this.tmpB).lerp(this.focusPos, this.focusW); + const off = base.clone().sub(look); + if (this.focusW > 0) off.setLength(THREE.MathUtils.lerp(off.length(), 30, this.focusW)); + off.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.yaw); + const right = new THREE.Vector3().crossVectors(off, new THREE.Vector3(0, 1, 0)).normalize(); + off.applyAxisAngle(right, -this.pitch); + this.camera.position.copy(look).add(off); + this.camera.lookAt(look); + + const { x: w, y: h } = this.size; + if (Math.abs(this.shift.x) > 0.5 || Math.abs(this.shift.y) > 0.5) this.camera.setViewOffset(w, h, this.shift.x, this.shift.y, w, h); + else this.camera.clearViewOffset(); + this.camera.updateMatrixWorld(); + + this.renderer.render(this.scene, this.camera); + this.placeLabels(); + this.dirty = false; + + let nearest = 0, nd = Infinity; + this.stars.forEach((s, i) => { const d = Math.abs(s.dist - this.p - 4); if (d < nd) { nd = d; nearest = i; } }); + if (moving || nearest !== this.lastNearest) this.cb.onProgress(this.p, nearest); + this.lastNearest = nearest; + } + + private v = new THREE.Vector3(); + private placeLabels() { + const { x: w, y: h } = this.size; + const cam = this.camera.position; + const shown: { l: SceneLabel; x: number; y: number; dist: number; rank: number }[] = []; + for (const l of this.labels) { + this.v.copy(l.pos).project(this.camera); + const dist = cam.distanceTo(l.pos); + const behind = this.v.z > 1 || this.v.z < -1; + const x = (this.v.x * 0.5 + 0.5) * w, y = (-this.v.y * 0.5 + 0.5) * h; + const off = x < -200 || x > w + 200 || y < -100 || y > h + 100; + if (behind || off || dist > l.fade) { l.el.style.visibility = "hidden"; continue; } + const c = l.el.classList; + const rank = l.star && (c.contains("is-hover") || c.contains("is-open") || c.contains("is-lit")) ? 0 : l.rank; + shown.push({ l, x, y, dist, rank }); + } + // Greedy de-cluttering: nearest, most important labels claim their screen box first. + shown.sort((a, b) => a.rank - b.rank || a.dist - b.dist); + const taken: [number, number, number, number][] = []; + for (const { l, x, y, dist, rank } of shown) { + if (!l.box) l.box = { ox: l.el.offsetLeft, oy: l.el.offsetTop, w: l.el.offsetWidth, h: l.el.offsetHeight }; + const b = l.box; + const r: [number, number, number, number] = [x + b.ox, y + b.oy, x + b.ox + b.w, y + b.oy + b.h]; + const hit = rank > 0 && taken.some((t) => r[0] < t[2] && r[2] > t[0] && r[1] < t[3] && r[3] > t[1]); + if (hit) { l.el.style.visibility = "hidden"; continue; } + taken.push(r); + const near = l.star ? THREE.MathUtils.clamp((dist - 3) / 6, 0, 1) : 1; + l.el.style.visibility = "visible"; + l.el.style.opacity = String(Math.min(1, (1 - dist / l.fade) * 2.2) * near); + l.el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`; + l.el.style.zIndex = String(1000 - Math.round(dist)); + } + } + + dispose() { + this.stop(); + this.ac.abort(); + this.ro.disconnect(); + this.scene.traverse((o) => { + const any = o as THREE.Mesh; + any.geometry?.dispose(); + const m = any.material as THREE.Material | THREE.Material[] | undefined; + if (Array.isArray(m)) m.forEach((x) => x.dispose()); + else m?.dispose(); + }); + this.renderer.dispose(); + this.renderer.forceContextLoss(); + this.renderer.domElement.remove(); + for (const l of this.labels) l.el.remove(); + this.labels = []; + } +} diff --git a/src/mockups/06-constellation/style.css b/src/mockups/06-constellation/style.css index ef862b4..0fd200f 100644 --- a/src/mockups/06-constellation/style.css +++ b/src/mockups/06-constellation/style.css @@ -1,2 +1,397 @@ -main { padding: 2rem; font-family: system-ui, sans-serif; } -h1::before { content: "06 · "; } +/* 06 · Constellation — an engraved star atlas of the voyage, flown through in deep space. */ +@import "@fontsource/cormorant-garamond/latin-400.css"; +@import "@fontsource/cormorant-garamond/latin-400-italic.css"; +@import "@fontsource/cormorant-garamond/latin-500-italic.css"; +@import "@fontsource/cormorant-garamond/latin-600-italic.css"; +@import "@fontsource/inter/latin-400.css"; +@import "@fontsource/inter/latin-500.css"; + +:root { + color-scheme: dark; + --void: #050816; + --void-2: #0a1030; + --glass: rgba(8, 12, 34, 0.84); + --starlight: #f3ead2; + --ink: #e4e2ef; + --muted: #9aa1c4; + --chart: #8d97c8; + --rule: rgba(141, 151, 200, 0.3); + --rule-strong: rgba(141, 151, 200, 0.55); + --serif: "Cormorant Garamond", "Iowan Old Style", "Palatino Linotype", Georgia, serif; + --sans: "Inter", system-ui, sans-serif; + --credits-h: 3rem; + --gutter: clamp(1rem, 2vw, 1.75rem); + --panel-w: min(27rem, 34vw); + --ease: cubic-bezier(0.2, 0.7, 0.1, 1); +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + overflow: hidden; + background: radial-gradient(120% 90% at 70% 20%, var(--void-2), var(--void) 60%); + color: var(--ink); + font: 400 0.875rem/1.5 var(--sans); + -webkit-font-smoothing: antialiased; +} +button { font: inherit; color: inherit; } +:focus-visible { outline: 2px solid var(--starlight); outline-offset: 3px; } + +.atlas { position: fixed; inset: 0; padding: 0; } + +/* ---------------------------------------------------------------- sky */ +.sky { position: absolute; inset: 0; z-index: 0; isolation: isolate; } +.sky__canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; } +.sky__canvas.is-dragging { cursor: grabbing; } +.sky-labels { position: absolute; inset: 0 0 var(--credits-h); pointer-events: none; overflow: hidden; } + +.sky-label { position: absolute; left: 0; top: 0; will-change: transform; white-space: nowrap; visibility: hidden; } + +.sky-label--star { + display: grid; + grid-template-columns: auto auto; + column-gap: 0.4rem; + align-items: baseline; + margin: -0.85rem 0 0 1.1rem; + font-family: var(--serif); + color: var(--starlight); + text-shadow: 0 0 6px var(--void), 0 0 2px var(--void); + transition: color 0.2s; +} +.sky-label__num { font: 400 0.68rem var(--sans); color: var(--muted); font-variant-numeric: oldstyle-nums; } +.sky-label__name { font-style: italic; font-weight: 500; font-size: 1.12rem; letter-spacing: 0.01em; } +.sky-label__parts { + grid-column: 2; + font: 500 0.7rem var(--sans); + color: var(--c, var(--muted)); +} +.sky-label__parts:empty { display: none; } +.sky-label__meta { + grid-column: 2; + display: none; + flex-direction: column; + margin-top: 0.3rem; + padding: 0.45rem 0.6rem 0.5rem; + border-left: 1px solid var(--starlight); + background: linear-gradient(90deg, rgba(8, 12, 34, 0.9), rgba(8, 12, 34, 0)); + font: 400 0.75rem/1.45 var(--sans); + color: var(--ink); +} +.sky-label__cta { color: var(--starlight); margin-top: 0.2rem; font-weight: 500; } +.sky-label--star.is-hover .sky-label__name { font-size: 1.5rem; } +.sky-label--star.is-hover .sky-label__meta { display: flex; } +.sky-label--star.is-open .sky-label__name { text-decoration: underline 1px; text-underline-offset: 0.25em; } +.sky-label--star.is-dim { color: rgba(243, 234, 210, 0.35); } +.sky-label--star.is-dim .sky-label__num { opacity: 0.4; } +.sky-label--star.is-lit .sky-label__name { color: var(--c); } + +.sky-label--saga { + transform-origin: 0 0; + margin: 0 0 0 -8rem; + width: 16rem; + text-align: center; + white-space: normal; + font: italic 400 clamp(1.6rem, 2.6vw, 2.4rem)/1 var(--serif); + letter-spacing: 0.14em; + color: var(--hue); + opacity: 0.8; + text-shadow: 0 0 18px var(--void); +} +.sky-label--year { + margin: -1.6rem 0 0 -6rem; + width: 12rem; + text-align: center; + font: 400 0.72rem var(--sans); + letter-spacing: 0.06em; + color: var(--chart); +} +.sky-label--gap { + margin: 0.4rem 0 0 -5rem; + width: 10rem; + text-align: center; + font: italic 400 1rem var(--serif); + color: var(--muted); +} +.sky-label--gap::before, .sky-label--gap::after { content: "~"; margin: 0 0.4em; opacity: 0.6; } + +/* ---------------------------------------------------------------- masthead */ +.masthead { + position: absolute; + top: var(--gutter); + left: var(--gutter); + max-width: 21rem; + pointer-events: none; + text-shadow: 0 0 12px var(--void); +} +.masthead__title { + margin: 0; + font: italic 500 clamp(2.2rem, 3.4vw, 3.1rem)/0.95 var(--serif); + letter-spacing: 0.01em; + color: var(--starlight); +} +.masthead__lede { margin: 0.6rem 0 0; color: var(--muted); max-width: 19rem; } + +/* ---------------------------------------------------------------- legend */ +.legend { + position: absolute; + left: var(--gutter); + top: calc(var(--gutter) + 8.6rem); + width: 16rem; + max-height: calc(100% - var(--credits-h) - 8.6rem - 7.5rem - var(--gutter)); + overflow-y: auto; + padding: 0.9rem 0.9rem 1rem; + background: var(--glass); + border: 1px solid var(--rule); + outline: 1px solid var(--rule); + outline-offset: -5px; + backdrop-filter: blur(6px); + scrollbar-width: thin; +} +.legend__title { margin: 0 0 0.2rem; font: italic 500 1.35rem var(--serif); color: var(--starlight); } +.legend__group { margin: 0.7rem 0 0.25rem; font: 500 0.72rem var(--sans); color: var(--muted); } +.legend__list { list-style: none; margin: 0; padding: 0; } +.legend__motif { + display: grid; + grid-template-columns: 2.25rem 1fr auto; + gap: 0.5rem; + align-items: center; + width: 100%; + padding: 0.28rem 0.35rem; + background: none; + border: 0; + border-radius: 2px; + text-align: left; + cursor: pointer; +} +.legend__motif:hover { background: rgba(141, 151, 200, 0.12); } +.legend__motif[aria-pressed="true"] { background: color-mix(in srgb, var(--c) 18%, transparent); box-shadow: inset 2px 0 var(--c); } +.legend__name { font-size: 0.85rem; } +.legend__count { font-size: 0.72rem; color: var(--muted); font-variant-numeric: tabular-nums; } +.glyph { width: 2.25rem; height: 0.75rem; display: block; } +.legend__key { margin: 0.8rem 0 0; color: var(--muted); font-size: 0.78rem; } +.legend__detail { margin-top: 0.8rem; padding-top: 0.7rem; border-top: 1px solid var(--rule); font-size: 0.8rem; } +.legend__detail p { margin: 0 0 0.5rem; } +.legend__appears { color: var(--muted); } +.legend__songs { margin: 0 0 0.6rem; padding: 0; list-style: none; } +.legend__songs button { + display: flex; justify-content: space-between; gap: 0.5rem; width: 100%; + padding: 0.2rem 0.3rem; background: none; border: 0; text-align: left; cursor: pointer; +} +.legend__songs button:hover { background: rgba(141, 151, 200, 0.12); } +.legend__song { font: italic 500 1rem var(--serif); color: var(--c); } +.legend__parts { font-size: 0.72rem; color: var(--muted); white-space: nowrap; } +.legend__clear { background: none; border: 0; padding: 0; color: var(--starlight); text-decoration: underline 1px; text-underline-offset: 3px; cursor: pointer; font-size: 0.78rem; } + +/* ---------------------------------------------------------------- ruler */ +.ruler { + position: absolute; + left: calc(var(--gutter) + 1rem); + right: calc(var(--gutter) + 1rem); + bottom: calc(var(--credits-h) + 0.6rem); + padding-top: 1.6rem; + transition: right 0.35s var(--ease); +} +body.has-panel .ruler { right: calc(var(--panel-w) + var(--gutter) * 2); } +.ruler__track { position: relative; height: 1.4rem; border-bottom: 1px solid var(--rule-strong); } +.ruler__year { + position: absolute; bottom: -0.35rem; height: 0.7rem; border-left: 1px solid var(--chart); + padding-left: 0.35rem; transform: translateY(-1.45rem); + font-size: 0.7rem; color: var(--chart); white-space: nowrap; line-height: 0.7rem; +} +.ruler__gap { + position: absolute; bottom: -1.35rem; text-align: center; + font: italic 400 0.85rem var(--serif); color: var(--muted); + border-top: 1px dotted var(--muted); padding-top: 0.05rem; line-height: 1.1; +} +.ruler__star { + position: absolute; bottom: -0.65rem; width: 1.3rem; height: 1.3rem; margin-left: -0.65rem; + padding: 0; background: none; border: 0; cursor: pointer; display: grid; place-items: center; +} +.ruler__dot { width: 0.42rem; height: 0.42rem; border-radius: 50%; background: var(--starlight); box-shadow: 0 0 6px var(--starlight); transition: transform 0.2s; } +.ruler__star:hover .ruler__dot, .ruler__star:focus-visible .ruler__dot { transform: scale(1.6); } +.ruler__star.is-lit .ruler__dot { background: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 35%, transparent), 0 0 8px var(--c); } +.ruler__star.is-dim .ruler__dot { opacity: 0.3; } +.ruler__star.is-open .ruler__dot { box-shadow: 0 0 0 2px var(--void), 0 0 0 3px var(--starlight); } +.ruler__cursor { + position: absolute; bottom: -0.2rem; width: 0; height: 0; margin-left: -0.35rem; + border: 0.35rem solid transparent; border-bottom-color: var(--starlight); transform: translateY(100%); + pointer-events: none; +} +.ruler__hint { display: flex; flex-wrap: wrap; gap: 0.3rem 1.5rem; margin: 1.6rem 0 0; font-size: 0.72rem; color: var(--muted); } + +/* ---------------------------------------------------------------- panel */ +.panel { + position: absolute; + top: var(--gutter); + right: var(--gutter); + bottom: calc(var(--credits-h) + 0.6rem); + width: var(--panel-w); + display: flex; + flex-direction: column; + background: var(--glass); + border: 1px solid var(--rule-strong); + outline: 1px solid var(--rule); + outline-offset: -6px; + backdrop-filter: blur(10px); + animation: panel-in 0.45s var(--ease); + z-index: 5; +} +.panel[hidden] { display: none; } +@keyframes panel-in { from { opacity: 0; transform: translateX(1.5rem); } } +.panel__scroll { flex: 1; overflow-y: auto; padding: 1.4rem 1.5rem 1rem; scrollbar-width: thin; } +.panel__head { position: relative; padding-right: 2rem; } +.panel__where { margin: 0; color: var(--muted); font-size: 0.78rem; } +.panel__title { margin: 0.2rem 0 0; font: italic 500 2.4rem/1.02 var(--serif); color: var(--starlight); } +.panel__close { + position: absolute; top: -0.4rem; right: -0.5rem; width: 2rem; height: 2rem; + background: none; border: 1px solid var(--rule); border-radius: 50%; cursor: pointer; font-size: 1.2rem; line-height: 1; +} +.panel__close:hover { border-color: var(--starlight); } +.panel__meta { margin: 1.1rem 0 0; display: grid; gap: 0.55rem; } +.panel__meta div { display: grid; grid-template-columns: 6.5rem 1fr; gap: 0.75rem; align-items: baseline; } +.panel__meta dt { color: var(--muted); font-size: 0.75rem; } +.panel__meta dd { margin: 0; } +.panel__meta small { display: block; color: var(--muted); font-size: 0.72rem; } +.panel__meta a { color: var(--starlight); text-decoration: underline 1px; text-underline-offset: 3px; } +.panel__muted { color: var(--muted); font-style: italic; } +.panel__themes { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem; } +.panel__themes small { display: inline; margin-left: 0.3rem; } +.dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--c); box-shadow: 0 0 6px var(--c); } + +.panel__sec { margin-top: 1.4rem; } +.panel__sec h3 { + display: flex; align-items: center; gap: 0.6rem; + margin: 0 0 0.6rem; font: italic 500 1.2rem var(--serif); color: var(--starlight); +} +.panel__sec h3::after { content: ""; flex: 1; height: 1px; background: var(--rule); } + +.parts { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.7rem; } +.part { + padding: 0.6rem 0.7rem 0.7rem; + border-left: 2px solid var(--rule); + background: rgba(141, 151, 200, 0.05); + transition: opacity 0.25s, border-color 0.25s, background 0.25s; +} +.part.is-lit { border-left-color: var(--c); background: color-mix(in srgb, var(--c) 12%, transparent); } +.part.is-dim { opacity: 0.45; } +.part__head { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; } +.part__name { font-weight: 500; } +.part__singers { color: var(--muted); font-size: 0.78rem; text-align: right; } +.part__lines { margin: 0.35rem 0 0.5rem; font: italic 400 1.02rem/1.35 var(--serif); color: rgba(228, 226, 239, 0.8); } +.part__lines p { margin: 0; } +.part__chips { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.chip { + display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.12rem 0.55rem 0.15rem 0.45rem; + border: 1px solid color-mix(in srgb, var(--c) 60%, transparent); + border-radius: 999px; background: none; cursor: pointer; font-size: 0.74rem; +} +.chip::before { content: ""; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: var(--c); } +.chip--character { border-style: dashed; } +.chip:hover { background: color-mix(in srgb, var(--c) 16%, transparent); } +.chip[aria-pressed="true"] { background: var(--c); color: var(--void); border-color: var(--c); } +.chip[aria-pressed="true"]::before { background: var(--void); } + +.panel__fact p { margin: 0; font: italic 400 1.15rem/1.4 var(--serif); } + +.panel .audio-slot { border: 1px dashed var(--rule-strong); border-radius: 2px; color: var(--muted); font-size: 0.8rem; opacity: 1; } +.panel .video-cards { display: grid; gap: 0.4rem; } +.panel .video-card { + position: relative; padding: 0.55rem 0.75rem 0.55rem 2.2rem; border: 1px solid var(--rule-strong); border-radius: 2px; + color: var(--ink); font-size: 0.82rem; +} +.panel .video-card::before { + content: ""; position: absolute; left: 0.8rem; top: 50%; translate: 0 -50%; + border: 0.32rem solid transparent; border-left: 0.5rem solid var(--starlight); border-right: 0; +} +.panel .video-card:hover { border-color: var(--starlight); opacity: 1; } + +.panel__nav { display: grid; grid-template-columns: 1fr 1fr; border-top: 1px solid var(--rule); } +.panel__nav button { + padding: 0.6rem 1rem 0.7rem; background: none; border: 0; text-align: left; cursor: pointer; + font: italic 500 1rem var(--serif); color: var(--starlight); +} +.panel__nav button + button { text-align: right; border-left: 1px solid var(--rule); } +.panel__nav button:disabled { color: var(--muted); cursor: default; } +.panel__nav button:not(:disabled):hover { background: rgba(141, 151, 200, 0.1); } +.panel__nav small { display: block; font: 400 0.7rem var(--sans); color: var(--muted); } + +/* ---------------------------------------------------------------- credits (always visible) */ +.fan-credits { + position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; + padding: 0.7rem var(--gutter); + background: rgba(5, 8, 22, 0.92); + border-top: 1px solid var(--rule); + color: var(--muted); + font-size: 0.75rem; + opacity: 1; +} + +/* ---------------------------------------------------------------- WebGL fallback */ +.no-webgl .sky { background: + radial-gradient(1px 1px at 20% 30%, var(--starlight), transparent), + radial-gradient(1px 1px at 70% 60%, var(--starlight), transparent), + radial-gradient(1.5px 1.5px at 45% 80%, var(--starlight), transparent), + radial-gradient(1px 1px at 85% 20%, var(--chart), transparent), + radial-gradient(60% 50% at 30% 40%, rgba(196, 137, 63, 0.12), transparent), + radial-gradient(50% 50% at 75% 60%, rgba(37, 105, 138, 0.16), transparent); } +.no-webgl .ruler__hint { display: none; } +.no-webgl .ruler { padding-bottom: 1.5rem; } +.no-webgl .ruler__cursor { display: none; } +.fallback { + position: absolute; + left: calc(var(--gutter) + 18rem); + right: var(--gutter); + top: var(--gutter); + bottom: calc(var(--credits-h) + 6rem); + overflow-y: auto; + max-width: 44rem; +} +body.has-panel .fallback { right: calc(var(--panel-w) + var(--gutter) * 2); } +.fallback__note { + padding: 1.1rem 1.3rem; margin-bottom: 1.2rem; + background: var(--glass); border: 1px solid var(--rule-strong); outline: 1px solid var(--rule); outline-offset: -5px; +} +.fallback__note h2 { margin: 0 0 0.4rem; font: italic 500 1.7rem var(--serif); color: var(--starlight); } +.fallback__note p { margin: 0.3rem 0 0; max-width: 60ch; } +.catalogue, .catalogue ol { list-style: none; margin: 0; padding: 0; } +.catalogue__year h3 { margin: 1rem 0 0.3rem; font: 500 0.75rem var(--sans); color: var(--chart); border-bottom: 1px solid var(--rule); padding-bottom: 0.3rem; } +.catalogue__star { + display: grid; grid-template-columns: 1.4rem 1.6rem 1fr auto; gap: 0.6rem; align-items: center; + width: 100%; padding: 0.35rem 0.4rem; background: none; border: 0; text-align: left; cursor: pointer; +} +.catalogue__star:hover, .catalogue__star[aria-current="true"] { background: rgba(141, 151, 200, 0.12); } +.catalogue__mag { width: calc(0.25rem + var(--m) * 0.14rem); height: calc(0.25rem + var(--m) * 0.14rem); border-radius: 50%; background: var(--starlight); box-shadow: 0 0 8px var(--starlight); justify-self: center; } +.catalogue__num { color: var(--muted); font-size: 0.72rem; } +.catalogue__name { font: italic 500 1.2rem var(--serif); color: var(--starlight); } +.catalogue__saga { color: var(--muted); font-size: 0.75rem; } +.catalogue__star.is-lit .catalogue__name { color: var(--c); } +.catalogue__star.is-lit .catalogue__mag { background: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 35%, transparent); } +.catalogue__star.is-dim { opacity: 0.4; } + +/* ---------------------------------------------------------------- narrow screens */ +@media (max-width: 760px) { + :root { --panel-w: 100%; } + .masthead { max-width: 60vw; } + .masthead__lede { display: none; } + .legend { + top: auto; left: var(--gutter); right: var(--gutter); width: auto; + bottom: calc(var(--credits-h) + 5.6rem); + max-height: 30vh; padding: 0.6rem 0.7rem; + } + .legend__group, .legend__key { display: none; } + .legend__list { display: flex; flex-wrap: wrap; gap: 0.2rem; } + .legend__motif { grid-template-columns: 1.6rem auto auto; width: auto; } + .glyph { width: 1.6rem; } + .ruler__hint { display: none; } + .ruler { padding-bottom: 1.5rem; } + body.has-panel .ruler, body.has-panel .legend { display: none; } + .panel { left: 0; right: 0; top: auto; width: auto; height: 62vh; bottom: var(--credits-h); } + .fallback { left: var(--gutter); bottom: calc(var(--credits-h) + 16rem); top: 5rem; } +} + +@media (prefers-reduced-motion: reduce) { + .panel { animation: none; } + .ruler, .part, .ruler__dot, .sky-label--star { transition: none; } +}