feat(mockups): 16 voyage readable (Inter labels, listen & lyrics tab, combined paths)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 03:29:27 +01:00
co-authored by Claude Opus 5.5
parent 27b1d2fe26
commit b500115a8b
13 changed files with 2743 additions and 185 deletions
+4
View File
@@ -310,6 +310,10 @@ What is in `~/epic-the-musical-content/` (1.5 GB, checked 2026-10-30):
`.lrc` lyrics and the actual Odyssey passage). Implemented as spec `docs/specs/003-content-mount.md`.
- Note: Butler's translation uses Roman names (Ulysses, Minerva, Jove). Worth a line in the UI.
- **16 is finished and committed**; report in `docs/design/002-sea-chart-spikes/16-voyage-readable-report.md`.
Preview rebuilt with 01–16 and the content folder mounted. Known: timeline at its height budget with
3 motif rows; the panel strip still shows numbers only; ribbon labels sit on the mountain face.
## 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,123 @@
# 16 Voyage, readable — report
Status: **done** (not committed). Built from `16-voyage-readable-brief.md`, starting from a copy of 15.
## What was built
`src/mockups/16-voyage-readable/` is 15 (look 2, five beats, toon/ink 3D, meander accents on every
menu, panel and HUD, beat 2's colours, placeholders) with three changes. URL params: `?beat=` `?t=`
`?play=` `?panel=0` as before, plus **`?tab=about|listen|paths|fan`**, **`?timeline=open|closed`**
(default open), **`?ribbons=1`**, **`?motif=<id>`** and **`?t_play=<s>`**. `t_play` starts the
first song's player at that second, playing, so a screenshot can show playback.
1. **Readable type.** 15's small Cormorant capitals with wide tracking are gone from every label.
- **Inter** (400/500/600) is used for all UI: caption eyebrow (15px), HUD (14px), tabs (15px,
sentence case), fact labels, panel eyebrow, strip and timeline labels, saga names, "you are
here", B labels and fan-work types. Tracking is 0 everywhere except the frieze's vertical saga
name (0.06em).
- **Cormorant** is kept only for big display text: the caption title, the panel song title, section
headings (22px), part headings in the lyrics, italic lyric lines (19px), the About fact (20px) and
the frieze inscriptions. These are now 27 units, which is about 19px on screen (15 used 19 units,
about 13px).
- **Nothing under 13px on screen.** This includes the SVG text: timeline titles, stop numbers and
saga names are 13px. The frieze's saga column and thread names are Inter at about 14px. The route
chart's 7px port numbers are hidden, and its place names were enlarged. Panel body text is 15px.
Text uses iron `#2b2118`, iron-soft `#5a4836` and clay-deep `#9a4520` on vellum, all about 5:1 or
better.
2. **Listen & lyrics, one tab** (`player.ts`, `panel.ts`). Tabs: About · Listen & lyrics · Paths · Fan
works. About keeps the facts, the Odyssey, parts and singers, and Watch. It also gains a "Listen
with the lyrics" button.
- **Player card** at the top of the tab, with a meander edge. It has a 56px play/pause button, the
current part and its singers, elapsed / total time, and a scrub bar with ticks at the part starts
(click or drag it; ←/→ seek 5s). Below the bar is one button per part ("Verse 2 0:50"), and the
current part is filled terracotta. The note under it reads "Preview player: no audio file yet.
Playback is simulated."
- **Lyrics by part** below the card. Each part heading is a button (▶ Verse 2 0:50) that jumps the
player there and plays. The current part gets a terracotta wash, an ink outline and a "Now
playing" chip, and it scrolls into view when the part changes. Singer pills and motif chips stay;
motif picks still dim and highlight parts.
- **Simulated playback.** A rAF clock runs over a made-up, stable duration per song (2:30–4:10).
Part starts use `part.startSec` when the data has it, and are otherwise spread evenly across the
song. If `song.audio` exists, a real `<audio>` element drives the same UI instead. The shared
`audio-slot.ts` is untouched.
- **Header play button.** A 52px terracotta disc beside the song title, always visible. One click
starts the song and switches to Listen & lyrics; a second click pauses. While a song plays, the
header eyebrow reads "Playing · Verse 1 · 0:41 / 3:52". Moving to a new song on the voyage loads
that song's player, stopped at 0:00.
3. **Paths combined.** The A/B/C toggle is removed.
- **The timeline at the bottom is home, open by default.** It shows all songs titled in Inter 13px,
saga dividers and names, the here-band, the sailing ship with "you are here", and the current
song's motif threads. Titles are measured and split into the narrowest one or two lines, and the
stops are spaced by title width, so neighbouring titles never touch. The HUD is **182px** tall,
under the 190px budget.
- **Collapse.** A "Hide timeline ⌄" pill at the right of the HUD row collapses it to a slim course:
the beat row plus numbered stop discs, saga ticks, and the ship with "you are here · Polyphemus".
It is about 113px tall in total.
- **The panel strip appears only when collapsed.** 15's A strip is pinned between the header and the
tabs. It now uses 13px numbers and motif names and gets the "Motif paths" heading. When the
timeline is open, the strip is hidden.
- **"Threads on the sea" switch** (role=switch, off by default). It turns on the 3D ribbons and their
labels, and the frieze's thread register in beats 1 and 4.
- **The label fix for 15's B.** Labels are grouped by the knot they point at. In landfall, the two
motifs returning in Survive now share one label, "Next: 7. Survive", with each motif as its own
button. Each label is lifted 96px above its knot on an ink leader line with a ring at the knot, so
it sits on the mountain above the cave mouth, not over it. Labels stack against each other using
measured sizes, and stay below the caption card.
- One motif pick still highlights everywhere: timeline, panel strip, Paths tab, lyrics chips and
parts, frieze threads, ribbons and labels.
- **Framing.** The stage lifts the 3D picture by part of the HUD height. Both cameras render a window
into a taller frame (`setViewOffset` with a compensated fov), so nothing zooms and the image only
shifts up. The frieze box's bottom inset follows the HUD's measured height. The morph's horizon
lock accounts for the shift.
Files: `main.ts` (HUD row, switch, timeline open/closed, label grouping and placement, framing),
`panel.ts` (tabs, header play, Listen & lyrics), `player.ts` (new), `threads.ts` (measured timeline
layout, slim strip, readable sizes), `scene.ts` (`setFrameShift`), `frieze.ts` (thread rows and names),
`style.css` (new type system and new components), and `chart.ts`, `fanworks.ts`, `figures.ts`,
`ribbons.ts` copied from 15.
## Screenshots (1440×900, `screenshots/`)
- `16-voyage-readable-beat1.png` … `-beat5.png`: timeline open, ribbons off
- `-listen.png`: beat 3, Listen & lyrics, `?t_play=40`, playing Verse 1
- `-collapsed.png`: timeline closed, strip pinned in the panel
- `-ribbons.png`: beat 3, ribbons on, `?motif=polyphemus`
- `-fan.png`: Fan works tab
- Extra: `-beat1-ribbons.png` (frieze thread register, `?motif=warrior`), `-beat2-ribbons.png`,
`-beat5-ribbons.png` (cave), `-paths-tab.png` (Paths tab, collapsed), `-panel-closed.png`
The credits footer is fully visible in every one.
## What works
- **It reads.** At 100%, every label in every screenshot reads without effort: the HUD, the timeline
titles, the tabs, the fact rows, the strip, the B labels and the frieze inscriptions. The meander
bands, the terracotta/black accents and beat 2's sea and sky keep 15's cohesion. Only the type
changed.
- **Listen is one click.** The header button is always there, and playing lands you in the lyrics with
the current part lit. The parts are the player's chapters, so listening and lyrics are one thing.
- **Paths no longer have to be chosen.** The timeline gives the overview. Collapsing it moves the
threads into the panel, so they are never hidden. The sea ribbons are an opt-in flourish, and their
labels no longer pile up on the cave (`-ribbons`, `-beat2-ribbons`, `-beat5-ribbons`).
- The landfall and cave are framed above the open timeline. In the cave the flock and the giant are
fully visible (`-beat5`).
- Checked: `bun test` (62 pass), `bun run typecheck` (clean) and a production build to the scratchpad. A scratch happy-dom run
(deleted afterwards) checked that the header play opens Listen & lyrics and plays, a part heading
jumps to 0:50 and highlights that part and chip, and pause works.
## What doesn't, or is rough
- **The timeline is still 182px of stage.** With three motif rows it is at the limit, and a song with
four motifs would need about 16px more. The thread rows are 16px apart, which is readable but snug.
- **Timeline spacing is uneven by design.** Stops are spaced by title width, so the voyage's rhythm is
not to scale; Survive and Remember Them sit closer than the others. Slim mode is evenly spaced.
- **The frieze thread names in beat 1** sit just under the ground line, close to the saga column's
rules. When a motif is picked, the other names dim to low contrast on clay, as intended.
- **B labels are lifted 96px on leader lines.** In landfall the label sits on the mountain's face. In
the open sea, ribbons near the camera still sweep under the HUD.
- **The pinned strip in the panel is dense.** Stops are 21px apart, so it shows numbers, not titles;
the titles are in the tooltips and the Paths tab.
- **The simulated player is a timer,** and real audio was not tested. With `?t_play` under headless
virtual time the clock advances a few seconds (40 → 0:41 in the shot). Desktop 1440×900 only.
- With the panel closed, a ring from 14's sun shows at the stage's right edge (`-panel-closed`). This is
inherited and unchanged.
+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>`;
}
@@ -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>`;
}
+119 -66
View File
@@ -5,7 +5,7 @@ import { hereAt } from "./chart";
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, COURSE_Y_C, hStrip, indexOf, routeOf, songMotifs, stripX } from "./threads";
import { applyPick, bright, byTrack, hStrip, indexOf, resetMeasure, songMotifs, stripX, stripY, type StripKind } from "./threads";
mountCredits();
@@ -25,8 +25,6 @@ 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)));
type Paths = "a" | "b" | "c";
const PATHS: Record<Paths, string> = { a: "In the panel", b: "In the world", c: "Timeline" };
/* ---------- params ---------- */
@@ -37,8 +35,12 @@ 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";
let paths: Paths = (["a", "b", "c"] as const).find((m) => m === params.get("paths")) ?? "a";
/** 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 ---------- */
@@ -55,23 +57,25 @@ app.innerHTML = `
<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="lbl cap__eyebrow" id="cap-eyebrow"></p>
<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="Beats and paths">
<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</button>
<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="lbl dots__label">${esc(b.short)}</span></button></li>`).join("")}
${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>
<div class="ptoggle" role="group" aria-label="Where the motif paths are shown">
<span class="lbl ptoggle__label">Paths</span>
${(["a", "b", "c"] as Paths[]).map((m) => `<button type="button" data-paths="${m}" title="${PATHS[m]}">${m.toUpperCase()}</button>`).join('<span class="ptoggle__sep" aria-hidden="true">·</span>')}
<span class="ptoggle__name" id="paths-name"></span>
</div>
<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>
@@ -117,7 +121,8 @@ const panel = createPanel($("panel"), {
open: startOpen,
reduced,
pick,
pinned: paths === "a",
pinned: !tlOpen,
playAt,
onTab: (t) => setParam("tab", t),
onOpen: (o) => { app.dataset.panel = o ? "open" : "closed"; setParam("panel", o ? null : "0"); },
onPick: (id) => setPick(id),
@@ -147,55 +152,82 @@ function setSong(next: Song) {
if (pick && !songMotifs(next).some((m) => m.id === pick)) pick = null;
panel.setSong(next);
scene?.setThreadSong(next);
renderLabels();
labelKey = "";
renderTimeline();
setPick(pick);
}
/* ---------- paths B: floating labels over the 3D ribbons ---------- */
function renderLabels() {
wlabels.innerHTML = songMotifs(song).map((m) => {
const r = routeOf(song, m.id);
return `<button type="button" class="wl" data-m="${m.id}" data-pick="${m.id}" aria-pressed="false" style="--c:${bright(m.id)}" title="${esc(m.name)}: ${esc(r.from)} · ${esc(r.next)}">
<span class="wl__name"><i aria-hidden="true"></i>${esc(m.name)}</span><span class="wl__note" data-note></span></button>`;
}).join("");
}
/* ---------- 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 || paths !== "b") return;
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);
const pts = scene.labelPoints(p).filter((pt) => pt.visible);
const cur = indexOf(song);
const btns = [...wlabels.querySelectorAll<HTMLElement>(".wl")];
// stack labels that would overlap, nearest the knot first
const placed: { x: number; y: number }[] = [];
pts.map((pt, k) => ({ pt, k })).sort((a, b) => b.pt.y - a.pt.y).forEach(({ pt, k }) => {
const el = btns[k];
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;
let y = pt.y;
for (const q of placed) if (Math.abs(q.x - pt.x) < 200 && Math.abs(q.y - y) < 54) y = q.y - 56;
placed.push({ x: pt.x, y });
el.hidden = !pt.visible;
el.style.transform = `translate(${Math.round(pt.x)}px, ${Math.round(y)}px)`;
const s = byTrack[pt.song]!;
const note = el.querySelector("[data-note]")!;
const text = pt.ahead ? `next: ${s.track}. ${s.title}` : pt.song === cur ? "not heard again after this" : `last heard: ${s.track}. ${s.title}`;
if (note.textContent !== text) note.textContent = text;
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;
}
/* ---------- paths C: the timeline at the bottom ---------- */
/* ---------- the timeline at the bottom: open (titles + threads) or slim (stops + you are here) ---------- */
const TL = { labelW: 168, rowH: 20, titles: true } as const;
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() {
if (paths !== "c") { tl.innerHTML = ""; return; }
const width = tl.clientWidth;
if (!width) return;
tl.innerHTML = hStrip({ ...TL, width, song, pick });
const o = tlOpts();
if (!o.width) return;
tl.innerHTML = hStrip({ ...o, song, pick });
}
/* ---------- state ---------- */
@@ -283,13 +315,11 @@ function renderDom() {
($("prev") as HTMLButtonElement).disabled = target <= 0;
($("next") as HTMLButtonElement).disabled = target >= 4;
// the ship markers on the strips (A in the panel, C at the bottom) sail with the voyage
// the ship markers on the strips (the timeline, and the panel's while it is pinned) sail with the voyage
const idx = shipIdx();
if (paths === "a") panel.setShip(idx);
if (paths === "c") {
const m = tl.querySelector<SVGGElement>("[data-ship]");
if (m) m.setAttribute("transform", `translate(${stripX({ ...TL, width: tl.clientWidth }, idx).toFixed(1)},${COURSE_Y_C})`);
}
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;
@@ -318,7 +348,7 @@ function crossCut(to: number) {
function setPlaying(on: boolean) {
playing = on;
playBtn.textContent = on ? "Pause" : "Play";
playBtn.textContent = on ? "Pause voyage" : "Play voyage";
playBtn.setAttribute("aria-pressed", String(on));
if (on) {
if (Math.round(p) >= 4 && !anim) go(0);
@@ -326,18 +356,37 @@ function setPlaying(on: boolean) {
}
}
function setPaths(m: Paths) {
paths = m;
document.body.dataset.paths = m;
panel.setPinned(m === "a");
scene?.setPaths(m === "b");
wlabels.hidden = m !== "b";
document.querySelectorAll<HTMLButtonElement>(".ptoggle button").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.paths === m)));
$("paths-name").textContent = PATHS[m];
setParam("paths", m === "a" ? null : m);
/** 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();
}
@@ -347,8 +396,8 @@ $("next").addEventListener("click", () => { setPlaying(false); go((anim ? anim.t
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); }));
document.querySelectorAll<HTMLButtonElement>(".ptoggle button").forEach((b) =>
b.addEventListener("click", () => setPaths(b.dataset.paths as Paths)));
$("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;
@@ -374,7 +423,11 @@ function frame(now: number) {
requestAnimationFrame(frame);
}
new ResizeObserver(() => { measureFit(); renderTimeline(); applyPick(stage, pick); }).observe(stage);
setPaths(paths);
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);
+386
View File
@@ -0,0 +1,386 @@
// 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, where a player (scrub bar with part markers) sits above the 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 { 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;
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;
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(() => syncPlayer());
// 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 {
const el = document.createElement("div");
el.className = "about";
const saga = sagaOf(song);
const o = song.odyssey;
const homer = o ? `<a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Book ${o.book}, lines ${esc(o.lines.replace("-", "–"))}</a>` : "No counterpart in Homer";
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>`;
el.querySelector(".slot-video")!.append(createVideoCards(song));
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 player on top, the lyrics by part below; parts are the player's chapters. */
function listen(): HTMLElement {
const el = document.createElement("div");
el.className = "listen";
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("");
el.innerHTML = `
<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>
<div class="scrub" role="slider" tabindex="0" aria-label="Position in ${esc(song.title)}" aria-valuemin="0" aria-valuemax="${Math.round(dur)}">
<div class="scrub__track"><div class="scrub__fill" data-fill></div>${ticks}<div class="scrub__head" data-head></div></div>
</div>
<div class="segs" role="group" aria-label="Parts">${segs}</div>
${player.simulated ? `<p class="player__note">Preview player: no audio file yet. Playback is simulated.</p>` : ""}
</section>
<p class="note">Placeholder lyrics. The real lyrics are not reproduced on this fan site.</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("")}`;
const scrub = el.querySelector<HTMLElement>(".scrub")!;
const seekAt = (clientX: number) => {
const r = scrub.getBoundingClientRect();
player.seek(((clientX - r.left) / r.width) * player.dur);
};
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); }
});
return el;
}
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];
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) {
eyebrow.textContent = active ? `${on ? "Playing" : "Paused"} · ${part?.name ?? ""} · ${fmt(player.t)} / ${fmt(player.dur)}` : `Track ${song.track} · now on the voyage`;
eyebrow.classList.toggle("is-live", on);
}
const lv = view.querySelector<HTMLElement>(".listen");
if (!lv) { shownPart = -1; return; }
const pct = player.dur ? (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);
lv.querySelector("[data-now-part]")!.innerHTML = part ? `${esc(part.name)} <span>· ${part.singers.map(esc).join(", ")}</span>` : "";
const pb = lv.querySelector<HTMLButtonElement>(".player__play")!;
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-valuenow", String(Math.round(player.t)));
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" });
}
}
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;
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 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(); }
});
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();
});
});
$("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)
player.load(next);
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) { 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;
}
+94
View File
@@ -0,0 +1,94 @@
// The Listen & lyrics player. A song with an audio file plays it through an <audio> element; the sample
// songs have none, so playback is simulated with a clock over a made-up duration. 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) {}
get simulated() { return !this.audio; }
load(song: Song) {
this.pause();
this.audio?.removeAttribute("src");
this.audio = null;
this.song = song;
this.t = 0;
this.dur = fakeDuration(song);
if (song.audio) {
const a = new Audio(song.audio);
a.preload = "metadata";
a.addEventListener("loadedmetadata", () => { if (Number.isFinite(a.duration)) { this.dur = a.duration; this.layout(); this.onChange(); } });
a.addEventListener("timeupdate", () => { this.t = a.currentTime; this.onChange(); });
a.addEventListener("ended", () => { this.playing = false; this.onChange(); });
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(); });
else { 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) {
this.t = Math.min(this.dur, Math.max(0, t));
if (this.audio) this.audio.currentTime = this.t;
this.onChange();
}
private tick = (now: number) => {
if (!this.playing) 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();
}
}
+227 -119
View File
@@ -1,10 +1,12 @@
/* 15 · Voyage, look 2 — 14's voyage in look 2 only, with look 3's cohesion (one ornament, one type
pairing, terracotta + black accents across frieze, panel and HUD) and the motif paths out in the open. */
/* 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 {
@@ -28,9 +30,8 @@
--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: 92px;
--fz-bot: 218px;
}
body[data-paths="c"] { --fz-bot: 218px; }
* { box-sizing: border-box; }
html, body { height: 100%; }
@@ -49,8 +50,7 @@ button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--clay); outline-offset: 2px; }
[hidden] { display: none !important; }
/* one label style everywhere: the frieze's inscriptions (Cormorant, capitals, wide tracking) */
.lbl { font: 600 0.875rem/1.2 var(--serif); letter-spacing: 0.16em; text-transform: uppercase; }
.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 {
@@ -118,23 +118,24 @@ button { font: inherit; color: inherit; }
.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; }
.inscr { font: 600 19px var(--serif); letter-spacing: 0.2em; fill: var(--glaze); }
.saga-name { font: 600 15px var(--serif); letter-spacing: 0.22em; fill: var(--glaze); }
/* 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; }
/* paths B in the frieze: the register under the ground line carries the motif threads */
/* threads on the sea, in the frieze: the register under the ground line carries the motif threads */
.f-threads { display: none; }
body[data-paths="b"] .f-threads { display: inline; }
body[data-paths="b"] .f-scroll { 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: 700 17px var(--serif); letter-spacing: 0.12em; fill: var(--glaze); }
.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; }
@@ -164,7 +165,7 @@ body[data-paths="b"] .f-scroll { display: none; }
overflow: hidden;
}
.cap::before { border-top: 0; }
.cap__eyebrow { margin: 0; color: var(--clay-deep); }
.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;
@@ -178,14 +179,13 @@ body[data-paths="b"] .f-scroll { display: none; }
cursor: pointer;
}
/* ---------- HUD: the step bar (A, B) or the bottom timeline (C) ---------- */
/* ---------- HUD: the step row and the voyage timeline across the bottom of the stage ---------- */
.hud {
position: absolute;
z-index: 7;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
padding: 20px 12px 8px;
left: 16px; right: 16px;
bottom: 16px;
padding: 16px 14px 4px;
background: var(--vellum-hi);
color: var(--iron);
border: 2px solid var(--glaze);
@@ -193,13 +193,13 @@ body[data-paths="b"] .f-scroll { display: none; }
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: 8px; }
.hud__row { display: flex; align-items: center; gap: 6px; padding-bottom: 5px; border-bottom: 1px solid var(--line); }
.hud button { cursor: pointer; }
.hud__play {
min-width: 74px;
padding: 7px 14px;
padding: 7px 15px;
border: 0;
border-radius: 999px;
background: var(--glaze);
@@ -208,90 +208,106 @@ body[data-paths="b"] .f-scroll { display: none; }
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: 5px;
padding: 5px 6px 5px 4px;
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__label { font-size: 0.8125rem; letter-spacing: 0.1em; }
.dots button:hover { color: var(--iron); background: rgb(43 33 24 / 0.06); }
.dots__n {
display: inline-grid;
place-items: center;
width: 21px; height: 21px;
width: 22px; height: 22px;
border: 1.5px solid currentColor;
border-radius: 50%;
font-size: 0.7rem;
font-size: 0.8125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.dots button[aria-current] { color: var(--iron); }
.dots button[aria-current] .dots__label { font-weight: 700; }
.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); }
.ptoggle { display: flex; align-items: center; gap: 4px; margin-left: 6px; padding-left: 12px; border-left: 1.5px solid var(--line); }
.ptoggle__label { margin-right: 4px; font-size: 0.8125rem; color: var(--iron-soft); }
.ptoggle__sep { color: var(--iron-soft); }
.ptoggle button {
width: 28px; height: 28px;
border: 1.5px solid transparent;
border-radius: 50%;
/* "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: 700 0.95rem var(--serif);
font-size: 0.875rem; font-weight: 600;
}
.ptoggle button:hover { border-color: var(--line); }
.ptoggle button[aria-pressed="true"] { background: var(--clay); border-color: var(--glaze); color: var(--glaze); }
.ptoggle__name { min-width: 84px; margin-left: 6px; font: italic 500 1rem var(--serif); color: var(--iron-soft); }
.tl { display: none; }
.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); }
/* C: the HUD grows into the voyage timeline across the bottom of the stage */
body[data-paths="c"] .hud { left: 20px; right: 20px; bottom: 16px; transform: none; padding: 20px 16px 6px; }
body[data-paths="c"] .hud__row { padding-bottom: 6px; border-bottom: 1px solid var(--line); }
body[data-paths="c"] .ptoggle { margin-left: auto; }
body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.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 (A in the panel, C at the bottom) ---------- */
/* ---------- 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: center; padding-bottom: calc(var(--course-h) - 32px); font: 600 0.75rem var(--serif); letter-spacing: 0.16em; text-transform: uppercase; color: var(--iron-soft); }
.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.78rem; font-weight: 600;
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: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.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.4; }
.hs__m.is-dim { opacity: 0.45; }
.hs__svg { display: block; overflow: visible; }
.hs__here-band { fill: rgb(196 106 51 / 0.14); }
.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 9.5px var(--serif); letter-spacing: 0.16em; fill: var(--iron-soft); }
.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 10px var(--sans); fill: var(--iron-soft); text-anchor: middle; }
.hs__num.is-current { fill: var(--iron); }
.hs__title { font: 500 11px var(--sans); fill: var(--iron-soft); text-anchor: middle; }
.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: italic 600 12px var(--serif); fill: var(--clay-deep); }
.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; }
@@ -300,33 +316,37 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.thread.is-on .thread__knot { fill: var(--c); }
.thread.is-dim { opacity: 0.2; }
/* ---------- B: floating labels over the 3D ribbons ---------- */
/* ---------- 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;
translate: -50% calc(-100% - 14px);
display: grid;
gap: 0;
padding: 4px 10px 5px;
padding: 7px 11px 8px;
border: 1.5px solid var(--glaze);
border-left: 5px solid var(--c);
border-radius: 2px;
background: var(--vellum-hi);
color: var(--iron);
text-align: left;
white-space: nowrap;
pointer-events: auto;
cursor: pointer;
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::after { content: ""; position: absolute; left: 50%; top: 100%; width: 1.5px; height: 14px; background: var(--glaze); }
.wl__name { display: flex; align-items: center; gap: 6px; font: 700 0.8125rem/1.2 var(--serif); letter-spacing: 0.12em; text-transform: uppercase; }
.wl__name i { width: 9px; height: 9px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.wl__note { font-size: 0.75rem; color: var(--iron-soft); }
.wl.is-on { outline: 2px solid var(--clay); outline-offset: 1px; }
.wl.is-dim { opacity: 0.45; }
.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 {
@@ -356,10 +376,29 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
}
.panel[data-open="false"] .panel__body { opacity: 0; transform: translateX(40px); pointer-events: none; }
.panel__head { display: flex; align-items: flex-start; gap: 12px; padding: 16px 22px 10px 24px; }
.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; color: var(--clay-deep); }
.panel__title { display: flex; align-items: baseline; gap: 10px; margin: 4px 0 2px; font: 600 2rem/1.05 var(--serif); }
.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;
@@ -371,8 +410,6 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
color: var(--glaze);
font: 700 0.9375rem var(--sans);
}
.panel__n { transform: translateY(-3px); }
.panel__sub { margin: 0; color: var(--iron-soft); }
.panel__hide {
flex: none;
margin-top: 2px;
@@ -381,17 +418,19 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
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); }
/* A: the pinned strip */
.pin { margin: 2px 0 0; padding: 8px 22px 6px 24px; border-top: 1px solid var(--line); background: rgb(255 250 236 / 0.35); transition: opacity 0.18s ease; }
.pin__head { display: flex; align-items: baseline; gap: 10px; margin: 0 0 2px; font-size: 0.8125rem; color: var(--iron); }
.pin__head span { font: italic 500 0.95rem var(--serif); letter-spacing: 0; text-transform: none; color: var(--iron-soft); }
/* 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: 4px; padding: 0 22px 0 24px; border-top: 1px solid var(--line); border-bottom: 1.5px solid var(--glaze); }
.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;
@@ -399,18 +438,18 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
border: 0;
background: transparent;
color: var(--iron-soft);
font: 600 0.9375rem var(--serif);
letter-spacing: 0.1em;
font-size: 0.9375rem;
font-weight: 500;
white-space: nowrap;
text-transform: uppercase;
cursor: pointer;
}
.tabs button[aria-selected="true"] { color: var(--iron); font-weight: 700; }
.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 22px 28px 24px;
padding: 18px 20px 28px 22px;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
transition: opacity 0.18s ease, transform 0.18s ease;
@@ -437,33 +476,42 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
}
.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: 600 1rem var(--serif); letter-spacing: 0.16em; text-transform: uppercase; }
.panel__tab-label { writing-mode: vertical-rl; font-size: 0.9375rem; font-weight: 600; }
/* ---------- panel content ---------- */
.sec { margin: 22px 0 8px; font: 600 1.3rem/1.2 var(--serif); }
.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 var(--line);
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.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 { padding-top: 2px; font: 600 0.8125rem var(--serif); letter-spacing: 0.14em; text-transform: uppercase; color: var(--iron-soft); }
.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.2rem/1.35 var(--serif); }
.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; }
.panel .audio-slot { padding: 12px 14px; border: 1px dashed var(--line); border-radius: 4px; color: var(--iron-soft); opacity: 1; font-size: 0.875rem; }
.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;
@@ -483,6 +531,81 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
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;
@@ -492,7 +615,7 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
border: 1.5px solid var(--glaze);
border-radius: 2px;
}
.route figcaption { margin-top: 4px; font: italic 500 0.95rem var(--serif); color: var(--iron-soft); }
.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; }
@@ -501,14 +624,14 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.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 { font: 600 7px var(--sans); fill: var(--iron); }
.c-place { font: 600 italic 14px var(--serif); fill: var(--iron); }
.c-sea { font: italic 500 9px var(--serif); fill: var(--clay-deep); }
.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 9.5px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: #efe6cd; stroke-width: 2.5px; }
.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; }
@@ -537,7 +660,7 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.vstrip__rows { grid-column: 1 / -1; grid-row: 1; margin: 0; padding: 0; list-style: none; }
.vstrip__row {
width: 100%;
height: 32px;
height: 34px;
display: flex;
align-items: center;
gap: 10px;
@@ -553,36 +676,21 @@ body[data-paths="c"] .tl { display: block; padding-top: 4px; }
.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: 18px; text-align: right; font-size: 0.8125rem; font-variant-numeric: tabular-nums; }
.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 7px; border: 1px solid var(--glaze); border-radius: 999px; background: var(--clay); color: var(--glaze); font-size: 0.75rem; font-weight: 600; }
.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; }
/* Lyrics */
.part { margin: 0 0 16px; padding: 4px 0 6px 14px; border-left: 4px solid var(--c); transition: opacity 0.2s ease, background-color 0.2s ease; }
.part.is-on { background: color-mix(in srgb, var(--c) 9%, transparent); }
.part.is-dim { opacity: 0.45; }
.part__head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 10px; margin-bottom: 4px; }
.part__head h3 { margin: 0; font: 600 1.3rem/1.2 var(--serif); font-variant-numeric: lining-nums; }
.part__who { display: flex; flex-wrap: wrap; gap: 4px; }
.singer { padding: 1px 9px; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8125rem; font-weight: 600; color: var(--iron-soft); }
.part__lines p { margin: 2px 0; font: italic 500 1.15rem/1.45 var(--serif); }
.part__motifs { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }
.mtag { display: inline-flex; align-items: center; gap: 6px; padding: 1px 9px 1px 7px; 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); }
/* 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: 2px; padding: 0 0 10px; border-radius: 2px; background: #f3ecd8; border: 1.5px solid var(--glaze); overflow: hidden; }
.fw__card > p { margin: 0 10px; }
.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.75rem; color: var(--clay-deep); }
.fw__title { font: 600 1.05rem/1.25 var(--serif); }
.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; }
+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));
});
}