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:
co-authored by
Claude Opus 5.5
parent
b7f423d08c
commit
dd106c5d0e
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user