feat(mockups): 07 sea voyage (three.js)

Stylised low-poly Aegean: ship sails a time-proportional route past saga
islands and song waypoints, motif flags and wake trails, WebGL fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-23 19:08:03 +02:00
co-authored by Claude Opus 5.5
parent b7f423d08c
commit dd106c5d0e
5 changed files with 1856 additions and 6 deletions
+18 -1
View File
@@ -8,7 +8,24 @@
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<main id="app" class="voyage">
<div class="sea" id="sea"></div>
<header class="chart">
<p class="chart__show">EPIC: The Musical</p>
<h1 class="chart__title">The voyage home, charted</h1>
<p class="chart__lede">Sail past every song. The longer the crossing, the more years pass in the story.</p>
<p class="chart__now" id="now" aria-live="polite"></p>
</header>
<nav class="pennants" id="pennants" aria-label="Motifs"></nav>
<section class="logline" id="logline" aria-label="Voyage log"></section>
<aside class="drawer" id="drawer" aria-label="Song details" hidden></aside>
<section class="fallback" id="fallback" hidden></section>
</main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
+406 -3
View File
@@ -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 = `<h1>Sea voyage (3D)</h1><p>stub</p>`;
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) => `
<div class="pennants__group">
<p class="pennants__heading">${title}</p>
<ul class="pennants__list">
${motifs.filter((m) => m.kind === kind).map((m) => `
<li><button type="button" class="pennant" data-motif="${m.id}" aria-pressed="false" style="--motif:${m.color}">
<span class="pennant__flag" aria-hidden="true"></span>
<span class="pennant__name">${esc(m.name)}</span>
<span class="pennant__count">${songsWithMotif(m.id).length}</span>
</button></li>`).join("")}
</ul>
</div>`;
pennants.innerHTML = `
${group("motif", "Musical motifs")}
${group("character", "Character themes")}
<p class="pennants__note" id="motif-note">Pick a motif to light every waypoint and part where it returns.</p>`;
pennants.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>("[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<HTMLButtonElement>("[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 = `<span class="pennants__desc">${esc(m.description)}</span> Flies at waypoints ${at.join(", ")}. <button type="button" class="link-btn" data-clear-motif>Clear</button>`;
} else {
note.textContent = "Pick a motif to light every waypoint and part where it returns.";
}
for (const b of logline.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>("[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) => `
<div class="band band--${s.saga.id}" style="left:${pct(s.first.d - 0.45)};width:calc(${pct(s.last.d + 0.45)} - ${pct(s.first.d - 0.45)})">
<span>${esc(s.saga.name.replace(/^The /, "").replace(/ Saga$/, ""))}</span>
</div>`).join("");
const legs = crossings.map((c) => `
<div class="leg" style="left:${pct(c.from.d)};width:calc(${pct(c.to.d)} - ${pct(c.from.d)})"><span>${yearsLabel(c.years)}</span></div>`).join("");
const seenYears = new Set<number>();
const years = stops.filter((s) => !seenYears.has(s.years) && seenYears.add(s.years)).map((s) =>
`<span class="yr" style="left:${pct(s.d)}">Year ${s.years}</span>`).join("");
const dots = stops.map((s) => `
<button type="button" class="stop" data-song="${s.song.id}" style="left:${pct(s.d)}" aria-label="Track ${s.song.track}, ${esc(s.song.title)}">
<span class="stop__n" aria-hidden="true">${s.song.track}</span>
</button>`).join("");
logline.innerHTML = `
<div class="logline__track" id="track">
<div class="logline__water" aria-hidden="true"></div>
${bands}${legs}${years}${dots}
<div class="logline__ship" id="shipmark" aria-hidden="true">
<svg viewBox="0 0 24 20" width="24" height="20"><path d="M12 1v12M5 4h14l-1 8H6z" fill="#efe9dc" stroke="#12313b" stroke-width="1.2"/><path d="M2 14h20l-3 4H5z" fill="#12313b"/></svg>
</div>
</div>
<div class="logline__controls">
<button type="button" class="sail-btn" id="sail" aria-pressed="false"></button>
<p class="logline__hint">Scroll, drag the sea or press <kbd>←</kbd> <kbd>→</kbd> to sail. Each year in the story is a long crossing; songs in the same year sit one short hop apart.</p>
</div>`;
const track = $("track");
track.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>(".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<HTMLElement>(".stop");
scene?.setHovered(btn?.dataset.song ?? null);
});
track.addEventListener("pointerleave", () => scene?.setHovered(null));
track.addEventListener("focusin", (e) => scene?.setHovered((e.target as HTMLElement).closest<HTMLElement>(".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
? `<a href="${song.odyssey.url}" target="_blank" rel="noopener noreferrer">Book ${song.odyssey.book}, lines ${esc(song.odyssey.lines)}</a>`
: `<span class="muted">No direct counterpart in Homer</span>`;
const chip = (m: Motif) => `<button type="button" class="chip" data-motif="${m.id}" aria-pressed="${m.id === motifId}" style="--motif:${m.color}"><span class="chip__flag" aria-hidden="true"></span>${esc(m.name)}</button>`;
drawer.innerHTML = `
<div class="drawer__scroll">
<header class="drawer__head">
<p class="drawer__where">Waypoint ${song.track} of ${songs.length}, ${esc(saga.name)}</p>
<h2 class="drawer__title" id="drawer-title" tabindex="-1">${esc(song.title)}</h2>
<dl class="drawer__meta">
<div><dt>Story time</dt><dd>${esc(song.storyTime.label)}, year ${song.storyTime.yearsAfterTroy} after Troy</dd></div>
<div><dt>In the Odyssey</dt><dd>${odyssey}</dd></div>
<div><dt>Motifs aboard</dt><dd class="chips">${motifsInSong(song).map(chip).join("")}</dd></div>
</dl>
<button type="button" class="drawer__close" id="drawer-close" aria-label="Close song">×</button>
</header>
<section class="drawer__section">
<h3>Parts</h3>
<ol class="parts">
${song.parts.map((p) => `
<li class="part" data-motifs="${p.motifIds.join(" ")}">
<div class="part__head"><h4>${esc(p.name)}</h4><p class="part__singers">Sung by ${esc(p.singers.join(" and "))}</p></div>
<div class="part__lines">${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</div>
<div class="chips">${p.motifIds.flatMap((mid) => motifById(mid) ?? []).map(chip).join("")}</div>
</li>`).join("")}
</ol>
</section>
<section class="drawer__section">
<h3>Fun fact</h3>
${song.facts.map((f) => `<p class="fact">${esc(f)}</p>`).join("")}
</section>
<section class="drawer__section" id="drawer-audio"><h3>Listen</h3></section>
<section class="drawer__section" id="drawer-videos"><h3>Watch</h3></section>
<nav class="drawer__nav" aria-label="Neighbouring songs">
${prev ? `<button type="button" class="nav-btn" data-go="${prev.song.id}">Back to ${esc(prev.song.title)}</button>` : "<span></span>"}
${next ? `<button type="button" class="nav-btn nav-btn--next" data-go="${next.song.id}">On to ${esc(next.song.title)}</button>` : ""}
</nav>
</div>`;
$("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<HTMLElement>("[data-go]");
if (go) { openSong(go.dataset.go!); return; }
const chip = el.closest<HTMLElement>("[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 <strong>${near.song.track}. ${esc(near.song.title)}</strong> <span>${esc(near.song.storyTime.label)}, year ${near.years}</span>`;
for (const b of logline.querySelectorAll<HTMLElement>(".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 = `
<div class="fallback__card">
<h2>The sea chart can't be drawn here</h2>
<p>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.</p>
<p>Meanwhile the voyage log still works. Pick a waypoint below to read its song.</p>
<ol class="fallback__list">
${stops.map((s) => `<li><button type="button" class="link-btn" data-open="${s.song.id}">${s.song.track}. ${esc(s.song.title)}</button> <span class="muted">${esc(s.song.storyTime.label)}</span></li>`).join("")}
</ol>
</div>`;
fallback.addEventListener("click", (e) => {
const b = (e.target as HTMLElement).closest<HTMLElement>("[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();
+65
View File
@@ -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`;
+839
View File
@@ -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<string, Atmos> = {
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 <common>", `#include <common>\n${WAVE_GLSL}\nvarying float vH;`)
.replace("#include <begin_vertex>", `#include <begin_vertex>
vec4 seaWp = modelMatrix * vec4(transformed, 1.0);
float seaH = seaWave(seaWp.xz);
transformed.z += seaH;
vH = seaH;`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\nuniform vec3 uDeep;\nuniform vec3 uCrest;\nvarying float vH;`)
.replace("#include <color_fragment>", `#include <color_fragment>
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", `<span class="isle-label__place">${place}</span><span class="isle-label__saga">${sagaName}, ${tracks.toLowerCase()}</span>`);
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", `<span class="wp-tag__n">${stop.song.track}</span><span class="wp-tag__more"><span class="wp-tag__title">${stop.song.title}</span><span class="wp-tag__time">${stop.song.storyTime.label}, year ${stop.years}</span></span>`);
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();
}
}
+528 -2
View File
@@ -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; }
}