feat(mockups): 17 voyage with real content (FLAC, synced lyrics, Odyssey passage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 04:45:08 +01:00
co-authored by Claude Opus 5.5
parent 6db056fba2
commit 93e9165a88
14 changed files with 4076 additions and 1 deletions
+7
View File
@@ -341,6 +341,13 @@ and brief one agent from a copy of 16, using `/content/library.json`, `parseLrc`
- Resumed from §18. 17 (real content) is started now, as agreed; the user's reaction to 16 is asked
in parallel.
- **17 is finished and committed**; report in `docs/design/002-sea-chart-spikes/17-voyage-real-report.md`.
Real FLAC playback with seeking, line-synced lyrics (click a line to seek), the Butler Odyssey passage
in a reading view, fallback to 16 without the folder. Agent's choices: "Play voyage" stays silent (no
excerpts, avoids near-autoplay); part buttons dropped in real mode (sample part times don't match the
recordings). Open: passage start points are keyword guesses; parts/motifs aren't timed to the
recordings yet (report proposes a small mapping file in the content folder).
## Open questions (in priority order)
1. ~~Which sea-chart approach (08–11) reads best, or which mix?~~ 11's navigation + 10's style (§9).
@@ -0,0 +1,133 @@
# 17 Voyage with real content — report
Status: **done** (not committed). Built from `17-voyage-real-brief.md`, starting from a copy of 16.
## What was built
`src/mockups/17-voyage-real/` is 16 unchanged on the stage (look 2, Inter labels, frieze accents, five
beats, stylised 3D, combined paths, fan works placeholders). Only the content side changed. URL params
are as in 16, plus **`?read=1`**, which opens About's Odyssey reading view for the first song (used for
screenshots).
- **`content.ts`** (new) reads the mounted folder at runtime:
- `loadLibrary()` fetches `/content/library.json` before the panel is built (top-level await, 4 s
timeout). A 404 or a failure means "not mounted".
- `realFor(song)` matches a sample song to the library by normalised title. Case, accents,
apostrophes and punctuation are ignored. A miss is logged once and does not crash.
- `loadLyrics()` parses `.lrc` files with `parseLrc` (`src/shared/lrc.ts`) and splits `.txt` files
into plain lines. `loadBook()` fetches `/content/homer/odyssey/<n>.json`. Both are cached.
- It also holds the keywords that mark each song's passage, `passageStart()`, and a footnote-marker
stripper for Gutenberg's inline note numbers.
- Only types are imported from `src/content/`.
1. **Real player** (`player.ts`, `panel.ts`).
- The `<audio>` element is real and sits hidden in the panel's DOM. Play/pause, elapsed time and
total time all come from the element, and the scrub bar seeks it (click, drag, ←/→ ±5 s).
- While the song plays, the clock is read every animation frame, so the highlight moves smoothly.
`timeupdate` alone fires only about 4 times a second.
- A seek made before the metadata has loaded (`?t_play`) sets the start position and is applied
again once the metadata arrives.
- The saga cover is a 56 px square in the player card. The card is sticky at the top of the tab, so
the controls stay put while the lyrics scroll under it.
- With real audio, the "Preview player" note, the part segment buttons and the part ticks are gone,
because the sample's part times are invented and would be wrong against the recording.
- The header play button and its eyebrow ("Playing · 1:00 / 3:36") use the real clock.
- If the file fails to load, the player drops back to the simulated clock and the tab re-renders
as 16.
2. **Line-synced lyrics.**
- The `.lrc` lines are shown in serif italic, with empty LRC lines drawn as a faint `· · ·` break.
- The current line has a terracotta wash, an ink outline and bold type, and lines already sung turn
iron-soft. The match leads the timestamp by 0.15 s.
- **Keeping the line in view.** The current line is smoothly scrolled to about a third of the way
down the space below the sticky player, once per line change.
- **The reader's own scrolling wins.** A wheel, touch, scrollbar drag or keyboard scroll in the view
pauses following for 4 s. After that the lyrics catch up on the next line. Seeking or clicking a
line resumes following at once.
- **Click-to-seek.** Clicking a line seeks to its timestamp and plays. The timestamp appears in the
gutter on hover or focus.
- **Plain `.txt` lyrics** are shown unsynced, with the note "These lyrics are not timed, so they do
not follow the song".
- **Missing or failed lyrics** each get a one-line note.
- **Parts and motifs.** Part headings are gone from the lyrics. A compact summary above them reads
"Parts: Verse 1 · Verse 2", followed by the motif chips (the motif pick works as before) and "From
the sample data, not yet timed to the recording".
3. **The Odyssey passage.**
- About's Odyssey row now reads "Book IX". The line ranges are dropped because the prose has no line
numbers.
- The **Read the passage →** button replaces About with a reading view, which has "← Back to About",
the book heading, Butler's summary line, the Roman-names note and the paragraphs.
- **Where the passage starts.** The view opens at the first paragraph that mentions the song's
keyword and puts a meander rule before it: "The passage for Polyphemus begins here · first mention
of 'Cyclops'". Keywords are highlighted in the text.
- **Earlier paragraphs.** "Show the 13 paragraphs before this passage" expands the whole book and
scrolls to the rule. If no keyword is found, the view starts at the beginning of the book.
- The text ends with "End of Book IX · Project Gutenberg #1727".
- **Keywords (Butler's spelling).**
- Horse and the Infant: horse (VIII, Demodocus).
- Full Speed Ahead: Cicons / Ismarus.
- Warrior of the Mind: Minerva (XIII).
- Polyphemus: Polyphemus / Cyclops.
- Survive: olive (the stake).
- Remember Them: rams.
- My Goodbye: "put your eye out" (the taunt).
- Storm: Aeolus.
- These are content guesses, the same as the sample's `TODO(verify)` references.
4. **Fallback.** Without `CONTENT_DIR` the page behaves like 16: the simulated player, placeholder
lyrics by part, and the Gutenberg link. The player note starts with **"Content folder not mounted."**,
and the end of About has a small "Content folder not mounted" note. `body[data-content]` is
`mounted` or `missing`.
5. **Voyage play.** It stays **silent: no excerpts**. Beats hold for 3.4 s, so an excerpt would be a
jarring audio stab, and starting sound without a click is close to the autoplay the brief rules out.
As in 16, moving to a new song loads that song stopped at 0:00. The old `<audio>` element is removed.
A song that is playing keeps playing while the beat changes but the song stays the same (Landfall →
Curtain).
## Screenshots (1440×900, `screenshots/`)
- `17-voyage-real-listen.png`: beat 3, Listen & lyrics, `?t_play=60`. It is playing at 1:00 / 3:36,
with the cover, the current line lit and kept in view under the sticky player.
- `17-voyage-real-odyssey.png`: Polyphemus, Book IX reading view, opened at the first "Cyclops"
paragraph.
- `17-voyage-real-nocontent.png`: served without `CONTENT_DIR`. It shows the simulated player, the
"Content folder not mounted" note and the placeholder lyrics.
- `17-voyage-real-nocontent-about.png`: About without content (Gutenberg link and the note).
- `17-voyage-real-beat1.png`: the Troy frieze, with About's "Book VIII · Read the passage →".
- `-txt.png` was skipped. No sample song (tracks 1–10) maps to a `.txt` lyric; in the library only
Scylla and two others outside the sample do.
The credits footer is fully visible in every one.
## Verified
- `bun test` (64 pass), `bun run typecheck` (clean) and `bun run build`. The scratchpad build was
served on port 4117 with and without `CONTENT_DIR`.
- A CDP script drove headless Chrome against the real server, with no virtual time:
- The real FLAC loads (`readyState` 4, duration 216 s).
- After the header play click, `currentTime` advances (2.94 → 4.95 s over 2 s).
- A scrub click at 50 % seeks to 109.4 s.
- Clicking a lyric line stamped 1:08 seeks there, and that line becomes current.
- A range request on the audio URL returns `206 bytes 1000000-1000099/42425888`.
- Following keeps the current line 249 px down a 520 px view.
- After a user scroll to the top, the view stays at 0 for 2.5 s, then follows again by 7.5 s.
- Moving to beat 5 swaps the element to Survive, stopped.
- The reading view (Survive → "olive", 20 earlier paragraphs) expands to all 44 paragraphs, and Back
returns to About.
- The console has no errors; the only message is a three.js warning inherited from 16.
## What doesn't, or is rough
- **Parts and motifs are not time-aligned.** Later, each part could get a `startSec` (the type already
has it), or better, an LRC line index, by marking the first line of each part once per song. That
could be a small JSON sidecar in the content folder, keyed by song, mapping part → first line index;
the line's timestamp then gives the time. Motifs could be stamped the same way, with a line range
per motif. With that, the player could show "Verse 2" again, put the part ticks on the scrub bar, and
light motif chips as they sound.
- **Keyword starts are approximate.** The Polyphemus passage opens where the Cyclops comes home, not
at the landing on the island (the first "Cyclopes", 8 paragraphs earlier). My Goodbye's Homer
counterpart is debatable. The keywords should be confirmed with the content work.
- Butler's summary line is in capitals from the source, and contains a doubled dash ("—-").
- Headless virtual time does not run the audio clock, so `?t_play` screenshots show the seek point,
not real progress. Real progress was checked over CDP instead.
- Library loading delays the panel by one request (fast locally). A slow server would hold the panel
for up to 4 s before it falls back.
- Desktop 1440×900 only. The `.txt` path is implemented but has not been seen on screen.
+65
View File
@@ -0,0 +1,65 @@
// Copied from 13: a small redraw of 12's general chart, with "You are here". In 14 it lives in the Paths tab.
// Coordinates are 12's general chart scaled into a 320×210 card.
const P = (x: number, y: number): [number, number] => [10 + ((x - 50) * 300) / 1050, 10 + ((y - 85) * 190) / 660];
const PORTS: [number, number][] = [
P(344, 277), P(404, 331), P(432, 407), P(420, 488), P(373, 552),
P(456, 498), P(543, 576), P(679, 576), P(766, 499), P(960, 400),
];
const LOOP = P(410, 592);
const AT_SEA = P(392, 584);
const pt = (p: [number, number]) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`;
const port = (i: number) => PORTS[i]!;
/** The ship's course on the card, as one path (12's red dashed line). */
const COURSE = `M${pt(port(0))} Q${pt(P(380, 300))} ${pt(port(1))} Q${pt(P(440, 360))} ${pt(port(2))} L${pt(port(3))} L${pt(port(4))}
Q${pt(P(380, 600))} ${pt(LOOP)} Q${pt(P(440, 570))} ${pt(port(5))} Q${pt(P(480, 570))} ${pt(port(6))} L${pt(port(7))}
Q${pt(P(730, 560))} ${pt(port(8))} Q${pt(P(860, 530))} ${pt(port(9))}`;
/** Where "You are here" sits: k=0 out at sea after Troy, k=1 at the first Cyclops port. */
export function hereAt(k: number): [number, number] {
const a = AT_SEA, b = LOOP, c = port(5);
// two straight legs are close enough at this size
if (k < 0.4) { const s = k / 0.4; return [a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s]; }
const s = (k - 0.4) / 0.6;
return [b[0] + (c[0] - b[0]) * s, b[1] + (c[1] - b[1]) * s];
}
export function chartSvg(): string {
const [ix, iy] = P(605, 470);
const coast = `M0,0 L${pt(P(275, 85))} Q${pt(P(300, 200))} ${pt(P(290, 300))} Q${pt(P(390, 400))} ${pt(P(375, 430))} Q${pt(P(330, 520))} ${pt(P(285, 550))} Q${pt(P(240, 680))} ${pt(P(200, 745))} L0,210 Z`;
const ports = PORTS.map((p, i) => `<circle class="c-port" cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="2.6"/><text class="c-num" x="${(p[0] + 4).toFixed(1)}" y="${(p[1] - 3).toFixed(1)}">${i + 1}</text>`).join("");
let rhumbs = "";
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
rhumbs += `M206,48 L${(206 + Math.cos(a) * 260).toFixed(0)},${(48 + Math.sin(a) * 260).toFixed(0)} `;
}
let spiral = "";
for (let r = 3; r < 15; r += 3) spiral += `<ellipse class="c-ink" cx="${port(9)[0].toFixed(1)}" cy="${(port(9)[1] - 4).toFixed(1)}" rx="${r * 1.5}" ry="${r * 0.8}"/>`;
return `<svg class="chart__svg" viewBox="0 0 320 210" role="img" aria-label="General chart of the voyage from Troy, with the ship's position marked">
<defs><clipPath id="chart-clip"><rect x="6" y="6" width="308" height="198"/></clipPath></defs>
<rect class="c-vellum" x="0" y="0" width="320" height="210"/>
<g clip-path="url(#chart-clip)">
<path class="c-rhumb" d="${rhumbs}"/>
<path class="c-land" d="${coast}"/>
<ellipse class="c-land" cx="${ix.toFixed(1)}" cy="${iy.toFixed(1)}" rx="38" ry="22"/>
<path class="c-ink" d="M${(ix - 6).toFixed(1)},${(iy + 6).toFixed(1)} a8,8 0 0 1 16,0" />
<ellipse class="c-land" cx="${P(450, 195)[0]}" cy="${P(450, 195)[1]}" rx="9" ry="6"/>
<ellipse class="c-land" cx="${P(722, 335)[0]}" cy="${P(722, 335)[1]}" rx="5" ry="3.5"/>
${spiral}
<path class="c-course" d="${COURSE}"/>
${ports}
<text class="c-place" x="16" y="34">Troy</text>
<text class="c-place" x="${(ix - 30).toFixed(1)}" y="${(iy - 28).toFixed(1)}">Cyclops' Island</text>
<text class="c-sea" x="${P(870, 440)[0].toFixed(1)}" y="${P(870, 440)[1].toFixed(1)}">two years at sea</text>
<g class="c-rose" transform="translate(292,178)"><circle r="11"/><path d="M0,-15 L3,0 L0,15 L-3,0 Z M-15,0 L0,3 L15,0 L0,-3 Z"/></g>
</g>
<g class="c-here" id="chart-here">
<circle class="c-here__ring" r="7"/>
<circle class="c-here__dot" r="3"/>
<text class="c-here__label" x="10" y="-8">You are here</text>
</g>
<rect class="c-frame" x="4" y="4" width="312" height="202"/>
</svg>`;
}
+114
View File
@@ -0,0 +1,114 @@
// The real content, read at runtime from the server's mounted folder (spec 003): the library (FLAC audio,
// timed or plain lyrics, saga covers) and Butler's Odyssey by book. Nothing here is copied into the repo:
// when the folder is not mounted every /content/ route is 404, and 17 falls back to 16's behaviour.
import type { HomerBook } from "../../content/homer";
import type { Library, LibrarySaga, LibrarySong } from "../../content/library";
import type { Song } from "../../data/types";
import { parseLrc, type LrcLine } from "../../shared/lrc";
export interface RealSong {
audio: string;
cover: string | null;
saga: string;
lyrics: LibrarySong["lyrics"];
}
export type Lyrics = { timed: true; lines: LrcLine[] } | { timed: false; lines: string[] };
/** Case-, accent- and punctuation-insensitive title key ("Wouldn’t You Like" = "wouldnt you like"). */
export const normTitle = (s: string) =>
s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[’'`]/g, "").replace(/[^a-z0-9]+/g, " ").trim();
let library: Library | null = null;
const byTitle = new Map<string, { song: LibrarySong; saga: LibrarySaga }>();
/** Loads /content/library.json; false when the folder is not mounted (404) or the request fails. */
export async function loadLibrary(timeoutMs = 4000): Promise<boolean> {
try {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
const res = await fetch("/content/library.json", { signal: ctl.signal });
clearTimeout(timer);
if (!res.ok) return false;
library = (await res.json()) as Library;
} catch {
return false;
}
for (const saga of library.sagas) for (const song of saga.songs) byTitle.set(normTitle(song.title), { song, saga });
return library.sagas.length > 0;
}
export const contentMounted = () => !!library && library.sagas.length > 0;
const missing = new Set<string>();
/** The library entry for a sample song, matched by normalised title; null (logged once) if none. */
export function realFor(song: Song): RealSong | null {
if (!contentMounted()) return null;
const hit = byTitle.get(normTitle(song.title));
if (!hit) {
if (!missing.has(song.id)) { missing.add(song.id); console.warn(`[17] no library match for "${song.title}"`); }
return null;
}
return { audio: hit.song.audio, cover: hit.saga.cover, saga: hit.saga.name, lyrics: hit.song.lyrics };
}
const lyricCache = new Map<string, Promise<Lyrics | null>>();
/** Fetches and parses a song's lyrics (.lrc → timed lines, .txt → plain lines). */
export function loadLyrics(ref: NonNullable<LibrarySong["lyrics"]>): Promise<Lyrics | null> {
let p = lyricCache.get(ref.url);
if (!p) {
p = fetch(ref.url)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((text): Lyrics => ref.timed
? { timed: true, lines: parseLrc(text) }
: { timed: false, lines: text.split(/\r?\n/).map((l) => l.trim()) })
.catch((e) => { console.warn("[17] lyrics failed", ref.url, e); return null; });
lyricCache.set(ref.url, p);
}
return p;
}
const bookCache = new Map<number, Promise<HomerBook | null>>();
export function loadBook(book: number): Promise<HomerBook | null> {
let p = bookCache.get(book);
if (!p) {
p = fetch(`/content/homer/odyssey/${book}.json`)
.then((r) => (r.ok ? (r.json() as Promise<HomerBook>) : null))
.catch(() => null);
bookCache.set(book, p);
}
return p;
}
/** Words that mark where each sample song's passage begins in Butler's prose (Roman names, his spelling). */
const KEYWORDS: Record<string, string[]> = {
"the-horse-and-the-infant": ["horse"],
"full-speed-ahead": ["Cicons", "Ismarus"],
"warrior-of-the-mind": ["Minerva"],
polyphemus: ["Polyphemus", "Cyclops"],
survive: ["olive"],
"remember-them": ["rams"],
"my-goodbye": ["put your eye out"],
storm: ["Aeolus"],
};
export const keywordsFor = (songId: string) => KEYWORDS[songId] ?? [];
const reEsc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const keywordRe = (words: string[]) => (words.length ? new RegExp(`\\b(${words.map(reEsc).join("|")})\\b`, "gi") : null);
/** Index of the first paragraph that mentions a keyword, and which one; paragraph 0 if none does. */
export function passageStart(book: HomerBook, words: string[]): { index: number; word: string | null } {
const re = keywordRe(words);
if (re) for (let i = 0; i < book.paragraphs.length; i++) {
re.lastIndex = 0;
const m = re.exec(book.paragraphs[i]!);
if (m) return { index: i, word: m[1]! };
}
return { index: 0, word: null };
}
/** Gutenberg's footnote markers sit glued to words ("the place.114"); the reading view drops them. */
export const stripFootnotes = (s: string) => s.replace(/([A-Za-z.,;:!?’”)])\d{1,3}(?=[\s,;:.’”]|$)/g, "$1");
const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV"];
export const roman = (n: number) => ROMAN[n] ?? String(n);
+72
View File
@@ -0,0 +1,72 @@
// Placeholder fan works for the panel's "Fan works" tab. Every title and handle here is made up and
// clearly marked as a placeholder: no real fan artists, no real links. Local to 14 (src/data/ has no
// fan-works model yet; see open question 3b in the round notes).
export type FanWorkType = "art" | "animatic" | "cover" | "cosplay";
export interface FanWork { type: FanWorkType; title: string; handle: string }
const P = "@fan-artist";
const BY_SONG: Record<string, FanWork[]> = {
"the-horse-and-the-infant": [
{ type: "art", title: "Placeholder: the horse at the gates, ink study", handle: P },
{ type: "animatic", title: "Placeholder: storyboard of the final night", handle: P },
{ type: "cover", title: "Placeholder: piano cover", handle: P },
],
"full-speed-ahead": [
{ type: "art", title: "Placeholder: the fleet under sail", handle: P },
{ type: "cover", title: "Placeholder: sea-shanty arrangement", handle: P },
{ type: "animatic", title: "Placeholder: crew at the oars, animatic", handle: P },
{ type: "cosplay", title: "Placeholder: Eurylochus armour build", handle: P },
],
polyphemus: [
{ type: "art", title: "Placeholder: one eye in the dark", handle: P },
{ type: "animatic", title: "Placeholder: the cave scene, animatic", handle: P },
{ type: "cosplay", title: "Placeholder: Cyclops costume and club", handle: P },
{ type: "cover", title: "Placeholder: low-strings cover", handle: P },
],
survive: [
{ type: "art", title: "Placeholder: the crew by firelight", handle: P },
{ type: "cover", title: "Placeholder: choir cover", handle: P },
{ type: "animatic", title: "Placeholder: escape plan, animatic", handle: P },
],
};
export function fanWorksFor(songId: string): FanWork[] {
return BY_SONG[songId] ?? [
{ type: "art", title: "Placeholder: illustration", handle: P },
{ type: "cover", title: "Placeholder: cover", handle: P },
{ type: "animatic", title: "Placeholder: animatic", handle: P },
];
}
export const TYPE_LABEL: Record<FanWorkType, string> = { art: "Art", animatic: "Animatic", cover: "Cover", cosplay: "Cosplay" };
/** A stylised placeholder thumbnail per type (no external images). Colours come from CSS custom properties. */
export function thumbSvg(type: FanWorkType, accent: string, seed: number): string {
const r = (n: number) => ((Math.sin(seed * 12.9898 + n * 78.233) * 43758.5453) % 1 + 1) % 1;
let art = "";
if (type === "art") {
art = `<circle cx="${120 + r(1) * 30}" cy="34" r="16" fill="${accent}"/>
<path d="M0 80 Q40 ${58 + r(2) * 10} 80 76 T160 70 V100 H0Z" class="t-ink"/>
<path d="M10 88 q14 -8 28 0 t28 0 t28 0 t28 0 t28 0" class="t-line"/>
<path d="M${40 + r(3) * 40} 70 l10 -30 l10 30z" class="t-ink"/>`;
} else if (type === "animatic") {
let frames = "";
for (let i = 0; i < 3; i++) frames += `<rect x="${8 + i * 50}" y="22" width="44" height="56" rx="2" class="t-frame"/><path d="M${18 + i * 50} ${66 - i * 6} q12 ${-18 + i * 6} 24 ${-4 - i * 3}" class="t-line" style="stroke:${accent}"/>`;
let holes = "";
for (let x = 6; x < 160; x += 12) holes += `<rect x="${x}" y="8" width="6" height="6" class="t-hole"/><rect x="${x}" y="86" width="6" height="6" class="t-hole"/>`;
art = `<rect x="0" y="0" width="160" height="100" class="t-ink"/>${holes}${frames}`;
} else if (type === "cover") {
let bars = "";
for (let i = 0; i < 18; i++) { const h = 10 + r(i) * 46; bars += `<rect x="${78 + i * 4.4}" y="${50 - h / 2}" width="2.6" height="${h}" fill="${accent}"/>`; }
art = `<circle cx="40" cy="50" r="30" class="t-ink"/><circle cx="40" cy="50" r="9" fill="${accent}"/><circle cx="40" cy="50" r="20" class="t-line"/>${bars}`;
} else {
art = `<path d="M80 18 a10 10 0 1 1 0.1 0 M64 22 q16 -18 34 -4 l-4 6 q-14 -8 -26 2z" class="t-ink"/>
<path d="M66 36 h28 l6 34 h-10 l-2 22 h-16 l-2 -22 h-10z" class="t-ink"/>
<path d="M100 30 l22 -14" class="t-line" style="stroke:${accent};stroke-width:4"/>
<path d="M66 52 h34" class="t-line" style="stroke:${accent}"/>`;
}
return `<svg class="fw__thumb" viewBox="0 0 160 100" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Placeholder image">
<rect width="160" height="100" class="t-bg"/>${art}</svg>`;
}
+269
View File
@@ -0,0 +1,269 @@
// Copied from 04-greek-frieze/figures.ts via 13 (mockups never import across folders).
// Black-figure painting kit: glaze (fill: var(--ink)) with "incised" detail lines scratched back to the clay (stroke: var(--inc)).
// Every panel shares one 300-unit-tall coordinate space with the ground line at GROUND.
export const PANEL_H = 300;
export const GROUND = 284;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw";
interface Person {
x: number; y?: number; s?: number; f?: 1 | -1; arms?: Arms;
helmet?: boolean; shield?: boolean; robe?: boolean; cyclops?: boolean; kneel?: boolean;
beard?: boolean; infant?: boolean; cup?: boolean;
}
export const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
export const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
export const ink = (d: string) => `<path class="ink" d="${d}"/>`;
// A limb: glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze.
export const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
export function person(p: Person): string {
const { x, y = GROUND, s = 1, f = 1, arms = "down" } = p;
const lift = p.kneel ? 36 : 0;
let legs = "";
if (p.kneel) {
legs = limb("M4,-44 L34,-40 L36,-2", 8) + limb("M-2,-44 L-24,-4 L-4,-2", 8);
} else if (!p.robe) {
legs = limb("M-2,-80 L-10,-40 L-24,-2 L-14,-2", 8) + limb("M4,-80 L14,-40 L30,-2 L40,-2", 8);
}
let upper = "";
// back arm sits behind the torso
if (arms === "open") upper += limb("M-8,-148 L-36,-176 L-44,-196");
if (arms === "mourn") upper += limb("M-8,-148 L-14,-120 L-6,-104");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5) + ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
export function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
export function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1 } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
export function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
const bolt = (x: number, y: number, s: number) =>
g(`translate(${x},${y}) scale(${s})`, ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
function cave(w: number): string {
return ink(`M0,0 H${w} V${PANEL_H} H${w - 30} Q${w - 20},${66} ${w / 2},${58} Q30,66 32,${PANEL_H} H0 Z`) +
inc(`M${w - 22},120 q-6,-30 -14,-48 M20,150 q6,-40 18,-70 M${w * 0.3},40 q30,-6 60,-2 M${w * 0.62},44 q24,0 46,10`, 1.6);
}
export interface PanelArt { w: number; art: string; inscription?: "dark" | "light"; inscrEnd?: boolean }
const ground = (w: number) => `<path class="ground" d="M0,${GROUND} H${w}"/>`;
export const PANELS: Record<string, PanelArt> = {
"the-horse-and-the-infant": { w: 370, art: trojanHorse(132, 0.9) + person({ x: 300, f: -1, arms: "reach", helmet: true, infant: true }) },
"just-a-man": {
w: 240,
art: person({ x: 96, arms: "mourn", helmet: true, beard: true }) +
limb("M178,284 L192,70", 3.5) + ink("M192,70 L186,48 L200,66 Z") +
`<circle class="ink" cx="176" cy="250" r="32"/><circle class="inc" cx="176" cy="250" r="26" stroke-width="1.6"/>` +
inc("M162,236 l28,28 M190,236 l-28,28", 1.4),
},
"full-speed-ahead": { w: 390, art: waves(0, 390, 290) + ship(190, 276, 1, { sail: true }) },
"open-arms": {
w: 300,
art: person({ x: 92, arms: "open", beard: true }) + person({ x: 214, f: -1, arms: "reach", robe: true }),
},
"warrior-of-the-mind": {
w: 340,
art: person({ x: 98, arms: "spear", helmet: true, shield: true, robe: true }) +
person({ x: 246, f: -1, arms: "spear", helmet: true, shield: true, beard: true }),
},
polyphemus: {
w: 340,
art: person({ x: 76, arms: "reach", beard: true, cup: true }) +
person({ x: 252, f: -1, s: 1.2, arms: "club", cyclops: true, beard: true }),
},
survive: {
w: 340, inscription: "light",
art: person({ x: 214, f: -1, s: 1.02, arms: "down", cyclops: true, beard: true }) +
person({ x: 82, s: 0.62, arms: "mourn", kneel: true }) + person({ x: 120, s: 0.62, arms: "reach" }) + cave(340),
},
"remember-them": {
w: 310,
art: stele(155) + person({ x: 62, arms: "mourn", kneel: true, beard: true }) + person({ x: 250, f: -1, arms: "mourn", robe: true }),
},
"my-goodbye": {
w: 400, inscrEnd: true,
art: waves(160, 400, 290) + person({ x: 78, s: 1.15, arms: "throw", cyclops: true, beard: true }) +
ship(292, 276, 0.62, { sail: true }),
},
storm: {
w: 350,
art: bolt(52, 40, 1) + bolt(282, 52, 0.8) + ship(176, 252, 0.86, { tilt: -9 }) + waves(0, 350, 278, 1.3) + waves(18, 350, 296, 0.9),
},
};
const escXml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** The painted scene for a song: figures plus its name written in the field, as on a vase. */
export function panelSvg(id: string, title: string, withTitle = true): string {
const p = PANELS[id] ?? { w: 260, art: person({ x: 130 }) };
const light = p.inscription === "light";
const x = p.inscrEnd ? p.w - 16 : light ? 46 : 16;
const label = withTitle
? `<text class="inscr${light ? " inscr--light" : ""}" x="${x}" y="${light ? 32 : 28}"${p.inscrEnd ? ' text-anchor="end"' : ""}>${escXml(title.toUpperCase())}</text>`
: "";
return `<svg class="scene" viewBox="0 0 ${p.w} ${PANEL_H}" aria-hidden="true" focusable="false">${p.art}${ground(p.w)}${label}</svg>`;
}
export function panelWidth(id: string): number {
return PANELS[id]?.w ?? 260;
}
/** Narrow ladder band between consecutive songs of one saga. */
export function ladderSvg(): string {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return `<svg class="sep-art" viewBox="0 0 26 300" aria-hidden="true"><path class="rule" d="${d}"/></svg>`;
}
export function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A saga boundary: palmettes framing the saga's name, painted vertically. */
export function sagaSvg(name: string, gap: number): string {
const tally = gap > 0
? `<text class="tally" x="66" y="296" text-anchor="middle">${gap} ${gap === 1 ? "year" : "years"} later</text>`
: "";
return `<svg class="sep-art" viewBox="0 0 132 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M128,0 V300"/>
${palmette(66, 52)}${palmette(66, gap > 0 ? 226 : 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${escXml(name.replace(/^The /, "").toUpperCase())}</text>
${tally}
</svg>`;
}
/** Elapsed story time between two songs, painted as a tally of years. */
export function gapSvg(gap: number): string {
const w = 40 + gap * 24;
let bars = "";
for (let i = 0; i < gap; i++) bars += `M${20 + i * 24 + 6},120 V180 `;
return `<svg class="sep-art" viewBox="0 0 ${w} 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M${w - 4},0 V300"/>
<path class="tally-bars" d="${bars}"/>
<text class="tally" transform="translate(${w / 2 + 4},250) rotate(-90)" >+${gap} yr</text>
</svg>`;
}
+226
View File
@@ -0,0 +1,226 @@
// From 13/14 via 15: the two frieze "curtains" in the 04 black-figure kit. In 15 the viewBox is cropped to the
// band, so it is fitted to the stage (no empty glaze above and below), and a register under the ground line
// holds a running-wave scroll, or (paths B) the current song's motif threads.
import type { Song } from "../../data/types";
import { GROUND, PANEL_H, g, inc, ink, limb, palmette, person, PANELS, ship, waves } from "./figures";
import { bright, byTrack, hitsOf, indexOf, routeOf, songMotifs } from "./threads";
export const VB_W = 1440;
export const VB_H = 850;
/** Band layout (stage units): top border, painted field (panels are 300 units tall, drawn at SCALE), bottom border. */
const BAND_TOP = 146;
const FIELD_Y = 222;
const SCALE = 1.24;
/** Where the painted ground line falls on the stage (also the 3D horizon at the start of the morph). */
export const GROUND_Y = FIELD_Y + GROUND * SCALE; // ≈ 574
/** The register above the band: a palmette chain, as on a vase's neck (fills the stage height in 15). */
const NECK_H = 96;
/** The register under the ground line: wave scroll (paths A, C) or motif threads (paths B). */
const REG_H = 78;
const BOT_Y = GROUND_Y + REG_H;
const BAND_BOT = BOT_Y + 100;
/** The cropped viewBox: the band plus a margin of glaze. */
export const VB_Y = BAND_TOP - NECK_H - 20;
export const VB_FIT_H = BAND_BOT + 22 - VB_Y;
/** Centre of the cave mouth in the Cyclops frieze, in stage units: the zoom goes through here. */
export const MOUTH = { x: 720, y: FIELD_Y + (GROUND - 100) * SCALE };
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
function defs(id: string): string {
const meander = (key: string, y: number) =>
`<pattern id="${id}-${key}" y="${y}" width="28" height="28" patternUnits="userSpaceOnUse"><path class="mean" d="M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7" /></pattern>`;
return `<defs>
${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)}
<pattern id="${id}-tongues" y="${BAND_TOP + 50}" width="18" height="22" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 0H16V13A7 7 0 0 1 2 13Z" />
</pattern>
<pattern id="${id}-chain" x="0" y="${BAND_TOP - NECK_H + 10}" width="84" height="78" patternUnits="userSpaceOnUse">
<path class="vol" d="M0 39 Q10.5 62 21 62 Q31.5 62 42 39 Q52.5 16 63 16 Q73.5 16 84 39"/>
${g("translate(21,60) scale(0.66)", palmette(0, 0))}${g("translate(63,18) scale(0.66)", palmette(0, 0, true))}
</pattern>
<pattern id="${id}-scroll" x="0" y="${GROUND_Y + 14}" width="56" height="52" patternUnits="userSpaceOnUse">
<path class="scroll" d="M0 44 C20 44 34 36 34 20 C34 9 24 5 18 11 C13 16 18 23 24 19" />
<path class="scroll" d="M34 20 C34 34 44 44 56 44" />
</pattern>
<pattern id="${id}-rays" y="${BOT_Y + 46}" width="32" height="54" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 54L16 6L30 54Z" />
</pattern>
</defs>`;
}
/**
* Shared chrome, split at the ground line into a top half (surround, band, top border) and a bottom half,
* so the morph can part them along the line that becomes the horizon.
*/
function chrome(id: string, threads = "") {
const X0 = -200, W = VB_W + 400;
const rule = (y: number) => `<path class="rule" d="M${X0} ${y}H${X0 + W}"/>`;
const frame = (y: number) => `<path class="frame-rule" d="M${X0} ${y}H${X0 + W}M${X0} ${y + 5}H${X0 + W}"/>`;
const top = `<g class="f-top">
<rect class="f-surround" x="${X0}" y="-600" width="${W}" height="${GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${BAND_TOP - NECK_H}" width="${W}" height="${GROUND_Y - BAND_TOP + NECK_H}"/>
${rule(BAND_TOP - NECK_H + 4)}
<rect x="${X0}" y="${BAND_TOP - NECK_H + 10}" width="${W}" height="78" fill="url(#${id}-chain)"/>
${frame(BAND_TOP - 12)}${rule(BAND_TOP + 8)}
<rect x="${X0}" y="${BAND_TOP + 14}" width="${W}" height="28" fill="url(#${id}-mt)"/>
${rule(BAND_TOP + 46)}
<rect x="${X0}" y="${BAND_TOP + 50}" width="${W}" height="22" fill="url(#${id}-tongues)"/>
</g>`;
const bottom = `<g class="f-bottom">
<rect class="f-surround" x="${X0}" y="${GROUND_Y}" width="${W}" height="${VB_H - GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${GROUND_Y}" width="${W}" height="${BAND_BOT - GROUND_Y}"/>
<rect class="f-scroll" x="${X0}" y="${GROUND_Y + 14}" width="${W}" height="52" fill="url(#${id}-scroll)"/>
<g class="f-threads">${threads}</g>
${rule(BOT_Y + 2)}
<rect x="${X0}" y="${BOT_Y + 8}" width="${W}" height="28" fill="url(#${id}-mb)"/>
${rule(BOT_Y + 40)}
<rect x="${X0}" y="${BOT_Y + 46}" width="${W}" height="54" fill="url(#${id}-rays)"/>
${frame(BAND_BOT + 6)}
</g>`;
return { top, bottom };
}
/**
* Paths B in a frieze: the song's motifs as ink-edged threads in the register under the ground line,
* knotted under each painted song where the motif plays, running off the band where it continues.
*/
function friezeThreads(song: Song, stops: { song: string; x: number }[]): string {
const ms = songMotifs(song);
const cur = indexOf(song);
let out = "";
ms.forEach((m, k) => {
const y = GROUND_Y + 16 + k * 23 - (ms.length - 3) * 8;
const hits = hitsOf(m.id);
const inFrieze = stops.filter((st) => hits.some((h) => h === stopIndex(st.song)));
const first = hits[0]!, last = hits[hits.length - 1]!;
const firstStop = stopIndex(stops[0]!.song), lastStop = stopIndex(stops[stops.length - 1]!.song);
const x0 = first < firstStop ? 304 : inFrieze[0]?.x ?? 304;
const x1 = last > lastStop ? VB_W + 200 : inFrieze[inFrieze.length - 1]?.x ?? x0;
const wav = (x: number) => y + Math.sin(x / 34 + k * 1.9) * 2.6;
let d = "";
for (let x = x0; x < x1; x += 8) d += `${d ? "L" : "M"}${x},${wav(x).toFixed(1)}`;
d += `L${x1},${wav(x1).toFixed(1)}`;
const r = routeOf(song, m.id);
out += `<g class="ft" data-m="${m.id}" data-pick="${m.id}" style="--c:${bright(m.id)}">
<title>${esc(m.name)}: ${esc(r.from)} · ${esc(r.next)}</title>
<path class="ft__edge" d="${d}"/><path class="ft__line" d="${d}"/>
${inFrieze.map((st) => `<circle class="ft__knot${stopIndex(st.song) === cur ? " is-here" : ""}" cx="${st.x}" cy="${wav(st.x).toFixed(1)}" r="${stopIndex(st.song) === cur ? 8 : 6}"/>`).join("")}
<circle class="ft__dot" cx="32" cy="${y}" r="6"/>
<text class="ft__name" x="46" y="${y + 7}">${esc(m.name)}</text>
</g>`;
});
return out;
}
const stopIndex = (songId: string) => hitsIndex.get(songId) ?? -1;
const hitsIndex = new Map(byTrack.map((s, i) => [s.id, i] as const));
const placed = (x: number, art: string) => g(`translate(${x},${FIELD_Y}) scale(${SCALE})`, art);
const inscription = (x: number, text: string, anchor: "start" | "middle" | "end" = "start") =>
`<text class="inscr" x="${x}" y="${FIELD_Y + 34}" text-anchor="${anchor}">${esc(text.toUpperCase())}</text>`;
/** A painted saga column (palmettes framing the saga's name), as in 04. */
function sagaColumn(x: number, name: string): string {
return placed(x, `<path class="rule" d="M4,0 V${PANEL_H} M128,0 V${PANEL_H}"/>${palmette(66, 52)}${palmette(66, 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${esc(name.toUpperCase())}</text>`);
}
const ladder = (x: number) => {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return placed(x, `<path class="rule" d="${d}"/>`);
};
/** Beat 1: the fall of Troy. */
export function troyFrieze(song: Song): string {
const id = "fz1";
const u = (n: number) => n * SCALE;
const stops: { song: string; x: number }[] = [];
let x = 22;
let figs = sagaColumn(x, "Troy Saga");
x += u(132) + 18;
figs += placed(x, PANELS["the-horse-and-the-infant"]!.art) + inscription(x + 18, "The Horse and the Infant");
stops.push({ song: "the-horse-and-the-infant", x: Math.round(x + u(185)) });
x += u(370);
figs += ladder(x);
x += u(26) + 6;
figs += placed(x, PANELS["just-a-man"]!.art) + inscription(x + 18, "Just a Man");
stops.push({ song: "just-a-man", x: Math.round(x + u(120)) });
x += u(240);
figs += ladder(x);
x += u(26) + 6;
figs += placed(x, waves(0, 330, 290) + ship(170, 276, 0.9, { sail: true })) + inscription(x + 18, "Full Speed Ahead");
stops.push({ song: "full-speed-ahead", x: Math.round(x + u(170)) });
const c = chrome(id, friezeThreads(song, stops));
return `<svg class="frieze frieze--troy" viewBox="0 ${VB_Y} ${VB_W} ${VB_FIT_H}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
${defs(id)}
${c.top}${c.bottom}
<g class="f-figs">${figs}</g>
<path class="ground horizon-line" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
</svg>`;
}
/* ---------- Cyclops frieze: crew, cave mouth, Polyphemus, sheep ---------- */
function sheep(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-14,-14 L-16,0", 4) + limb("M-4,-14 L-3,0", 4) + limb("M12,-14 L13,0", 4) + limb("M20,-14 L22,0", 4);
b += ink("M-24,-30 Q-26,-46 -10,-48 Q2,-58 14,-48 Q30,-50 28,-32 Q32,-16 16,-14 L-14,-14 Q-30,-14 -24,-30 Z");
b += inc("M-14,-38 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 M-16,-26 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0", 1.3);
b += ink("M26,-40 L40,-44 Q46,-38 40,-30 L28,-30 Z");
b += `<circle class="clay" cx="38" cy="-39" r="1.6"/>`;
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** The rock and the arch of the cave mouth; the mouth itself is cut out with a mask so the 3D cave shows through. */
const MOUTH_W = 120; // half-width of the arch, panel units
const MOUTH_H = 200; // height of the arch above the ground line
function mouthPath(cx: number): string {
return `M${cx - MOUTH_W},${GROUND} V${GROUND - 92} Q${cx - MOUTH_W},${GROUND - MOUTH_H} ${cx},${GROUND - MOUTH_H} Q${cx + MOUTH_W},${GROUND - MOUTH_H} ${cx + MOUTH_W},${GROUND - 92} V${GROUND} Z`;
}
function caveRock(cx: number): string {
const r = MOUTH_W + 46;
let b = ink(`M${cx - r - 40},${GROUND} Q${cx - r - 30},${GROUND - 170} ${cx - r + 20},${GROUND - 222} Q${cx - 60},${GROUND - 268} ${cx + 10},${GROUND - 262} Q${cx + r - 10},${GROUND - 270} ${cx + r + 18},${GROUND - 200} Q${cx + r + 46},${GROUND - 110} ${cx + r + 40},${GROUND} Z`);
b += `<path class="inc" d="${mouthPath(cx)}" stroke-width="5" transform="translate(${cx},${GROUND}) scale(1.06) translate(${-cx},${-GROUND})"/>`;
b += inc(`M${cx - r - 18},${GROUND - 120} q18,-30 46,-40 M${cx - r + 4},${GROUND - 190} q34,-24 70,-26 M${cx + r - 30},${GROUND - 214} q30,6 44,40 M${cx + r + 10},${GROUND - 80} q8,-30 4,-60 M${cx - 40},${GROUND - 246} q40,-8 84,2`, 2);
return b;
}
export function cyclopsFrieze(song: Song): string {
const id = "fz4";
const c = chrome(id, friezeThreads(song, [{ song: "polyphemus", x: 640 }, { song: "survive", x: 1275 }]));
const u = (n: number) => n * SCALE;
const panelX = (stageX: number) => (stageX - 0) / SCALE; // field art is placed at x=0
const cx = panelX(MOUTH.x);
let art = "";
// crew on the left: Odysseus offers the cup, two men behind him
art += person({ x: panelX(250), s: 0.82, arms: "spear", helmet: true, shield: true });
art += person({ x: panelX(326), s: 0.82, arms: "down", helmet: true });
art += person({ x: panelX(398), s: 0.94, arms: "reach", beard: true, cup: true });
art += caveRock(cx);
// Polyphemus at his door, then the flock
art += person({ x: panelX(1060), f: -1, s: 1.16, arms: "club", cyclops: true, beard: true });
art += sheep(panelX(1180), 0.95) + sheep(panelX(1275), 0.85, -1) + sheep(panelX(1370), 0.92);
let figs = sagaColumn(22, "Cyclops Saga");
figs += placed(0, art);
figs += inscription(u(132) + 60, "Polyphemus");
figs += inscription(VB_W - 34, "Survive", "end");
const mouthStage = `translate(0,${FIELD_Y}) scale(${SCALE})`;
return `<svg class="frieze frieze--cyclops" viewBox="0 ${VB_Y} ${VB_W} ${VB_FIT_H}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
${defs(id)}
<mask id="${id}-mouth" maskUnits="userSpaceOnUse" x="-210" y="-610" width="${VB_W + 420}" height="${VB_H + 1220}">
<rect x="-210" y="-610" width="${VB_W + 420}" height="${VB_H + 1220}" fill="#fff"/>
<path d="${mouthPath(cx)}" transform="${mouthStage}" fill="#000"/>
</mask>
<radialGradient id="${id}-glow" cx="0.5" cy="0.92" r="0.75">
<stop offset="0" class="glow-a"/><stop offset="0.55" class="glow-b"/><stop offset="1" class="glow-c"/>
</radialGradient>
<g class="f-zoom">
<g mask="url(#${id}-mouth)">
${c.top}${c.bottom}
<g class="f-figs">${figs}</g>
<path class="ground" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
</g>
<path class="mouth-fill" d="${mouthPath(cx)}" transform="${mouthStage}" fill="url(#${id}-glow)"/>
</g>
</svg>`;
}
+437
View File
@@ -1,3 +1,440 @@
import { songs } from "../../data/sample";
import type { Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { hereAt } from "./chart";
import { contentMounted, loadLibrary } from "./content";
import { cyclopsFrieze, GROUND_Y, MOUTH, troyFrieze, VB_FIT_H, VB_H, VB_W, VB_Y } from "./frieze";
import { createPanel, TABS, type Tab } from "./panel";
import { VoyageScene, ease, webglAvailable } from "./scene";
import { applyPick, bright, byTrack, hStrip, indexOf, resetMeasure, songMotifs, stripX, stripY, type StripKind } from "./threads";
mountCredits();
// 17: the real content (audio, lyrics, Homer) comes from the server's mounted folder. Without it every
// /content/ route is 404 and the page behaves as 16 did. Loaded before the panel so its first song is real.
await loadLibrary();
document.body.dataset.content = contentMounted() ? "mounted" : "missing";
/* ---------- beats (13's five, each with the song the panel follows) ---------- */
interface Beat { short: string; eyebrow: string; caption: string; songId: string }
const BEATS: Beat[] = [
{ short: "Frieze", eyebrow: "The Troy Saga · year 0", caption: "Troy falls", songId: "the-horse-and-the-infant" },
{ short: "To sea", eyebrow: "Between sagas · a year at sea", caption: "Out on the open sea", songId: "full-speed-ahead" },
{ short: "Landfall", eyebrow: "The Cyclops Saga · year 1", caption: "Landfall: Cyclops' Island", songId: "polyphemus" },
{ short: "Curtain", eyebrow: "The Cyclops Saga", caption: "Polyphemus", songId: "polyphemus" },
{ short: "Cave", eyebrow: "Inside the cave", caption: "Into the cave", songId: "survive" },
];
const SEG_DUR = [1.8, 2.0, 1.2, 1.6];
const HOLD = 3.4;
const songById = (id: string) => songs.find((s) => s.id === id)!;
/** Each beat's song as an index on the voyage (track order), so strip markers can sail between them. */
const BEAT_IDX = BEATS.map((b) => indexOf(songById(b.songId)));
/* ---------- params ---------- */
const params = new URLSearchParams(location.search);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const clampBeat = (n: number) => Math.min(5, Math.max(1, Math.round(n) || 1));
const startBeat = clampBeat(Number(params.get("beat") ?? 1));
const freezeT = params.has("t") ? Math.min(1, Math.max(0, Number(params.get("t")) || 0)) : null;
const startTab: Tab = TABS.some((t) => t.id === params.get("tab")) ? (params.get("tab") as Tab) : "about";
const startOpen = params.get("panel") !== "0";
/** Paths, combined: the bottom timeline (open by default, collapsible), the panel strip while it is
* collapsed, and the ribbons on the sea as an on/off switch. */
let tlOpen = params.get("timeline") !== "closed";
let ribbons = params.get("ribbons") === "1";
let pick: string | null = params.get("motif");
const playAt = params.has("t_play") ? Math.max(0, Number(params.get("t_play")) || 0) : null;
/* ---------- DOM ---------- */
const app = document.getElementById("app")!;
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
app.className = "shell";
app.innerHTML = `
<section class="stage" id="stage" aria-label="The voyage, in five beats">
<div class="stage__three" id="three"></div>
<div class="stage__fallback" id="fallback" hidden><p>3D needs WebGL; the frieze beats still play.</p></div>
<div class="frieze-box" id="fzbox" aria-hidden="true"></div>
<div class="frieze-host frieze-host--troy" id="troy">${troyFrieze(songById(BEATS[0]!.songId))}</div>
<div class="frieze-host frieze-host--cyclops" id="cyclops">${cyclopsFrieze(songById(BEATS[3]!.songId))}</div>
<div class="wlabels" id="wlabels" aria-label="Motif threads in the world"></div>
<div class="cut" id="cut" aria-hidden="true"></div>
<header class="cap" id="cap" aria-live="polite">
<p class="cap__eyebrow" id="cap-eyebrow"></p>
<h1 class="cap__title" id="cap-title"></h1>
<button type="button" class="back" id="back" hidden>Back to sea</button>
</header>
<nav class="hud" id="hud" aria-label="The voyage: beats and timeline">
<div class="hud__row">
<button type="button" class="hud__play" id="play" aria-pressed="false">Play voyage</button>
<button type="button" class="hud__step" id="prev" aria-label="Previous beat">←</button>
<ol class="dots" id="dots">
${BEATS.map((b, i) => `<li><button type="button" data-beat="${i + 1}"><span class="dots__n">${i + 1}</span><span class="dots__label">${esc(b.short)}</span></button></li>`).join("")}
</ol>
<button type="button" class="hud__step" id="next" aria-label="Next beat">→</button>
<span class="hud__gap"></span>
<button type="button" class="switch" id="ribbons" role="switch" aria-checked="false">
<span class="switch__track" aria-hidden="true"><span class="switch__knob"></span></span>Threads on the sea
</button>
<button type="button" class="tlbtn" id="tlbtn" aria-controls="tl" aria-expanded="true">
<span class="tlbtn__text" id="tlbtn-text">Hide timeline</span><span class="tlbtn__chev" aria-hidden="true">⌄</span>
</button>
</div>
<div class="tl" id="tl"></div>
</nav>
</section>
<aside class="panel" id="panel" aria-label="Song companion"></aside>`;
const $ = (id: string) => document.getElementById(id)!;
const stage = $("stage");
const troy = $("troy");
const cyclops = $("cyclops");
const cut = $("cut");
const capEl = $("cap");
const tl = $("tl");
const wlabels = $("wlabels");
const playBtn = $("play") as HTMLButtonElement;
const backBtn = $("back") as HTMLButtonElement;
const troyParts = {
figs: troy.querySelector<SVGGElement>(".f-figs")!,
top: troy.querySelector<SVGGElement>(".f-top")!,
bottom: troy.querySelector<SVGGElement>(".f-bottom")!,
horizon: troy.querySelector<SVGPathElement>(".horizon-line")!,
};
const cyParts = {
zoom: cyclops.querySelector<SVGGElement>(".f-zoom")!,
mouth: cyclops.querySelector<SVGPathElement>(".mouth-fill")!,
};
const setParam = (k: string, v: string | null) => {
const u = new URL(location.href);
if (v === null) u.searchParams.delete(k);
else u.searchParams.set(k, v);
history.replaceState(null, "", u);
};
let scene: VoyageScene | null = null;
if (webglAvailable()) {
try { scene = new VoyageScene($("three"), reduced); } catch { scene = null; }
}
if (!scene) $("fallback").hidden = false;
const panel = createPanel($("panel"), {
tab: startTab,
open: startOpen,
reduced,
pick,
pinned: !tlOpen,
playAt,
read: params.get("read") === "1",
onTab: (t) => setParam("tab", t),
onOpen: (o) => { app.dataset.panel = o ? "open" : "closed"; setParam("panel", o ? null : "0"); },
onPick: (id) => setPick(id),
});
app.dataset.panel = startOpen ? "open" : "closed";
const here = panel.here;
/* ---------- motif pick: one choice, highlighted everywhere ---------- */
let song: Song = songById(BEATS[0]!.songId);
function setPick(id: string | null) {
pick = id;
panel.setPick(id);
scene?.setPick(id);
applyPick(stage, id);
setParam("motif", id);
}
stage.addEventListener("click", (e) => {
const b = (e.target as Element).closest<Element>("[data-pick]");
if (b) { const id = b.getAttribute("data-pick"); setPick(pick === id ? null : id); }
});
/** The song the panel, the threads and the labels follow (switches at each transition's midpoint). */
function setSong(next: Song) {
song = next;
if (pick && !songMotifs(next).some((m) => m.id === pick)) pick = null;
panel.setSong(next);
scene?.setThreadSong(next);
labelKey = "";
renderTimeline();
setPick(pick);
}
/* ---------- threads on the sea: floating labels over the 3D ribbons ---------- */
// Labels are grouped by the knot they point at (two motifs returning in the same song share one label),
// lifted well above it on a leader line so they never cover the cave mouth, and stacked so they never overlap.
let labelKey = "";
const LIFT = 96;
function placeLabels() {
if (!scene || !ribbons) return;
const vis = p < 2 ? clamp01((p - 0.6) / 0.25) * (1 - clamp01((p - 2) / 0.25)) : p < 3.5 ? 1 - clamp01((p - 2) / 0.25) : clamp01((p - 3.78) / 0.15);
wlabels.style.opacity = String(vis);
wlabels.style.visibility = vis > 0.02 ? "visible" : "hidden";
if (vis <= 0.02) return;
const pts = scene.labelPoints(p).filter((pt) => pt.visible);
const cur = indexOf(song);
const groups = new Map<string, { song: number; ahead: boolean; ids: string[]; x: number; y: number }>();
for (const pt of pts) {
const k = `${pt.song}:${pt.ahead}`;
const g = groups.get(k);
if (g) { g.ids.push(pt.id); g.x = (g.x + pt.x) / 2; g.y = Math.max(g.y, pt.y); }
else groups.set(k, { song: pt.song, ahead: pt.ahead, ids: [pt.id], x: pt.x, y: pt.y });
}
const list = [...groups.values()].sort((a, b) => b.y - a.y);
const key = list.map((g) => `${g.song}${g.ahead}${g.ids.join()}`).join("|");
if (key !== labelKey) {
labelKey = key;
wlabels.innerHTML = `<svg class="wl-leaders" aria-hidden="true"></svg>` + list.map((g) => {
const s = byTrack[g.song]!;
const head = g.ahead ? `Next: ${s.track}. ${esc(s.title)}` : g.song === cur ? "Not heard again after this" : `Last heard: ${s.track}. ${esc(s.title)}`;
return `<div class="wl"><p class="wl__head">${head}</p><p class="wl__ms">${g.ids.map((id) => {
const m = songMotifs(song).find((x) => x.id === id)!;
return `<button type="button" class="wl__m" data-m="${id}" data-pick="${id}" aria-pressed="${pick === id}" style="--c:${bright(id)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`;
}).join("")}</p></div>`;
}).join("");
applyPick(wlabels, pick);
}
const els = [...wlabels.querySelectorAll<HTMLElement>(".wl")];
const W = stage.clientWidth;
const top = capEl.offsetTop + capEl.offsetHeight + 10;
const placed: { l: number; r: number; t: number; b: number }[] = [];
let lines = "";
list.forEach((g, i) => {
const el = els[i];
if (!el) return;
const w = el.offsetWidth, h = el.offsetHeight;
let l = Math.min(W - w - 12, Math.max(12, g.x - w / 2));
let b = g.y - LIFT;
// stack above anything already placed that this would overlap
for (let guard = 0; guard < 6; guard++) {
const hit = placed.find((q) => l < q.r + 8 && l + w > q.l - 8 && b > q.t - 8 && b - h < q.b + 8);
if (!hit) break;
b = hit.t - 10;
}
b = Math.max(top + h, b);
placed.push({ l, r: l + w, t: b - h, b });
el.style.transform = `translate(${Math.round(l)}px, ${Math.round(b - h)}px)`;
const ax = Math.min(l + w - 14, Math.max(l + 14, g.x));
lines += `<path d="M${ax.toFixed(1)},${b.toFixed(1)} L${g.x.toFixed(1)},${(g.y - 6).toFixed(1)}"/><circle cx="${g.x.toFixed(1)}" cy="${g.y.toFixed(1)}" r="4"/>`;
});
const svg = wlabels.querySelector<SVGSVGElement>(".wl-leaders");
if (svg && svg.innerHTML !== lines) svg.innerHTML = lines;
}
/* ---------- the timeline at the bottom: open (titles + threads) or slim (stops + you are here) ---------- */
const TL_LABEL_W = 156;
const tlOpts = (): { width: number; labelW: number; rowH: number; kind: StripKind } =>
({ width: tl.clientWidth, labelW: tlOpen ? TL_LABEL_W : 112, rowH: 16, kind: tlOpen ? "timeline" : "slim" });
function renderTimeline() {
const o = tlOpts();
if (!o.width) return;
tl.innerHTML = hStrip({ ...o, song, pick });
}
/* ---------- state ---------- */
let p = freezeT !== null ? Math.max(0, startBeat - 2 + freezeT) : startBeat - 1;
let anim: { from: number; to: number; start: number; dur: number } | null = null;
let cutting = false;
let playing = false;
let holdUntil = 0;
let shownBeat = -1;
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const easeIn = (x: number) => { const t = clamp01(x); return t * t * t; };
const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
/** The ship's place on the voyage (fractional song index) at beat position p. */
function shipIdx(): number {
const i = Math.min(3, Math.floor(p));
const k = i === 1 ? easeInOut(p - 1) : ease(p - i);
return BEAT_IDX[i]! + (BEAT_IDX[i + 1]! - BEAT_IDX[i]!) * k;
}
/** How the band-cropped frieze sits in its box on the stage (px). */
let fit = { s: 1, oy: 0 };
function measureFit() {
const box = $("fzbox");
const w = box.clientWidth, h = box.clientHeight;
if (!w || !h) return;
const s = Math.min(w / VB_W, h / VB_FIT_H);
fit = { s, oy: box.offsetTop + (h - VB_FIT_H * s) / 2 };
const H = stage.clientHeight;
scene?.setHorizon((fit.oy + (GROUND_Y - VB_Y) * s) / H);
}
/* ---------- render: everything is a function of p ---------- */
function renderDom() {
const s0 = clamp01(p);
troy.hidden = s0 >= 1;
if (!troy.hidden) {
const flat = ease(s0 / 0.32);
troyParts.figs.setAttribute("transform", `translate(0,${GROUND_Y}) scale(1,${(1 - flat).toFixed(4)}) translate(0,${-GROUND_Y})`);
troyParts.figs.style.opacity = String(1 - ease((s0 - 0.1) / 0.24));
const part = easeInOut((s0 - 0.12) / 0.5);
troyParts.top.setAttribute("transform", `translate(0,${(-part * (GROUND_Y + 700)).toFixed(1)})`);
troyParts.bottom.setAttribute("transform", `translate(0,${(part * (VB_H - GROUND_Y + 700)).toFixed(1)})`);
troyParts.horizon.style.opacity = String(1 - ease((s0 - 0.5) / 0.22));
}
// "You are here" on the route chart (Paths tab) follows the ship from open sea to the island.
const [hx, hy] = hereAt(ease((p - 1.1) / 0.8));
here.setAttribute("transform", `translate(${hx.toFixed(1)},${hy.toFixed(1)})`);
cyclops.hidden = p <= 2;
if (!cyclops.hidden) {
const drop = easeInOut(p - 2);
// the curtain falls from fully above the stage: its glaze reaches 600 units below the viewBox
const travel = fit.oy + (VB_H + 600 - VB_Y) * fit.s + 24;
cyclops.style.transform = `translateY(${((drop - 1) * travel).toFixed(1)}px)`;
const z = clamp01(p - 3);
cyParts.mouth.style.opacity = String(1 - ease(z / 0.28));
const scale = 1 + 15 * easeIn((z - 0.08) / 0.92);
cyParts.zoom.setAttribute("transform", `translate(${MOUTH.x},${MOUTH.y}) scale(${scale.toFixed(4)}) translate(${-MOUTH.x},${-MOUTH.y})`);
cyclops.style.opacity = String(1 - ease((z - 0.72) / 0.26));
}
stage.dataset.world = p < 0.5 ? "frieze" : p < 2.5 ? "sea" : p < 3.5 ? "frieze" : "cave";
// Captions dip and swap at each transition's midpoint; the panel and threads follow the same beat.
const near = Math.round(p);
const d = Math.abs(p - near);
capEl.style.opacity = String(clamp01(1 - d * 3.2));
if (near !== shownBeat) {
shownBeat = near;
const b = BEATS[near]!;
$("cap-eyebrow").textContent = b.eyebrow;
$("cap-title").textContent = b.caption;
backBtn.hidden = near !== 4;
if (b.songId !== song.id || !panelReady) { panelReady = true; setSong(songById(b.songId)); }
}
const target = anim ? anim.to : Math.round(p);
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((btn, i) => {
if (i === target) btn.setAttribute("aria-current", "step");
else btn.removeAttribute("aria-current");
});
($("prev") as HTMLButtonElement).disabled = target <= 0;
($("next") as HTMLButtonElement).disabled = target >= 4;
// the ship markers on the strips (the timeline, and the panel's while it is pinned) sail with the voyage
const idx = shipIdx();
if (!tlOpen) panel.setShip(idx);
const m = tl.querySelector<SVGGElement>("[data-ship]");
if (m) { const o = tlOpts(); m.setAttribute("transform", `translate(${stripX(o, idx).toFixed(1)},${stripY(o.kind)})`); }
placeLabels();
}
let panelReady = false;
/* ---------- navigation ---------- */
function go(to: number) {
if (freezeT !== null || cutting) return;
to = Math.min(4, Math.max(0, to));
if (anim) { p = anim.to; anim = null; }
if (to === p) return;
if (reduced || Math.abs(to - p) !== 1) crossCut(to);
else anim = { from: p, to, start: performance.now(), dur: SEG_DUR[Math.min(p, to)]! * 1000 };
}
function crossCut(to: number) {
cutting = true;
cut.classList.add("is-on");
window.setTimeout(() => {
p = to;
renderDom();
cut.classList.remove("is-on");
window.setTimeout(() => { cutting = false; holdUntil = performance.now() + HOLD * 1000; }, 380);
}, reduced ? 260 : 300);
}
function setPlaying(on: boolean) {
playing = on;
playBtn.textContent = on ? "Pause voyage" : "Play voyage";
playBtn.setAttribute("aria-pressed", String(on));
if (on) {
if (Math.round(p) >= 4 && !anim) go(0);
holdUntil = performance.now() + 1200;
}
}
/** Keep the frieze box and the 3D framing clear of the HUD, whatever its height. */
function frameAroundHud() {
const hud = $("hud");
const reserve = hud.offsetHeight + 16;
stage.style.setProperty("--fz-bot", `${reserve + 12}px`);
// the step bar of 13–15 (about 92px) was the framing the scenes were tuned for; lift the view by part of the rest
scene?.setFrameShift(Math.max(0, (reserve - 92) * 0.55));
measureFit();
}
function setTimeline(open: boolean) {
tlOpen = open;
document.body.dataset.timeline = open ? "open" : "closed";
panel.setPinned(!open);
$("tlbtn").setAttribute("aria-expanded", String(open));
$("tlbtn-text").textContent = open ? "Hide timeline" : "Show timeline";
setParam("timeline", open ? null : "closed");
renderTimeline();
applyPick(stage, pick);
frameAroundHud();
renderDom();
}
function setRibbons(on: boolean) {
ribbons = on;
document.body.dataset.ribbons = on ? "on" : "off";
scene?.setPaths(on);
wlabels.hidden = !on;
labelKey = "";
$("ribbons").setAttribute("aria-checked", String(on));
setParam("ribbons", on ? "1" : null);
renderDom();
}
playBtn.addEventListener("click", () => setPlaying(!playing));
$("prev").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) - 1); });
$("next").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) + 1); });
backBtn.addEventListener("click", () => { setPlaying(false); go(1); });
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((b) =>
b.addEventListener("click", () => { setPlaying(false); go(Number(b.dataset.beat) - 1); }));
$("tlbtn").addEventListener("click", () => setTimeline(!tlOpen));
$("ribbons").addEventListener("click", () => setRibbons(!ribbons));
window.addEventListener("keydown", (e) => {
// Keys inside the panel (tabs, audio, links) belong to the panel.
if (e.target instanceof Element && e.target.closest(".panel")) return;
if (e.key === "ArrowRight") { setPlaying(false); go((anim ? anim.to : p) + 1); }
else if (e.key === "ArrowLeft") { setPlaying(false); go((anim ? anim.to : p) - 1); }
else if (e.key === " " && !(e.target instanceof HTMLButtonElement)) { e.preventDefault(); setPlaying(!playing); }
});
/* ---------- loop ---------- */
const t0 = performance.now();
function frame(now: number) {
if (anim) {
const k = clamp01((now - anim.start) / anim.dur);
p = anim.from + (anim.to - anim.from) * k;
if (k >= 1) { p = anim.to; anim = null; holdUntil = now + HOLD * 1000; }
} else if (playing && !cutting && now >= holdUntil) {
if (Math.round(p) >= 4) setPlaying(false);
else go(Math.round(p) + 1);
}
scene?.render(p, (now - t0) / 1000 + 2.2);
renderDom();
requestAnimationFrame(frame);
}
new ResizeObserver(() => { renderTimeline(); applyPick(stage, pick); frameAroundHud(); }).observe(stage);
new ResizeObserver(() => frameAroundHud()).observe($("hud"));
setRibbons(ribbons);
setTimeline(tlOpen);
// timeline titles are laid out by measured width: measure again once Inter has loaded
document.fonts?.ready.then(() => { resetMeasure(); renderTimeline(); applyPick(stage, pick); });
requestAnimationFrame(frame);
if (params.get("play") === "1" && freezeT === null) setPlaying(true);
+599
View File
@@ -0,0 +1,599 @@
// The persistent right-side companion: About · Listen & lyrics · Paths · Fan works for the song of the
// current beat. A play button sits beside the song title in the header; it starts the song and opens
// Listen & lyrics. With the content folder mounted (17), the player plays the real FLAC, the lyrics are
// the song's .lrc lines with the current line lit and click-to-seek, and About's Odyssey row opens
// Butler's text in a reading view. Without it, everything falls back to 16 (simulated player, lyrics by part).
// While the bottom timeline is collapsed, a compact thread strip is pinned between the header and the tabs.
// The picked motif is owned by the stage (main.ts); the panel reports clicks and reflects the pick.
import { motifsInSong, sagaOf } from "../../data/query";
import { motifs, songs } from "../../data/sample";
import type { Motif, Song } from "../../data/types";
import { createVideoCards } from "../../shared/video-cards";
import { chartSvg } from "./chart";
import { fanWorksFor, thumbSvg, TYPE_LABEL } from "./fanworks";
import { contentMounted, keywordRe, keywordsFor, loadBook, loadLyrics, passageStart, realFor, roman, stripFootnotes, type Lyrics, type RealSong } from "./content";
import { fmt, SongPlayer } from "./player";
import { applyPick, byTrack, hitsOf, hStrip, inked, routeOf, stripX, stripY } from "./threads";
export type Tab = "about" | "listen" | "paths" | "fan";
export const TABS: { id: Tab; label: string }[] = [
{ id: "about", label: "About" },
{ id: "listen", label: "Listen & lyrics" },
{ id: "paths", label: "Paths" },
{ id: "fan", label: "Fan works" },
];
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const STRIP = { width: 372, labelW: 160, rowH: 24, kind: "panel" } as const;
const PLAY_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 3.5 L16.5 10 L6 16.5 Z"/></svg>`;
const PAUSE_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 3.5 H8.6 V16.5 H5 Z M11.4 3.5 H15 V16.5 H11.4 Z"/></svg>`;
export interface Panel {
setSong(song: Song): void;
setTab(tab: Tab): void;
setOpen(open: boolean): void;
setPick(id: string | null): void;
/** Show or hide the pinned strip (shown while the bottom timeline is collapsed). */
setPinned(on: boolean): void;
/** Move the pinned strip's ship marker to a fractional song index. */
setShip(idx: number): void;
/** The chart's "You are here" marker, so the stage can move it as the ship sails. */
here: SVGGElement;
}
export interface PanelOpts {
tab: Tab; open: boolean; reduced: boolean; pick: string | null; pinned: boolean;
/** Start the first song's player at this time (s), playing: for screenshots mid-playback. */
playAt: number | null;
/** Open About's Odyssey reading view for the first song (for screenshots). */
read: boolean;
onTab(tab: Tab): void; onOpen(open: boolean): void; onPick(id: string | null): void;
}
export function createPanel(host: HTMLElement, opts: PanelOpts): Panel {
let tab = opts.tab;
let song: Song = songs[0]!;
let pick = opts.pick;
let songPick: string | null = null;
let swapTimer = 0;
let shownPart = -1;
let playAt = opts.playAt;
/** The real recording for this song (null: not mounted, no match, or the file failed). */
let real: RealSong | null = null;
let lyrics: Lyrics | "none" | "loading" | "failed" = "none";
/** About's reading view of the Odyssey passage, and whether it shows the paragraphs before it. */
let reading = false;
let readAll = false;
/** Synced lyrics: the lit line, the line last scrolled to, and until when the user's own scroll wins. */
let lineNow = -1;
let lineScrolled = -1;
let userScrollUntil = 0;
let renderedReal = false;
host.innerHTML = `
<button type="button" class="panel__tab" id="panel-open" aria-controls="panel-body" aria-expanded="false">
<span class="panel__tab-arrow" aria-hidden="true">‹</span>
<span class="panel__tab-n" id="panel-tab-n"></span>
<span class="panel__tab-label">About · Listen &amp; lyrics · Paths · Fan works</span>
</button>
<div class="panel__body" id="panel-body">
<header class="panel__head">
<button type="button" class="pplay" id="panel-play" aria-pressed="false"></button>
<div class="panel__song" id="panel-song" aria-live="polite"></div>
<button type="button" class="panel__hide" id="panel-hide" aria-controls="panel-body" aria-expanded="true" title="Collapse the panel">Hide <span aria-hidden="true">›</span></button>
</header>
<section class="pin" id="pin" aria-label="Motif paths of this song"></section>
<div class="tabs" role="tablist" aria-label="About this song">
${TABS.map((t) => `<button type="button" role="tab" id="tab-${t.id}" aria-controls="panel-view" data-tab="${t.id}">${t.label.replace("&", "&amp;")}</button>`).join("")}
</div>
<div class="panel__view" id="panel-view" role="tabpanel" tabindex="0"></div>
</div>`;
const $ = (id: string) => host.querySelector<HTMLElement>(`#${id}`)!;
const view = $("panel-view");
const songEl = $("panel-song");
const pin = $("pin");
const headPlay = $("panel-play") as HTMLButtonElement;
const player = new SongPlayer(() => {
// the real file failed: drop back to the simulated player and re-render the tab
if (player.failed && real) { real = null; if (tab === "listen") rerender(true); }
syncPlayer();
}, host);
const isReal = () => !!real && !player.simulated;
const MOUNT_NOTE = `<p class="cnote"><b>Content folder not mounted.</b> Real audio, lyrics and the Odyssey text appear when the server has <code>CONTENT_DIR</code>.</p>`;
// The route chart is built once and moved into the Paths tab, so "You are here" keeps its place.
const chartFig = document.createElement("figure");
chartFig.className = "route";
chartFig.innerHTML = `${chartSvg()}<figcaption>The ship's course so far, on 12's general chart.</figcaption>`;
const here = chartFig.querySelector<SVGGElement>("#chart-here")!;
/* ----- head and pinned strip ----- */
function renderHead() {
const saga = sagaOf(song);
songEl.innerHTML = `<p class="panel__eyebrow" id="panel-eyebrow"></p>
<h2 class="panel__title">${esc(song.title)}</h2>
<p class="panel__sub">${esc(saga.name)} · ${esc(song.storyTime.label)}</p>`;
$("panel-tab-n").textContent = String(song.track);
}
let shipIdx = 0;
function renderPin() {
pin.innerHTML = `<p class="pin__head"><span class="pin__title">Motif paths</span><span class="pin__sub">heard before · here · returns</span></p>${hStrip({ ...STRIP, song, pick })}`;
api.setShip(shipIdx);
}
/* ----- tabs ----- */
const motifTag = (m: Motif) => `<button type="button" class="mtag" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${pick === m.id}" style="--c:${inked(m.id)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`;
function about(): HTMLElement {
if (reading && song.odyssey) return odysseyView(song.odyssey.book);
const el = document.createElement("div");
el.className = "about";
const saga = sagaOf(song);
const o = song.odyssey;
// Butler's prose has books and paragraphs, not line numbers, so the reference is the book alone
const homer = !o ? "No counterpart in Homer"
: contentMounted()
? `<span class="odref">Book ${roman(o.book)}</span><button type="button" class="readbtn" data-act="read">Read the passage <span aria-hidden="true">→</span></button>`
: `<span class="odref">Book ${roman(o.book)}</span> · <a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Butler's text on Gutenberg</a>`;
el.innerHTML = `
<dl class="facts">
<div><dt>Track</dt><dd>${song.track} of ${songs.length} in this sample</dd></div>
<div><dt>Saga</dt><dd>${esc(saga.name)}</dd></div>
<div><dt>Story time</dt><dd>${song.storyTime.yearsAfterTroy === 0 ? "Year 0" : `Year ${song.storyTime.yearsAfterTroy}`} · ${esc(song.storyTime.label)}</dd></div>
<div><dt>The Odyssey</dt><dd>${homer}</dd></div>
</dl>
${song.facts.map((f) => `<p class="about__fact">${esc(f)}</p>`).join("")}
<h3 class="sec">Parts and singers</h3>
<ol class="parts">${song.parts.map((p) => `<li><span class="parts__name">${esc(p.name)}</span><span class="parts__who">${p.singers.map(esc).join(", ")}</span></li>`).join("")}</ol>
<button type="button" class="golisten" data-act="go-listen">${PLAY_ICON}<span>Listen with the lyrics</span></button>
<h3 class="sec">Watch</h3><div class="slot-video"></div>
${contentMounted() ? "" : MOUNT_NOTE}`;
el.querySelector(".slot-video")!.append(createVideoCards(song));
return el;
}
/** The Odyssey passage for this song: Butler's book, opened at the first paragraph naming the song's keyword. */
function odysseyView(bookNo: number): HTMLElement {
const el = document.createElement("div");
el.className = "reading";
el.innerHTML = `
<button type="button" class="reading__back" data-act="unread"><span aria-hidden="true">←</span> Back to About</button>
<p class="reading__eyebrow">The Odyssey · Book ${roman(bookNo)}</p>
<div class="reading__body" aria-busy="true"><p class="hint">Opening Book ${roman(bookNo)}…</p></div>`;
const body = el.querySelector<HTMLElement>(".reading__body")!;
const forSong = song.id;
loadBook(bookNo).then((book) => {
if (song.id !== forSong || !reading || !body.isConnected) return;
body.removeAttribute("aria-busy");
if (!book) { body.innerHTML = `<p class="note">Book ${roman(bookNo)} could not be loaded from the content folder.</p>`; return; }
const words = keywordsFor(song.id);
const at = passageStart(book, words);
const re = keywordRe(words);
const para = (t: string) => {
let h = esc(stripFootnotes(t));
if (re) h = h.replace(re, "<mark>$1</mark>");
return `<p class="reading__p">${h}</p>`;
};
const from = readAll ? 0 : at.index;
const marker = `<p class="reading__mark" id="reading-mark">${at.word
? `The passage for ${esc(song.title)} begins here · first mention of “${esc(at.word)}”`
: `No keyword for ${esc(song.title)} found · Book ${roman(bookNo)} from the start`}</p>`;
body.innerHTML = `
<h3 class="reading__title">${esc(book.heading.replace(/^BOOK\b/, "Book"))}</h3>
<p class="reading__sum">${esc(book.summary)}</p>
<p class="reading__names">Samuel Butler's translation (public domain) uses Roman names: Ulysses = Odysseus, Minerva = Athena, Jove = Zeus, Neptune = Poseidon.</p>
${from > 0 ? `<button type="button" class="reading__more" data-act="read-all">Show the ${from} paragraph${from === 1 ? "" : "s"} before this passage</button>` : ""}
${book.paragraphs.slice(from).map((t, i) => (from + i === at.index ? marker : "") + para(t)).join("")}
<p class="reading__end">End of Book ${roman(bookNo)} · Project Gutenberg #1727</p>`;
if (readAll) el.querySelector("#reading-mark")?.scrollIntoView({ block: "start" });
});
return el;
}
function paths(): HTMLElement {
const el = document.createElement("div");
el.className = "paths";
const ms = motifsInSong(song);
const sec = document.createElement("div");
sec.innerHTML = `<h3 class="sec">Motif threads</h3>
<p class="hint">Each thread is a motif; a knot is a song where it plays. Pick a motif to follow it everywhere.</p>
<ul class="mlist">${ms.map((m) => {
const r = routeOf(song, m.id);
return `<li><button type="button" class="mpick" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${pick === m.id}" style="--c:${inked(m.id)}">
<span class="mpick__name"><i aria-hidden="true"></i>${esc(m.name)}</span><span class="mpick__path">${esc(r.from)} · ${esc(r.next)}</span></button></li>`;
}).join("")}</ul>`;
el.append(sec, strip(ms));
const route = document.createElement("h3");
route.className = "sec";
route.textContent = "The route";
el.append(route, chartFig);
return el;
}
/** The vertical thread strip: one row per song, one soft thread per motif of this song, knotted where it plays. */
function strip(ms: Motif[]): HTMLElement {
const ROW = 34, COL = 26, X0 = 16;
const wrap = document.createElement("div");
wrap.className = "vstrip";
wrap.style.setProperty("--cols", String(ms.length));
const rows = byTrack.map((s) => {
const cls = ["vstrip__row", s.id === song.id ? "is-current" : "", s.id === songPick ? "is-picked" : ""].join(" ");
return `<li><button type="button" class="${cls}" data-song="${s.id}" aria-pressed="${s.id === songPick}">
<span class="vstrip__n">${s.track}</span><span class="vstrip__t">${esc(s.title)}</span>${s.id === song.id ? '<span class="vstrip__now">now</span>' : ""}</button></li>`;
}).join("");
const H = byTrack.length * ROW;
const W = X0 * 2 + (ms.length - 1) * COL;
let svg = "";
ms.forEach((m, k) => {
const x = X0 + k * COL;
const ys = hitsOf(m.id).map((i) => i * ROW + ROW / 2);
const y0 = ys[0]!, y1 = ys[ys.length - 1]!;
const wx = (y: number) => (x + Math.sin(y / 22 + k * 1.7) * 2.4).toFixed(1);
let d = "";
for (let y = y0; y < y1; y += 6) d += `${d ? "L" : "M"}${wx(y)},${y}`;
d += `${d ? "L" : "M"}${wx(y1)},${y1}`;
svg += `<g class="thread" data-m="${m.id}" style="--c:${inked(m.id)}"><path class="thread__line" d="${d}"/>${ys.map((y) => `<circle class="thread__knot" cx="${wx(y)}" cy="${y}" r="5"/>`).join("")}</g>`;
});
wrap.innerHTML = `<ol class="vstrip__rows">${rows}</ol>
<svg class="vstrip__threads" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" aria-hidden="true">${svg}</svg>`;
wrap.querySelectorAll<HTMLButtonElement>(".vstrip__row").forEach((b) => b.addEventListener("click", () => {
songPick = songPick === b.dataset.song ? null : b.dataset.song!;
rerender(true);
}));
return wrap;
}
/** Listen & lyrics: the real player and the song's synced lyrics; without the content folder, 16's
* simulated player and the placeholder lyrics by part. */
function listen(): HTMLElement {
const el = document.createElement("div");
el.className = "listen";
renderedReal = isReal();
el.innerHTML = renderedReal ? realListen() : previewListen();
const scrub = el.querySelector<HTMLElement>(".scrub")!;
const seekAt = (clientX: number) => {
const r = scrub.getBoundingClientRect();
player.seek(((clientX - r.left) / r.width) * player.dur);
followNow();
};
scrub.addEventListener("pointerdown", (e) => {
seekAt(e.clientX);
scrub.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) => seekAt(ev.clientX);
scrub.addEventListener("pointermove", move);
scrub.addEventListener("pointerup", () => scrub.removeEventListener("pointermove", move), { once: true });
});
scrub.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 5 : e.key === "ArrowLeft" ? -5 : 0;
if (d) { e.preventDefault(); player.seek(player.t + d); followNow(); }
});
return el;
}
const scrubHtml = (ticks: string) => `
<div class="scrub" role="slider" tabindex="0" aria-label="Position in ${esc(song.title)}" aria-valuemin="0" aria-valuemax="${Math.round(player.dur)}">
<div class="scrub__track"><div class="scrub__fill" data-fill></div>${ticks}<div class="scrub__head" data-head></div></div>
</div>`;
function realListen(): string {
const r = real!;
const ms = motifsInSong(song);
const partsLine = song.parts.map((p) => esc(p.name)).join(" · ");
let body: string;
if (lyrics === "loading") body = `<p class="hint">Loading the lyrics…</p>`;
else if (lyrics === "failed") body = `<p class="note">The lyrics file could not be loaded.</p>`;
else if (lyrics === "none") body = `<p class="note">This song has no lyrics file in the content folder.</p>`;
else if (!lyrics.timed) {
body = `<p class="note">These lyrics are not timed, so they do not follow the song.</p>
<div class="lyrics__plain">${lyrics.lines.map((l) => (l ? `<p>${esc(l)}</p>` : `<p class="lyrics__gap" aria-hidden="true"></p>`)).join("")}</div>`;
} else {
body = `<p class="hint">Follows the song. Click a line to jump there.</p>
<ol class="lyrics__lines">${lyrics.lines.map((l, i) => (l.text
? `<li><button type="button" class="ly" data-line="${i}" title="Play from ${fmt(l.time)}"><span class="ly__at">${fmt(l.time)}</span><span class="ly__x">${esc(l.text)}</span></button></li>`
: `<li class="ly ly--gap" data-line="${i}" aria-hidden="true"><span class="ly__x">· · ·</span></li>`)).join("")}</ol>`;
}
return `
<section class="player player--real" aria-label="Player">
<div class="player__top">
${r.cover ? `<img class="player__cover" src="${esc(r.cover)}" alt="${esc(r.saga)} cover art" width="56" height="56" />` : ""}
<div class="player__meta">
<p class="player__part">${esc(r.saga)}</p>
<p class="player__time"><span data-elapsed>0:00</span> <span class="player__of">/ <span data-total>${player.ready ? fmt(player.dur) : "…"}</span></span></p>
</div>
<button type="button" class="player__play" data-act="toggle" aria-pressed="false"></button>
</div>
${scrubHtml("")}
</section>
<section class="psum" aria-label="Parts and motifs">
<p class="psum__row"><span class="psum__k">Parts</span><span class="psum__v">${partsLine}</span></p>
${ms.length ? `<p class="psum__row"><span class="psum__k">Motifs</span><span class="psum__chips">${ms.map(motifTag).join("")}</span></p>` : ""}
<p class="psum__note">From the sample data, not yet timed to the recording.</p>
</section>
<section class="lyrics" aria-label="Lyrics">
<h3 class="sec">Lyrics</h3>
${body}
</section>`;
}
function previewListen(): string {
const dur = player.dur;
const segs = song.parts.map((p, i) => {
const a = player.starts[i]!;
return `<button type="button" class="seg" data-seek="${i}" title="Jump to ${esc(p.name)}">
<span class="seg__name">${esc(p.name)}</span><span class="seg__time">${fmt(a)}</span></button>`;
}).join("");
const ticks = player.starts.slice(1).map((s) => `<span class="scrub__tick" style="left:${((s / dur) * 100).toFixed(2)}%"></span>`).join("");
const why = contentMounted() ? "No recording of this song in the content folder." : "Content folder not mounted.";
return `
<section class="player" aria-label="Player">
<div class="player__top">
<button type="button" class="player__play" data-act="toggle" aria-pressed="false"></button>
<div class="player__meta">
<p class="player__part" data-now-part></p>
<p class="player__time"><span data-elapsed>0:00</span> <span class="player__of">/ ${fmt(dur)}</span></p>
</div>
</div>
${scrubHtml(ticks)}
<div class="segs" role="group" aria-label="Parts">${segs}</div>
<p class="player__note"><b>${why}</b> Preview player: playback is simulated.</p>
</section>
<p class="note">Placeholder lyrics. Real lyrics are shown only from the mounted content folder.</p>
${song.parts.map((p, i) => {
const ms = p.motifIds.flatMap((id) => motifs.find((m) => m.id === id) ?? []);
const c = ms[0] ? inked(ms[0].id) : "transparent";
return `<section class="part" data-part="${i}" data-ms="${p.motifIds.join(" ")}" style="--c:${c}">
<header class="part__head">
<button type="button" class="part__jump" data-seek="${i}" title="Play from ${esc(p.name)}">
<span class="part__icon" aria-hidden="true">${PLAY_ICON}</span><h3>${esc(p.name)}</h3><span class="part__at">${fmt(player.starts[i]!)}</span>
</button>
<span class="part__now">Now playing</span>
</header>
<p class="part__who">${p.singers.map((s) => `<span class="singer">${esc(s)}</span>`).join("")}</p>
<div class="part__lines">${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</div>
${ms.length ? `<p class="part__motifs">${ms.map(motifTag).join("")}</p>` : ""}
</section>`;
}).join("")}`;
}
/** After a seek or a line click, the lyrics follow the song again at once. */
function followNow() { userScrollUntil = 0; lineScrolled = -1; }
/** Smoothly brings a lyric line to about a third of the way down the space under the sticky player. */
function scrollToLine(line: HTMLElement) {
const card = view.querySelector<HTMLElement>(".player--real");
const capH = card ? card.offsetHeight : 0;
const vr = view.getBoundingClientRect();
const lr = line.getBoundingClientRect();
const top = view.scrollTop + (lr.top - vr.top) - (capH + (view.clientHeight - capH) * 0.3);
view.scrollTo({ top: Math.max(0, top), behavior: opts.reduced ? "auto" : "smooth" });
}
/** Index of the timed line playing at time t (a small lead so the highlight lands with the voice). */
function lineAt(t: number): number {
if (lyrics === "none" || lyrics === "loading" || lyrics === "failed" || !lyrics.timed) return -1;
const ls = lyrics.lines;
let k = -1;
for (let i = 0; i < ls.length && ls[i]!.time <= t + 0.15; i++) k = i;
return k;
}
function fan(): HTMLElement {
const el = document.createElement("div");
el.className = "fan";
const accent = inked(motifsInSong(song)[0]?.id ?? "odysseus");
el.innerHTML = `<p class="note">Placeholder fan works — real ones will be credited and linked to their creators.</p>
<ul class="fw">${fanWorksFor(song.id).map((w, i) => `<li class="fw__card">
${thumbSvg(w.type, accent, song.track * 7 + i)}
<p class="fw__type">${TYPE_LABEL[w.type]}</p>
<p class="fw__title">${esc(w.title)}</p>
<p class="fw__by">by ${esc(w.handle)}</p>
<p class="fw__credit">Credit + link to the original <span>(placeholder)</span></p>
</li>`).join("")}</ul>`;
return el;
}
/* ----- the player, reflected in the header and the Listen & lyrics tab ----- */
function syncPlayer() {
const on = player.playing;
const active = on || player.t > 0;
const k = player.partAt();
const part = song.parts[k];
const realNow = isReal();
// buttons are only rewritten when their state changes, so a click is never lost to a re-render
const headKey = `${on}|${song.id}`;
if (headPlay.dataset.key !== headKey) {
headPlay.dataset.key = headKey;
headPlay.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"} ${esc(song.title)}</span>`;
headPlay.setAttribute("aria-pressed", String(on));
headPlay.title = `${on ? "Pause" : "Play"} ${song.title}`;
}
const eyebrow = host.querySelector<HTMLElement>("#panel-eyebrow");
if (eyebrow) {
const total = realNow && !player.ready ? "…" : fmt(player.dur);
eyebrow.textContent = !active ? `Track ${song.track} · now on the voyage`
: realNow ? `${on ? "Playing" : "Paused"} · ${fmt(player.t)} / ${total}`
: `${on ? "Playing" : "Paused"} · ${part?.name ?? ""} · ${fmt(player.t)} / ${total}`;
eyebrow.classList.toggle("is-live", on);
}
const lv = view.querySelector<HTMLElement>(".listen");
if (!lv) { shownPart = -1; lineNow = -1; return; }
const pct = player.dur && !(realNow && !player.ready) ? Math.min(100, (player.t / player.dur) * 100) : 0;
lv.querySelector<HTMLElement>("[data-fill]")!.style.width = `${pct}%`;
lv.querySelector<HTMLElement>("[data-head]")!.style.left = `${pct}%`;
lv.querySelector("[data-elapsed]")!.textContent = fmt(player.t);
const pb = lv.querySelector<HTMLButtonElement>(".player__play")!;
if (pb.dataset.on !== String(on)) {
pb.dataset.on = String(on);
pb.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"}</span>`;
pb.setAttribute("aria-pressed", String(on));
}
const scrub = lv.querySelector<HTMLElement>(".scrub")!;
scrub.setAttribute("aria-valuemax", String(Math.round(player.dur)));
scrub.setAttribute("aria-valuenow", String(Math.round(player.t)));
if (realNow) {
const tot = lv.querySelector("[data-total]");
if (tot && player.ready) tot.textContent = fmt(player.dur);
scrub.setAttribute("aria-valuetext", `${fmt(player.t)} of ${player.ready ? fmt(player.dur) : "unknown length"}`);
syncLines(lv);
return;
}
lv.querySelector("[data-now-part]")!.innerHTML = part ? `${esc(part.name)} <span>· ${part.singers.map(esc).join(", ")}</span>` : "";
scrub.setAttribute("aria-valuetext", `${fmt(player.t)} of ${fmt(player.dur)}, ${part?.name ?? ""}`);
lv.querySelectorAll<HTMLElement>(".seg").forEach((s, i) => s.classList.toggle("is-now", active && i === k));
lv.querySelectorAll<HTMLElement>(".part").forEach((s, i) => s.classList.toggle("is-now", active && i === k));
if (active && k !== shownPart) {
shownPart = k;
const el = lv.querySelector<HTMLElement>(`.part[data-part="${k}"]`);
if (el && k > 0) view.scrollTo({ top: Math.max(0, el.offsetTop - view.offsetTop - 12), behavior: opts.reduced ? "auto" : "smooth" });
else if (el) view.scrollTo({ top: 0, behavior: opts.reduced ? "auto" : "smooth" });
}
}
/** Lights the lyric line playing now (earlier lines are marked sung) and keeps it in view, unless the
* reader has scrolled the lyrics in the last 4 s. */
function syncLines(lv: HTMLElement) {
const k = player.t > 0 || player.playing ? lineAt(player.t) : -1;
if (k !== lineNow) {
lineNow = k;
lv.querySelectorAll<HTMLElement>("[data-line]").forEach((n) => {
const i = Number(n.dataset.line);
n.classList.toggle("is-now", i === k);
n.classList.toggle("is-past", i < k);
if (i === k) n.setAttribute("aria-current", "true"); else n.removeAttribute("aria-current");
});
}
if (k >= 0 && k !== lineScrolled && performance.now() >= userScrollUntil) {
const el = lv.querySelector<HTMLElement>(`[data-line="${k}"]`);
if (el) { lineScrolled = k; scrollToLine(el); }
}
}
function reflectPick() {
applyPick(host, pick);
view.querySelectorAll<HTMLElement>(".part").forEach((el) => {
const has = !!pick && (el.dataset.ms ?? "").split(" ").includes(pick);
el.classList.toggle("is-on", has);
el.classList.toggle("is-dim", !!pick && !has);
});
}
function rerender(keepScroll = false) {
const top = view.scrollTop;
view.replaceChildren(tab === "about" ? about() : tab === "paths" ? paths() : tab === "listen" ? listen() : fan());
view.setAttribute("aria-labelledby", `tab-${tab}`);
host.querySelectorAll<HTMLButtonElement>("[role=tab]").forEach((b) => {
const on = b.dataset.tab === tab;
b.setAttribute("aria-selected", String(on));
b.tabIndex = on ? 0 : -1;
});
reflectPick();
view.scrollTop = keepScroll ? top : 0;
shownPart = -1;
lineNow = -1;
if (!keepScroll) lineScrolled = -1;
syncPlayer();
}
/* ----- wiring ----- */
host.addEventListener("click", (e) => {
const t = e.target as Element;
const b = t.closest<HTMLElement>("[data-pick]");
if (b) { opts.onPick(pick === b.dataset.pick ? null : b.dataset.pick!); return; }
const line = t.closest<HTMLElement>("button[data-line]");
if (line && lyrics !== "none" && lyrics !== "loading" && lyrics !== "failed" && lyrics.timed) {
player.seek(lyrics.lines[Number(line.dataset.line)]!.time);
followNow();
player.play();
return;
}
const seek = t.closest<HTMLElement>("[data-seek]");
if (seek) { player.seek(player.starts[Number(seek.dataset.seek)] ?? 0); player.play(); return; }
const act = t.closest<HTMLElement>("[data-act]")?.dataset.act;
if (act === "toggle") player.toggle();
if (act === "go-listen") { api.setTab("listen"); opts.onTab(tab); player.play(); }
if (act === "read") { reading = true; readAll = false; rerender(); }
if (act === "unread") { reading = false; rerender(); }
if (act === "read-all") { readAll = true; rerender(true); }
});
headPlay.addEventListener("click", () => {
if (player.playing) { player.pause(); return; }
if (tab !== "listen") { api.setTab("listen"); opts.onTab(tab); }
player.play();
});
host.querySelectorAll<HTMLButtonElement>("[role=tab]").forEach((b, i, all) => {
b.addEventListener("click", () => { api.setTab(b.dataset.tab as Tab); opts.onTab(tab); });
b.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
e.stopPropagation();
const nb = all[(i + d + all.length) % all.length]!;
nb.focus();
nb.click();
});
});
// the reader's own scrolling of the lyrics wins for ~4 s, then the lyrics follow the song again
const userScrolled = () => { userScrollUntil = performance.now() + 4000; lineScrolled = -1; };
view.addEventListener("wheel", userScrolled, { passive: true });
view.addEventListener("touchmove", userScrolled, { passive: true });
view.addEventListener("pointerdown", (e) => { if (e.target === view) userScrolled(); });
view.addEventListener("keydown", (e) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key) && !(e.target as Element).closest(".scrub")) userScrolled();
});
$("panel-open").addEventListener("click", () => { api.setOpen(true); opts.onOpen(true); });
$("panel-hide").addEventListener("click", () => { api.setOpen(false); opts.onOpen(false); });
const api: Panel = {
here,
setSong(next) {
if (next.id === song.id && songEl.childElementCount) return;
const first = !songEl.childElementCount;
song = next;
songPick = null;
// a new song on the voyage loads its own player (stopped at 0:00): the real recording if there is one
real = realFor(next);
reading = opts.read && first && tab === "about";
readAll = false;
lineNow = -1;
lineScrolled = -1;
lyrics = "none";
player.load(next, real?.audio ?? null);
if (real?.lyrics) {
lyrics = "loading";
const id = next.id;
loadLyrics(real.lyrics).then((l) => {
if (song.id !== id) return;
lyrics = l ?? "failed";
if (tab === "listen") rerender(true);
});
}
if (playAt !== null) { player.seek(playAt); player.play(); playAt = null; }
const swap = () => { renderHead(); renderPin(); rerender(); };
if (first || opts.reduced) { swap(); return; }
// a gentle swap: the panel's content dips and settles when the voyage reaches a new song
host.classList.add("is-swapping");
window.clearTimeout(swapTimer);
swapTimer = window.setTimeout(() => { swap(); host.classList.remove("is-swapping"); }, 180);
},
setTab(t) { reading = false; tab = t; rerender(); },
setPick(id) { pick = id; reflectPick(); },
setPinned(on) { pin.hidden = !on; host.dataset.pinned = String(on); },
setShip(idx) {
shipIdx = idx;
const m = pin.querySelector<SVGGElement>("[data-ship]");
if (!m) return;
m.setAttribute("transform", `translate(${stripX(STRIP, idx).toFixed(1)},${stripY("panel")})`);
},
setOpen(open) {
host.dataset.open = String(open);
$("panel-open").setAttribute("aria-expanded", String(open));
$("panel-hide").setAttribute("aria-expanded", String(open));
($("panel-body") as HTMLElement).inert = !open;
$("panel-open").tabIndex = open ? -1 : 0;
},
};
api.setOpen(opts.open);
api.setPinned(opts.pinned);
return api;
}
+139
View File
@@ -0,0 +1,139 @@
// The Listen & lyrics player. With the content folder mounted, a song plays its real FLAC through an
// <audio> element (kept in the DOM, hidden); otherwise playback is simulated with a clock over a made-up
// duration, as in 16. Part start times come from `part.startSec` where the data has it, and are otherwise
// spread across the song.
import type { Song } from "../../data/types";
/** A made-up but stable length per song (2:30–4:10), only for the preview player. */
const fakeDuration = (song: Song) => 150 + ((song.track * 47) % 100);
export const fmt = (sec: number) => {
const s = Math.max(0, Math.floor(sec));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};
export class SongPlayer {
song: Song | null = null;
t = 0;
dur = 0;
playing = false;
/** Start time (s) of each part, in order. */
starts: number[] = [];
private audio: HTMLAudioElement | null = null;
private raf = 0;
private last = 0;
constructor(private onChange: () => void, private host: HTMLElement) {}
get simulated() { return !this.audio; }
/** Whether the real file's length is known yet (until then `dur` is the made-up one). */
ready = false;
/** Set when the real file fails to load; the player falls back to the simulated clock. */
failed = false;
load(song: Song, audioUrl: string | null = song.audio ?? null) {
this.pause();
if (this.audio) { this.audio.removeAttribute("src"); this.audio.load(); this.audio.remove(); }
this.audio = null;
this.song = song;
this.t = 0;
this.dur = fakeDuration(song);
this.ready = false;
this.failed = false;
if (audioUrl) {
const a = document.createElement("audio");
a.preload = "metadata";
a.hidden = true;
a.dataset.song = song.id;
a.src = audioUrl;
a.addEventListener("loadedmetadata", () => {
if (a !== this.audio || !Number.isFinite(a.duration)) return;
this.dur = a.duration;
this.ready = true;
// a seek asked for before the length was known (e.g. ?t_play) is applied now
if (this.t > 0 && Math.abs(a.currentTime - this.t) > 0.5) a.currentTime = this.t;
this.layout();
this.onChange();
});
a.addEventListener("timeupdate", () => { if (a === this.audio && this.ready) { this.t = a.currentTime; this.onChange(); } });
a.addEventListener("pause", () => { if (a === this.audio && this.playing && !a.ended) { this.playing = false; this.onChange(); } });
a.addEventListener("ended", () => { if (a === this.audio) { this.playing = false; this.onChange(); } });
a.addEventListener("error", () => {
if (a !== this.audio) return;
console.warn("[17] audio failed to load", audioUrl);
this.audio = null;
this.failed = true;
this.playing = false;
a.remove();
this.layout();
this.onChange();
});
this.host.append(a);
this.audio = a;
}
this.layout();
this.onChange();
}
private layout() {
const parts = this.song?.parts ?? [];
const n = parts.length;
this.starts = parts.map((p, i) => Math.min(this.dur - 1, p.startSec ?? (i === 0 ? 0 : (i * this.dur) / n)));
}
/** Index of the part playing at time t. */
partAt(t = this.t): number {
let k = 0;
this.starts.forEach((s, i) => { if (t >= s) k = i; });
return k;
}
play() {
if (!this.song || this.playing) return;
if (this.t >= this.dur - 0.05) this.seek(0);
this.playing = true;
if (this.audio) this.audio.play().catch(() => { this.playing = false; this.onChange(); });
this.last = performance.now();
this.raf = requestAnimationFrame(this.tick);
this.onChange();
}
pause() {
if (!this.playing) return;
this.playing = false;
this.audio?.pause();
cancelAnimationFrame(this.raf);
this.onChange();
}
toggle() { if (this.playing) this.pause(); else this.play(); }
seek(t: number) {
// before the real length is known, allow any time; loadedmetadata applies it
this.t = Math.max(0, this.audio && !this.ready ? t : Math.min(this.dur, t));
// before metadata this sets the element's default start position; loadedmetadata re-applies it if needed
if (this.audio) this.audio.currentTime = this.t;
this.onChange();
}
/** The real <audio> element, if this song has one. */
get element() { return this.audio; }
private tick = (now: number) => {
if (!this.playing) return;
// real audio: read its clock every frame so the lyric highlight is smooth (timeupdate is ~4 Hz)
if (this.audio) {
// until the length is known, `t` holds a pending seek; afterwards the element's clock is the truth
if (this.ready) this.t = this.audio.currentTime;
this.onChange();
this.raf = requestAnimationFrame(this.tick);
return;
}
this.t += (now - this.last) / 1000;
this.last = now;
if (this.t >= this.dur) { this.t = this.dur; this.playing = false; }
this.onChange();
if (this.playing) this.raf = requestAnimationFrame(this.tick);
};
}
+190
View File
@@ -0,0 +1,190 @@
// Paths B in the 3D world: the current song's motifs as flat, ink-edged ribbons laid along the voyage's
// route. Every song has a fixed place on the route (its index along a curve through anchor points); a
// ribbon runs from the first to the last song where its motif plays, solid behind the ship and dashed
// ahead of it, with a knot (an open ring, as in 12) at each song where the motif plays.
import * as THREE from "three";
import type { Song } from "../../data/types";
import { bright, byTrack, hitsOf, songMotifs } from "./threads";
export interface RibbonOpts {
/** One anchor per song in track order: where that song sits on the route. */
anchors: THREE.Vector3[];
width: number;
/** Sideways distance between neighbouring ribbons. */
gap: number;
/** Height above the surface. */
lift: number;
surface: (x: number, z: number, t: number) => number;
knotR: number;
/** Samples per song along the curve. */
res: number;
/** Dash length, in song-index units (3 = three dashes per song). */
dash: number;
ink: string;
/** Distance from the camera under which ribbons fade out. */
near: number;
}
interface Ribbon {
id: string;
fill: THREE.Mesh; edge: THREE.Mesh;
fillMat: THREE.MeshBasicMaterial; edgeMat: THREE.MeshBasicMaterial;
xz: Float32Array; // base x,z per vertex (fill and edge share the layout)
knots: { g: THREE.Group; i: number; x: number; z: number }[];
}
const SONGS = byTrack.length;
export class RibbonSet {
readonly group = new THREE.Group();
private curve: THREE.CatmullRomCurve3;
private ribbons: Ribbon[] = [];
private split = { value: 0 };
private pick: string | null = null;
private time = 0;
constructor(private o: RibbonOpts) {
this.curve = new THREE.CatmullRomCurve3(o.anchors, false, "centripetal");
this.group.renderOrder = 2;
}
/** Point and sideways normal on the route at a (fractional) song index. */
private at(idx: number): { p: THREE.Vector3; n: THREE.Vector3 } {
const t = THREE.MathUtils.clamp(idx / (SONGS - 1), 0, 1);
const p = this.curve.getPoint(t);
const d = this.curve.getTangent(t);
const n = new THREE.Vector3(-d.z, 0, d.x).normalize();
return { p, n };
}
private material(color: string, layer: number): THREE.MeshBasicMaterial {
const m = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 });
m.onBeforeCompile = (sh) => {
sh.uniforms.uSplit = this.split;
sh.uniforms.uNear = { value: this.o.near };
sh.vertexShader = sh.vertexShader
.replace("#include <common>", "#include <common>\nattribute float aU;\nvarying float vU;\nvarying float vDepth;")
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvU = aU;")
.replace("#include <project_vertex>", "#include <project_vertex>\nvDepth = -mvPosition.z;");
sh.fragmentShader = sh.fragmentShader
.replace("#include <common>", "#include <common>\nuniform float uSplit;\nuniform float uNear;\nvarying float vU;\nvarying float vDepth;")
.replace("#include <color_fragment>", "#include <color_fragment>\n// fade out right in front of the camera, where a flat ribbon would become a wide road\ndiffuseColor.a *= smoothstep(uNear * 0.45, uNear, vDepth);")
.replace("#include <clipping_planes_fragment>", `#include <clipping_planes_fragment>
// ahead of the ship the ribbon is dashed: where the motif is still to come
if (vU > uSplit + 0.12 && fract(vU * ${this.o.dash.toFixed(1)}) > 0.58) discard;`);
};
return m;
}
private strip(i0: number, i1: number, off: number, w: number): { geo: THREE.BufferGeometry; xz: Float32Array } {
const n = Math.max(2, Math.ceil((i1 - i0) * this.o.res) + 1);
const pos = new Float32Array(n * 2 * 3);
const u = new Float32Array(n * 2);
const xz = new Float32Array(n * 2 * 2);
const idx: number[] = [];
for (let k = 0; k < n; k++) {
const s = i0 + ((i1 - i0) * k) / (n - 1);
const { p, n: nr } = this.at(s);
const c = p.clone().addScaledVector(nr, off);
for (const side of [0, 1]) {
const v = c.clone().addScaledVector(nr, (side ? 1 : -1) * w / 2);
const j = k * 2 + side;
pos.set([v.x, 0, v.z], j * 3);
xz.set([v.x, v.z], j * 2);
u[j] = s;
}
if (k) { const a = (k - 1) * 2; idx.push(a, a + 1, a + 2, a + 1, a + 3, a + 2); }
}
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
geo.setAttribute("aU", new THREE.BufferAttribute(u, 1));
geo.setIndex(idx);
return { geo, xz };
}
setSong(song: Song) {
for (const r of this.ribbons) {
r.fill.geometry.dispose(); r.edge.geometry.dispose(); r.fillMat.dispose(); r.edgeMat.dispose();
r.knots.forEach((k) => k.g.traverse((o) => { if (o instanceof THREE.Mesh) o.geometry.dispose(); }));
}
this.group.clear();
this.ribbons = [];
const ms = songMotifs(song);
ms.forEach((m, k) => {
const hits = hitsOf(m.id);
const off = (k - (ms.length - 1) / 2) * this.o.gap;
const i0 = hits[0]!, i1 = Math.max(hits[hits.length - 1]!, i0 + 0.01);
const fillMat = this.material(bright(m.id), 2);
const edgeMat = this.material(this.o.ink, 1);
const f = this.strip(i0, i1, off, this.o.width);
const e = this.strip(i0, i1, off, this.o.width * 1.75);
const fill = new THREE.Mesh(f.geo, fillMat);
const edge = new THREE.Mesh(e.geo, edgeMat);
fill.renderOrder = 3; edge.renderOrder = 2;
fill.frustumCulled = edge.frustumCulled = false;
this.group.add(edge, fill);
const knots = hits.map((i) => {
const { p, n } = this.at(i);
const c = p.addScaledVector(n, off);
const g = new THREE.Group();
const disc = (r: number, color: string, layer: number) => {
const mesh = new THREE.Mesh(new THREE.CircleGeometry(r, 20), new THREE.MeshBasicMaterial({ color, transparent: true, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 }));
mesh.rotation.x = -Math.PI / 2;
mesh.renderOrder = 3 + layer;
g.add(mesh);
};
disc(this.o.knotR * 1.32, this.o.ink, 3);
disc(this.o.knotR, bright(m.id), 4);
disc(this.o.knotR * 0.46, "#f3ead6", 5);
g.position.set(c.x, 0, c.z);
this.group.add(g);
return { g, i, x: c.x, z: c.z };
});
this.ribbons.push({ id: m.id, fill, edge, fillMat, edgeMat, xz: f.xz, knots });
// the edge strip has its own layout; keep its xz on the mesh
edge.userData.xz = e.xz;
});
this.setPick(this.pick && ms.some((m) => m.id === this.pick) ? this.pick : null);
}
setPick(id: string | null) {
this.pick = id;
for (const r of this.ribbons) {
const op = id && r.id !== id ? 0.3 : 1;
r.fillMat.opacity = op;
r.edgeMat.opacity = op;
r.fill.scale.setScalar(1);
r.knots.forEach((k) => {
k.g.scale.setScalar(id === r.id ? 1.35 : 1);
k.g.traverse((o) => { if (o instanceof THREE.Mesh) (o.material as THREE.MeshBasicMaterial).opacity = op; });
});
}
}
/** Follow the waves, and move the solid/dashed split to the ship's place on the route. */
update(t: number, split: number) {
this.time = t;
this.split.value = split;
const lift = (x: number, z: number) => this.o.surface(x, z, t) + this.o.lift;
for (const r of this.ribbons) {
for (const [mesh, xz] of [[r.fill, r.xz], [r.edge, r.edge.userData.xz as Float32Array]] as const) {
const pos = mesh.geometry.getAttribute("position") as THREE.BufferAttribute;
for (let j = 0; j < pos.count; j++) pos.setY(j, lift(xz[j * 2]!, xz[j * 2 + 1]!));
pos.needsUpdate = true;
}
for (const k of r.knots) k.g.position.y = lift(k.x, k.z) + 0.04;
}
}
/**
* Where each ribbon's label goes: on its next knot ahead of the ship, or, if the motif is not heard
* again, on its last knot. Returned in world space, a little above the knot.
*/
labels(split: number): { id: string; pos: THREE.Vector3; song: number; ahead: boolean }[] {
return this.ribbons.map((r) => {
const ahead = r.knots.find((k) => k.i > split + 0.35);
const k = ahead ?? r.knots[r.knots.length - 1]!;
return { id: r.id, pos: new THREE.Vector3(k.x, this.o.surface(k.x, k.z, this.time) + this.o.lift + 1.2, k.z), song: k.i, ahead: !!ahead };
});
}
}
+732
View File
@@ -0,0 +1,732 @@
// The 3D half of the voyage (from 14, look 2 only): toon shading with a stepped gradient, ink outlines
// (inverted hulls), posterised sky bands, a flat graphic sun and a sea of drawn wave strokes. Choreography
// is a pure function of the beat position p (0..4). Paths B adds motif ribbons on the sea and cave floor.
import * as THREE from "three";
import { mergeVertices } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import type { Song } from "../../data/types";
import { RibbonSet } from "./ribbons";
export function webglAvailable(): boolean {
try {
const c = document.createElement("canvas");
return !!(c.getContext("webgl2") ?? c.getContext("webgl"));
} catch {
return false;
}
}
/* ---------- wave field (GLSL and JS agree) ---------- */
const WAVE_GLSL = /* glsl */ `
uniform float uTime;
float seaWave(vec2 p) {
return 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);
}`;
const seaWave = (x: number, z: number, t: number) =>
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);
/* ---------- palettes: a short list of flat inks per look ---------- */
interface Palette {
sky: [string, string, string, string]; // horizon band → zenith
sun: string; sunRing: string;
deep: string; mid: string; crest: string; contour: string; fog: string; fogNear: number; fogFar: number;
ink: string; inkSoft: number; light: string; lightI: number; ambient: string; ambientI: number;
hull: string; band: string; wood: string; sail: string; sailStripe: string; eye: string;
sand: string; rock: string; top: string; mouth: string;
caveRock: string; caveFloor: string; caveInk: string; fire: string; fireCore: string; caveAmbient: number;
giant: string; giantInc: string; giantEye: string; wool: string; face: string;
}
// Two worlds: a poster-paint sea, teal and cream, inked in the frieze's black.
const PAL: Palette = {
sky: ["#f3dfb6", "#dcdcc4", "#b4cdc8", "#88b1c0"], sun: "#fff4dc", sunRing: "#f7e6c0",
deep: "#1d5a6e", mid: "#2b7b88", crest: "#9cd0c4", contour: "#f1efdc", fog: "#f0d7aa", fogNear: 90, fogFar: 360,
ink: "#1d140e", inkSoft: 0.4, light: "#fff6e6", lightI: 2.4, ambient: "#9fb8c2", ambientI: 1.2,
hull: "#2a211b", band: "#c46a33", wood: "#5b3c20", sail: "#f3ead6", sailStripe: "#1d5f86", eye: "#f3ead6",
sand: "#e6cf98", rock: "#b0804e", top: "#6f7d3e", mouth: "#1d140e",
caveRock: "#7a4a2c", caveFloor: "#4a2c1a", caveInk: "#1d140e", fire: "#f08a3a", fireCore: "#ffe08a", caveAmbient: 0.55,
giant: "#16100c", giantInc: "#c46a33", giantEye: "#f6ead0", wool: "#efe6d2", face: "#1d140e",
};
/* ---------- 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 clamp01 = (x: number) => Math.min(1, Math.max(0, x));
export const ease = (x: number) => { const t = clamp01(x); return t * t * (3 - 2 * t); };
const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
const v3 = (x: number, y: number, z: number) => new THREE.Vector3(x, y, z);
/** Three flat tones per surface: shadow, mid, lit. */
function stepMap(): THREE.DataTexture {
const steps = [70, 160, 255];
const data = new Uint8Array(steps.length * 4);
steps.forEach((v, i) => data.set([v, v, v, 255], i * 4));
const t = new THREE.DataTexture(data, steps.length, 1, THREE.RGBAFormat);
t.minFilter = t.magFilter = THREE.NearestFilter;
t.generateMipmaps = false;
t.needsUpdate = true;
return t;
}
const STEPS = stepMap();
const toon = (color: string, opts: THREE.MeshToonMaterialParameters = {}) =>
new THREE.MeshToonMaterial({ color, gradientMap: STEPS, ...opts });
/** Inverted hull: the same shape with smoothed normals, pushed out by t and drawn back-faces only. */
function hullOf(src: THREE.BufferGeometry, t: number): THREE.BufferGeometry {
let g = src.clone();
for (const k of Object.keys(g.attributes)) if (k !== "position") g.deleteAttribute(k);
g = mergeVertices(g, 1e-3);
g.computeVertexNormals();
const p = g.getAttribute("position") as THREE.BufferAttribute;
const n = g.getAttribute("normal") as THREE.BufferAttribute;
for (let i = 0; i < p.count; i++) p.setXYZ(i, p.getX(i) + n.getX(i) * t, p.getY(i) + n.getY(i) * t, p.getZ(i) + n.getZ(i) * t);
return g;
}
/** A flat ribbon along a 2D polyline (for incised lines on the giant). */
function ribbon(pts: [number, number][], w: number, z: number): THREE.BufferGeometry {
const pos: number[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const [ax, ay] = pts[i]!, [bx, by] = pts[i + 1]!;
const dx = bx - ax, dy = by - ay, l = Math.hypot(dx, dy) || 1;
const nx = (-dy / l) * w / 2, ny = (dx / l) * w / 2;
pos.push(ax + nx, ay + ny, z, ax - nx, ay - ny, z, bx + nx, by + ny, z, bx + nx, by + ny, z, ax - nx, ay - ny, z, bx - nx, by - ny, z);
}
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pos, 3));
return g;
}
const shape = (pts: [number, number][]) => {
const s = new THREE.Shape();
pts.forEach(([x, y], i) => (i ? s.lineTo(x, y) : s.moveTo(x, y)));
s.closePath();
return s;
};
interface IslandOpts { radius: number; height: number; seed: number }
/** A bold graphic island: few facets, three height bands (sand, rock, scrub) as vertex colours. */
function islandGeometry(o: IslandOpts, cols: { sand: THREE.Color; rock: THREE.Color; top: THREE.Color }): THREE.BufferGeometry {
let g: THREE.BufferGeometry = new THREE.ConeGeometry(o.radius, o.height, 9, 3, true);
g.translate(0, o.height / 2 - 3, 0);
const pos = g.getAttribute("position") as THREE.BufferAttribute;
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.5;
pos.setXYZ(i, x + (hash(kx, ky, kz) - 0.5) * o.radius * 0.22 * j, y + (hash(ky, kz, kx) - 0.5) * o.height * 0.1, z + (hash(kz, kx, ky) - 0.5) * o.radius * 0.22 * j);
}
g = g.toNonIndexed();
paintIsland(g, o.height, cols);
g.computeVertexNormals();
return g;
}
function paintIsland(g: THREE.BufferGeometry, height: number, c: { sand: THREE.Color; rock: THREE.Color; top: THREE.Color }) {
const p = g.getAttribute("position") as THREE.BufferAttribute;
const colors = new Float32Array(p.count * 3);
for (let f = 0; f < p.count; f += 3) {
const y = (p.getY(f) + p.getY(f + 1) + p.getY(f + 2)) / 3;
const col = y < 1.2 ? c.sand : y > height * 0.45 - 3 ? c.top : c.rock;
for (let k = 0; k < 3; k++) col.toArray(colors, (f + k) * 3);
}
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
}
/* ---------- choreography ---------- */
const SHIP_A = v3(0, 0, 0); // beat 2: out at sea
const SHIP_B = v3(-4, 0, -150); // beat 3: anchored off the island
const ISLAND = v3(-34, 0, -190);
const ISLAND_SUNK = -48;
/** The ship's course from open sea to the anchorage, s in 0..1, with a gentle S. */
const shipAt = (s: number) =>
v3(lerp(SHIP_A.x, SHIP_B.x, s) + Math.sin(s * Math.PI) * 9, 0, lerp(SHIP_A.z, SHIP_B.z, s));
export class VoyageScene {
private renderer: THREE.WebGLRenderer;
private sea = new THREE.Scene();
private cave = new THREE.Scene();
private camera = new THREE.PerspectiveCamera(46, 16 / 9, 0.5, 1400);
private caveCam = new THREE.PerspectiveCamera(54, 16 / 9, 0.3, 400);
private waveU = { uTime: { value: 0 } };
private seaU = { uDeep: { value: new THREE.Color() }, uMid: { value: new THREE.Color() }, uCrest: { value: new THREE.Color() }, uContour: { value: new THREE.Color() } };
private skyU = {
uB0: { value: new THREE.Color() }, uB1: { value: new THREE.Color() }, uB2: { value: new THREE.Color() }, uB3: { value: new THREE.Color() },
uSun: { value: new THREE.Color() }, uRing: { value: new THREE.Color() },
uSunDir: { value: new THREE.Vector3(0.3, 0.17, -0.94).normalize() },
};
private fog = new THREE.Fog(0xffffff, 80, 330);
private ambient = new THREE.AmbientLight(0xffffff, 1);
private sunLight = new THREE.DirectionalLight(0xffffff, 2);
private inkMat = new THREE.MeshBasicMaterial({ color: "#1d140e", side: THREE.BackSide });
private edgeMat = new THREE.LineBasicMaterial({ color: "#1d140e", transparent: true, opacity: 0.4 });
private ship = new THREE.Group();
private island = new THREE.Group();
private islandMat = toon("#ffffff", { vertexColors: true });
private islandGeos: { geo: THREE.BufferGeometry; height: number }[] = [];
private mouthMat = new THREE.MeshBasicMaterial({ color: "#1d140e", side: THREE.DoubleSide });
private shipMats = { hull: toon("#2a211b"), band: toon("#c46a33"), wood: new THREE.MeshBasicMaterial({ color: "#5b3c20" }), sail: toon("#ffffff", { side: THREE.DoubleSide }), eye: new THREE.MeshBasicMaterial({ color: "#f3ead6" }), pupil: new THREE.MeshBasicMaterial({ color: "#1d140e" }) };
private sailCanvas = document.createElement("canvas");
private sailTex: THREE.CanvasTexture;
// cave
private caveMats = {
rock: toon("#7a4a2c", { side: THREE.BackSide }), floor: toon("#4a2c1a"), boulder: toon("#4a2c1a"),
ink: new THREE.MeshBasicMaterial({ color: "#1d140e", side: THREE.BackSide }), edge: new THREE.LineBasicMaterial({ color: "#1d140e", transparent: true, opacity: 0.55 }),
giant: new THREE.MeshBasicMaterial({ color: "#16100c" }), giantInc: new THREE.MeshBasicMaterial({ color: "#c46a33", side: THREE.DoubleSide }), giantHull: new THREE.MeshBasicMaterial({ color: "#c46a33", side: THREE.BackSide }),
eye: new THREE.MeshBasicMaterial({ color: "#f6ead0" }),
wool: toon("#efe6d2"), face: new THREE.MeshBasicMaterial({ color: "#1d140e" }),
fire: new THREE.MeshBasicMaterial({ color: "#f08a3a", side: THREE.DoubleSide }), core: new THREE.MeshBasicMaterial({ color: "#ffe08a", side: THREE.DoubleSide }),
};
private fireLight = new THREE.PointLight(0xff8a3a, 900, 0, 1.7);
private caveAmbient = new THREE.AmbientLight(0xffffff, 0.5);
private flames: THREE.Mesh[] = [];
private eye!: THREE.Mesh;
private ro: ResizeObserver;
private seaRibbons: RibbonSet;
private caveRibbons: RibbonSet;
private split = 2;
private tmp = new THREE.Vector3();
/** Fraction down the stage where the frieze's ground line sits; at p=0 the horizon sits exactly on it. */
private horizonAt = 0.62;
/** How far (px) the picture is lifted to clear the bottom timeline. */
private shift = 0;
constructor(private host: HTMLElement, private reduced: boolean) {
this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance", preserveDrawingBuffer: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
this.renderer.domElement.className = "stage__canvas";
this.renderer.domElement.setAttribute("aria-hidden", "true");
host.append(this.renderer.domElement);
this.camera.rotation.order = "YXZ";
this.sea.fog = this.fog;
this.sea.add(this.ambient, this.sunLight);
this.sunLight.position.set(-60, 90, 40);
this.buildSky();
this.buildSea();
this.buildIsland();
this.sailCanvas.width = this.sailCanvas.height = 64;
this.sailTex = new THREE.CanvasTexture(this.sailCanvas);
this.sailTex.colorSpace = THREE.SRGBColorSpace;
this.sailTex.magFilter = THREE.NearestFilter;
this.shipMats.sail.map = this.sailTex;
this.buildShip();
this.buildCave();
// Paths B: each song has a place on the route. Sea: two songs behind the ship's start, Full Speed
// Ahead where it starts, Polyphemus at the anchorage, Survive at the cave mouth, the rest beyond.
const mouth = v3(SHIP_B.x + 14, 0, SHIP_B.z + 52).sub(ISLAND).setY(0).normalize().multiplyScalar(22).add(ISLAND);
this.seaRibbons = new RibbonSet({
anchors: [v3(0, 0, 112), v3(0, 0, 56), SHIP_A.clone(), shipAt(1 / 3), shipAt(2 / 3), SHIP_B.clone(), mouth, v3(12, 0, -204), v3(34, 0, -250), v3(52, 0, -300)],
width: 0.5, gap: 2.6, lift: 0.55, surface: seaWave, knotR: 1.0, res: 40, dash: 3, ink: PAL.ink, near: 34,
});
this.sea.add(this.seaRibbons.group);
// Cave: Survive at the camera's feet, the songs after it running deeper in between the fire and the giant.
this.caveRibbons = new RibbonSet({
anchors: Array.from({ length: 10 }, (_, i) => v3(3.5 - (i - 6) * 0.9, 0, 4 - (i - 6) * 12)),
width: 0.55, gap: 2.3, lift: 0.06, surface: () => -9, knotR: 0.85, res: 30, dash: 3, ink: PAL.ink, near: 12,
});
this.cave.add(this.caveRibbons.group);
this.setPaths(false);
this.applyPalette();
this.ro = new ResizeObserver(() => this.resize());
this.ro.observe(host);
this.resize();
}
private outline<T extends THREE.Mesh>(m: T, t: number, mat: THREE.Material = this.inkMat): T {
const h = new THREE.Mesh(hullOf(m.geometry, t), mat);
m.add(h);
return m;
}
/* ----- build: sea ----- */
private buildSky() {
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 uB0; uniform vec3 uB1; uniform vec3 uB2; uniform vec3 uB3; uniform vec3 uSun; uniform vec3 uRing; uniform vec3 uSunDir; varying vec3 vDir;
void main(){
vec3 d = normalize(vDir);
float h = d.y + 0.012 * sin(atan(d.x, d.z) * 9.0);
vec3 col = uB0;
col = mix(col, uB1, step(0.035, h));
col = mix(col, uB2, step(0.09, h));
col = mix(col, uB3, step(0.18, h));
// a flat paper sun with one ring: no glare
float s = dot(d, uSunDir);
col = mix(col, uRing, step(0.99863, s) * (1.0 - step(0.99897, s)));
col = mix(col, uSun, step(0.99939, s));
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`,
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(1000, 48, 24), mat);
sky.renderOrder = -1;
this.sea.add(sky);
}
private buildSea() {
const g = new THREE.PlaneGeometry(1300, 1300, 170, 170);
const mat = new THREE.MeshBasicMaterial({ color: 0xffffff });
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;\nvarying vec2 vWp;`)
.replace("#include <begin_vertex>", `#include <begin_vertex>
vec4 seaWp = modelMatrix * vec4(transformed, 1.0);
float seaH = seaWave(seaWp.xz);
transformed.z += seaH;
vH = seaH;
vWp = seaWp.xz;`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\nuniform float uTime;\nuniform vec3 uDeep;\nuniform vec3 uMid;\nuniform vec3 uCrest;\nuniform vec3 uContour;\nvarying float vH;\nvarying vec2 vWp;`)
.replace("#include <color_fragment>", `#include <color_fragment>
// Two flat bands by swell, then thin drawn wave strokes on the upper swells (no filled crest
// patches: in 14 those read like lily pads). Strokes thin out with distance to avoid moire.
float h = vH + 0.2 * sin(vWp.x * 0.21 + vWp.y * 0.13 + uTime * 0.4);
vec3 c = mix(uDeep, uMid, step(-0.3, h));
float s = sin(vWp.y * 0.8 + 1.7 * sin(vWp.x * 0.085 + uTime * 0.22) - uTime * 1.1);
float fs = fwidth(s);
float stroke = smoothstep(0.935 - fs, 0.935 + fs, s);
float gate = smoothstep(0.3, 0.6, h) * smoothstep(0.1, 0.45, sin(vWp.x * 0.16 + vWp.y * 0.05 + 1.3));
float fade = 1.0 - smoothstep(0.3, 0.8, fs);
float halo = smoothstep(0.75 - fs, 0.75 + fs, s) * gate * fade;
c = mix(c, uCrest, halo * 0.3);
diffuseColor.rgb = mix(c, uContour, stroke * gate * fade * 0.95);`);
};
const sea = new THREE.Mesh(g, mat);
sea.rotation.x = -Math.PI / 2;
sea.position.set(0, 0, -120);
this.sea.add(sea);
}
private buildIsland() {
const white = new THREE.Color("#ffffff");
const cols = { sand: white, rock: white, top: white };
const add = (o: IslandOpts, x: number, z: number, parent: THREE.Object3D, ink: number) => {
const geo = islandGeometry(o, cols);
this.islandGeos.push({ geo, height: o.height });
const m = this.outline(new THREE.Mesh(geo, this.islandMat), ink);
m.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 32), this.edgeMat));
m.position.set(x, 0, z);
parent.add(m);
return m;
};
add({ radius: 26, height: 34, seed: 23 }, 0, 0, this.island, 0.45);
add({ radius: 17, height: 22, seed: 41 }, -20, -14, this.island, 0.4);
add({ radius: 11, height: 13, seed: 57 }, 21, -6, this.island, 0.35);
// The cave mouth: a black arch facing the anchorage.
const toShip = v3(SHIP_B.x + 14, 0, SHIP_B.z + 52).sub(ISLAND).setY(0).normalize();
const arch = new THREE.Shape();
arch.moveTo(-6, -1); arch.lineTo(-6, 4); arch.absarc(0, 4, 6, Math.PI, 0, true); arch.lineTo(6, -1); arch.closePath();
const mouth = new THREE.Mesh(new THREE.ShapeGeometry(arch, 8), this.mouthMat);
mouth.position.copy(toShip.clone().multiplyScalar(22)).setY(0.6);
mouth.lookAt(mouth.position.clone().add(toShip));
this.island.add(mouth);
this.island.position.copy(ISLAND);
this.sea.add(this.island);
// a few far islets, so the sea is a place
const spots: [number, number, number][] = [[95, -280, 9], [-150, -330, 12], [80, -95, 5], [-110, -70, 6], [170, -170, 7]];
spots.forEach(([x, z, r], i) => add({ radius: r, height: 4 + r * 0.9, seed: 100 + i }, x, z, this.sea, 0.3));
}
private buildShip() {
const s = this.ship;
const outline = new THREE.Shape();
outline.moveTo(0, 4.8);
outline.quadraticCurveTo(1.8, 1.6, 1.6, -1.6);
outline.quadraticCurveTo(1.35, -3.4, 0, -3.9);
outline.quadraticCurveTo(-1.35, -3.4, -1.6, -1.6);
outline.quadraticCurveTo(-1.8, 1.6, 0, 4.8);
const hullGeo = new THREE.ExtrudeGeometry(outline, { depth: 1.6, bevelEnabled: false, curveSegments: 6 });
hullGeo.rotateX(Math.PI / 2);
hullGeo.translate(0, 1.2, 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();
s.add(this.outline(new THREE.Mesh(hullGeo, this.shipMats.hull), 0.12));
// a painted band along the gunwale
const band = new THREE.Mesh(new THREE.BoxGeometry(3.45, 0.32, 6.4), this.shipMats.band);
band.position.set(0, 1.0, 0.35);
s.add(this.outline(band, 0.07));
// tall curled stern post and a ram at the bow: silhouette first
const stern = new THREE.Mesh(new THREE.TorusGeometry(0.75, 0.17, 5, 10, Math.PI * 1.1), this.shipMats.hull);
stern.rotation.y = Math.PI / 2;
stern.position.set(0, 2.15, -3.9);
s.add(this.outline(stern, 0.08));
const ram = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.8, 4), this.shipMats.hull);
ram.rotation.x = Math.PI / 2;
ram.position.set(0, 0.35, 5.2);
s.add(this.outline(ram, 0.06));
// the painted eye on each side of the bow
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(new THREE.CircleGeometry(0.38, 12), this.shipMats.eye);
eye.position.set(side * 1.25, 1.45, 3.1);
eye.rotation.y = side * (Math.PI / 2 - 0.5);
const pupil = new THREE.Mesh(new THREE.CircleGeometry(0.17, 10), this.shipMats.pupil);
pupil.position.z = 0.02;
eye.add(pupil);
s.add(eye);
for (let i = 0; i < 5; i++) {
const oar = new THREE.Mesh(new THREE.BoxGeometry(3.6, 0.1, 0.2), this.shipMats.wood);
oar.position.set(side * 2.7, 0.6, -1.8 + i * 1.05);
oar.rotation.z = side * -0.38;
s.add(oar);
}
}
const mast = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.16, 8.4, 5), this.shipMats.wood);
mast.position.y = 5;
const yard = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 6.8, 5), this.shipMats.wood);
yard.rotation.z = Math.PI / 2;
yard.position.y = 8.2;
s.add(mast, yard);
const sailGeo = new THREE.PlaneGeometry(6.2, 4.6, 6, 4);
const sp = sailGeo.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < sp.count; i++) {
const x = sp.getX(i) / 3.1, y = sp.getY(i) / 2.3;
sp.setZ(i, (1 - x * x) * (1 - 0.4 * y * y) * 1.0);
}
sailGeo.computeVertexNormals();
const sail = new THREE.Mesh(sailGeo, this.shipMats.sail);
sail.position.set(0, 5.8, 0.2);
const sailEdge = new THREE.LineSegments(new THREE.EdgesGeometry(sailGeo, 30), new THREE.LineBasicMaterial({ color: "#1d140e" }));
sail.add(sailEdge);
sail.userData.edge = sailEdge;
s.add(sail);
this.sea.add(s);
}
/* ----- build: cave ----- */
private buildCave() {
const c = this.cave;
// A faceted dome, seen from inside, with its facets inked.
let dome: THREE.BufferGeometry = new THREE.IcosahedronGeometry(1, 2);
const dp = dome.getAttribute("position") as THREE.BufferAttribute;
for (let i = 0; i < dp.count; i++) {
const x = dp.getX(i), y = dp.getY(i), z = dp.getZ(i);
const n = 1 + (hash(Math.round(x * 40), Math.round(y * 40), Math.round(z * 40)) - 0.5) * 0.24;
dp.setXYZ(i, x * n, y * n, z * n);
}
dome = dome.toNonIndexed();
dome.computeVertexNormals();
const shell = new THREE.Mesh(dome, this.caveMats.rock);
shell.add(new THREE.LineSegments(new THREE.EdgesGeometry(dome, 1), this.caveMats.edge));
shell.scale.set(44, 30, 56);
shell.position.set(0, 4, -16);
c.add(shell);
const floor = new THREE.Mesh(new THREE.CircleGeometry(70, 24), this.caveMats.floor);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -9;
c.add(floor);
for (let i = 0; i < 8; i++) {
const r = 1.6 + hash(i, 2, 9) * 3.2;
const b = new THREE.Mesh(new THREE.DodecahedronGeometry(r, 0), this.caveMats.boulder);
const side = i % 2 ? 1 : -1;
b.position.set(side * (16 + hash(i, 4, 1) * 16), -9 + r * 0.45, -hash(1, i, 4) * 44 + 6);
c.add(this.outline(b, 0.18, this.caveMats.ink));
}
// The fire: flat, layered flame shapes, as painted.
const flame = (w: number, h: number) => {
const s = new THREE.Shape();
s.moveTo(-w, 0);
s.quadraticCurveTo(-w * 1.1, h * 0.45, 0, h);
s.quadraticCurveTo(w * 1.1, h * 0.45, w, 0);
s.closePath();
return new THREE.ShapeGeometry(s, 6);
};
const fire = new THREE.Group();
fire.position.set(-12, -9, -30);
[[-2.2, 1.8, 5.5, 0], [0, 2.6, 8.5, 1], [2.3, 1.7, 6, 2], [0, 1.4, 4.6, 3], [-1, 0.9, 3, 4], [1.2, 0.8, 2.8, 5]].forEach(([x, w, h, i]) => {
const core = i! >= 3;
const f = new THREE.Mesh(flame(w!, h!), core ? this.caveMats.core : this.caveMats.fire);
f.position.set(x!, 0, core ? 0.3 : 0);
f.userData = { phase: i! * 1.7 };
fire.add(f);
this.flames.push(f);
});
const logs = new THREE.Mesh(new THREE.BoxGeometry(6, 0.8, 1.2), this.caveMats.face);
logs.position.y = 0.2;
fire.add(logs);
c.add(fire);
this.fireLight.position.set(-12, -2, -26);
c.add(this.fireLight, this.caveAmbient);
this.buildGiant();
this.buildFlock();
}
/** Polyphemus as a black-figure silhouette: one profile cut-out, incised in clay, with one great eye. */
private buildGiant() {
const gm = this.caveMats.giant;
const giant = new THREE.Group();
const D = 2.4; // depth of the cut-out
const part = (pts: [number, number][]) => {
const geo = new THREE.ExtrudeGeometry(shape(pts), { depth: D, bevelEnabled: false });
geo.translate(0, 0, -D / 2);
giant.add(this.outline(new THREE.Mesh(geo, gm), 0.15, this.caveMats.giantHull));
};
// Facing left (towards his flock and the cave mouth), striding, club raised behind.
part([[-9.5, 0], [-5.2, 0], [-4.4, 1.2], [-1.6, 12.5], [-4.6, 13], [-7.4, 1.6], [-9.6, 1.2]]); // front leg
part([[3.4, 0], [8.2, 0], [8.2, 1.3], [6.0, 1.6], [4.0, 12.8], [0.8, 12.4], [3.6, 1.4]]); // back leg
part([[-5.6, 11.6], [5.2, 11.4], [4.2, 15.2], [3.4, 25.6], [1.6, 27.4], [-3.4, 27.4], [-4.8, 25.6], [-4.6, 15.2]]); // body and chiton
part([[-4.2, 25.8], [-10.4, 19.4], [-11.6, 17.2], [-10.4, 16.4], [-8.4, 18.4], [-2.4, 22.6]]); // front arm, reaching
part([[1.6, 27.0], [6.0, 31.4], [7.6, 34.6], [6.4, 35.6], [4.4, 33.0], [0.4, 24.4]]); // back arm, raised
part([[5.0, 33.6], [6.6, 32.8], [14.6, 47.4], [14.6, 49.8], [12.6, 50.6], [10.6, 48.6]]); // the club
// head with brow and a pointed beard
// head in profile: brow, a heavy nose, a pointed beard
const head = new THREE.Shape();
head.moveTo(-0.8, 35.4);
head.quadraticCurveTo(-4.4, 35.4, -5.0, 32.8);
head.lineTo(-6.8, 30.6);
head.lineTo(-5.2, 30.2);
head.lineTo(-5.3, 28.8);
head.lineTo(-6.0, 24.6);
head.lineTo(-2.0, 26.6);
head.lineTo(1.0, 27.2);
head.quadraticCurveTo(3.2, 29.0, 2.8, 32.0);
head.quadraticCurveTo(2.4, 35.4, -0.8, 35.4);
const hg = new THREE.ExtrudeGeometry(head, { depth: D, bevelEnabled: false, curveSegments: 8 });
hg.translate(0, 0, -D / 2);
giant.add(this.outline(new THREE.Mesh(hg, gm), 0.15, this.caveMats.giantHull));
// incisions: belt, hem, knee, muscle and beard lines
const z = D / 2 + 0.03;
const inc = (pts: [number, number][], w = 0.26) => giant.add(new THREE.Mesh(ribbon(pts, w, z), this.caveMats.giantInc));
inc([[-4.7, 16.2], [4.2, 16.0]]);
inc([[-5.4, 12.6], [-4.2, 13.6], [-3.0, 12.6], [-1.8, 13.6], [-0.6, 12.6], [0.6, 13.6], [1.8, 12.6], [3.0, 13.6], [4.4, 12.6]], 0.2);
inc([[-4.6, 6.6], [-3.2, 7.2]]);
inc([[3.0, 7.2], [4.6, 6.8]]);
inc([[-3.8, 24.0], [-0.6, 21.2], [2.4, 23.6]], 0.2);
inc([[-4.4, 28.6], [-5.0, 25.8]], 0.18);
inc([[-3.0, 28.6], [-3.4, 26.6]], 0.18);
inc([[-0.6, 28.4], [0.4, 31.6], [-0.2, 34.2]], 0.2); // ear and hairline
inc([[-1.0, 33.8], [-2.8, 34.2], [-4.6, 33.2]], 0.3); // brow
// the single eye: a big painted ring with a dark pupil, catching the firelight
const eye = new THREE.Mesh(new THREE.CircleGeometry(1.15, 20), this.caveMats.eye);
eye.position.set(-2.7, 31.9, z + 0.01);
const pupil = new THREE.Mesh(new THREE.CircleGeometry(0.55, 14), this.caveMats.face);
pupil.position.set(-0.4, 0, 0.02);
eye.add(pupil);
giant.add(eye);
this.eye = eye;
giant.position.set(7, -9, -34);
giant.scale.setScalar(0.72);
giant.rotation.y = -0.18;
this.cave.add(giant);
}
/** The flock: round wool puffs with black heads and legs. */
private buildFlock() {
for (let i = 0; i < 6; i++) {
const sheep = new THREE.Group();
const body = new THREE.Mesh(new THREE.IcosahedronGeometry(1.35, 1), this.caveMats.wool);
body.scale.set(1.4, 1, 1);
sheep.add(this.outline(body, 0.06, this.caveMats.ink));
for (const [px, py] of [[-0.8, 0.8], [0.3, 1.0], [-0.2, -0.1]] as const) {
const puff = new THREE.Mesh(new THREE.IcosahedronGeometry(0.8, 1), this.caveMats.wool);
puff.position.set(px, py, 0);
sheep.add(this.outline(puff, 0.05, this.caveMats.ink));
}
const head = new THREE.Mesh(new THREE.ConeGeometry(0.55, 1.4, 6), this.caveMats.face);
head.rotation.z = Math.PI / 2 + 0.4;
head.position.set(2.0, 0.35, 0);
sheep.add(head);
for (const [lx, lz] of [[-0.8, -0.45], [-0.8, 0.45], [0.8, -0.45], [0.8, 0.45]] as const) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(0.24, 1.1, 0.24), this.caveMats.face);
leg.position.set(lx, -1.15, lz);
sheep.add(leg);
}
sheep.position.set(-24 + i * 3.8 + hash(i, 3, 3) * 2, -7.4, -1 - hash(3, i, 3) * 9);
sheep.rotation.y = (hash(i, 9, 2) - 0.5) * 1.2 + (i % 2 ? Math.PI : 0);
this.cave.add(sheep);
}
}
/* ----- palette ----- */
private applyPalette() {
const p = PAL;
p.sky.forEach((h, i) => (this.skyU[`uB${i}` as "uB0"].value as THREE.Color).set(h));
this.skyU.uSun.value.set(p.sun);
this.skyU.uRing.value.set(p.sunRing);
this.seaU.uDeep.value.set(p.deep);
this.seaU.uMid.value.set(p.mid);
this.seaU.uCrest.value.set(p.crest);
this.seaU.uContour.value.set(p.contour);
this.fog.color.set(p.fog);
this.fog.near = p.fogNear;
this.fog.far = p.fogFar;
this.ambient.color.set(p.ambient);
this.ambient.intensity = p.ambientI;
this.sunLight.color.set(p.light);
this.sunLight.intensity = p.lightI;
this.inkMat.color.set(p.ink);
this.edgeMat.color.set(p.ink);
this.edgeMat.opacity = p.inkSoft;
this.mouthMat.color.set(p.mouth);
const cols = { sand: new THREE.Color(p.sand), rock: new THREE.Color(p.rock), top: new THREE.Color(p.top) };
for (const { geo, height } of this.islandGeos) { paintIsland(geo, height, cols); (geo.getAttribute("color") as THREE.BufferAttribute).needsUpdate = true; }
this.shipMats.hull.color.set(p.hull);
this.shipMats.band.color.set(p.band);
this.shipMats.wood.color.set(p.wood);
this.shipMats.eye.color.set(p.eye);
const ctx = this.sailCanvas.getContext("2d");
if (ctx) {
ctx.fillStyle = p.sail; ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = p.sailStripe;
for (const x of [8, 27, 46]) ctx.fillRect(x, 0, 10, 64);
}
this.sailTex.needsUpdate = true;
this.ship.traverse((o) => { if (o.userData.edge) ((o.userData.edge as THREE.LineSegments).material as THREE.LineBasicMaterial).color.set(p.ink); });
// cave
this.caveMats.rock.color.set(p.caveRock);
this.caveMats.floor.color.set(p.caveFloor);
this.caveMats.boulder.color.set(p.caveFloor);
this.caveMats.ink.color.set(p.caveInk);
this.caveMats.edge.color.set(p.caveInk);
this.caveMats.giant.color.set(p.giant);
this.caveMats.giantInc.color.set(p.giantInc);
this.caveMats.giantHull.color.set(p.giantInc);
this.caveMats.eye.color.set(p.giantEye);
this.caveMats.wool.color.set(p.wool);
this.caveMats.face.color.set(p.face);
this.caveMats.fire.color.set(p.fire);
this.caveMats.core.color.set(p.fireCore);
this.fireLight.color.set(p.fire);
this.caveAmbient.intensity = p.caveAmbient;
}
/* ----- frame ----- */
setHorizon(fraction: number) { this.horizonAt = fraction; }
/** Lift the picture by px (the principal point moves up, nothing is zoomed), so the ship and the island
* sit above the bottom timeline. Both cameras render a window into a taller frame. */
setFrameShift(px: number) {
if (Math.abs(px - this.shift) < 0.5) return;
this.shift = px;
this.applyView();
}
private applyView() {
const w = this.host.clientWidth, h = this.host.clientHeight;
if (!w || !h) return;
const s = this.shift, fullH = h + 2 * s;
for (const [cam, fov] of [[this.camera, 46], [this.caveCam, 54]] as const) {
cam.fov = THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(fov / 2)) * (fullH / h)));
cam.aspect = w / fullH;
if (s > 0) cam.setViewOffset(w, fullH, 0, 2 * s, w, h);
else cam.clearViewOffset();
cam.updateProjectionMatrix();
}
}
/* ----- paths B ----- */
setPaths(on: boolean) { this.seaRibbons.group.visible = this.caveRibbons.group.visible = on; }
setThreadSong(song: Song) { this.seaRibbons.setSong(song); this.caveRibbons.setSong(song); }
setPick(id: string | null) { this.seaRibbons.setPick(id); this.caveRibbons.setPick(id); }
/** Screen positions (px in the canvas) for the ribbons' floating labels at beat position p. */
labelPoints(p: number): { id: string; x: number; y: number; song: number; ahead: boolean; visible: boolean }[] {
const inCave = p >= 2.9;
const cam = inCave ? this.caveCam : this.camera;
const set = inCave ? this.caveRibbons : this.seaRibbons;
const w = this.host.clientWidth, h = this.host.clientHeight;
return set.labels(inCave ? 6 : this.split).map((l) => {
const v = l.pos.clone().project(cam);
return { id: l.id, x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, song: l.song, ahead: l.ahead, visible: v.z < 1 && Math.abs(v.x) < 1.05 && Math.abs(v.y) < 1.05 };
});
}
private resize() {
const w = this.host.clientWidth, h = this.host.clientHeight;
if (!w || !h) return;
this.renderer.setSize(w, h, false);
this.applyView();
}
/** Points the sea camera from pos at target; at p=0 the pitch is forced so the horizon sits on the frieze's ground line. */
private aimSea(pos: THREE.Vector3, target: THREE.Vector3, horizonLock: number) {
const d = this.tmp.copy(target).sub(pos);
const yaw = Math.atan2(-d.x, -d.z);
const pitchAim = Math.atan2(d.y, Math.hypot(d.x, d.z));
// in the un-lifted frame: the horizon's offset from the picture's centre, which sits `shift` px above the stage's
const tanHalf = Math.tan(THREE.MathUtils.degToRad(46 / 2));
const h = this.host.clientHeight || 1;
const pitchLock = Math.atan((this.horizonAt - 0.5 + this.shift / h) * 2 * tanHalf);
this.camera.position.copy(pos);
this.camera.rotation.set(lerp(pitchAim, pitchLock, horizonLock), yaw, 0);
}
/** Render the world at beat position p (0 = frieze … 4 = cave), wall-clock time in seconds. */
render(p: number, time: number) {
const t = this.reduced ? time * 0.25 : time;
this.waveU.uTime.value = t;
if (p < 2.9) this.renderSea(p, t);
else this.renderCave(p, t);
}
private renderSea(p: number, t: number) {
const sail = easeInOut(p - 1);
const shipPos = shipAt(sail);
this.split = 2 + 3 * sail;
this.seaRibbons.update(t, this.split);
const ahead = shipAt(Math.min(1, sail + 0.01)).sub(shipAt(Math.max(0, sail - 0.01)));
const heading = Math.atan2(ahead.x, ahead.z);
this.ship.position.set(shipPos.x, seaWave(shipPos.x, shipPos.z, t) * 0.6 - 0.15, shipPos.z);
this.ship.rotation.set(Math.sin(t * 0.9) * 0.03, heading, Math.sin(t * 1.1) * 0.05);
const rise = easeInOut((p - 1.05) / 0.7);
this.island.position.y = lerp(ISLAND_SUNK, 0, rise);
this.island.visible = rise > 0;
// Camera: a fixed side (behind the ship's right shoulder) the whole way; landfall only pulls back and up,
// so the island grows ahead-left of the ship instead of shuffling across the frame (13's rough spot).
const m = ease((p - 0.42) / 0.58);
const n = easeInOut(p - 1);
const off0 = v3(0, 3.2, 74), off1 = v3(12, 9, 34), off2 = v3(15, 19, 74);
const camOff = p <= 1 ? off0.lerp(off1, m) : off1.lerp(off2, n);
const pos = shipPos.clone().add(camOff);
const tgt1 = shipPos.clone().add(v3(-6, 3, -26));
const tgt2 = shipPos.clone().lerp(ISLAND, 0.5).setY(7);
const target = p <= 1 ? tgt1 : tgt1.lerp(tgt2, n);
this.aimSea(pos, target, p <= 1 ? 1 - m : 0);
this.renderer.render(this.sea, this.camera);
}
private renderCave(p: number, t: number) {
const k = easeInOut((p - 3.12) / 0.88);
const pos = v3(lerp(0, 1, k), lerp(1, -1, k), lerp(42, 30, k));
const target = v3(lerp(0, -4, k), lerp(0, 5, k), -40);
this.caveCam.position.copy(pos);
this.caveCam.lookAt(target);
for (const f of this.flames) {
const s = 0.82 + 0.18 * Math.sin(t * 8 + f.userData.phase) + 0.08 * Math.sin(t * 19 + f.userData.phase * 2);
f.scale.set(1 + (1 - s) * 0.4, s, 1);
f.lookAt(this.caveCam.position.x, f.parent!.position.y + f.position.y, this.caveCam.position.z);
}
this.caveRibbons.update(t, 6);
this.fireLight.intensity = 820 * (0.9 + 0.1 * Math.sin(t * 11) * Math.sin(t * 4.3));
this.caveMats.eye.color.set(PAL.giantEye).multiplyScalar(0.85 + 0.15 * Math.sin(t * 2));
this.renderer.render(this.cave, this.caveCam);
}
dispose() {
this.ro.disconnect();
this.renderer.dispose();
}
}
+857 -1
View File
@@ -1 +1,857 @@
body { margin: 0; --stub-17: 1; }
/* 17 Voyage with real content: 16's look plus the real player, synced lyrics and the Odyssey reading view. */
/* 16 · Voyage, readable — 15 (look 2, the meander accents, terracotta + black) with readable type:
Inter for every label and control, Cormorant only for big titles and the frieze's inscriptions, nothing
under 13px. Listen and lyrics share one tab; the paths are combined (timeline, panel strip, sea ribbons). */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-700.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
:root {
/* black-figure (04): the frieze, and now the accents of the whole interface */
--clay: #c46a33;
--clay-deep: #9a4520;
--glaze: #1d140e;
--added-white: #efe3c8;
/* vellum (12): the reading surfaces */
--vellum: #e3d6b4;
--vellum-hi: #ece2c6;
--vellum-lo: #e0d1ab;
--iron: #2b2118;
--iron-soft: #5a4836;
--line: rgb(43 33 24 / 0.2);
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
--panel-w: 420px;
--panel-slim: 56px;
/* the frieze's key band, as a strip: shared by the frieze, the panel's top edge and the HUD */
--meander: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28'%3E%3Crect width='28' height='28' fill='%23c46a33'/%3E%3Cpath d='M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7' fill='none' stroke='%231d140e' stroke-width='3.2'/%3E%3C/svg%3E");
/* where the fitted frieze sits on the stage: between the caption and the HUD */
--fz-top: 116px;
--fz-bot: 218px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
height: 100dvh;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
background: var(--glaze);
color: var(--iron);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--clay); outline-offset: 2px; }
[hidden] { display: none !important; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* the shared ornament: a meander strip between two glaze rules */
.meander-edge::before, .cap::before, .hud::before, .panel::before {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 14px;
background: var(--meander) 0 0 / 14px 14px repeat-x;
border-top: 2px solid var(--glaze);
border-bottom: 2px solid var(--glaze);
pointer-events: none;
}
/* ---------- shell: stage | panel, credits below both ---------- */
.shell {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--panel-w);
transition: grid-template-columns 0.42s cubic-bezier(0.22, 0.8, 0.2, 1);
}
.shell[data-panel="closed"] { grid-template-columns: minmax(0, 1fr) var(--panel-slim); }
[data-fan-credits] {
position: relative;
z-index: 5;
padding: 0.8rem 1.5rem;
background: var(--glaze);
color: var(--added-white);
opacity: 1;
border-top: 2px solid var(--clay);
}
/* ---------- stage ---------- */
.stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--glaze);
isolation: isolate;
}
.stage__three, .cut, .stage__fallback { position: absolute; inset: 0; }
.stage__three { z-index: 0; }
.stage__canvas { display: block; width: 100%; height: 100%; }
.stage__fallback { display: grid; place-items: center; background: linear-gradient(#a9c3c0, #f4dfb4 60%, #1d5a6e 61%); color: var(--iron); }
/* ---------- friezes: fitted between caption and HUD ---------- */
.frieze-host, .frieze-box { position: absolute; left: 0; right: 0; top: var(--fz-top); bottom: var(--fz-bot); }
.frieze-box { visibility: hidden; pointer-events: none; }
.frieze-host { z-index: 2; pointer-events: none; will-change: transform, opacity; }
.frieze { display: block; width: 100%; height: 100%; overflow: visible; }
.frieze-host--cyclops { z-index: 3; }
.f-surround { fill: var(--glaze); }
.f-band { fill: var(--clay); }
.ink { fill: var(--glaze); }
.clay { fill: var(--clay); }
.inc { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.limb { fill: none; stroke: var(--glaze); stroke-linecap: round; stroke-linejoin: round; }
.limb-edge { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.oar { stroke: var(--glaze); stroke-width: 3.2; stroke-linecap: round; }
.rig { fill: none; stroke: var(--glaze); stroke-width: 1.4; }
.wave { fill: none; stroke: var(--glaze); stroke-linecap: round; }
.ground { stroke: var(--glaze); stroke-width: 3.5; }
.rule { fill: none; stroke: var(--glaze); stroke-width: 2.6; }
.vol { fill: none; stroke: var(--glaze); stroke-width: 3; stroke-linecap: round; }
.mean { fill: none; stroke: var(--glaze); stroke-width: 3; }
.scroll { fill: none; stroke: var(--glaze); stroke-width: 3.4; stroke-linecap: round; }
.frame-rule { display: none; }
/* the frieze is drawn at ~0.7 of its units on this stage: 27 units ≈ 19px on screen */
.inscr { font: 700 27px var(--serif); letter-spacing: 0.08em; fill: var(--glaze); }
.saga-name { font: 600 20px var(--sans); letter-spacing: 0.06em; fill: var(--glaze); }
.glow-a { stop-color: #e2803a; }
.glow-b { stop-color: #3a170a; }
.glow-c { stop-color: #0c0705; }
/* threads on the sea, in the frieze: the register under the ground line carries the motif threads */
.f-threads { display: none; }
body[data-ribbons="on"] .f-threads { display: inline; }
body[data-ribbons="on"] .f-scroll { display: none; }
.ft { pointer-events: auto; cursor: pointer; transition: opacity 0.2s ease; }
.ft__edge { fill: none; stroke: var(--glaze); stroke-width: 8; stroke-linecap: round; }
.ft__line { fill: none; stroke: var(--c); stroke-width: 4.2; stroke-linecap: round; }
.ft__knot { fill: var(--added-white); stroke: var(--glaze); stroke-width: 3; }
.ft__knot.is-here { fill: var(--c); }
.ft__dot { fill: var(--c); stroke: var(--glaze); stroke-width: 2.4; }
.ft__name { font: 600 20px var(--sans); letter-spacing: 0.02em; fill: var(--glaze); paint-order: stroke; stroke: var(--clay); stroke-width: 4px; }
.ft.is-on .ft__line { stroke-width: 6; }
.ft.is-on .ft__edge { stroke-width: 10; }
.ft.is-dim { opacity: 0.28; }
.cut {
z-index: 6;
background: var(--glaze);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.cut.is-on { opacity: 1; }
/* ---------- caption card ---------- */
.cap {
position: absolute;
z-index: 7;
top: 22px;
left: 24px;
max-width: 440px;
padding: 22px 20px 12px;
background: var(--vellum-hi);
color: var(--iron);
border: 2px solid var(--glaze);
border-radius: 2px;
box-shadow: 0 10px 28px -14px rgb(0 0 0 / 0.6);
overflow: hidden;
}
.cap::before { border-top: 0; }
.cap__eyebrow { margin: 0; font: 600 0.9375rem/1.3 var(--sans); color: var(--clay-deep); }
.cap__title { margin: 4px 0 0; font: 600 2rem/1.1 var(--serif); }
.back {
margin-top: 8px;
padding: 5px 14px;
border: 0;
border-radius: 999px;
background: var(--glaze);
color: var(--added-white);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
/* ---------- HUD: the step row and the voyage timeline across the bottom of the stage ---------- */
.hud {
position: absolute;
z-index: 7;
left: 16px; right: 16px;
bottom: 16px;
padding: 16px 14px 4px;
background: var(--vellum-hi);
color: var(--iron);
border: 2px solid var(--glaze);
border-radius: 2px;
box-shadow: 0 12px 30px -14px rgb(0 0 0 / 0.65);
white-space: nowrap;
overflow: hidden;
font-size: 0.875rem;
}
.hud::before { border-top: 0; height: 12px; background-size: 12px 12px; }
.hud__row { display: flex; align-items: center; gap: 6px; padding-bottom: 5px; border-bottom: 1px solid var(--line); }
.hud button { cursor: pointer; }
.hud__play {
padding: 7px 15px;
border: 0;
border-radius: 999px;
background: var(--glaze);
color: var(--added-white);
font-weight: 600;
font-size: 0.875rem;
}
.hud__step {
flex: none;
width: 32px; height: 32px;
border: 1.5px solid var(--glaze);
border-radius: 50%;
background: transparent;
font-size: 1rem;
}
.hud__step:disabled { opacity: 0.3; cursor: default; }
.hud__gap { flex: 1; }
.dots { display: flex; gap: 0; margin: 0; padding: 0; list-style: none; }
.dots button {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px 5px 4px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--iron-soft);
font-size: 0.875rem;
font-weight: 500;
}
.dots button:hover { color: var(--iron); background: rgb(43 33 24 / 0.06); }
.dots__n {
display: inline-grid;
place-items: center;
width: 22px; height: 22px;
border: 1.5px solid currentColor;
border-radius: 50%;
font-size: 0.8125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.dots button[aria-current] { color: var(--iron); font-weight: 600; }
.dots button[aria-current] .dots__n { background: var(--clay); border-color: var(--glaze); color: var(--glaze); }
/* "Threads on the sea": an on/off switch */
.switch {
display: inline-flex; align-items: center; gap: 8px;
padding: 5px 10px 5px 6px;
border: 0; border-radius: 999px;
background: transparent;
font-size: 0.875rem; font-weight: 600;
}
.switch:hover { background: rgb(43 33 24 / 0.06); }
.switch__track { position: relative; width: 34px; height: 20px; border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo); transition: background-color 0.2s ease; }
.switch__knob { position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%; background: var(--glaze); transition: transform 0.2s ease; }
.switch[aria-checked="true"] .switch__track { background: var(--clay); }
.switch[aria-checked="true"] .switch__knob { transform: translateX(14px); }
.tlbtn {
display: inline-flex; align-items: center; gap: 6px;
margin-left: 4px;
padding: 5px 10px 5px 12px;
border: 1.5px solid var(--line);
border-radius: 999px;
background: transparent;
font-size: 0.875rem; font-weight: 600;
color: var(--iron);
}
.tlbtn:hover { border-color: var(--glaze); }
.tlbtn__chev { display: inline-block; font-size: 1rem; line-height: 1; transform: translateY(-2px); transition: transform 0.2s ease; }
body[data-timeline="closed"] .tlbtn__chev { transform: translateY(2px) rotate(180deg); }
.tl { padding-top: 2px; }
/* ---------- horizontal thread strips (the timeline, and the panel's pinned strip) ---------- */
.hs { display: grid; grid-template-columns: var(--label-w) auto; align-items: start; }
.hs__labels { display: flex; flex-direction: column; }
.hs__course-label { height: var(--course-h); display: flex; align-items: flex-start; padding-top: calc(var(--course-y) - 9px); font-size: 0.8125rem; font-weight: 600; line-height: 1.3; color: var(--iron-soft); }
.hs__course-label--slim { height: auto; padding-top: 28px; }
.hs__m {
display: flex; align-items: center; gap: 7px;
padding: 0 6px 0 0;
border: 0; border-radius: 3px;
background: transparent;
color: var(--c);
font-size: 0.8125rem; font-weight: 600; line-height: 1;
text-align: left; white-space: nowrap;
cursor: pointer;
transition: opacity 0.2s ease;
}
.hs__m i { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.hs__m:hover { text-decoration: underline; text-underline-offset: 3px; }
.hs__m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.hs__m.is-dim { opacity: 0.45; }
.hs__svg { display: block; overflow: visible; }
.hs__here-band { fill: rgb(196 106 51 / 0.16); }
.hs__course { fill: none; stroke: var(--clay-deep); stroke-width: 1.6; stroke-dasharray: 5 3.5; }
.hs__saga { stroke: var(--glaze); stroke-width: 1.4; }
.hs__saga-name { font: 600 13px var(--sans); fill: var(--clay-deep); }
.hs__stop circle { fill: var(--vellum-hi); stroke: var(--glaze); stroke-width: 1.6; }
.hs__stop.is-current circle { fill: var(--clay); stroke-width: 2; }
.hs__num { font: 600 13px var(--sans); fill: var(--iron-soft); text-anchor: middle; font-variant-numeric: tabular-nums; }
.hs__num.is-current { fill: var(--glaze); }
.hs__stop--slim .hs__num { fill: var(--iron); }
.hs__title { font: 500 13px var(--sans); fill: var(--iron-soft); }
.hs__title.is-current { font-weight: 600; fill: var(--iron); }
.hs__ship-hull, .hs__ship-sail { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.4; stroke-linejoin: round; }
.hs__ship-mast { stroke: var(--glaze); stroke-width: 1.6; }
.hs__here { font: 600 13px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: var(--vellum-hi); stroke-width: 4px; stroke-linejoin: round; }
.thread { transition: opacity 0.2s ease; }
.thread__line { fill: none; stroke: var(--c); stroke-width: 2.4; stroke-linecap: round; opacity: 0.85; }
.thread__knot { fill: var(--vellum-hi); stroke: var(--c); stroke-width: 2.2; }
.thread__knot.is-here { fill: var(--c); stroke: var(--glaze); stroke-width: 1.6; }
.thread.is-on .thread__line { stroke-width: 3.8; opacity: 1; }
.thread.is-on .thread__knot { fill: var(--c); }
.thread.is-dim { opacity: 0.2; }
/* ---------- threads on the sea: labels over the 3D ribbons, lifted on leader lines ---------- */
.wlabels { position: absolute; inset: 0; z-index: 5; pointer-events: none; transition: opacity 0.2s linear; }
.wl-leaders { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.wl-leaders path { stroke: var(--glaze); stroke-width: 1.6; fill: none; }
.wl-leaders circle { fill: var(--added-white); stroke: var(--glaze); stroke-width: 1.6; }
.wl {
position: absolute;
left: 0; top: 0;
padding: 7px 11px 8px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: var(--vellum-hi);
color: var(--iron);
white-space: nowrap;
pointer-events: auto;
box-shadow: 0 6px 14px -8px rgb(0 0 0 / 0.6);
}
.wl::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 4px; background: var(--clay); }
.wl__head { margin: 2px 0 4px; font-size: 0.8125rem; font-weight: 600; color: var(--iron-soft); }
.wl__ms { display: flex; flex-direction: column; gap: 2px; margin: 0; }
.wl__m {
display: flex; align-items: center; gap: 7px;
padding: 1px 0;
border: 0; background: transparent;
font-size: 0.9375rem; font-weight: 600; text-align: left;
cursor: pointer;
transition: opacity 0.2s ease;
}
.wl__m i { width: 11px; height: 11px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.wl__m:hover, .wl__m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.wl__m.is-dim { opacity: 0.45; }
/* ---------- the panel ---------- */
.panel {
position: relative;
z-index: 8;
min-height: 0;
min-width: 0;
overflow: hidden;
background:
repeating-linear-gradient(90deg, rgb(91 65 40 / 0.03) 0 1px, transparent 1px 7px),
linear-gradient(180deg, var(--vellum-hi), #e6d9b8);
color: var(--iron);
border-left: 2px solid var(--glaze);
box-shadow: -14px 0 30px -18px rgb(0 0 0 / 0.7);
font-size: 15px;
line-height: 1.5;
}
.panel::before { z-index: 3; height: 18px; background-size: 18px 18px; border-top: 0; }
.panel__body {
position: absolute;
inset: 0;
width: calc(var(--panel-w) - 2px);
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
padding-top: 18px;
transition: opacity 0.25s ease, transform 0.42s cubic-bezier(0.22, 0.8, 0.2, 1);
}
.panel[data-open="false"] .panel__body { opacity: 0; transform: translateX(40px); pointer-events: none; }
.panel__head { display: flex; align-items: flex-start; gap: 14px; padding: 18px 20px 12px 22px; }
.panel__song { flex: 1; min-width: 0; transition: opacity 0.18s ease, transform 0.18s ease; }
.panel__eyebrow { margin: 0; font-size: 0.8125rem; font-weight: 600; color: var(--clay-deep); font-variant-numeric: tabular-nums; }
.panel__eyebrow.is-live::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--clay); box-shadow: 0 0 0 1.5px var(--glaze); vertical-align: 1px; }
.panel__title { margin: 2px 0 2px; font: 600 2rem/1.05 var(--serif); }
.panel__sub { margin: 0; font-size: 0.9375rem; color: var(--iron-soft); }
.pplay, .player__play {
flex: none;
display: grid; place-items: center;
width: 52px; height: 52px;
margin-top: 4px;
border: 2px solid var(--glaze);
border-radius: 50%;
background: var(--clay);
color: var(--glaze);
cursor: pointer;
box-shadow: 0 3px 0 var(--glaze);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.pplay svg, .player__play svg { width: 22px; height: 22px; fill: currentColor; }
.pplay:hover, .player__play:hover { transform: translateY(-1px); box-shadow: 0 4px 0 var(--glaze); }
.pplay:active, .player__play:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--glaze); }
.pplay[aria-pressed="true"], .player__play[aria-pressed="true"] { background: var(--glaze); color: var(--added-white); }
.panel__n, .panel__tab-n {
flex: none;
display: inline-grid;
place-items: center;
width: 30px; height: 30px;
border: 1.5px solid var(--glaze);
border-radius: 50%;
background: var(--clay);
color: var(--glaze);
font: 700 0.9375rem var(--sans);
}
.panel__hide {
flex: none;
margin-top: 2px;
padding: 5px 12px;
border: 1.5px solid var(--line);
border-radius: 999px;
background: transparent;
font-size: 0.875rem;
font-weight: 500;
color: var(--iron-soft);
cursor: pointer;
}
.panel__hide:hover { color: var(--iron); border-color: var(--glaze); }
/* the strip pinned in the panel while the bottom timeline is collapsed */
.pin { margin: 0; padding: 10px 20px 8px 22px; border-top: 1px solid var(--line); background: rgb(255 250 236 / 0.4); transition: opacity 0.18s ease; }
.pin__head { display: flex; align-items: baseline; gap: 10px; margin: 0 0 4px; }
.pin__title { font-size: 0.875rem; font-weight: 600; color: var(--iron); }
.pin__sub { font-size: 0.8125rem; color: var(--iron-soft); }
.tabs { display: flex; gap: 2px; padding: 0 20px 0 22px; border-top: 1px solid var(--line); border-bottom: 1.5px solid var(--glaze); }
.panel[data-pinned="false"] .tabs { border-top: 0; }
.tabs button {
position: relative;
padding: 11px 9px 10px;
border: 0;
background: transparent;
color: var(--iron-soft);
font-size: 0.9375rem;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
}
.tabs button:hover { color: var(--iron); }
.tabs button[aria-selected="true"] { color: var(--iron); font-weight: 600; }
.tabs button[aria-selected="true"]::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1.5px; height: 4px; background: var(--clay); border: 1px solid var(--glaze); border-bottom: 0; }
.panel__view {
min-height: 0;
overflow: auto;
padding: 18px 20px 28px 22px;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.panel.is-swapping .panel__view, .panel.is-swapping .panel__song, .panel.is-swapping .pin { opacity: 0; transform: translateY(6px); }
/* the slim tab, shown when collapsed */
.panel__tab {
position: absolute;
inset: 18px 0 0;
width: var(--panel-slim);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 22px 0;
border: 0;
background: transparent;
color: var(--iron);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease 0.1s;
}
.panel[data-open="false"] .panel__tab { opacity: 1; pointer-events: auto; }
.panel__tab-arrow { font-size: 1.5rem; line-height: 1; color: var(--clay-deep); }
.panel__tab-label { writing-mode: vertical-rl; font-size: 0.9375rem; font-weight: 600; }
/* ---------- panel content ---------- */
.sec { margin: 24px 0 8px; font: 600 1.375rem/1.2 var(--serif); }
.panel__view > div > .sec:first-child, .panel__view > div > div:first-child > .sec:first-child { margin-top: 0; }
.note {
margin: 0 0 16px;
padding: 9px 12px;
border: 1px dashed rgb(43 33 24 / 0.35);
border-radius: 4px;
color: var(--iron-soft);
font-size: 0.875rem;
}
.hint { margin: 0 0 10px; color: var(--iron-soft); font-size: 0.9375rem; }
/* About */
.facts { margin: 0; display: grid; gap: 0; }
.facts div { display: grid; grid-template-columns: 112px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.facts dt { font-size: 0.875rem; font-weight: 600; color: var(--iron-soft); }
.facts dd { margin: 0; }
.facts a, .about a { color: var(--clay-deep); text-underline-offset: 3px; }
.about__fact { margin: 14px 0 0; font: italic 500 1.25rem/1.35 var(--serif); }
.parts { margin: 0; padding: 0; list-style: none; }
.parts li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--line); }
.parts__name { font-weight: 600; }
.parts__who { color: var(--iron-soft); text-align: right; }
.golisten {
display: inline-flex; align-items: center; gap: 9px;
margin-top: 14px;
padding: 8px 16px 8px 12px;
border: 0; border-radius: 999px;
background: var(--glaze); color: var(--added-white);
font-size: 0.9375rem; font-weight: 600;
cursor: pointer;
}
.golisten svg { width: 15px; height: 15px; fill: var(--clay); }
.panel .video-cards { gap: 8px; }
.panel .video-card {
position: relative;
padding: 9px 14px 9px 36px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: #f3ecd8;
color: var(--iron);
font-weight: 600;
}
.panel .video-card::before {
content: "";
position: absolute;
left: 13px; top: 50%;
border: 6px solid transparent;
border-left: 9px solid var(--clay);
transform: translateY(-50%);
}
/* Listen & lyrics: the player */
.player {
position: relative;
margin: 0 0 16px;
padding: 22px 16px 14px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: #f3ecd8;
overflow: hidden;
}
.player::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 10px;
background: var(--meander) 0 0 / 10px 10px repeat-x;
border-bottom: 1.5px solid var(--glaze);
}
.player__top { display: flex; align-items: center; gap: 14px; }
.player__play { margin-top: 0; width: 56px; height: 56px; }
.player__play svg { width: 24px; height: 24px; }
.player__meta { min-width: 0; }
.player__part { margin: 0; font-size: 1.0625rem; font-weight: 600; }
.player__part span { font-weight: 400; color: var(--iron-soft); font-size: 0.9375rem; }
.player__time { margin: 0; font-size: 0.9375rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.player__of { font-weight: 400; color: var(--iron-soft); }
.scrub { position: relative; margin: 16px 0 0; padding: 8px 0; cursor: pointer; touch-action: none; }
.scrub:focus-visible { outline-offset: 0; }
.scrub__track { position: relative; height: 8px; border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo); }
.scrub__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 999px; background: var(--clay); }
.scrub__tick { position: absolute; top: -6px; bottom: -6px; width: 2px; margin-left: -1px; background: var(--glaze); }
.scrub__head { position: absolute; top: 50%; left: 0; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid var(--glaze); border-radius: 50%; background: var(--added-white); }
.segs { display: flex; gap: 4px; margin-top: 4px; }
.seg {
flex: 1 1 0;
min-width: max-content;
display: flex; align-items: baseline; gap: 6px;
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 2px;
background: transparent;
text-align: left;
font-size: 0.875rem; font-weight: 600;
cursor: pointer;
}
.seg:hover { border-color: var(--glaze); }
.seg__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seg__time { font-weight: 400; color: var(--iron-soft); font-variant-numeric: tabular-nums; }
.seg.is-now { background: var(--clay); border-color: var(--glaze); color: var(--glaze); }
.seg.is-now .seg__time { color: var(--glaze); }
.player__note { margin: 10px 0 0; font-size: 0.8125rem; color: var(--iron-soft); }
/* Listen & lyrics: the lyrics, by part */
.part { margin: 0 0 14px; padding: 8px 10px 10px 14px; border-left: 4px solid var(--c); border-radius: 0 2px 2px 0; transition: opacity 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease; scroll-margin-top: 12px; }
.part.is-on { background: color-mix(in srgb, var(--c) 9%, transparent); }
.part.is-dim { opacity: 0.5; }
.part.is-now { background: rgb(196 106 51 / 0.16); box-shadow: inset 0 0 0 1.5px var(--glaze); opacity: 1; }
.part__head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 4px; }
.part__jump { display: inline-flex; align-items: baseline; gap: 9px; padding: 0; border: 0; background: transparent; text-align: left; cursor: pointer; }
.part__jump h3 { margin: 0; font: 600 1.375rem/1.2 var(--serif); font-variant-numeric: lining-nums; }
.part__jump:hover h3 { text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 4px; }
.part__icon { align-self: center; display: grid; place-items: center; width: 24px; height: 24px; border: 1.5px solid var(--glaze); border-radius: 50%; background: var(--vellum-hi); }
.part__icon svg { width: 11px; height: 11px; fill: var(--glaze); margin-left: 1px; }
.part__at { font-size: 0.875rem; color: var(--iron-soft); font-variant-numeric: tabular-nums; }
.part__now { display: none; padding: 1px 9px; border: 1px solid var(--glaze); border-radius: 999px; background: var(--clay); color: var(--glaze); font-size: 0.8125rem; font-weight: 600; }
.part.is-now .part__now { display: inline-block; }
.part.is-now .part__icon { background: var(--clay); }
.part__who { display: flex; flex-wrap: wrap; gap: 5px; margin: 0 0 6px; }
.singer { padding: 1px 10px; border: 1px solid rgb(43 33 24 / 0.3); border-radius: 999px; font-size: 0.8125rem; font-weight: 600; color: var(--iron-soft); }
.part__lines p { margin: 2px 0; font: italic 500 1.1875rem/1.45 var(--serif); }
.part__motifs { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
.mtag { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px 2px 8px; border: 1px solid transparent; border-radius: 999px; background: color-mix(in srgb, var(--c) 16%, transparent); color: var(--c); font-size: 0.8125rem; font-weight: 600; cursor: pointer; }
.mtag i { width: 8px; height: 8px; border-radius: 50%; background: var(--c); }
.mtag:hover, .mtag.is-on { border-color: var(--c); }
.mtag.is-on { background: color-mix(in srgb, var(--c) 26%, transparent); }
/* Paths: route chart */
.route {
margin: 0;
padding: 8px 9px 6px;
background: linear-gradient(160deg, #ece2c6, #dccca5);
color: var(--iron);
border: 1.5px solid var(--glaze);
border-radius: 2px;
}
.route figcaption { margin-top: 4px; font-size: 0.875rem; color: var(--iron-soft); }
.chart__svg { display: block; width: 100%; height: auto; }
.c-vellum { fill: #e6dab9; }
.c-frame { fill: none; stroke: var(--iron); stroke-width: 0.8; }
.c-rhumb { fill: none; stroke: rgb(154 69 32 / 0.18); stroke-width: 0.5; }
.c-land { fill: #c9b07a; stroke: var(--iron); stroke-width: 0.9; }
.c-ink { fill: none; stroke: var(--iron); stroke-width: 0.7; }
.c-course { fill: none; stroke: var(--clay-deep); stroke-width: 1.3; stroke-dasharray: 3 2.4; }
.c-port { fill: #f3ebd6; stroke: var(--iron); stroke-width: 0.9; }
.c-num { display: none; }
.c-place { font: 600 italic 18px var(--serif); fill: var(--iron); }
.c-sea { font: italic 600 14px var(--serif); fill: var(--clay-deep); }
.c-rose circle { fill: none; stroke: var(--iron-soft); stroke-width: 0.6; }
.c-rose path { fill: var(--iron-soft); }
.c-here__ring { fill: rgb(196 106 51 / 0.25); stroke: var(--clay-deep); stroke-width: 1.4; }
.c-here__dot { fill: var(--clay-deep); }
.c-here__label { font: 600 12px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: #efe6cd; stroke-width: 3px; }
/* Paths: motif picks */
.mlist { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; }
.mpick {
width: 100%;
display: grid;
gap: 1px;
padding: 8px 12px 9px;
border: 1px solid var(--line);
border-left: 4px solid var(--c);
border-radius: 2px;
background: transparent;
text-align: left;
cursor: pointer;
transition: opacity 0.2s ease;
}
.mpick:hover { background: var(--vellum-lo); }
.mpick[aria-pressed="true"] { background: var(--vellum-lo); border-color: var(--c); }
.mpick.is-dim { opacity: 0.5; }
.mpick__name { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--c); }
.mpick__name i { width: 10px; height: 10px; border-radius: 50%; background: var(--c); }
.mpick__path { color: var(--iron-soft); font-size: 0.875rem; }
/* Paths: the vertical thread strip */
.vstrip { position: relative; display: grid; grid-template-columns: 1fr auto; align-items: start; }
.vstrip__rows { grid-column: 1 / -1; grid-row: 1; margin: 0; padding: 0; list-style: none; }
.vstrip__row {
width: 100%;
height: 34px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 calc(var(--cols) * 26px + 30px) 0 8px;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--iron-soft);
text-align: left;
font-size: 0.9375rem;
cursor: pointer;
}
.vstrip__row:hover { background: var(--vellum-lo); }
.vstrip__row.is-current { color: var(--iron); font-weight: 600; background: rgb(196 106 51 / 0.14); }
.vstrip__row.is-picked { outline: 2px solid var(--clay); outline-offset: -2px; color: var(--iron); }
.vstrip__n { width: 20px; text-align: right; font-size: 0.875rem; font-variant-numeric: tabular-nums; }
.vstrip__t { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vstrip__now { padding: 0 8px; border: 1px solid var(--glaze); border-radius: 999px; background: var(--clay); color: var(--glaze); font-size: 0.8125rem; font-weight: 600; }
.vstrip__threads { grid-column: 2; grid-row: 1; margin-right: 12px; pointer-events: none; overflow: visible; }
/* Fan works */
.fw { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.fw__card { display: grid; align-content: start; gap: 3px; padding: 0 0 12px; border-radius: 2px; background: #f3ecd8; border: 1.5px solid var(--glaze); overflow: hidden; }
.fw__card > p { margin: 0 11px; }
.fw__thumb { display: block; width: 100%; aspect-ratio: 16 / 10; margin-bottom: 6px; border-bottom: 1.5px solid var(--glaze); }
.t-bg, .t-frame, .t-hole { fill: #d9c8a0; }
.t-ink { fill: var(--iron); }
.t-line { fill: none; stroke: #f3ead6; stroke-width: 2; stroke-linecap: round; }
.fw__type { font-size: 0.8125rem; font-weight: 600; color: var(--clay-deep); }
.fw__title { font-size: 0.9375rem; font-weight: 600; line-height: 1.3; }
.fw__by { font-size: 0.875rem; color: var(--iron-soft); }
.fw__credit { margin-top: 4px !important; font-size: 0.8125rem; color: var(--iron-soft); text-decoration: underline dotted; text-underline-offset: 3px; }
.fw__credit span { text-decoration: none; display: inline-block; }
@media (prefers-reduced-motion: reduce) {
.cut { transition-duration: 0.25s; }
.shell, .panel__body, .panel__view, .panel__song, .pin { transition: none; }
}
/* ---------- 17: the real content ---------- */
/* a missing content folder, said once and quietly */
.cnote {
margin: 18px 0 0;
padding: 8px 12px;
border-left: 4px solid var(--clay);
background: var(--vellum-hi);
color: var(--iron-soft);
font-size: 0.875rem;
}
.cnote b, .player__note b { color: var(--iron); font-weight: 600; }
.cnote code { font-size: 0.8125rem; }
/* the real player: cover art, time, play; it stays pinned while the lyrics scroll under it */
.player--real {
position: sticky;
top: -18px;
z-index: 2;
margin-top: -18px;
border-top-width: 0;
box-shadow: 0 10px 14px -12px rgb(29 20 14 / 0.55);
}
.player--real .player__top { gap: 12px; }
.player--real .player__meta { flex: 1; }
.player__cover {
flex: none;
width: 56px; height: 56px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
object-fit: cover;
background: var(--glaze);
}
.player--real .player__part { font-size: 0.9375rem; color: var(--iron-soft); font-weight: 500; }
.player--real .player__time { font-size: 1.0625rem; }
/* the sample's parts and motifs, compact, above the lyrics */
.psum { margin: 0 0 6px; padding: 0 0 12px; border-bottom: 1px solid var(--line); }
.psum__row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 6px; }
.psum__k { flex: none; width: 58px; font-size: 0.875rem; font-weight: 600; color: var(--iron-soft); }
.psum__v { font-size: 0.9375rem; line-height: 1.4; }
.psum__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.psum__note { margin: 2px 0 0 68px; font-size: 0.8125rem; color: var(--iron-soft); }
/* synced lyrics: the line playing now is lit; earlier lines are sung */
.lyrics .sec { margin-top: 16px; }
.lyrics__lines { margin: 0; padding: 0; list-style: none; }
.ly {
display: grid;
grid-template-columns: 46px 1fr;
align-items: baseline;
width: 100%;
margin: 0;
padding: 4px 10px 4px 0;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--iron);
text-align: left;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
}
.ly__at {
justify-self: end;
padding-right: 8px;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--iron-soft);
opacity: 0;
transition: opacity 0.15s ease;
}
.ly__x { font: italic 500 1.1875rem/1.4 var(--serif); }
.ly:hover .ly__at, .ly:focus-visible .ly__at { opacity: 1; }
.ly:hover .ly__x { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; text-decoration-color: rgb(43 33 24 / 0.4); }
.ly.is-past { color: var(--iron-soft); }
.ly.is-now { background: rgb(196 106 51 / 0.18); box-shadow: inset 0 0 0 1.5px var(--glaze); color: var(--iron); }
.ly.is-now .ly__at { opacity: 1; color: var(--glaze); font-weight: 600; }
.ly.is-now .ly__x { font-weight: 700; }
.ly--gap { cursor: default; padding-top: 2px; padding-bottom: 2px; }
.ly--gap .ly__x { grid-column: 2; font: 500 0.8125rem/1 var(--sans); color: rgb(90 72 54 / 0.6); letter-spacing: 0.3em; }
.ly--gap.is-now { background: transparent; box-shadow: inset 3px 0 0 var(--clay); }
.lyrics__plain p { margin: 2px 0; font: italic 500 1.1875rem/1.4 var(--serif); }
.lyrics__plain .lyrics__gap { height: 12px; }
/* About: the Odyssey reference opens Butler's text */
.odref { font-weight: 600; }
.readbtn {
display: inline-flex; align-items: center; gap: 6px;
margin-left: 10px;
padding: 2px 12px;
border: 1.5px solid var(--glaze);
border-radius: 999px;
background: var(--vellum-hi);
color: var(--iron);
font-size: 0.875rem; font-weight: 600;
cursor: pointer;
}
.readbtn:hover { background: var(--clay); }
/* the reading view */
.reading__back {
display: inline-flex; gap: 6px;
margin: 0 0 12px;
padding: 4px 0;
border: 0;
background: transparent;
color: var(--clay-deep);
font-size: 0.9375rem; font-weight: 600;
cursor: pointer;
}
.reading__back:hover { text-decoration: underline; text-underline-offset: 3px; }
.reading__eyebrow { margin: 0; font-size: 0.875rem; font-weight: 600; color: var(--iron-soft); }
.reading__title { margin: 2px 0 6px; font: 600 1.75rem/1.15 var(--serif); }
.reading__sum { margin: 0 0 12px; font-size: 0.8125rem; font-weight: 600; line-height: 1.45; letter-spacing: 0.02em; color: var(--iron-soft); }
.reading__names {
margin: 0 0 16px;
padding: 7px 12px;
border-left: 4px solid var(--clay);
background: var(--vellum-hi);
font-size: 0.875rem;
line-height: 1.45;
}
.reading__more {
display: block;
width: 100%;
margin: 0 0 6px;
padding: 8px 12px;
border: 1px dashed rgb(43 33 24 / 0.45);
border-radius: 4px;
background: transparent;
color: var(--iron-soft);
font-size: 0.875rem; font-weight: 600;
cursor: pointer;
}
.reading__more:hover { color: var(--iron); border-color: var(--glaze); }
.reading__mark {
position: relative;
margin: 18px 0 10px;
padding: 16px 0 0;
color: var(--clay-deep);
font-size: 0.875rem; font-weight: 600;
scroll-margin-top: 8px;
}
.reading__mark::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 8px;
background: var(--meander) 0 0 / 8px 8px repeat-x;
border-top: 1px solid var(--glaze);
border-bottom: 1px solid var(--glaze);
}
.reading__p { margin: 0 0 12px; font: 500 1.1875rem/1.5 var(--serif); hanging-punctuation: first; }
.reading__p mark { padding: 0 2px; border-radius: 2px; background: rgb(196 106 51 / 0.3); color: inherit; }
.reading__end { margin: 18px 0 0; font-size: 0.8125rem; color: var(--iron-soft); text-align: center; }
+236
View File
@@ -0,0 +1,236 @@
// Motif threads (12's chosen style: soft coloured threads, knotted where a motif plays), shared by the
// three placements, combined in 16: the bottom timeline (open, or slim when collapsed), the strip pinned in
// the panel while the timeline is collapsed, and the frieze register and 3D ribbons ("Threads on the sea").
// Everything here is a pure function of the song, the picked motif and a width.
import { motifsInSong } from "../../data/query";
import { motifs, sagas, songs } from "../../data/sample";
import type { Motif, Song } from "../../data/types";
export const byTrack = [...songs].sort((a, b) => a.track - b.track);
/** The shared motif colours are tuned for dark pages; on vellum and clay they are re-inked darker (12's INK). */
const INK: Record<string, string> = {
warrior: "#93650b", "open-arms": "#b2472c", "just-a-man": "#2e5d8b", remember: "#3b7347",
odysseus: "#5b3c20", athena: "#6a4a9d", polyphemus: "#8e1e24",
};
/** On the teal sea and the clay frieze the brighter shared colours pop; ink edges keep them legible. */
export const bright = (id: string) => motifs.find((m) => m.id === id)?.color ?? "#efe3c8";
export const inked = (id: string) => INK[id] ?? bright(id);
export const plays = (s: Song, id: string) => s.parts.some((p) => p.motifIds.includes(id));
/** Track indices (0-based, in track order) where a motif plays. */
export const hitsOf = (id: string) => byTrack.flatMap((s, i) => (plays(s, id) ? [i] : []));
export const indexOf = (song: Song) => byTrack.findIndex((s) => s.id === song.id);
export interface Route { from: string; next: string; prevSong?: Song; nextSong?: Song }
/** "comes from … / first heard here" and "returns in … / not heard again", in words. */
export function routeOf(song: Song, id: string): Route {
const hits = hitsOf(id).map((i) => byTrack[i]!);
const before = hits.filter((s) => s.track < song.track);
const after = hits.filter((s) => s.track > song.track);
const prevSong = before[before.length - 1];
const nextSong = after[0];
return {
from: prevSong ? `comes from ${prevSong.title}` : "first heard here",
next: nextSong ? `returns in ${nextSong.title}${after.length > 1 ? ` (+${after.length - 1} more)` : ""}` : "not heard again",
prevSong, nextSong,
};
}
export const songMotifs = (song: Song): Motif[] => motifsInSong(song);
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const pickCls = (id: string, pick: string | null) => (pick === id ? " is-on" : pick ? " is-dim" : "");
/** The course line's y inside a strip; markers are translated to it. */
export const COURSE_Y = 28;
/** In the timeline the course sits lower, so saga names fit above the ship marker. */
export const COURSE_Y_C = 36;
/** In the slim (collapsed) timeline the stops are numbered discs; the ship floats above them. */
export const COURSE_Y_S = 38;
/** "panel": the strip pinned in the panel (numbers). "timeline": the open bottom timeline (titles). */
export type StripKind = "panel" | "timeline" | "slim";
export interface StripOpts {
song: Song;
pick: string | null;
/** Total width in px of the strip (labels + threads). */
width: number;
labelW: number;
rowH: number;
kind: StripKind;
}
const PAD: Record<StripKind, number> = { panel: 12, timeline: 46, slim: 22 };
const courseYOf = (k: StripKind) => (k === "panel" ? COURSE_Y : k === "timeline" ? COURSE_Y_C : COURSE_Y_S);
/* Timeline titles are laid out by their measured width (13px Inter, semibold to allow for the current
one), each split into the one or two lines that make it narrowest; stops are spaced so that neighbouring
titles never touch, and the slack is shared out evenly. */
let measureCtx: CanvasRenderingContext2D | null | undefined;
function textW(s: string): number {
if (measureCtx === undefined) measureCtx = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
if (!measureCtx) return s.length * 7.4;
measureCtx.font = '600 13px "Inter", system-ui, sans-serif';
return measureCtx.measureText(s).width;
}
function split(title: string): { lines: string[]; w: number } {
const words = title.split(" ");
let best = { lines: [title], w: textW(title) };
for (let i = 1; i < words.length; i++) {
const lines = [words.slice(0, i).join(" "), words.slice(i).join(" ")];
const w = Math.max(...lines.map(textW));
if (w < best.w - 0.5) best = { lines, w };
}
return best;
}
const TITLE_GAP = 12;
const xsCache = new Map<string, number[]>();
/** Forget measured layouts (call once the web font has loaded). */
export const resetMeasure = () => xsCache.clear();
/** x of every stop on a strip of width w (labels excluded). */
function stopXs(kind: StripKind, w: number): number[] {
const key = `${kind}:${w}`;
const hit = xsCache.get(key);
if (hit) return hit;
const xs = layoutXs(kind, w);
xsCache.set(key, xs);
return xs;
}
function layoutXs(kind: StripKind, w: number): number[] {
const n = byTrack.length;
if (kind !== "timeline") {
const pad = PAD[kind], step = (w - pad * 2) / (n - 1);
return byTrack.map((_, i) => pad + i * step);
}
const ws = byTrack.map((s) => split(s.title).w);
const need = ws.reduce((a, b) => a + b, 0) + TITLE_GAP * (n - 1);
const slack = Math.max(0, w - need) / n;
const xs: number[] = [];
let x = 0;
ws.forEach((tw) => { xs.push(x + (tw + slack) / 2); x += tw + slack + TITLE_GAP; });
return xs;
}
function shipMarker(x: number, y: number, flip: boolean, label: string, lift = 0): string {
return `<g class="hs__ship" data-ship transform="translate(${x.toFixed(1)},${y})"><g transform="translate(0,${-lift})">
<path class="hs__ship-hull" d="M-8,-8 Q0,-1 8,-8 Z"/><path class="hs__ship-mast" d="M0,-8 V-20"/><path class="hs__ship-sail" d="M0.5,-19 L7,-11.5 H0.5 Z"/>
<text class="hs__here" x="${flip ? -12 : 12}" y="-9"${flip ? ' text-anchor="end"' : ""}>${label}</text></g></g>`;
}
/**
* A horizontal thread strip: the voyage's songs as knots on a course line, "you are here" on the current
* song, then one thread per motif of the current song, knotted where it plays. Motif names are buttons.
*/
export function hStrip(o: StripOpts): string {
if (o.kind === "slim") return slimStrip(o);
const titles = o.kind === "timeline";
const ms = songMotifs(o.song);
const cur = indexOf(o.song);
const n = byTrack.length;
const w = o.width - o.labelW;
const xs = stopXs(o.kind, w);
const X = (i: number) => xs[i]!;
const courseY = courseYOf(o.kind);
const courseH = titles ? 76 : 54;
const H = courseH + ms.length * o.rowH + 2;
const wav = (x: number, k: number) => Math.sin(x / 26 + k * 1.7) * 1.6;
// the course: a dashed line through every song, saga boundaries as ticks
const bandL = cur > 0 ? (X(cur - 1) + X(cur)) / 2 : X(cur) - (X(1) - X(0)) / 2;
const bandR = cur < n - 1 ? (X(cur) + X(cur + 1)) / 2 : X(cur) + (X(n - 1) - X(n - 2)) / 2;
let svg = `<rect class="hs__here-band" x="${bandL.toFixed(1)}" y="0" width="${(bandR - bandL).toFixed(1)}" height="${H}" rx="3"/>`;
svg += `<path class="hs__course" d="M${X(0) - 8},${courseY}H${X(n - 1) + 8}"/>`;
byTrack.forEach((s, i) => {
if (i > 0 && s.sagaId !== byTrack[i - 1]!.sagaId) {
const x = (X(i) + X(i - 1)) / 2;
svg += `<path class="hs__saga" d="M${x.toFixed(1)},${titles ? 0 : courseY - 10}V${titles ? H : courseY + 10}"/>`;
if (titles) {
// the name sits right of its divider; where the saga is too short for it, " Saga" is dropped
const full = sagas.find((g) => g.id === s.sagaId)!.name.replace(/^The /, "");
const name = x + 6 + textW(full) > w ? full.replace(/ Saga$/, "") : full;
const tx = x + 6;
svg += `<text class="hs__saga-name" x="${tx.toFixed(1)}" y="11">${esc(name)}</text>`;
}
}
const isCur = i === cur;
svg += `<g class="hs__stop${isCur ? " is-current" : ""}"><title>${s.track}. ${esc(s.title)}</title>
<circle cx="${X(i).toFixed(1)}" cy="${courseY}" r="${isCur ? 6.5 : 5}"/>
${titles ? "" : `<text class="hs__num${isCur ? " is-current" : ""}" x="${X(i).toFixed(1)}" y="${courseY + 22}">${s.track}</text>`}</g>`;
if (titles) {
const tx = X(i).toFixed(1);
svg += `<text class="hs__title${isCur ? " is-current" : ""}" x="${tx}" y="${courseY + 20}" text-anchor="middle">${split(s.title).lines.map((l, j) => `<tspan x="${tx}" dy="${j ? 14 : 0}">${esc(l)}</tspan>`).join("")}</text>`;
}
});
svg += shipMarker(X(cur), courseY, cur > n * 0.6, "you are here");
// one thread per motif
ms.forEach((m, k) => {
const y = courseH + k * o.rowH + o.rowH / 2;
const hits = hitsOf(m.id);
const x0 = X(hits[0]!), x1 = X(hits[hits.length - 1]!);
let d = "";
for (let x = x0; x < x1; x += 5) d += `${d ? "L" : "M"}${x.toFixed(1)},${(y + wav(x, k)).toFixed(1)}`;
d += `${d ? "L" : "M"}${x1.toFixed(1)},${(y + wav(x1, k)).toFixed(1)}`;
svg += `<g class="thread${pickCls(m.id, o.pick)}" data-m="${m.id}" style="--c:${inked(m.id)}">
<path class="thread__line" d="${d}"/>
${hits.map((i) => `<circle class="thread__knot${i === cur ? " is-here" : ""}" cx="${X(i).toFixed(1)}" cy="${(y + wav(X(i), k)).toFixed(1)}" r="${i === cur ? 5.5 : 4.4}"><title>${esc(m.name)} in ${esc(byTrack[i]!.title)}</title></circle>`).join("")}
</g>`;
});
const labels = ms.map((m) => {
const r = routeOf(o.song, m.id);
return `<button type="button" class="hs__m${pickCls(m.id, o.pick)}" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${o.pick === m.id}" style="--c:${inked(m.id)};height:${o.rowH}px" title="${esc(m.name)}: ${esc(r.from)} · ${esc(r.next)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`;
}).join("");
return `<div class="hs hs--${o.kind}" style="--label-w:${o.labelW}px;--course-h:${courseH}px;--course-y:${courseY}px">
<div class="hs__labels"><span class="hs__course-label">${titles ? "The voyage" : "Songs 1–10"}</span>${labels}</div>
<svg class="hs__svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}" aria-hidden="true">${svg}</svg>
</div>`;
}
/** The collapsed timeline: numbered stops on the course and the ship with "you are here", no threads. */
function slimStrip(o: StripOpts): string {
const cur = indexOf(o.song);
const n = byTrack.length;
const w = o.width - o.labelW;
const pad = PAD.slim;
const step = (w - pad * 2) / (n - 1);
const X = (i: number) => pad + i * step;
const y = COURSE_Y_S;
const H = y + 14;
let svg = `<path class="hs__course" d="M${X(0) - 12},${y}H${X(n - 1) + 12}"/>`;
byTrack.forEach((s, i) => {
if (i > 0 && s.sagaId !== byTrack[i - 1]!.sagaId) {
const x = (X(i) + X(i - 1)) / 2;
svg += `<path class="hs__saga" d="M${x.toFixed(1)},${y - 14}V${y + 14}"/>`;
}
const isCur = i === cur;
svg += `<g class="hs__stop hs__stop--slim${isCur ? " is-current" : ""}"><title>${s.track}. ${esc(s.title)}</title>
<circle cx="${X(i).toFixed(1)}" cy="${y}" r="11"/>
<text class="hs__num${isCur ? " is-current" : ""}" x="${X(i).toFixed(1)}" y="${y + 4.6}">${s.track}</text></g>`;
});
svg += shipMarker(X(cur), y, cur > n * 0.6, `you are here · ${esc(o.song.title)}`, 12);
return `<div class="hs hs--slim" style="--label-w:${o.labelW}px">
<div class="hs__labels"><span class="hs__course-label hs__course-label--slim">The voyage</span></div>
<svg class="hs__svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}" aria-hidden="true">${svg}</svg>
</div>`;
}
/** Where the ship marker sits on a strip of this width, for a fractional song index. */
export function stripX(o: Pick<StripOpts, "width" | "labelW" | "kind">, idx: number): number {
const xs = stopXs(o.kind, o.width - o.labelW);
const i = Math.min(xs.length - 2, Math.max(0, Math.floor(idx)));
return xs[i]! + (xs[i + 1]! - xs[i]!) * (idx - i);
}
export const stripY = (k: StripKind) => courseYOf(k);
/** Toggle is-on / is-dim on every element that carries data-m, so one pick highlights everywhere. */
export function applyPick(root: ParentNode, pick: string | null) {
root.querySelectorAll<Element>("[data-m]").forEach((el) => {
const id = el.getAttribute("data-m");
el.classList.toggle("is-on", !!pick && id === pick);
el.classList.toggle("is-dim", !!pick && id !== pick);
if (el instanceof HTMLButtonElement && el.hasAttribute("aria-pressed")) el.setAttribute("aria-pressed", String(!!pick && id === pick));
});
}