diff --git a/src/mockups/07-sea-voyage/index.html b/src/mockups/07-sea-voyage/index.html index b7a3229..a5dd6ea 100644 --- a/src/mockups/07-sea-voyage/index.html +++ b/src/mockups/07-sea-voyage/index.html @@ -8,7 +8,24 @@ -
+
+
+ +
+

EPIC: The Musical

+

The voyage home, charted

+

Sail past every song. The longer the crossing, the more years pass in the story.

+

+
+ + + +
+ + + + +
diff --git a/src/mockups/07-sea-voyage/main.ts b/src/mockups/07-sea-voyage/main.ts index c737fea..fc48215 100644 --- a/src/mockups/07-sea-voyage/main.ts +++ b/src/mockups/07-sea-voyage/main.ts @@ -1,5 +1,408 @@ +import { motifs, songs } 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 { clampD, crossings, nearestStop, routeMax, routeMin, sagaSpans, stops, toUnit, yearsLabel, type Stop } from "./route"; +import { VoyageScene, webglAvailable } from "./scene"; -mountCredits(); -const app = document.getElementById("app")!; -app.innerHTML = `

Sea voyage (3D)

stub

`; +const credits = mountCredits(); +const $ = (id: string) => document.getElementById(id)!; +const sea = $("sea"); +const pennants = $("pennants"); +const logline = $("logline"); +const drawer = $("drawer"); +const fallback = $("fallback"); +const nowEl = $("now"); + +const params = new URLSearchParams(location.search); +const reducedMq = window.matchMedia("(prefers-reduced-motion: reduce)"); +const narrowMq = window.matchMedia("(max-width: 760px)"); +const SAIL_SPEED = 0.32; // sea lengths per second + +const esc = (s: string) => s.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`); +const motifById = (id: string) => motifs.find((m) => m.id === id); +const stopById = (id: string) => stops.find((s) => s.song.id === id); +const pct = (d: number) => `${(toUnit(d) * 100).toFixed(3)}%`; + +let scene: VoyageScene | null = null; +let target = routeMin + 0.15; +let current = target; +let sailing = !reducedMq.matches; +let motifId: string | null = null; +let openId: string | null = null; +let lastNear: string | null = null; +let raf = 0; +let last = 0; +let returnFocus: HTMLElement | null = null; + +/* ---------- credits always sit above everything; panels stop above them ---------- */ + +const footRo = new ResizeObserver(() => { + document.documentElement.style.setProperty("--foot-h", `${credits.offsetHeight}px`); +}); +footRo.observe(credits); + +/* ---------- motif pennants ---------- */ + +function renderPennants() { + const group = (kind: Motif["kind"], title: string) => ` +
+

${title}

+ +
`; + pennants.innerHTML = ` + ${group("motif", "Musical motifs")} + ${group("character", "Character themes")} +

Pick a motif to light every waypoint and part where it returns.

`; + pennants.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest("[data-motif]"); + if (btn) selectMotif(btn.dataset.motif === motifId ? null : btn.dataset.motif!); + if ((e.target as HTMLElement).closest("[data-clear-motif]")) selectMotif(null); + }); +} + +function selectMotif(id: string | null) { + motifId = id; + for (const b of pennants.querySelectorAll("[data-motif]")) b.setAttribute("aria-pressed", String(b.dataset.motif === id)); + const m = id ? motifById(id) : undefined; + const note = $("motif-note"); + if (m) { + const at = songsWithMotif(m.id).map((s) => s.track); + note.innerHTML = `${esc(m.description)} Flies at waypoints ${at.join(", ")}. `; + } else { + note.textContent = "Pick a motif to light every waypoint and part where it returns."; + } + for (const b of logline.querySelectorAll(".stop")) { + const song = stopById(b.dataset.song!)!.song; + const has = !!id && song.parts.some((p) => p.motifIds.includes(id)); + b.classList.toggle("is-lit", has); + b.classList.toggle("is-dim", !!id && !has); + b.style.setProperty("--motif", m?.color ?? ""); + } + for (const part of drawer.querySelectorAll(".part")) { + const has = !!id && (part.dataset.motifs ?? "").split(" ").includes(id); + part.classList.toggle("is-lit", has); + part.style.setProperty("--motif", m?.color ?? ""); + } + for (const chip of drawer.querySelectorAll("[data-motif]")) chip.setAttribute("aria-pressed", String(chip.dataset.motif === id)); + scene?.setMotif(id); +} + +/* ---------- the log line: the whole voyage to scale ---------- */ + +function renderLogline() { + const bands = sagaSpans.map((s) => ` +
+ ${esc(s.saga.name.replace(/^The /, "").replace(/ Saga$/, ""))} +
`).join(""); + const legs = crossings.map((c) => ` +
${yearsLabel(c.years)}
`).join(""); + const seenYears = new Set(); + const years = stops.filter((s) => !seenYears.has(s.years) && seenYears.add(s.years)).map((s) => + `Year ${s.years}`).join(""); + const dots = stops.map((s) => ` + `).join(""); + logline.innerHTML = ` +
+ + ${bands}${legs}${years}${dots} + +
+
+ +

Scroll, drag the sea or press ← → to sail. Each year in the story is a long crossing; songs in the same year sit one short hop apart.

+
`; + const track = $("track"); + track.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest(".stop"); + if (btn) { openSong(btn.dataset.song!); return; } + const r = track.getBoundingClientRect(); + const u = (e.clientX - r.left) / r.width; + sailTo(routeMin + u * (routeMax - routeMin)); + }); + track.addEventListener("pointerover", (e) => { + const btn = (e.target as HTMLElement).closest(".stop"); + scene?.setHovered(btn?.dataset.song ?? null); + }); + track.addEventListener("pointerleave", () => scene?.setHovered(null)); + track.addEventListener("focusin", (e) => scene?.setHovered((e.target as HTMLElement).closest(".stop")?.dataset.song ?? null)); + $("sail").addEventListener("click", () => setSailing(!sailing)); + setSailing(sailing); +} + +function setSailing(on: boolean) { + sailing = on; + if (on && target >= routeMax - 0.01) target = current = routeMin; + const btn = document.getElementById("sail"); + if (!btn) return; + btn.setAttribute("aria-pressed", String(on)); + btn.textContent = on ? "Drop anchor" : "Set sail"; +} + +function sailTo(d: number) { + setSailing(false); + target = clampD(d); + if (reducedMq.matches) current = target; +} + +/* ---------- song detail: a page of the ship's log ---------- */ + +function openSong(id: string, opts: { focus?: boolean } = {}) { + const stop = stopById(id); + if (!stop) return; + if (!openId) returnFocus = document.activeElement as HTMLElement | null; + openId = id; + sailTo(stop.d); + renderDrawer(stop); + drawer.hidden = false; + document.body.classList.add("has-drawer"); + scene?.setOpen(id); + updateShift(); + if (opts.focus !== false) $("drawer-title").focus({ preventScroll: true }); +} + +function closeSong() { + if (!openId) return; + openId = null; + drawer.hidden = true; + document.body.classList.remove("has-drawer"); + scene?.setOpen(null); + updateShift(); + returnFocus?.focus?.({ preventScroll: true }); +} + +function updateShift() { + scene?.setViewShift(openId && !narrowMq.matches ? drawer.offsetWidth / 2 : 0); +} + +function renderDrawer(stop: Stop) { + const song: Song = stop.song; + const saga = sagaOf(song); + const prev = stops[stop.index - 1]; + const next = stops[stop.index + 1]; + const odyssey = song.odyssey + ? `Book ${song.odyssey.book}, lines ${esc(song.odyssey.lines)}` + : `No direct counterpart in Homer`; + const chip = (m: Motif) => ``; + drawer.innerHTML = ` +
+
+

Waypoint ${song.track} of ${songs.length}, ${esc(saga.name)}

+

${esc(song.title)}

+
+
Story time
${esc(song.storyTime.label)}, year ${song.storyTime.yearsAfterTroy} after Troy
+
In the Odyssey
${odyssey}
+
Motifs aboard
${motifsInSong(song).map(chip).join("")}
+
+ +
+
+

Parts

+
    + ${song.parts.map((p) => ` +
  1. +

    ${esc(p.name)}

    Sung by ${esc(p.singers.join(" and "))}

    +
    ${p.lines.map((l) => `

    ${esc(l)}

    `).join("")}
    +
    ${p.motifIds.flatMap((mid) => motifById(mid) ?? []).map(chip).join("")}
    +
  2. `).join("")} +
+
+
+

Fun fact

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

${esc(f)}

`).join("")} +
+

Listen

+

Watch

+ +
`; + $("drawer-audio").append(createAudioSlot(song)); + $("drawer-videos").append(createVideoCards(song)); + selectMotif(motifId); +} + +drawer.addEventListener("click", (e) => { + const el = e.target as HTMLElement; + if (el.closest("#drawer-close")) { closeSong(); return; } + const go = el.closest("[data-go]"); + if (go) { openSong(go.dataset.go!); return; } + const chip = el.closest("[data-motif]"); + if (chip) selectMotif(chip.dataset.motif === motifId ? null : chip.dataset.motif!); +}); + +/* ---------- HUD: "now passing" + ship marker on the log line ---------- */ + +function updateHud() { + const mark = document.getElementById("shipmark"); + if (mark) mark.style.left = pct(current); + const near = nearestStop(current); + if (near.song.id !== lastNear) { + lastNear = near.song.id; + nowEl.innerHTML = `Nearest waypoint ${near.song.track}. ${esc(near.song.title)} ${esc(near.song.storyTime.label)}, year ${near.years}`; + for (const b of logline.querySelectorAll(".stop")) b.classList.toggle("is-near", b.dataset.song === near.song.id); + } +} + +/* ---------- input: scroll, drag, keys ---------- */ + +function bindInput() { + sea.addEventListener("wheel", (e) => { + e.preventDefault(); + const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; + sailTo(target + delta * (e.deltaMode === 1 ? 0.06 : 0.0035)); + }, { passive: false }); + + let drag: { x: number; y: number; moved: number; start: number } | null = null; + sea.addEventListener("pointerdown", (e) => { + drag = { x: e.clientX, y: e.clientY, moved: 0, start: target }; + sea.setPointerCapture(e.pointerId); + }); + sea.addEventListener("pointermove", (e) => { + if (drag) { + const dx = e.clientX - drag.x, dy = e.clientY - drag.y; + drag.moved = Math.max(drag.moved, Math.hypot(dx, dy)); + if (drag.moved > 5) { sailTo(drag.start + (-dx + dy) * 0.012); sea.classList.add("is-dragging"); } + return; + } + const id = scene?.pick(e.clientX, e.clientY) ?? null; + scene?.setHovered(id); + sea.classList.toggle("is-pointing", !!id); + }); + const end = (e: PointerEvent) => { + if (!drag) return; + const wasClick = drag.moved <= 5; + drag = null; + sea.classList.remove("is-dragging"); + if (wasClick) { + const id = scene?.pick(e.clientX, e.clientY); + if (id) openSong(id); + } + }; + sea.addEventListener("pointerup", end); + sea.addEventListener("pointercancel", () => { drag = null; sea.classList.remove("is-dragging"); }); + sea.addEventListener("pointerleave", () => { if (!drag) scene?.setHovered(null); }); + + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { closeSong(); return; } + const t = e.target as HTMLElement; + if (t.closest("input, textarea, select, .drawer, .pennants")) return; + const near = nearestStop(target); + const idx = stops.indexOf(near); + if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "PageDown") { + e.preventDefault(); + const next = near.d > target + 0.05 ? near : stops[idx + 1]; + if (next) (openId ? openSong(next.song.id, { focus: false }) : sailTo(next.d)); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp" || e.key === "PageUp") { + e.preventDefault(); + const prev = near.d < target - 0.05 ? near : stops[idx - 1]; + if (prev) (openId ? openSong(prev.song.id, { focus: false }) : sailTo(prev.d)); + } else if (e.key === "Home") { e.preventDefault(); sailTo(routeMin); } + else if (e.key === "End") { e.preventDefault(); sailTo(routeMax); } + else if (e.key === "Enter" && t === document.body) { openSong(near.song.id); } + }); + + narrowMq.addEventListener("change", updateShift); + window.addEventListener("resize", updateShift); + reducedMq.addEventListener("change", () => { if (reducedMq.matches) setSailing(false); }); +} + +/* ---------- render loop (paused when hidden) ---------- */ + +function tick(now: number) { + const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016); + last = now; + if (sailing) { + target = clampD(target + SAIL_SPEED * dt); + if (target >= routeMax) setSailing(false); + } + current += (target - current) * (reducedMq.matches ? 1 : Math.min(1, dt * 3.5)); + if (Math.abs(target - current) < 1e-4) current = target; + updateHud(); + scene?.frame(dt, current); + raf = requestAnimationFrame(tick); +} + +function start() { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } } +function stop() { cancelAnimationFrame(raf); raf = 0; } + +document.addEventListener("visibilitychange", () => (document.hidden ? stop() : start())); + +/* ---------- WebGL or the styled fallback ---------- */ + +function initScene(): boolean { + if (params.has("nogl") || !webglAvailable()) return false; + try { + scene = new VoyageScene(sea, { reducedMotion: reducedMq.matches }); + scene.setMotif(motifId); + scene.setOpen(openId); + updateShift(); + return true; + } catch { + scene = null; + return false; + } +} + +function showFallback() { + document.body.classList.add("no-gl"); + setSailing(false); + fallback.hidden = false; + fallback.innerHTML = ` +
+

The sea chart can't be drawn here

+

This view paints a 3D sea with WebGL, which this browser has switched off or doesn't support. Turn on hardware acceleration, or try another browser, to sail it.

+

Meanwhile the voyage log still works. Pick a waypoint below to read its song.

+
    + ${stops.map((s) => `
  1. ${esc(s.song.storyTime.label)}
  2. `).join("")} +
+
`; + fallback.addEventListener("click", (e) => { + const b = (e.target as HTMLElement).closest("[data-open]"); + if (b) openSong(b.dataset.open!); + }); +} + +window.addEventListener("pagehide", () => { + stop(); + scene?.dispose(); + scene = null; +}); +window.addEventListener("pageshow", (e) => { + if (e.persisted && !scene && !document.body.classList.contains("no-gl")) initScene(); + start(); +}); + +renderPennants(); +renderLogline(); +bindInput(); +if (!initScene()) showFallback(); + +const songParam = params.get("song"); +const motifParam = params.get("motif"); +if (motifParam && motifById(motifParam)) selectMotif(motifParam); +if (songParam && stopById(songParam)) { + openSong(songParam, { focus: false }); + current = target; +} else if (params.has("at")) { + setSailing(false); + target = current = clampD(Number(params.get("at")) || 0); +} +const hoverParam = params.get("hover"); +if (hoverParam) (scene as VoyageScene | null)?.setHovered(hoverParam); +updateHud(); +start(); diff --git a/src/mockups/07-sea-voyage/route.ts b/src/mockups/07-sea-voyage/route.ts new file mode 100644 index 0000000..f4be4a3 --- /dev/null +++ b/src/mockups/07-sea-voyage/route.ts @@ -0,0 +1,65 @@ +import { sagas, songs } from "../../data/sample"; +import { gapYears } from "../../data/query"; +import type { Saga, Song } from "../../data/types"; + +/** + * Route layout in "sea lengths". Distance along the route is in-story time: + * every year between two songs is YEAR_LENGTHS of open water. Songs that fall in + * the same year are moored one short SAME_YEAR_HOP apart so they stay clickable; + * the chart and the log line label every crossing with its years. + */ +export const YEAR_LENGTHS = 4; +export const SAME_YEAR_HOP = 1; +export const LEAD_IN = 1.4; +export const LEAD_OUT = 1.8; + +export interface Stop { song: Song; index: number; d: number; years: number } +export interface Crossing { from: Stop; to: Stop; years: number } +export interface SagaSpan { saga: Saga; first: Stop; last: Stop; centre: number } + +export const stops: Stop[] = []; +{ + let d = 0; + songs.forEach((song, index) => { + const prev = songs[index - 1]; + if (prev) { + const gap = gapYears(prev, song); + d += gap > 0 ? gap * YEAR_LENGTHS : SAME_YEAR_HOP; + } + stops.push({ song, index, d, years: song.storyTime.yearsAfterTroy }); + }); +} + +export const routeEnd = stops[stops.length - 1]?.d ?? 0; +export const routeMin = -LEAD_IN; +export const routeMax = routeEnd + LEAD_OUT; +export const routeSpan = routeMax - routeMin; + +/** 0..1 position of a route distance on the whole voyage. */ +export const toUnit = (d: number) => (d - routeMin) / routeSpan; +export const clampD = (d: number) => Math.min(routeMax, Math.max(routeMin, d)); + +export const crossings: Crossing[] = stops.slice(1).flatMap((to, i) => { + const from = stops[i]!; + const years = to.years - from.years; + return years > 0 ? [{ from, to, years }] : []; +}); + +export const sagaSpans: SagaSpan[] = sagas + .slice() + .sort((a, b) => a.order - b.order) + .flatMap((saga) => { + const own = stops.filter((s) => s.song.sagaId === saga.id); + const first = own[0]; + const last = own[own.length - 1]; + if (!first || !last) return []; + return [{ saga, first, last, centre: (first.d + last.d) / 2 }]; + }); + +export function nearestStop(d: number): Stop { + let best = stops[0]!; + for (const s of stops) if (Math.abs(s.d - d) < Math.abs(best.d - d)) best = s; + return best; +} + +export const yearsLabel = (y: number) => `${y} ${y === 1 ? "year" : "years"} at sea`; diff --git a/src/mockups/07-sea-voyage/scene.ts b/src/mockups/07-sea-voyage/scene.ts new file mode 100644 index 0000000..2eb92b9 --- /dev/null +++ b/src/mockups/07-sea-voyage/scene.ts @@ -0,0 +1,839 @@ +import * as THREE from "three"; +import { CSS2DObject, CSS2DRenderer } from "three/addons/renderers/CSS2DRenderer.js"; +import { motifs } from "../../data/sample"; +import { motifsInSong } from "../../data/query"; +import { crossings, sagaSpans, stops, toUnit, yearsLabel, type Stop } from "./route"; + +export function webglAvailable(): boolean { + try { + const c = document.createElement("canvas"); + return !!(c.getContext("webgl2") ?? c.getContext("webgl")); + } catch { + return false; + } +} + +/* ---------- shared wave field (GLSL and JS must agree) ---------- */ + +const STORM_R = 80; +const WAVE_GLSL = /* glsl */ ` +uniform float uTime; +uniform vec2 uStormC; +uniform float uStormR; +float seaWave(vec2 p) { + float k = 1.0 + 1.6 * (1.0 - smoothstep(0.0, uStormR, distance(p, uStormC))); + return k * (0.55 * sin(p.x * 0.09 + uTime * 0.8) + + 0.40 * sin(p.y * 0.12 - uTime * 0.6 + p.x * 0.03) + + 0.18 * sin((p.x + p.y) * 0.31 + uTime * 1.7)); +}`; + +function seaWave(x: number, z: number, t: number, storm: THREE.Vector2): number { + const dist = Math.hypot(x - storm.x, z - storm.y); + const s = Math.min(1, Math.max(0, dist / STORM_R)); + const k = 1 + 1.6 * (1 - s * s * (3 - 2 * s)); + return k * (0.55 * Math.sin(x * 0.09 + t * 0.8) + 0.4 * Math.sin(z * 0.12 - t * 0.6 + x * 0.03) + 0.18 * Math.sin((x + z) * 0.31 + t * 1.7)); +} + +/* ---------- saga atmospheres: Troy burns gold, the Cyclops coast is grey, the storm is wine-dark ---------- */ + +interface Atmos { top: THREE.Color; horizon: THREE.Color; deep: THREE.Color; crest: THREE.Color; sun: THREE.Color; sunI: number } +const atmos = (top: string, horizon: string, deep: string, crest: string, sun: string, sunI: number): Atmos => ({ + top: new THREE.Color(top), horizon: new THREE.Color(horizon), deep: new THREE.Color(deep), + crest: new THREE.Color(crest), sun: new THREE.Color(sun), sunI, +}); +const ATMOS: Record = { + troy: atmos("#6f9fb4", "#efc996", "#185a70", "#5fb0b0", "#ffd49c", 2.4), + cyclops: atmos("#6c8a92", "#c6d2cb", "#134656", "#4c9496", "#f2efe4", 1.9), + ocean: atmos("#2b2230", "#6e5466", "#3a1a2c", "#7d4a63", "#cdb7c4", 0.9), +}; +const FALLBACK_ATMOS = ATMOS.troy!; + +/* ---------- small helpers ---------- */ + +function hash(x: number, y: number, z: number): number { + const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453; + return s - Math.floor(s); +} + +const c = (hex: string) => new THREE.Color(hex); + +interface IslandOpts { radius: number; height: number; seed: number; plateau?: number; sand: string; rock: string; top: string } + +/** Low-poly island: jittered cone, coloured per face by height (sand, rock, scrub). */ +function islandMesh(o: IslandOpts): THREE.Mesh { + let g: THREE.BufferGeometry = new THREE.ConeGeometry(o.radius, o.height, 11, 6, true); + g.translate(0, o.height / 2 - 3, 0); + const pos = g.getAttribute("position") as THREE.BufferAttribute; + const top = o.plateau ? o.height * o.plateau - 3 : Infinity; + for (let i = 0; i < pos.count; i++) { + const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i); + const kx = Math.round(x * 10), ky = Math.round(y * 10), kz = Math.round(z * 10) + o.seed; + const j = 1 - Math.min(1, (y + 3) / o.height) * 0.6; + pos.setXYZ( + i, + x + (hash(kx, ky, kz) - 0.5) * o.radius * 0.28 * j, + Math.min(top, y + (hash(ky, kz, kx) - 0.5) * o.height * 0.14), + z + (hash(kz, kx, ky) - 0.5) * o.radius * 0.28 * j, + ); + } + g = g.toNonIndexed(); + const p = g.getAttribute("position") as THREE.BufferAttribute; + const colors = new Float32Array(p.count * 3); + const sand = c(o.sand), rock = c(o.rock), scrub = c(o.top), tmp = new THREE.Color(); + for (let f = 0; f < p.count; f += 3) { + const y = (p.getY(f) + p.getY(f + 1) + p.getY(f + 2)) / 3; + const n = hash(f, o.seed, 3); + if (y < 0.9) tmp.copy(sand); + else if (y > o.height * 0.38 - 3 && n > 0.35) tmp.copy(scrub); + else tmp.copy(rock); + tmp.offsetHSL(0, 0, (n - 0.5) * 0.06); + for (let k = 0; k < 3; k++) tmp.toArray(colors, (f + k) * 3); + } + g.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + g.computeVertexNormals(); + return new THREE.Mesh(g, new THREE.MeshStandardMaterial({ vertexColors: true, flatShading: true, roughness: 0.95 })); +} + +function solid(color: string, opts: THREE.MeshStandardMaterialParameters = {}) { + return new THREE.MeshStandardMaterial({ color, flatShading: true, roughness: 0.85, ...opts }); +} + +function label(className: string, html: string): CSS2DObject { + const el = document.createElement("div"); + el.className = className; + el.innerHTML = html; + return new CSS2DObject(el); +} + +/* ---------- ribbon on the water (route lane, motif wakes, ship wake) ---------- */ + +const RIBBON_VERT = /* glsl */ ` +${WAVE_GLSL} +uniform float uLift; +attribute float aAlong; +attribute float aAcross; +varying float vAlong; +varying float vAcross; +varying float vDist; +void main() { + vec3 p = position; + p.y += seaWave(p.xz) + uLift; + vec4 mv = modelViewMatrix * vec4(p, 1.0); + vDist = -mv.z; + vAlong = aAlong; + vAcross = aAcross; + gl_Position = projectionMatrix * mv; +}`; +const RIBBON_FRAG = /* glsl */ ` +uniform vec3 uColor; +uniform float uOpacity; +uniform float uFogFar; +uniform float uDash; +uniform float uTail; +varying float vAlong; +varying float vAcross; +varying float vDist; +void main() { + float ends = smoothstep(0.0, uTail, vAlong) * smoothstep(1.0, 0.92, vAlong); + float edge = 1.0 - smoothstep(0.45, 1.0, abs(vAcross)); + float fog = 1.0 - smoothstep(uFogFar * 0.5, uFogFar, vDist); + float dash = uDash > 0.0 ? step(0.45, fract(vAlong * uDash)) : 1.0; + float a = uOpacity * ends * edge * fog * dash; + if (a < 0.003) discard; + gl_FragColor = vec4(uColor, a); +}`; + +/* ---------- the scene ---------- */ + +export interface VoyageSceneOptions { reducedMotion: boolean } + +interface Waypoint { + stop: Stop; + group: THREE.Group; + flags: { mesh: THREE.Mesh; motifId: string; phase: number }[]; + ring: THREE.Mesh; + focusRing: THREE.Mesh; + tag: HTMLElement; + hit: THREE.Mesh; + base: THREE.Vector3; + heading: number; +} + +export class VoyageScene { + private renderer: THREE.WebGLRenderer; + private labels: CSS2DRenderer; + private farLabels: CSS2DObject[] = []; + private scene = new THREE.Scene(); + private camera = new THREE.PerspectiveCamera(46, 1, 0.5, 1400); + private curve: THREE.CatmullRomCurve3; + private waveU = { uTime: { value: 0 }, uStormC: { value: new THREE.Vector2() }, uStormR: { value: STORM_R } }; + private seaU = { uDeep: { value: new THREE.Color() }, uCrest: { value: new THREE.Color() } }; + private skyU = { uTop: { value: new THREE.Color() }, uHorizon: { value: new THREE.Color() }, uSun: { value: new THREE.Color() }, uSunDir: { value: new THREE.Vector3(0.4, 0.35, -0.85).normalize() } }; + private fog = new THREE.Fog(0xffffff, 80, 330); + private hemi = new THREE.HemisphereLight(0xffffff, 0x224455, 1.3); + private sun = new THREE.DirectionalLight(0xffffff, 2); + private sky: THREE.Mesh; + private ship = new THREE.Group(); + private wake!: { mesh: THREE.Mesh; geo: THREE.BufferGeometry }; + private waypoints: Waypoint[] = []; + private streaks: { mesh: THREE.Mesh; motifId: string }[] = []; + private trails: { mesh: THREE.Mesh; motifId: string }[] = []; + private smoke: THREE.Mesh[] = []; + private ribbonMats: THREE.ShaderMaterial[] = []; + private rain: THREE.LineSegments | null = null; + private stormCentre = new THREE.Vector3(); + private raycaster = new THREE.Raycaster(); + private ro: ResizeObserver; + private time = 2.2; + private shift = 0; + private shiftTarget = 0; + private flash = 0; + private nextFlash = 5; + private motif: string | null = null; + private hovered: string | null = null; + private open: string | null = null; + private tmpA = new THREE.Vector3(); + private tmpB = new THREE.Vector3(); + private tmpC = new THREE.Vector3(); + + constructor(private host: HTMLElement, private opts: VoyageSceneOptions) { + this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75)); + this.renderer.domElement.className = "sea__canvas"; + this.renderer.domElement.setAttribute("aria-hidden", "true"); + host.append(this.renderer.domElement); + this.labels = new CSS2DRenderer(); + this.labels.domElement.className = "sea__labels"; + host.append(this.labels.domElement); + + this.scene.fog = this.fog; + this.scene.add(this.hemi, this.sun); + this.sun.position.set(80, 120, -160); + + this.curve = this.buildCurve(); + const oceanStop = stops.find((s) => s.song.sagaId === "ocean") ?? stops[stops.length - 1]!; + this.stormCentre.copy(this.at(oceanStop.d)); + this.waveU.uStormC.value.set(this.stormCentre.x, this.stormCentre.z); + + this.sky = this.buildSky(); + this.buildSea(); + this.buildRouteLane(); + this.buildIslands(); + this.buildWaypoints(); + this.buildMotifWakes(); + this.buildShip(); + this.buildCrossingLabels(); + + this.ro = new ResizeObserver(() => this.resize()); + this.ro.observe(host); + this.resize(); + } + + /* ----- route geometry ----- */ + + private buildCurve(): THREE.CatmullRomCurve3 { + const pts: THREE.Vector3[] = []; + for (let i = 0; i < 13; i++) { + pts.push(new THREE.Vector3(20 * Math.sin(i * 0.7) + 5 * Math.sin(i * 1.9), 0, -i * 30)); + } + const curve = new THREE.CatmullRomCurve3(pts, false, "centripetal"); + curve.arcLengthDivisions = 2400; + curve.updateArcLengths(); + return curve; + } + + private u(d: number) { return Math.min(1, Math.max(0, toUnit(d))); } + private at(d: number, out = new THREE.Vector3()) { return this.curve.getPointAt(this.u(d), out); } + private tangent(d: number, out = new THREE.Vector3()) { + const a = this.curve.getPointAt(Math.max(0, this.u(d) - 0.012), this.tmpA); + const b = this.curve.getPointAt(Math.min(1, this.u(d) + 0.012), this.tmpB); + return out.subVectors(b, a).setY(0).normalize(); + } + /** Starboard (right-hand) direction at route distance d. */ + private side(d: number, out = new THREE.Vector3()) { + const t = this.tangent(d, this.tmpC); + return out.set(-t.z, 0, t.x); + } + + /* ----- sky + sea ----- */ + + private buildSky(): THREE.Mesh { + const mat = new THREE.ShaderMaterial({ + side: THREE.BackSide, depthWrite: false, fog: false, + uniforms: this.skyU, + vertexShader: /* glsl */ `varying vec3 vDir; void main(){ vDir = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`, + fragmentShader: /* glsl */ ` + uniform vec3 uTop; uniform vec3 uHorizon; uniform vec3 uSun; uniform vec3 uSunDir; varying vec3 vDir; + void main(){ + float h = smoothstep(-0.02, 0.45, vDir.y); + vec3 col = mix(uHorizon, uTop, h); + float s = max(dot(normalize(vDir), uSunDir), 0.0); + col += uSun * (pow(s, 24.0) * 0.55 + pow(s, 400.0) * 0.8); + gl_FragColor = vec4(col, 1.0); + }`, + }); + const sky = new THREE.Mesh(new THREE.SphereGeometry(1000, 32, 16), mat); + sky.renderOrder = -1; + this.scene.add(sky); + return sky; + } + + private buildSea() { + const g = new THREE.PlaneGeometry(1300, 1300, 170, 170); + const mat = new THREE.MeshStandardMaterial({ color: 0xffffff, flatShading: true, roughness: 0.62, metalness: 0.05 }); + mat.onBeforeCompile = (shader) => { + Object.assign(shader.uniforms, this.waveU, this.seaU); + shader.vertexShader = shader.vertexShader + .replace("#include ", `#include \n${WAVE_GLSL}\nvarying float vH;`) + .replace("#include ", `#include + vec4 seaWp = modelMatrix * vec4(transformed, 1.0); + float seaH = seaWave(seaWp.xz); + transformed.z += seaH; + vH = seaH;`); + shader.fragmentShader = shader.fragmentShader + .replace("#include ", `#include \nuniform vec3 uDeep;\nuniform vec3 uCrest;\nvarying float vH;`) + .replace("#include ", `#include + diffuseColor.rgb = mix(uDeep, uCrest, smoothstep(-1.3, 1.5, vH)); + diffuseColor.rgb = mix(diffuseColor.rgb, vec3(0.93, 0.95, 0.93), smoothstep(1.05, 1.6, vH) * 0.55 * (1.0 - 0.5 * smoothstep(1.8, 2.6, vH)));`); + }; + const sea = new THREE.Mesh(g, mat); + sea.rotation.x = -Math.PI / 2; + const mid = this.at(stops[Math.floor(stops.length / 2)]?.d ?? 0); + sea.position.set(mid.x, 0, mid.z); + this.scene.add(sea); + } + + private ribbonMaterial(color: string, opacity: number, lift: number, extra: { dash?: number; tail?: number } = {}) { + const mat = new THREE.ShaderMaterial({ + transparent: true, depthWrite: false, side: THREE.DoubleSide, + uniforms: { + ...this.waveU, + uLift: { value: lift }, uColor: { value: c(color) }, uOpacity: { value: opacity }, + uFogFar: { value: this.fog.far }, uDash: { value: extra.dash ?? 0 }, uTail: { value: extra.tail ?? 0.12 }, + }, + vertexShader: RIBBON_VERT, fragmentShader: RIBBON_FRAG, + }); + this.ribbonMats.push(mat); + return mat; + } + + /** Writes a ribbon between route distances d0..d1 into geo (allocating if needed). */ + private ribbon(d0: number, d1: number, offset: number, width: (k: number) => number, segs: number, geo?: THREE.BufferGeometry) { + const g = geo ?? new THREE.BufferGeometry(); + const n = segs + 1; + let pos = g.getAttribute("position") as THREE.BufferAttribute | undefined; + if (!pos) { + pos = new THREE.BufferAttribute(new Float32Array(n * 2 * 3), 3); + pos.setUsage(geo ? THREE.DynamicDrawUsage : THREE.StaticDrawUsage); + const along = new Float32Array(n * 2), across = new Float32Array(n * 2), idx: number[] = []; + for (let i = 0; i < n; i++) { + along[i * 2] = along[i * 2 + 1] = i / segs; + across[i * 2] = -1; across[i * 2 + 1] = 1; + if (i < segs) { const a = i * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); } + } + g.setAttribute("position", pos); + g.setAttribute("aAlong", new THREE.BufferAttribute(along, 1)); + g.setAttribute("aAcross", new THREE.BufferAttribute(across, 1)); + g.setIndex(idx); + } + const p = new THREE.Vector3(), s = new THREE.Vector3(); + for (let i = 0; i < n; i++) { + const k = i / segs; + const d = d0 + (d1 - d0) * k; + this.at(d, p); + this.side(d, s); + const w = width(k) / 2; + pos.setXYZ(i * 2, p.x + s.x * (offset - w), 0, p.z + s.z * (offset - w)); + pos.setXYZ(i * 2 + 1, p.x + s.x * (offset + w), 0, p.z + s.z * (offset + w)); + } + pos.needsUpdate = true; + g.computeBoundingSphere(); + return g; + } + + private buildRouteLane() { + const first = stops[0]!.d - 1.4, last = stops[stops.length - 1]!.d + 1.8; + const geo = this.ribbon(first, last, 0, () => 0.55, 900); + const lane = new THREE.Mesh(geo, this.ribbonMaterial("#f4f7f3", 0.55, 0.45, { dash: 260, tail: 0.02 })); + lane.frustumCulled = false; + this.scene.add(lane); + } + + /* ----- islands ----- */ + + private buildIslands() { + const troy = sagaSpans.find((s) => s.saga.id === "troy"); + const cyclops = sagaSpans.find((s) => s.saga.id === "cyclops"); + const ocean = sagaSpans.find((s) => s.saga.id === "ocean"); + const place = (d: number, lateral: number) => this.at(d).addScaledVector(this.side(d), lateral); + + if (troy) { + const g = new THREE.Group(); + const centreD = troy.first.d + 4.4; // just past the opening cluster, at the start of the first long crossing + g.position.copy(place(centreD, 36)); + g.add(islandMesh({ radius: 22, height: 17, seed: 11, plateau: 0.72, sand: "#e2cf9f", rock: "#b49a74", top: "#8a8a52" })); + const topY = 17 * 0.72 - 3; + const wall = solid("#d7c3a0"); + for (let i = 0; i < 14; i++) { + const a = (i / 14) * Math.PI * 2; + const seg = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.6, 1.1), wall); + seg.position.set(Math.cos(a) * 8.5, topY + 1.2, Math.sin(a) * 8.5); + seg.rotation.y = -a + Math.PI / 2; + g.add(seg); + if (i % 3 === 0) { + const tower = new THREE.Mesh(new THREE.CylinderGeometry(1.3, 1.5, 5, 6), wall); + tower.position.set(Math.cos(a) * 8.6, topY + 2.4, Math.sin(a) * 8.6); + g.add(tower); + } + } + const house = solid("#c9b28c"); + for (let i = 0; i < 9; i++) { + const h = new THREE.Mesh(new THREE.BoxGeometry(2.2 + hash(i, 1, 2) * 2, 1.6 + hash(i, 2, 1) * 2.2, 2.4), house); + h.position.set((hash(i, 3, 3) - 0.5) * 10, topY + 1, (hash(3, i, 3) - 0.5) * 10); + g.add(h); + } + // The wooden horse, left on the shore facing the sea lane. + const wood = solid("#8a5a32"); + const horse = new THREE.Group(); + const part = (w: number, h: number, dpt: number, x: number, y: number, z: number, rx = 0) => { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, dpt), wood); + m.position.set(x, y, z); m.rotation.x = rx; horse.add(m); + }; + part(2.2, 2.2, 5, 0, 3.4, 0); + part(1.6, 3, 1.6, 0, 5.4, 2.4, -0.5); + part(1.4, 1.3, 2.6, 0, 6.8, 3.4); + for (const [x, z] of [[-0.7, -1.8], [0.7, -1.8], [-0.7, 1.8], [0.7, 1.8]] as const) part(0.5, 2.6, 0.5, x, 1.2, z); + part(3, 0.4, 6, 0, 0.1, 0); + const toLane = this.at(centreD).sub(g.position).setY(0).normalize(); + horse.position.copy(toLane.clone().multiplyScalar(15)).setY(0.4); + horse.scale.setScalar(0.85); + horse.lookAt(horse.position.clone().add(toLane)); + g.add(horse); + // Troy still burning: slow smoke columns. + const smokeMat = new THREE.MeshStandardMaterial({ color: "#5d5650", flatShading: true, transparent: true, opacity: 0.55, depthWrite: false }); + for (let i = 0; i < 12; i++) { + const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(2.4 + hash(i, 9, 9) * 2.5, 0), smokeMat); + puff.userData = { phase: i / 12, x: (hash(i, 5, 5) - 0.5) * 6, z: (hash(5, i, 5) - 0.5) * 6, base: topY + 3 }; + g.add(puff); + this.smoke.push(puff); + } + this.scene.add(g); + this.sagaLabel(troy.saga.name, "Troy", `Tracks ${troy.first.song.track} to ${troy.last.song.track}`, g.position.clone().setY(27)); + } + + if (cyclops) { + const g = new THREE.Group(); + g.position.copy(place(cyclops.centre, -44)); + const basalt = { sand: "#b8ab8c", rock: "#5d5a55", top: "#56603f" }; + g.add(islandMesh({ radius: 25, height: 38, seed: 23, ...basalt })); + const peak2 = islandMesh({ radius: 16, height: 24, seed: 41, ...basalt }); + peak2.position.set(14, 0, -12); + g.add(peak2); + const toLane = this.at(cyclops.centre).sub(g.position).setY(0).normalize(); + const cave = new THREE.Mesh(new THREE.CircleGeometry(4.8, 7, 0, Math.PI), new THREE.MeshBasicMaterial({ color: "#120e0d" })); + cave.position.copy(toLane.clone().multiplyScalar(17.5)).setY(1.4); + cave.lookAt(cave.position.clone().add(toLane)); + g.add(cave); + const boulder = new THREE.Mesh(new THREE.DodecahedronGeometry(2.6, 0), solid("#76716a")); + boulder.position.copy(toLane.clone().multiplyScalar(19)).add(new THREE.Vector3(5, 1.8, 0)); + g.add(boulder); + const wool = solid("#f1efe8"); + for (let i = 0; i < 6; i++) { + const sheep = new THREE.Mesh(new THREE.IcosahedronGeometry(0.9, 0), wool); + const a = Math.atan2(toLane.z, toLane.x) + (i - 2.5) * 0.22; + sheep.position.set(Math.cos(a) * 20.5, 1.4 + hash(i, 7, 1) * 0.8, Math.sin(a) * 20.5); + g.add(sheep); + } + this.scene.add(g); + this.sagaLabel(cyclops.saga.name, "The Cyclops' island", `Tracks ${cyclops.first.song.track} to ${cyclops.last.song.track}`, g.position.clone().setY(44)); + } + + if (ocean) { + // No island: the Ocean saga is open water under a storm cell. + const cloud = new THREE.MeshStandardMaterial({ color: "#3b3340", flatShading: true, roughness: 1 }); + const centre = this.stormCentre; + for (let i = 0; i < 18; i++) { + const r = 7 + hash(i, 4, 4) * 9; + const m = new THREE.Mesh(new THREE.IcosahedronGeometry(r, 0), cloud); + m.position.set(centre.x + (hash(i, 1, 8) - 0.5) * 90, 42 + hash(i, 8, 1) * 14, centre.z + (hash(8, i, 1) - 0.5) * 90); + m.scale.y = 0.55; + this.scene.add(m); + } + const rainPts = new Float32Array(500 * 6); + for (let i = 0; i < 500; i++) { + const x = centre.x + (hash(i, 2, 7) - 0.5) * 120, z = centre.z + (hash(7, i, 2) - 0.5) * 120, y = hash(2, 7, i) * 40; + rainPts.set([x, y, z, x - 0.4, y - 3.2, z], i * 6); + } + const rg = new THREE.BufferGeometry(); + rg.setAttribute("position", new THREE.BufferAttribute(rainPts, 3)); + this.rain = new THREE.LineSegments(rg, new THREE.LineBasicMaterial({ color: "#c9bccb", transparent: true, opacity: 0.35 })); + this.scene.add(this.rain); + const stack = islandMesh({ radius: 5, height: 13, seed: 77, sand: "#5b4f55", rock: "#3d3438", top: "#463c42" }); + stack.position.copy(place(ocean.centre, -22)); + this.scene.add(stack); + this.sagaLabel(ocean.saga.name, "The open sea", `Track ${ocean.first.song.track}`, centre.clone().setY(34)); + } + + // Scattered islets for scale. + for (let i = 0; i < 16; i++) { + const d = stops[0]!.d - 1 + hash(i, 6, 6) * (stops[stops.length - 1]!.d + 3); + const sideSign = hash(6, i, 6) > 0.5 ? 1 : -1; + const m = islandMesh({ radius: 4 + hash(i, 3, 9) * 8, height: 4 + hash(9, 3, i) * 10, seed: 100 + i, sand: "#d9c9a2", rock: "#9f8e74", top: "#7b8250" }); + m.position.copy(place(d, sideSign * (72 + hash(i, 9, 3) * 90))); + this.scene.add(m); + } + } + + private sagaLabel(sagaName: string, place: string, tracks: string, at: THREE.Vector3) { + const l = label("isle-label", `${place}${sagaName}, ${tracks.toLowerCase()}`); + l.position.copy(at); + this.scene.add(l); + this.farLabels.push(l); + } + + private buildCrossingLabels() { + for (const cr of crossings) { + const mid = (cr.from.d + cr.to.d) / 2; + const l = label("crossing-label", `${yearsLabel(cr.years)}`); + l.position.copy(this.at(mid).addScaledVector(this.side(mid), -10)).setY(2.5); + this.scene.add(l); + this.farLabels.push(l); + } + } + + /* ----- waypoints ----- */ + + private buildWaypoints() { + const buoyMat = solid("#eef1ec"); + const bandMat = solid("#1d5f86"); + const mastMat = solid("#3a2e28"); + const pennant = new THREE.BufferGeometry(); + pennant.setAttribute("position", new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, -1.25, 0, 2.8, -0.62, 0]), 3)); + const ringGeo = new THREE.RingGeometry(2.4, 3.1, 40); + ringGeo.rotateX(-Math.PI / 2); + const focusGeo = new THREE.RingGeometry(3.5, 3.85, 40); + focusGeo.rotateX(-Math.PI / 2); + const hitGeo = new THREE.SphereGeometry(5.5, 8, 6); + const hitMat = new THREE.MeshBasicMaterial({ visible: false }); + + for (const stop of stops) { + const group = new THREE.Group(); + const base = this.at(stop.d).addScaledVector(this.side(stop.d), 6); + group.position.copy(base); + const t = this.tangent(stop.d); + const heading = Math.atan2(t.x, t.z); + group.rotation.y = heading; + + const buoy = new THREE.Mesh(new THREE.CylinderGeometry(0.9, 1.25, 1.6, 8), buoyMat); + buoy.position.y = 0.3; + const band = new THREE.Mesh(new THREE.CylinderGeometry(1.02, 1.1, 0.4, 8), bandMat); + band.position.y = 0.55; + const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.13, 8.5, 5), mastMat); + mast.position.y = 4.8; + group.add(buoy, band, mast); + + const flags: Waypoint["flags"] = []; + motifsInSong(stop.song).forEach((m, i) => { + const mesh = new THREE.Mesh(pennant, new THREE.MeshBasicMaterial({ color: m.color, side: THREE.DoubleSide, transparent: true })); + mesh.position.set(0.08, 8.9 - i * 1.45, 0); + group.add(mesh); + flags.push({ mesh, motifId: m.id, phase: stop.index * 0.7 + i * 1.3 }); + }); + + const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({ color: "#ffffff", transparent: true, opacity: 0.9, depthWrite: false, side: THREE.DoubleSide })); + ring.position.y = 0.45; + ring.visible = false; + const focusRing = new THREE.Mesh(focusGeo, new THREE.MeshBasicMaterial({ color: "#f7f8f5", transparent: true, opacity: 0.95, depthWrite: false, side: THREE.DoubleSide })); + focusRing.position.y = 0.5; + focusRing.visible = false; + group.add(ring, focusRing); + + const hit = new THREE.Mesh(hitGeo, hitMat); + hit.position.y = 4; + hit.userData.songId = stop.song.id; + group.add(hit); + + const tag = label("wp-tag", `${stop.song.track}${stop.song.title}${stop.song.storyTime.label}, year ${stop.years}`); + tag.position.set(0, 11, 0); + group.add(tag); + + this.scene.add(group); + this.waypoints.push({ stop, group, flags, ring, focusRing, tag: tag.element, hit, base, heading }); + } + } + + private lane(motifId: string) { + const i = motifs.findIndex((m) => m.id === motifId); + return 2.4 + (i - (motifs.length - 1) / 2) * 0.95; + } + + private buildMotifWakes() { + for (const m of motifs) { + const at = stops.filter((s) => s.song.parts.some((p) => p.motifIds.includes(m.id))); + if (!at.length) continue; + const off = this.lane(m.id); + for (const s of at) { + const geo = this.ribbon(s.d - 0.55, s.d + 0.15, off, () => 0.7, 24); + const mesh = new THREE.Mesh(geo, this.ribbonMaterial(m.color, 0.5, 0.5, { tail: 0.7 })); + this.scene.add(mesh); + this.streaks.push({ mesh, motifId: m.id }); + } + const a = at[0]!.d - 0.6, b = at[at.length - 1]!.d + 0.4; + const geo = this.ribbon(a, b, off, () => 0.9, Math.max(24, Math.round((b - a) * 30))); + const mesh = new THREE.Mesh(geo, this.ribbonMaterial(m.color, 0, 0.55, { tail: 0.03 })); + mesh.visible = false; + this.scene.add(mesh); + this.trails.push({ mesh, motifId: m.id }); + } + } + + /* ----- ship ----- */ + + private buildShip() { + const s = this.ship; + const outline = new THREE.Shape(); + outline.moveTo(0, 4.6); + outline.quadraticCurveTo(1.7, 1.6, 1.55, -1.6); + outline.quadraticCurveTo(1.3, -3.4, 0, -3.8); + outline.quadraticCurveTo(-1.3, -3.4, -1.55, -1.6); + outline.quadraticCurveTo(-1.7, 1.6, 0, 4.6); + const hullGeo = new THREE.ExtrudeGeometry(outline, { depth: 1.5, bevelEnabled: false, curveSegments: 5 }); + hullGeo.rotateX(Math.PI / 2); // shape y becomes +z (bow forward), extrusion runs down -y + hullGeo.translate(0, 1.1, 0); + const hp = hullGeo.getAttribute("position") as THREE.BufferAttribute; + for (let i = 0; i < hp.count; i++) if (hp.getY(i) < 0.5) hp.setX(i, hp.getX(i) * 0.35); + hullGeo.computeVertexNormals(); + const hull = new THREE.Mesh(hullGeo, solid("#2b2522")); + const stripe = new THREE.Mesh(new THREE.BoxGeometry(3.25, 0.28, 6.2), solid("#1d5f86")); + stripe.position.set(0, 0.95, 0.3); + s.add(hull, stripe); + const stern = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.3, 3.2, 5), solid("#2b2522")); + stern.position.set(0, 2.2, -3.9); + stern.rotation.x = -0.6; + s.add(stern); + // Painted eyes on the bow. + for (const side of [-1, 1]) { + const eye = new THREE.Mesh(new THREE.CircleGeometry(0.32, 10), new THREE.MeshBasicMaterial({ color: "#f3f1ea" })); + eye.position.set(side * 0.62, 0.8, 3.45); + eye.rotation.y = side * 1.15; + const pupil = new THREE.Mesh(new THREE.CircleGeometry(0.15, 8), new THREE.MeshBasicMaterial({ color: "#14100e" })); + pupil.position.set(0, 0, 0.01); + eye.add(pupil); + s.add(eye); + } + const wood = solid("#6d4a30"); + for (const side of [-1, 1]) { + for (let i = 0; i < 5; i++) { + const oar = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.08, 0.16), wood); + oar.position.set(side * 2.6, 0.55, -1.8 + i * 1.05); + oar.rotation.z = side * -0.35; + s.add(oar); + } + } + const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.14, 8, 5), wood); + mast.position.y = 4.8; + const yard = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 6.2, 5), wood); + yard.rotation.z = Math.PI / 2; + yard.position.y = 7.9; + s.add(mast, yard); + + const cv = document.createElement("canvas"); + cv.width = 64; cv.height = 64; + const ctx = cv.getContext("2d"); + if (ctx) { + ctx.fillStyle = "#efe9dc"; ctx.fillRect(0, 0, 64, 64); + ctx.fillStyle = "#1d5f86"; + for (const x of [10, 27, 44]) ctx.fillRect(x, 0, 9, 64); + } + const tex = new THREE.CanvasTexture(cv); + tex.colorSpace = THREE.SRGBColorSpace; + const sailGeo = new THREE.PlaneGeometry(5.8, 4.4, 6, 4); + const sp = sailGeo.getAttribute("position") as THREE.BufferAttribute; + for (let i = 0; i < sp.count; i++) { + const x = sp.getX(i) / 2.9, y = sp.getY(i) / 2.2; + sp.setZ(i, (1 - x * x) * (1 - 0.4 * y * y) * 0.9); + } + sailGeo.computeVertexNormals(); + const sail = new THREE.Mesh(sailGeo, new THREE.MeshStandardMaterial({ map: tex, side: THREE.DoubleSide, flatShading: true, roughness: 1 })); + sail.position.set(0, 5.6, 0.2); + s.add(sail); + s.scale.setScalar(0.95); + this.scene.add(s); + + const geo = new THREE.BufferGeometry(); + const mesh = new THREE.Mesh(geo, this.ribbonMaterial("#f6f8f4", 0.5, 0.6, { tail: 0.85 })); + mesh.frustumCulled = false; + this.scene.add(mesh); + this.wake = { mesh, geo }; + } + + /* ----- public API ----- */ + + setMotif(id: string | null) { + this.motif = id; + const colour = id ? motifs.find((m) => m.id === id)?.color : undefined; + for (const w of this.waypoints) { + const has = !!id && w.flags.some((f) => f.motifId === id); + for (const f of w.flags) { + const mat = f.mesh.material as THREE.MeshBasicMaterial; + mat.opacity = !id || f.motifId === id ? 1 : 0.18; + f.mesh.scale.setScalar(f.motifId === id ? 1.55 : 1); + } + w.ring.visible = has; + if (has && colour) (w.ring.material as THREE.MeshBasicMaterial).color.set(colour); + w.tag.classList.toggle("is-lit", has); + w.tag.classList.toggle("is-dim", !!id && !has); + } + for (const s of this.streaks) { + (s.mesh.material as THREE.ShaderMaterial).uniforms.uOpacity!.value = !id ? 0.5 : s.motifId === id ? 0.95 : 0.06; + } + for (const t of this.trails) { + t.mesh.visible = t.motifId === id; + (t.mesh.material as THREE.ShaderMaterial).uniforms.uOpacity!.value = t.motifId === id ? 0.6 : 0; + } + } + + setHovered(id: string | null) { + this.hovered = id; + for (const w of this.waypoints) w.tag.classList.toggle("is-hover", w.stop.song.id === id); + } + + setOpen(id: string | null) { + this.open = id; + for (const w of this.waypoints) w.tag.classList.toggle("is-open", w.stop.song.id === id); + } + + /** Shift the projection so the ship stays centred in the area left of an open drawer. */ + setViewShift(px: number) { this.shiftTarget = px; } + + pick(clientX: number, clientY: number): string | null { + const r = this.renderer.domElement.getBoundingClientRect(); + const ndc = new THREE.Vector2(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1); + this.raycaster.setFromCamera(ndc, this.camera); + const hits = this.raycaster.intersectObjects(this.waypoints.map((w) => w.hit), false); + return (hits[0]?.object.userData.songId as string | undefined) ?? null; + } + + resize() { + const w = Math.max(1, this.host.clientWidth), h = Math.max(1, this.host.clientHeight); + this.renderer.setSize(w, h); + this.labels.setSize(w, h); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + } + + /** Advance one frame with the ship at route distance d. */ + frame(dt: number, d: number) { + const animate = !this.opts.reducedMotion; + if (animate) this.time += dt; + const t = this.time; + this.waveU.uTime.value = t; + const storm = this.waveU.uStormC.value; + + // Atmosphere blends by proximity to each saga's stretch of the route. + let wsum = 0; + const mix = { top: new THREE.Color(0, 0, 0), horizon: new THREE.Color(0, 0, 0), deep: new THREE.Color(0, 0, 0), crest: new THREE.Color(0, 0, 0), sun: new THREE.Color(0, 0, 0), sunI: 0 }; + let stormW = 0; + for (const span of sagaSpans) { + const a = ATMOS[span.saga.id] ?? FALLBACK_ATMOS; + const lo = span.first.d - 1.5, hi = span.last.d + 1.5; + const dist = d < lo ? lo - d : d > hi ? d - hi : 0; + const w = Math.exp(-((dist / 2.6) ** 2)); + wsum += w; + if (span.saga.id === "ocean") stormW = w; + for (const k of ["top", "horizon", "deep", "crest", "sun"] as const) mix[k].add(a[k].clone().multiplyScalar(w)); + mix.sunI += a.sunI * w; + } + const inv = 1 / Math.max(wsum, 1e-4); + stormW *= inv; + this.skyU.uTop.value.copy(mix.top.multiplyScalar(inv)); + this.skyU.uHorizon.value.copy(mix.horizon.multiplyScalar(inv)); + this.skyU.uSun.value.copy(mix.sun.multiplyScalar(inv)); + this.seaU.uDeep.value.copy(mix.deep.multiplyScalar(inv)); + this.seaU.uCrest.value.copy(mix.crest.multiplyScalar(inv)); + this.fog.color.copy(this.skyU.uHorizon.value); + this.sun.color.copy(this.skyU.uSun.value); + this.sun.intensity = mix.sunI * inv; + this.hemi.color.copy(this.skyU.uTop.value).lerp(new THREE.Color("#ffffff"), 0.5); + this.hemi.groundColor.copy(this.seaU.uDeep.value); + this.fog.near = 80 - stormW * 40; + this.fog.far = 330 - stormW * 110; + + // Lightning in the storm (skipped with reduced motion). + if (animate && stormW > 0.45) { + this.nextFlash -= dt; + if (this.nextFlash < 0) { this.flash = 1; this.nextFlash = 4 + Math.random() * 6; } + } + this.flash = Math.max(0, this.flash - dt * 5); + this.hemi.intensity = 1.3 + this.flash * 2.5; + + // Ship. + const p = this.at(d, new THREE.Vector3()); + const tan = this.tangent(d, new THREE.Vector3()); + const sd = this.side(d, new THREE.Vector3()); + const hAt = (x: number, z: number) => seaWave(x, z, t, storm); + const h = hAt(p.x, p.z); + const pitch = Math.atan2(hAt(p.x + tan.x * 3, p.z + tan.z * 3) - hAt(p.x - tan.x * 3, p.z - tan.z * 3), 6); + const roll = Math.atan2(hAt(p.x + sd.x * 1.6, p.z + sd.z * 1.6) - hAt(p.x - sd.x * 1.6, p.z - sd.z * 1.6), 3.2); + this.ship.position.set(p.x, h - 0.15, p.z); + this.ship.rotation.set(0, 0, 0); + this.ship.rotation.y = Math.atan2(tan.x, tan.z); + this.ship.rotateX(-pitch); + this.ship.rotateZ(roll); + this.ribbon(d - 0.6, d - 0.05, 0, (k) => 0.8 + (1 - k) * 3.5, 30, this.wake.geo); + + // Waypoints bob; flags flutter. + for (const w of this.waypoints) { + w.group.position.y = hAt(w.base.x, w.base.z) - 0.2; + w.group.rotation.z = Math.sin(t * 1.3 + w.stop.index) * 0.04; + for (const f of w.flags) f.mesh.rotation.y = Math.sin(t * 2.4 + f.phase) * 0.35; + const lit = w.ring.visible; + if (lit) w.ring.scale.setScalar(1 + (animate ? Math.sin(t * 2.2 + w.stop.index) * 0.08 : 0)); + w.focusRing.visible = w.stop.song.id === this.hovered || w.stop.song.id === this.open; + } + + for (const puff of this.smoke) { + const u = (puff.userData.phase + t * 0.035) % 1; + puff.position.set(puff.userData.x + u * 9, puff.userData.base + u * 26, puff.userData.z - u * 3); + puff.scale.setScalar(0.6 + u * 1.4); + puff.rotation.y = u * 3; + } + if (this.rain) this.rain.position.y = animate ? -((t * 38) % 40) + 20 : 0; + + // Camera trails the ship, looking ahead along the route. + const back = 32, up = 15; + this.camera.position.set(p.x - tan.x * back - sd.x * 9, up, p.z - tan.z * back - sd.z * 9); + this.camera.lookAt(p.x + tan.x * 34, -4, p.z + tan.z * 34); + this.sky.position.copy(this.camera.position); + + this.shift += (this.shiftTarget - this.shift) * (animate ? Math.min(1, dt * 6) : 1); + const w = this.host.clientWidth, hgt = this.host.clientHeight; + if (Math.abs(this.shift) > 0.5) this.camera.setViewOffset(w, hgt, this.shift, 0, w, hgt); + else this.camera.clearViewOffset(); + + for (const l of this.farLabels) { + const dist = l.position.distanceTo(this.camera.position); + l.element.style.opacity = String(Math.max(0, Math.min(1, (this.fog.far * 0.72 - dist) / 50))); + } + for (const m of this.ribbonMats) m.uniforms.uFogFar!.value = this.fog.far; + + this.renderer.render(this.scene, this.camera); + this.labels.render(this.scene, this.camera); + } + + dispose() { + this.ro.disconnect(); + this.scene.traverse((o) => { + const mesh = o as THREE.Mesh; + mesh.geometry?.dispose(); + const mats = Array.isArray(mesh.material) ? mesh.material : mesh.material ? [mesh.material] : []; + for (const m of mats) { + (m as THREE.MeshStandardMaterial).map?.dispose(); + m.dispose(); + } + }); + this.renderer.dispose(); + this.renderer.domElement.remove(); + this.labels.domElement.remove(); + } +} diff --git a/src/mockups/07-sea-voyage/style.css b/src/mockups/07-sea-voyage/style.css index 1946617..4ce4194 100644 --- a/src/mockups/07-sea-voyage/style.css +++ b/src/mockups/07-sea-voyage/style.css @@ -1,2 +1,528 @@ -main { padding: 2rem; font-family: system-ui, sans-serif; } -h1::before { content: "07 · "; } +/* 07 Sea voyage: Cycladic whitewash and Aegean blue over a painted low-poly sea. */ +@import "@fontsource/cormorant-garamond/latin-500.css"; +@import "@fontsource/cormorant-garamond/latin-500-italic.css"; +@import "@fontsource/cormorant-garamond/latin-600.css"; +@import "@fontsource/inter/latin-400.css"; +@import "@fontsource/inter/latin-500.css"; +@import "@fontsource/inter/latin-600.css"; + +:root { + --whitewash: #f7f8f5; + --whitewash-a: rgba(247, 248, 245, 0.92); + --ink: #12313b; + --ink-soft: #4a656d; + --aegean: #1d5f86; + --dome: #2a7ab0; + --line: rgba(18, 49, 59, 0.16); + --sea-deep: #185a70; + --serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif; + --sans: "Inter", system-ui, sans-serif; + --foot-h: 40px; + --gutter: 20px; + --drawer-w: min(440px, calc(100vw - 2 * var(--gutter))); + color-scheme: light; +} + +* { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + overflow: hidden; + background: var(--sea-deep); + color: var(--ink); + font: 400 14px/1.5 var(--sans); + -webkit-font-smoothing: antialiased; +} + +:focus-visible { outline: 2px solid var(--dome); outline-offset: 2px; } + +button { font: inherit; color: inherit; } + +.muted { color: var(--ink-soft); } + +.link-btn { + padding: 0; + border: 0; + background: none; + color: var(--aegean); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +/* ---------- the sea ---------- */ + +.sea { + position: fixed; + inset: 0; + cursor: grab; + touch-action: none; +} +.sea.is-pointing { cursor: pointer; } +.sea.is-dragging { cursor: grabbing; } +.sea__canvas { display: block; width: 100%; height: 100%; } +.sea__labels { position: absolute; inset: 0; pointer-events: none; overflow: hidden; } + +/* Saga islands */ +.isle-label { + display: grid; + justify-items: center; + color: var(--whitewash); + text-shadow: 0 1px 2px rgba(10, 30, 38, 0.55), 0 0 18px rgba(10, 30, 38, 0.35); + white-space: nowrap; +} +.isle-label__place { font: italic 500 34px/1 var(--serif); } +.isle-label__saga { margin-top: 4px; font: 500 12px/1.2 var(--sans); } + +.crossing-label { + padding: 2px 12px 3px; + border: 1px dashed rgba(247, 248, 245, 0.75); + border-radius: 999px; + color: var(--whitewash); + font: italic 500 18px/1.2 var(--serif); + white-space: nowrap; + text-shadow: 0 1px 2px rgba(10, 30, 38, 0.6); +} + +/* Waypoint tags: a numbered buoy tag that opens into a label on hover */ +.wp-tag { + position: relative; + transition: opacity 0.2s; +} +.wp-tag__n { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--whitewash); + color: var(--ink); + font: 600 11px/1 var(--sans); + box-shadow: 0 1px 3px rgba(10, 30, 38, 0.35); +} +.wp-tag__more { + display: none; + position: absolute; + left: 32px; + top: 50%; + transform: translateY(-50%); + padding: 6px 12px 7px; + border-radius: 4px 12px 12px 12px; + background: var(--whitewash); + box-shadow: 0 6px 20px rgba(10, 30, 38, 0.28); + white-space: nowrap; +} +.wp-tag__title { display: block; font: 600 20px/1.1 var(--serif); } +.wp-tag__time { display: block; font: 400 12px/1.3 var(--sans); color: var(--ink-soft); } +.wp-tag.is-hover .wp-tag__more, .wp-tag.is-open .wp-tag__more { display: block; } +.wp-tag.is-hover .wp-tag__n, .wp-tag.is-open .wp-tag__n { background: var(--aegean); color: var(--whitewash); } +.wp-tag.is-lit .wp-tag__n { box-shadow: 0 0 0 3px var(--whitewash), 0 0 0 5px var(--aegean); } +.wp-tag.is-dim { opacity: 0.35; } +/* With the drawer on the right, labels open leftwards so they stay in view. */ +body.has-drawer .wp-tag__more { left: auto; right: 32px; border-radius: 12px 4px 12px 12px; } + +/* ---------- floating panels ---------- */ + +.chart, .pennants, .logline, .drawer { + position: fixed; + z-index: 10; + background: var(--whitewash-a); + backdrop-filter: blur(8px); + box-shadow: 0 10px 34px rgba(8, 32, 42, 0.22); +} + +.chart { + top: var(--gutter); + left: var(--gutter); + width: 340px; + padding: 16px 20px 16px; + border-radius: 4px 18px 18px 18px; + border-left: 4px solid var(--aegean); +} +.chart__show { margin: 0; font: 500 13px/1.3 var(--sans); color: var(--aegean); } +.chart__title { margin: 2px 0 6px; font: 600 34px/1.02 var(--serif); letter-spacing: -0.01em; } +.chart__lede { margin: 0 0 10px; font-size: 13.5px; color: var(--ink-soft); max-width: 34ch; } +.chart__now { + margin: 0; + padding-top: 10px; + border-top: 1px solid var(--line); + font-size: 12.5px; + color: var(--ink-soft); +} +.chart__now strong { display: block; color: var(--ink); font: 600 20px/1.15 var(--serif); } +.chart__now span { display: block; } + +.pennants { + top: var(--gutter); + right: var(--gutter); + width: 268px; + padding: 14px 14px 12px; + border-radius: 18px 4px 18px 18px; + transition: right 0.3s ease; +} +.pennants__group + .pennants__group { margin-top: 10px; } +.pennants__heading { margin: 0 0 4px 4px; font: 600 12px/1.3 var(--sans); color: var(--ink-soft); } +.pennants__list { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; } +.pennant { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: 8px; + background: none; + text-align: left; + cursor: pointer; +} +.pennant:hover { background: rgba(29, 95, 134, 0.07); } +.pennant[aria-pressed="true"] { + background: color-mix(in srgb, var(--motif) 24%, var(--whitewash)); + border-color: color-mix(in srgb, var(--motif) 70%, var(--ink)); +} +.pennant__flag, .chip__flag { + flex: none; + width: 20px; + height: 13px; + background: var(--motif); + clip-path: polygon(0 0, 100% 50%, 0 100%); + box-shadow: inset 2px 0 0 var(--ink); +} +.pennant__name { flex: 1; font-weight: 500; } +.pennant__count { font-size: 12px; color: var(--ink-soft); font-variant-numeric: tabular-nums; } +.pennants__note { + margin: 10px 4px 0; + padding-top: 10px; + border-top: 1px solid var(--line); + font-size: 12.5px; + color: var(--ink-soft); +} +.pennants__desc { display: block; color: var(--ink); margin-bottom: 2px; } + +/* ---------- log line: the whole voyage to scale ---------- */ + +.logline { + left: var(--gutter); + right: var(--gutter); + bottom: calc(var(--foot-h) + 12px); + padding: 12px 22px 10px; + border-radius: 18px; + transition: right 0.3s ease; +} +.logline__track { position: relative; height: 64px; margin: 0 6px; cursor: pointer; } +.logline__water { + position: absolute; + left: 0; + right: 0; + top: 34px; + height: 2px; + background: repeating-linear-gradient(90deg, var(--aegean) 0 6px, transparent 6px 11px); + opacity: 0.55; +} +.band { + position: absolute; + top: 0; + height: 16px; + border-radius: 8px; + overflow: hidden; +} +.band span { display: block; padding: 0 8px; font: 600 11px/16px var(--sans); white-space: nowrap; } +.band--troy { background: #efd3aa; } +.band--cyclops { background: #cbd8d1; } +.band--ocean { background: #6e5466; color: var(--whitewash); } +.leg { + position: absolute; + top: 46px; + height: 16px; + display: grid; + place-items: center; + border-left: 1px solid var(--line); + border-right: 1px solid var(--line); +} +.leg span { font: italic 500 15px/1 var(--serif); color: var(--ink); white-space: nowrap; } +.yr { + position: absolute; + top: 48px; + transform: translateX(-50%); + font: 500 11px/1 var(--sans); + color: var(--ink-soft); + white-space: nowrap; +} +.stop { + position: absolute; + top: 35px; + width: 20px; + height: 20px; + padding: 0; + transform: translate(-50%, -50%); + border: 1.5px solid var(--ink); + border-radius: 50%; + background: var(--whitewash); + cursor: pointer; + transition: transform 0.15s, opacity 0.2s; +} +.stop__n { font: 600 9.5px/1 var(--sans); } +.stop:hover, .stop:focus-visible { transform: translate(-50%, -50%) scale(1.2); } +.stop.is-near { background: var(--ink); color: var(--whitewash); } +.stop.is-lit { background: var(--motif); color: var(--ink); box-shadow: 0 0 0 3px color-mix(in srgb, var(--motif) 40%, transparent); } +.stop.is-dim { opacity: 0.35; } +.logline__ship { + position: absolute; + top: 15px; + z-index: 2; + transform: translateX(-50%); + pointer-events: none; +} +.logline__ship svg { display: block; } +.logline__controls { + display: flex; + align-items: center; + gap: 16px; + margin-top: 6px; + padding-top: 8px; + border-top: 1px solid var(--line); +} +.sail-btn { + flex: none; + padding: 5px 14px; + border: 1.5px solid var(--aegean); + border-radius: 999px; + background: var(--aegean); + color: var(--whitewash); + font-weight: 600; + font-size: 12.5px; + cursor: pointer; +} +.sail-btn[aria-pressed="true"] { background: transparent; color: var(--aegean); } +.logline__hint { margin: 0; font-size: 12.5px; color: var(--ink-soft); } +kbd { + display: inline-block; + min-width: 18px; + padding: 0 4px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--whitewash); + font: 500 11px/16px var(--sans); + text-align: center; +} + +/* ---------- song drawer: a page of the ship's log ---------- */ + +.drawer { + top: var(--gutter); + right: var(--gutter); + bottom: calc(var(--foot-h) + 12px); + width: var(--drawer-w); + z-index: 20; + border-radius: 18px 4px 18px 18px; + background: var(--whitewash); + overflow: hidden; +} +.drawer[hidden] { display: none; } +.drawer__scroll { height: 100%; overflow-y: auto; padding: 22px 26px 26px; overscroll-behavior: contain; } +.drawer__head { position: relative; padding-bottom: 14px; border-bottom: 1px solid var(--line); } +.drawer__where { margin: 0; font: 500 12.5px/1.3 var(--sans); color: var(--aegean); } +.drawer__title { margin: 4px 40px 12px 0; font: 600 44px/0.98 var(--serif); letter-spacing: -0.01em; outline: none; } +.drawer__close { + position: absolute; + top: -6px; + right: -8px; + width: 36px; + height: 36px; + border: 0; + border-radius: 50%; + background: rgba(29, 95, 134, 0.08); + font-size: 22px; + line-height: 1; + cursor: pointer; +} +.drawer__close:hover { background: rgba(29, 95, 134, 0.16); } +.drawer__meta { margin: 0; display: grid; gap: 8px; } +.drawer__meta div { display: grid; grid-template-columns: 104px 1fr; gap: 10px; align-items: baseline; } +.drawer__meta dt { font-size: 12px; color: var(--ink-soft); } +.drawer__meta dd { margin: 0; } +.drawer__meta a { color: var(--aegean); text-underline-offset: 2px; } +.drawer__section { padding: 16px 0 4px; } +.drawer__section h3 { margin: 0 0 10px; font: 600 13px/1.2 var(--sans); color: var(--ink-soft); } + +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 3px 10px 3px 7px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--whitewash); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} +.chip .chip__flag { width: 14px; height: 9px; } +.chip:hover { border-color: var(--ink-soft); } +.chip[aria-pressed="true"] { background: color-mix(in srgb, var(--motif) 32%, var(--whitewash)); border-color: color-mix(in srgb, var(--motif) 70%, var(--ink)); } + +.parts { margin: 0; padding: 0; list-style: none; display: grid; gap: 10px; } +.part { + padding: 10px 14px 12px; + border-left: 3px solid var(--line); + border-radius: 0 10px 10px 0; + background: rgba(29, 95, 134, 0.035); + transition: background 0.2s, border-color 0.2s; +} +.part.is-lit { border-left-color: var(--motif); background: color-mix(in srgb, var(--motif) 18%, var(--whitewash)); } +.part__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; } +.part h4 { margin: 0; font: 600 20px/1.2 var(--serif); } +.part__singers { margin: 0; font-size: 12.5px; color: var(--ink-soft); } +.part__lines { margin: 6px 0 8px; } +.part__lines p { margin: 0; font: italic 500 17px/1.45 var(--serif); color: var(--ink); } +.fact { margin: 0 0 6px; font: 500 18px/1.45 var(--serif); } + +.drawer .audio-slot { border-color: var(--line); border-radius: 10px; color: var(--ink-soft); font-size: 13px; } +.drawer .video-cards { gap: 8px; } +.drawer .video-card { + padding: 8px 12px 8px 34px; + border-color: var(--line); + border-radius: 10px; + position: relative; + font-weight: 500; + font-size: 13px; +} +.drawer .video-card::before { + content: ""; + position: absolute; + left: 12px; + top: 50%; + width: 0; + height: 0; + margin-top: -5px; + border-left: 10px solid var(--aegean); + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; +} +.drawer .video-card:hover { opacity: 1; border-color: var(--aegean); } + +.drawer__nav { display: flex; justify-content: space-between; gap: 10px; padding-top: 18px; margin-top: 12px; border-top: 1px solid var(--line); } +.nav-btn { + padding: 7px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: none; + font-size: 12.5px; + cursor: pointer; + text-align: left; +} +.nav-btn--next { margin-left: auto; text-align: right; } +.nav-btn:hover { border-color: var(--aegean); color: var(--aegean); } + +/* With a song open, the motif pennants dock as a strip above the log line. */ +body.has-drawer .pennants { + top: auto; + right: calc(var(--drawer-w) + 2 * var(--gutter)); + left: var(--gutter); + bottom: calc(var(--foot-h) + 150px); + width: auto; + display: flex; + flex-wrap: wrap; + gap: 2px 4px; + padding: 8px; + border-radius: 14px; +} +body.has-drawer .pennants__group { display: contents; } +body.has-drawer .pennants__heading, body.has-drawer .pennants__note { display: none; } +body.has-drawer .pennants__list { display: contents; } +body.has-drawer .pennant { width: auto; white-space: nowrap; padding: 4px 8px; } +body.has-drawer .logline { right: calc(var(--drawer-w) + 2 * var(--gutter)); } + +/* ---------- credits: always on top, never covered ---------- */ + +.fan-credits { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 50; + padding: 10px 20px; + background: var(--whitewash); + color: var(--ink); + opacity: 1; + font: 400 12px/1.4 var(--sans); + border-top: 2px solid var(--aegean); +} + +/* ---------- WebGL fallback: a painted, still sea ---------- */ + +body.no-gl .sea { + cursor: default; + background: + radial-gradient(120% 60% at 50% 0%, #efc996 0%, transparent 55%), + repeating-radial-gradient(ellipse 60px 14px at 50% 100%, transparent 0 9px, rgba(255, 255, 255, 0.08) 9px 11px, transparent 11px 18px), + linear-gradient(180deg, #6f9fb4 0%, #2f7f93 38%, #185a70 70%, #0f4152 100%); +} +.fallback { + position: fixed; + z-index: 5; + inset: 0; + display: grid; + place-items: center; + padding: 220px 20px 200px; + pointer-events: none; +} +.fallback[hidden] { display: none; } +.fallback__card { + pointer-events: auto; + max-width: 520px; + max-height: 100%; + overflow: auto; + padding: 22px 26px; + border-radius: 4px 18px 18px 18px; + border-left: 4px solid var(--aegean); + background: var(--whitewash); + box-shadow: 0 10px 34px rgba(8, 32, 42, 0.25); +} +.fallback__card h2 { margin: 0 0 8px; font: 600 30px/1.05 var(--serif); } +.fallback__card p { margin: 0 0 8px; color: var(--ink-soft); } +.fallback__list { margin: 10px 0 0; padding-left: 0; list-style: none; columns: 2; column-gap: 20px; font-size: 13px; } +.fallback__list li { break-inside: avoid; margin-bottom: 4px; } +.fallback__list .muted { display: block; font-size: 12px; } + +/* ---------- small screens ---------- */ + +@media (max-width: 760px) { + :root { --gutter: 12px; } + .chart { width: auto; right: var(--gutter); padding: 12px 16px; } + .chart__title { font-size: 26px; } + .chart__lede { display: none; } + .chart__now strong { font-size: 17px; } + .pennants { + top: auto; + left: var(--gutter); + right: var(--gutter); + bottom: calc(var(--foot-h) + 128px); + width: auto; + padding: 8px; + overflow-x: auto; + } + .pennants__group { display: contents; } + .pennants__heading, .pennants__note { display: none; } + .pennants { display: flex; gap: 4px; } + .pennants__list { display: flex; gap: 4px; } + .pennant { white-space: nowrap; width: auto; } + .logline { padding: 10px 12px 8px; } + .logline__hint { display: none; } + .band span { font-size: 10px; padding: 0 4px; } + .leg span { display: none; } + .logline__track { height: 58px; } + body.has-drawer .pennants { display: none; } + .drawer { top: 28vh; left: var(--gutter); width: auto; } + body.has-drawer .pennants, body.has-drawer .logline { right: var(--gutter); } + .drawer__title { font-size: 34px; } + .fallback { padding: 150px 12px 220px; } + .fallback__list { columns: 1; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition: none !important; } +}