feat(mockups): 15 voyage look 2 (cohesive look 2, paths A strip / B in world / C timeline)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 02:26:27 +01:00
co-authored by Claude Opus 5.5
parent 50c54c1949
commit bd84e177e6
13 changed files with 3057 additions and 1 deletions
+3
View File
@@ -82,3 +82,6 @@ Small, specific, independently actionable. Remove only when actually done.
- [ ] `src/mockups/07-sea-voyage/`: per-frame `Color`/`Vector3` allocations; WebGL probe context is not released.
- [ ] `src/mockups/07-sea-voyage/`: no `webglcontextlost` handler; DPR is read once.
- [ ] `src/mockups/07-sea-voyage/`: waypoint tag `innerHTML` is unescaped; drawer clips the "The open sea" label.
- Mockup 14: the cave-mouth mask may repeat as holes during the curtain drop (Chrome, oversized mask
region). Fixed in 15 by shrinking the region; 14 left as is (superseded).
+6
View File
@@ -250,6 +250,12 @@ mockup of the transition.
- **C. Timeline at the bottom** — the step bar becomes a horizontal song timeline with motif threads
under it (the original "big horizontal line"), with "you are here".
- **15 is finished and committed** (`feat/sea-chart-round`, not pushed); report in
`docs/design/002-sea-chart-spikes/15-voyage-look2-report.md`. Preview rebuilt with 01–15.
Agent's recommendation (unconfirmed): C (bottom timeline) as the home for paths, with B's ribbons
as an optional extra in 3D. Known: B's labels stack over the cave mouth; A is dense (numbers only);
C takes ~220px of stage height.
## 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 @@
# 15 Voyage, look 2 — report
Status: **done** (not committed). Built from `15-voyage-look2-brief.md`, starting from a copy of 14.
## What was built
`src/mockups/15-voyage-look2/` is 14's five-beat voyage with its side panel, tabs, placeholders and URL
params (`?beat=` `?t=` `?play=` `?tab=` `?panel=0`). New params: **`?paths=a|b|c`** (default `a`) and
**`?motif=<id>`** (starts with that motif highlighted).
1. **Look 2 only.** The look toggle, look 3's palette, sail and panel code and the `Look` type are gone.
Look 2's colours are unchanged: teal and cream sea, cream sky, terracotta frieze, bright motif colours.
2. **Look 3's cohesion, on look 2's colours.**
- **One ornament.** The frieze's meander key band, drawn as a strip between glaze rules, runs along
the top of the panel (18px), the caption card and the step bar or timeline (12–14px). It is the
same tile as in the frieze.
- **One type pairing.** All labels use the frieze inscriptions' style: Cormorant, capitals, wide
tracking. That covers the caption eyebrow, the panel eyebrow, tabs, fact labels, beat labels,
"Paths", strip headings, fan-work types and the B labels. Titles stay in Cormorant and body text in
Inter.
- **One accent pair.** Terracotta and glaze black replace 14's rhumb-red. Track badges and the
current beat are terracotta discs with black numerals, as on the frieze. Play and Back are black
pills. Cards have black 2px rules. The current tab has a terracotta tongue. The footer is glaze with
a terracotta rule. The vellum roller edge is replaced by a black rule, so the panel reads as the
same book as the frieze.
- **Fitted frieze.** The viewBox is now cropped to the band, and the band sits in a box between the
caption and the HUD. Two registers fill the height: a **palmette chain** above (a vase neck) and a
**running-wave scroll** under the ground line. The horizon sync and the curtain drop were rewritten
for the new box. The morph still starts on the painted ground line (`-morph.png`).
- **Sea.** The filled crest patches (14's "lily pads") are gone. The sea is now two flat swell bands
plus thin cream wave strokes on the upper swells, which thin out with distance.
- **Giant.** The outline hulls are half as thick (0.32 → 0.15). The giant is scaled 0.8 → 0.72, so the
club stays well inside the frame. The flock's outlines are halved too.
3. **Paths out in the open.** "Paths A · B · C" sits in the HUD with a short name (In the panel / In the
world / Timeline). In all three modes, the current song's motifs show without any click. **One pick
highlights everywhere**: the pinned strip, the Paths tab (buttons and vertical strip), lyrics (the
matching parts light up, the others dim, and the chips are buttons), frieze threads, 3D ribbons and
their labels, and the timeline. The pick is kept across songs when the new song has that motif, and
is mirrored in `?motif=`. The Paths tab stays as the full detail view.
- **A. Pinned strip** (`threads.ts` `hStrip`, about 150px). It sits between the song header and the
tabs, whatever tab is open. All 10 songs are knots on a dashed course, numbered, with saga ticks
and a terracotta "here" column. A small ship with "you are here" **sails between songs as the beats
play**. One soft thread per motif, knotted where it plays (12's style). The motif names are buttons
with a from/returns tooltip.
- **B. Threads in the world** (`ribbons.ts`). Every song has a fixed place on the route: two behind
the start, Full Speed Ahead at the start, Polyphemus at the anchorage, Survive at the cave mouth,
and the rest beyond the island. Each motif is a flat ribbon with a black edge that rides the waves.
It is **solid behind the ship and dashed ahead**, with open-ring knots. Each ribbon has a floating
vellum label on its next knot ahead ("POLYPHEMUS · next: 7. Survive"), and labels stack when they
would overlap. Ribbons fade out near the camera so they don't become roads. In the cave, ribbons run
along the floor between the flock and the giant. In the frieze beats, the wave-scroll register
becomes the **thread register**: ink-edged threads in the bright colours, knotted under each painted
song, running off the band where the motif continues, with names on the left (clickable).
- **C. Timeline at the bottom.** The HUD becomes a full-width vellum timeline. Its top row holds Play,
the steps, the beats and the paths toggle. Below are all songs as stops with titles, saga names,
the "here" column, the sailing ship with "you are here", and the motif threads underneath. The
frieze box shrinks so the band still fits above it.
Files: `main.ts` (beats, paths mode, motif pick, B labels, C timeline, fitted frieze), `panel.ts`,
`threads.ts` (new: shared thread data and the horizontal strip), `ribbons.ts` (new: 3D ribbons),
`scene.ts` (one palette, sea strokes, thinner outlines, ribbons and label projection), `frieze.ts`
(cropped viewBox, chain and wave or thread registers, tighter mouth mask), `figures.ts`, `chart.ts`,
`fanworks.ts` (copied), `style.css`.
## Screenshots (1440×900, `screenshots/`)
- Paths A: `15-voyage-look2-beat1.png` … `-beat5.png`
- Beat 3 with `?motif=polyphemus`: `-paths-a.png`, `-paths-b.png`, `-paths-c.png`
- `-beat1-paths-b.png` (frieze threads), `-beat5-paths-c.png`, `-lyrics.png` (A, `?motif=odysseus`)
- Extra: `-beat2-paths-b.png`, `-beat4-paths-b.png`, `-beat5-paths-b.png` (cave ribbons), `-paths-tab.png`,
`-collapsed.png` (panel collapsed, C), `-morph.png` (1→2 at 40%), `-mid-curtain.png` (3→4 at 50%, B),
`-mid-cave.png` (4→5 at 60%)
The credits footer is fully visible in every one.
## What works
- **Cohesion.** The meander strip, the capitals labels and the terracotta/black accents tie the
caption, HUD and panel to the frieze, and the sea and sky keep look 2's pop. It now reads as one site.
- **The frieze fills the stage.** The band sits between the caption and the HUD with only a small
margin of glaze (`-beat1`, `-beat4`). The new registers look native to the vase.
- **The sea** reads as drawn strokes, not pads. The giant is lighter and fully in frame.
- **A** is always visible without changing the panel's job, and the sailing ship makes it feel alive.
- **B** is the most striking: the threads literally run ahead of the ship to the cave mouth
(`-paths-b`). In the frieze, the thread register is a natural vase band (`-beat1-paths-b`).
- **C** is the clearest overview. It is the user's "big horizontal line", with every song titled.
- Checked: `bun test` (50 pass), `bun run typecheck`, and a production build to the scratchpad.
## What doesn't, or is rough
- **B labels on the island** can stack over the cave mouth, as Odysseus and Polyphemus both return in
Survive (`-paths-b`). They are readable, but the leader line of the upper label points at nothing.
- **B ribbons behind the ship** sweep towards the camera at the lower left in beat 2. They now fade, but
still cross under the HUD. The far ribbons (tracks 8–10, beyond the island) are only small dashes.
- **B's frieze labels** are small (about 12px on screen at this stage width).
- **A at 372px is dense.** The 10 knots are about 22px apart, so the numbers carry the meaning and song
titles appear only in tooltips. "Warrior of the Mind" just fits the label column.
- **C costs about 220px of stage.** The landfall and cave are framed higher, and in the cave the flock
sits behind the timeline (`-beat5-paths-c`).
- The morph frame shows a large flat sun with a ring. This is 14's camera at horizon lock, not changed.
- Play timing was not checked in a live browser (headless virtual time, same caveat as 13 and 14).
Desktop 1440×900 only.
## Paths A / B / C, honestly
**A (pinned strip)** is the safest. It is always there and compact, it keeps the stage clean, and the
sailing ship makes "you are here" concrete. But it is small and abstract: numbers, not names, in a busy
panel. It fixes "too hidden" without making paths feel like part of the voyage.
**B (threads in the world)** is the most "EPIC". The motifs become something the ship sails along, and
you see a theme run ahead into the Cyclops' cave before the song gets there. The frieze version is
lovely. The costs: it depends on camera framing (labels stack, ribbons near the camera need fading),
and it shows only the current song's motifs, with no overview of the whole voyage. It is the hardest
to build well for every landing.
**C (timeline at the bottom)** is the clearest. Every song is named, the threads are long enough to
read as journeys, and the step controls sit with it naturally. It costs stage height, and the
interface feels more like a tool and less like a picture.
My read: **C as the home for paths, with B's ribbons as an optional flourish in the 3D** (the frieze
thread register in particular is cheap and beautiful). A works as a fallback when the panel is the
main surface, for example on smaller screens. Each needs one more pass: C could collapse to a slim
course line while the 3D plays, and B labels need smarter placement.
+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>`;
}
+72
View File
@@ -0,0 +1,72 @@
// Placeholder fan works for the panel's "Fan works" tab. Every title and handle here is made up and
// clearly marked as a placeholder: no real fan artists, no real links. Local to 14 (src/data/ has no
// fan-works model yet; see open question 3b in the round notes).
export type FanWorkType = "art" | "animatic" | "cover" | "cosplay";
export interface FanWork { type: FanWorkType; title: string; handle: string }
const P = "@fan-artist";
const BY_SONG: Record<string, FanWork[]> = {
"the-horse-and-the-infant": [
{ type: "art", title: "Placeholder: the horse at the gates, ink study", handle: P },
{ type: "animatic", title: "Placeholder: storyboard of the final night", handle: P },
{ type: "cover", title: "Placeholder: piano cover", handle: P },
],
"full-speed-ahead": [
{ type: "art", title: "Placeholder: the fleet under sail", handle: P },
{ type: "cover", title: "Placeholder: sea-shanty arrangement", handle: P },
{ type: "animatic", title: "Placeholder: crew at the oars, animatic", handle: P },
{ type: "cosplay", title: "Placeholder: Eurylochus armour build", handle: P },
],
polyphemus: [
{ type: "art", title: "Placeholder: one eye in the dark", handle: P },
{ type: "animatic", title: "Placeholder: the cave scene, animatic", handle: P },
{ type: "cosplay", title: "Placeholder: Cyclops costume and club", handle: P },
{ type: "cover", title: "Placeholder: low-strings cover", handle: P },
],
survive: [
{ type: "art", title: "Placeholder: the crew by firelight", handle: P },
{ type: "cover", title: "Placeholder: choir cover", handle: P },
{ type: "animatic", title: "Placeholder: escape plan, animatic", handle: P },
],
};
export function fanWorksFor(songId: string): FanWork[] {
return BY_SONG[songId] ?? [
{ type: "art", title: "Placeholder: illustration", handle: P },
{ type: "cover", title: "Placeholder: cover", handle: P },
{ type: "animatic", title: "Placeholder: animatic", handle: P },
];
}
export const TYPE_LABEL: Record<FanWorkType, string> = { art: "Art", animatic: "Animatic", cover: "Cover", cosplay: "Cosplay" };
/** A stylised placeholder thumbnail per type (no external images). Colours come from CSS custom properties. */
export function thumbSvg(type: FanWorkType, accent: string, seed: number): string {
const r = (n: number) => ((Math.sin(seed * 12.9898 + n * 78.233) * 43758.5453) % 1 + 1) % 1;
let art = "";
if (type === "art") {
art = `<circle cx="${120 + r(1) * 30}" cy="34" r="16" fill="${accent}"/>
<path d="M0 80 Q40 ${58 + r(2) * 10} 80 76 T160 70 V100 H0Z" class="t-ink"/>
<path d="M10 88 q14 -8 28 0 t28 0 t28 0 t28 0 t28 0" class="t-line"/>
<path d="M${40 + r(3) * 40} 70 l10 -30 l10 30z" class="t-ink"/>`;
} else if (type === "animatic") {
let frames = "";
for (let i = 0; i < 3; i++) frames += `<rect x="${8 + i * 50}" y="22" width="44" height="56" rx="2" class="t-frame"/><path d="M${18 + i * 50} ${66 - i * 6} q12 ${-18 + i * 6} 24 ${-4 - i * 3}" class="t-line" style="stroke:${accent}"/>`;
let holes = "";
for (let x = 6; x < 160; x += 12) holes += `<rect x="${x}" y="8" width="6" height="6" class="t-hole"/><rect x="${x}" y="86" width="6" height="6" class="t-hole"/>`;
art = `<rect x="0" y="0" width="160" height="100" class="t-ink"/>${holes}${frames}`;
} else if (type === "cover") {
let bars = "";
for (let i = 0; i < 18; i++) { const h = 10 + r(i) * 46; bars += `<rect x="${78 + i * 4.4}" y="${50 - h / 2}" width="2.6" height="${h}" fill="${accent}"/>`; }
art = `<circle cx="40" cy="50" r="30" class="t-ink"/><circle cx="40" cy="50" r="9" fill="${accent}"/><circle cx="40" cy="50" r="20" class="t-line"/>${bars}`;
} else {
art = `<path d="M80 18 a10 10 0 1 1 0.1 0 M64 22 q16 -18 34 -4 l-4 6 q-14 -8 -26 2z" class="t-ink"/>
<path d="M66 36 h28 l6 34 h-10 l-2 22 h-16 l-2 -22 h-10z" class="t-ink"/>
<path d="M100 30 l22 -14" class="t-line" style="stroke:${accent};stroke-width:4"/>
<path d="M66 52 h34" class="t-line" style="stroke:${accent}"/>`;
}
return `<svg class="fw__thumb" viewBox="0 0 160 100" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Placeholder image">
<rect width="160" height="100" class="t-bg"/>${art}</svg>`;
}
+269
View File
@@ -0,0 +1,269 @@
// Copied from 04-greek-frieze/figures.ts via 13 (mockups never import across folders).
// Black-figure painting kit: glaze (fill: var(--ink)) with "incised" detail lines scratched back to the clay (stroke: var(--inc)).
// Every panel shares one 300-unit-tall coordinate space with the ground line at GROUND.
export const PANEL_H = 300;
export const GROUND = 284;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw";
interface Person {
x: number; y?: number; s?: number; f?: 1 | -1; arms?: Arms;
helmet?: boolean; shield?: boolean; robe?: boolean; cyclops?: boolean; kneel?: boolean;
beard?: boolean; infant?: boolean; cup?: boolean;
}
export const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
export const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
export const ink = (d: string) => `<path class="ink" d="${d}"/>`;
// A limb: glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze.
export const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
export function person(p: Person): string {
const { x, y = GROUND, s = 1, f = 1, arms = "down" } = p;
const lift = p.kneel ? 36 : 0;
let legs = "";
if (p.kneel) {
legs = limb("M4,-44 L34,-40 L36,-2", 8) + limb("M-2,-44 L-24,-4 L-4,-2", 8);
} else if (!p.robe) {
legs = limb("M-2,-80 L-10,-40 L-24,-2 L-14,-2", 8) + limb("M4,-80 L14,-40 L30,-2 L40,-2", 8);
}
let upper = "";
// back arm sits behind the torso
if (arms === "open") upper += limb("M-8,-148 L-36,-176 L-44,-196");
if (arms === "mourn") upper += limb("M-8,-148 L-14,-120 L-6,-104");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5) + ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
export function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
export function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1 } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
export function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
const bolt = (x: number, y: number, s: number) =>
g(`translate(${x},${y}) scale(${s})`, ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
function cave(w: number): string {
return ink(`M0,0 H${w} V${PANEL_H} H${w - 30} Q${w - 20},${66} ${w / 2},${58} Q30,66 32,${PANEL_H} H0 Z`) +
inc(`M${w - 22},120 q-6,-30 -14,-48 M20,150 q6,-40 18,-70 M${w * 0.3},40 q30,-6 60,-2 M${w * 0.62},44 q24,0 46,10`, 1.6);
}
export interface PanelArt { w: number; art: string; inscription?: "dark" | "light"; inscrEnd?: boolean }
const ground = (w: number) => `<path class="ground" d="M0,${GROUND} H${w}"/>`;
export const PANELS: Record<string, PanelArt> = {
"the-horse-and-the-infant": { w: 370, art: trojanHorse(132, 0.9) + person({ x: 300, f: -1, arms: "reach", helmet: true, infant: true }) },
"just-a-man": {
w: 240,
art: person({ x: 96, arms: "mourn", helmet: true, beard: true }) +
limb("M178,284 L192,70", 3.5) + ink("M192,70 L186,48 L200,66 Z") +
`<circle class="ink" cx="176" cy="250" r="32"/><circle class="inc" cx="176" cy="250" r="26" stroke-width="1.6"/>` +
inc("M162,236 l28,28 M190,236 l-28,28", 1.4),
},
"full-speed-ahead": { w: 390, art: waves(0, 390, 290) + ship(190, 276, 1, { sail: true }) },
"open-arms": {
w: 300,
art: person({ x: 92, arms: "open", beard: true }) + person({ x: 214, f: -1, arms: "reach", robe: true }),
},
"warrior-of-the-mind": {
w: 340,
art: person({ x: 98, arms: "spear", helmet: true, shield: true, robe: true }) +
person({ x: 246, f: -1, arms: "spear", helmet: true, shield: true, beard: true }),
},
polyphemus: {
w: 340,
art: person({ x: 76, arms: "reach", beard: true, cup: true }) +
person({ x: 252, f: -1, s: 1.2, arms: "club", cyclops: true, beard: true }),
},
survive: {
w: 340, inscription: "light",
art: person({ x: 214, f: -1, s: 1.02, arms: "down", cyclops: true, beard: true }) +
person({ x: 82, s: 0.62, arms: "mourn", kneel: true }) + person({ x: 120, s: 0.62, arms: "reach" }) + cave(340),
},
"remember-them": {
w: 310,
art: stele(155) + person({ x: 62, arms: "mourn", kneel: true, beard: true }) + person({ x: 250, f: -1, arms: "mourn", robe: true }),
},
"my-goodbye": {
w: 400, inscrEnd: true,
art: waves(160, 400, 290) + person({ x: 78, s: 1.15, arms: "throw", cyclops: true, beard: true }) +
ship(292, 276, 0.62, { sail: true }),
},
storm: {
w: 350,
art: bolt(52, 40, 1) + bolt(282, 52, 0.8) + ship(176, 252, 0.86, { tilt: -9 }) + waves(0, 350, 278, 1.3) + waves(18, 350, 296, 0.9),
},
};
const escXml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** The painted scene for a song: figures plus its name written in the field, as on a vase. */
export function panelSvg(id: string, title: string, withTitle = true): string {
const p = PANELS[id] ?? { w: 260, art: person({ x: 130 }) };
const light = p.inscription === "light";
const x = p.inscrEnd ? p.w - 16 : light ? 46 : 16;
const label = withTitle
? `<text class="inscr${light ? " inscr--light" : ""}" x="${x}" y="${light ? 32 : 28}"${p.inscrEnd ? ' text-anchor="end"' : ""}>${escXml(title.toUpperCase())}</text>`
: "";
return `<svg class="scene" viewBox="0 0 ${p.w} ${PANEL_H}" aria-hidden="true" focusable="false">${p.art}${ground(p.w)}${label}</svg>`;
}
export function panelWidth(id: string): number {
return PANELS[id]?.w ?? 260;
}
/** Narrow ladder band between consecutive songs of one saga. */
export function ladderSvg(): string {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return `<svg class="sep-art" viewBox="0 0 26 300" aria-hidden="true"><path class="rule" d="${d}"/></svg>`;
}
export function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A saga boundary: palmettes framing the saga's name, painted vertically. */
export function sagaSvg(name: string, gap: number): string {
const tally = gap > 0
? `<text class="tally" x="66" y="296" text-anchor="middle">${gap} ${gap === 1 ? "year" : "years"} later</text>`
: "";
return `<svg class="sep-art" viewBox="0 0 132 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M128,0 V300"/>
${palmette(66, 52)}${palmette(66, gap > 0 ? 226 : 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${escXml(name.replace(/^The /, "").toUpperCase())}</text>
${tally}
</svg>`;
}
/** Elapsed story time between two songs, painted as a tally of years. */
export function gapSvg(gap: number): string {
const w = 40 + gap * 24;
let bars = "";
for (let i = 0; i < gap; i++) bars += `M${20 + i * 24 + 6},120 V180 `;
return `<svg class="sep-art" viewBox="0 0 ${w} 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M${w - 4},0 V300"/>
<path class="tally-bars" d="${bars}"/>
<text class="tally" transform="translate(${w / 2 + 4},250) rotate(-90)" >+${gap} yr</text>
</svg>`;
}
+226
View File
@@ -0,0 +1,226 @@
// From 13/14: 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 + 20 + k * 20 - (ms.length - 3) * 7;
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 + 5.5}">${esc(m.name.toUpperCase())}</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>`;
}
+377
View File
@@ -1,3 +1,380 @@
import { songs } from "../../data/sample";
import type { Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
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";
mountCredits();
/* ---------- beats (13's five, each with the song the panel follows) ---------- */
interface Beat { short: string; eyebrow: string; caption: string; songId: string }
const BEATS: Beat[] = [
{ short: "Frieze", eyebrow: "The Troy Saga · year 0", caption: "Troy falls", songId: "the-horse-and-the-infant" },
{ short: "To sea", eyebrow: "Between sagas · a year at sea", caption: "Out on the open sea", songId: "full-speed-ahead" },
{ short: "Landfall", eyebrow: "The Cyclops Saga · year 1", caption: "Landfall: Cyclops' Island", songId: "polyphemus" },
{ short: "Curtain", eyebrow: "The Cyclops Saga", caption: "Polyphemus", songId: "polyphemus" },
{ short: "Cave", eyebrow: "Inside the cave", caption: "Into the cave", songId: "survive" },
];
const SEG_DUR = [1.8, 2.0, 1.2, 1.6];
const HOLD = 3.4;
const songById = (id: string) => songs.find((s) => s.id === id)!;
/** Each beat's song as an index on the voyage (track order), so strip markers can sail between them. */
const BEAT_IDX = BEATS.map((b) => indexOf(songById(b.songId)));
type Paths = "a" | "b" | "c";
const PATHS: Record<Paths, string> = { a: "In the panel", b: "In the world", c: "Timeline" };
/* ---------- params ---------- */
const params = new URLSearchParams(location.search);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const clampBeat = (n: number) => Math.min(5, Math.max(1, Math.round(n) || 1));
const startBeat = clampBeat(Number(params.get("beat") ?? 1));
const freezeT = params.has("t") ? Math.min(1, Math.max(0, Number(params.get("t")) || 0)) : null;
const startTab: Tab = TABS.some((t) => t.id === params.get("tab")) ? (params.get("tab") as Tab) : "about";
const startOpen = params.get("panel") !== "0";
let paths: Paths = (["a", "b", "c"] as const).find((m) => m === params.get("paths")) ?? "a";
let pick: string | null = params.get("motif");
/* ---------- DOM ---------- */
const app = document.getElementById("app")!;
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
app.className = "shell";
app.innerHTML = `
<section class="stage" id="stage" aria-label="The voyage, in five beats">
<div class="stage__three" id="three"></div>
<div class="stage__fallback" id="fallback" hidden><p>3D needs WebGL; the frieze beats still play.</p></div>
<div class="frieze-box" id="fzbox" aria-hidden="true"></div>
<div class="frieze-host frieze-host--troy" id="troy">${troyFrieze(songById(BEATS[0]!.songId))}</div>
<div class="frieze-host frieze-host--cyclops" id="cyclops">${cyclopsFrieze(songById(BEATS[3]!.songId))}</div>
<div class="wlabels" id="wlabels" aria-label="Motif threads in the world"></div>
<div class="cut" id="cut" aria-hidden="true"></div>
<header class="cap" id="cap" aria-live="polite">
<p class="lbl 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">
<div class="hud__row">
<button type="button" class="hud__play" id="play" aria-pressed="false">Play</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("")}
</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>
</div>
<div class="tl" id="tl"></div>
</nav>
</section>
<aside class="panel" id="panel" aria-label="Song companion"></aside>`;
const $ = (id: string) => document.getElementById(id)!;
const stage = $("stage");
const troy = $("troy");
const cyclops = $("cyclops");
const cut = $("cut");
const capEl = $("cap");
const tl = $("tl");
const wlabels = $("wlabels");
const playBtn = $("play") as HTMLButtonElement;
const backBtn = $("back") as HTMLButtonElement;
const troyParts = {
figs: troy.querySelector<SVGGElement>(".f-figs")!,
top: troy.querySelector<SVGGElement>(".f-top")!,
bottom: troy.querySelector<SVGGElement>(".f-bottom")!,
horizon: troy.querySelector<SVGPathElement>(".horizon-line")!,
};
const cyParts = {
zoom: cyclops.querySelector<SVGGElement>(".f-zoom")!,
mouth: cyclops.querySelector<SVGPathElement>(".mouth-fill")!,
};
const setParam = (k: string, v: string | null) => {
const u = new URL(location.href);
if (v === null) u.searchParams.delete(k);
else u.searchParams.set(k, v);
history.replaceState(null, "", u);
};
let scene: VoyageScene | null = null;
if (webglAvailable()) {
try { scene = new VoyageScene($("three"), reduced); } catch { scene = null; }
}
if (!scene) $("fallback").hidden = false;
const panel = createPanel($("panel"), {
tab: startTab,
open: startOpen,
reduced,
pick,
pinned: paths === "a",
onTab: (t) => setParam("tab", t),
onOpen: (o) => { app.dataset.panel = o ? "open" : "closed"; setParam("panel", o ? null : "0"); },
onPick: (id) => setPick(id),
});
app.dataset.panel = startOpen ? "open" : "closed";
const here = panel.here;
/* ---------- motif pick: one choice, highlighted everywhere ---------- */
let song: Song = songById(BEATS[0]!.songId);
function setPick(id: string | null) {
pick = id;
panel.setPick(id);
scene?.setPick(id);
applyPick(stage, id);
setParam("motif", id);
}
stage.addEventListener("click", (e) => {
const b = (e.target as Element).closest<Element>("[data-pick]");
if (b) { const id = b.getAttribute("data-pick"); setPick(pick === id ? null : id); }
});
/** The song the panel, the threads and the labels follow (switches at each transition's midpoint). */
function setSong(next: Song) {
song = next;
if (pick && !songMotifs(next).some((m) => m.id === pick)) pick = null;
panel.setSong(next);
scene?.setThreadSong(next);
renderLabels();
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("");
}
function placeLabels() {
if (!scene || paths !== "b") 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 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];
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;
});
}
/* ---------- paths C: the timeline at the bottom ---------- */
const TL = { labelW: 168, rowH: 20, titles: true } as const;
function renderTimeline() {
if (paths !== "c") { tl.innerHTML = ""; return; }
const width = tl.clientWidth;
if (!width) return;
tl.innerHTML = hStrip({ ...TL, width, song, pick });
}
/* ---------- state ---------- */
let p = freezeT !== null ? Math.max(0, startBeat - 2 + freezeT) : startBeat - 1;
let anim: { from: number; to: number; start: number; dur: number } | null = null;
let cutting = false;
let playing = false;
let holdUntil = 0;
let shownBeat = -1;
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const easeIn = (x: number) => { const t = clamp01(x); return t * t * t; };
const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
/** The ship's place on the voyage (fractional song index) at beat position p. */
function shipIdx(): number {
const i = Math.min(3, Math.floor(p));
const k = i === 1 ? easeInOut(p - 1) : ease(p - i);
return BEAT_IDX[i]! + (BEAT_IDX[i + 1]! - BEAT_IDX[i]!) * k;
}
/** How the band-cropped frieze sits in its box on the stage (px). */
let fit = { s: 1, oy: 0 };
function measureFit() {
const box = $("fzbox");
const w = box.clientWidth, h = box.clientHeight;
if (!w || !h) return;
const s = Math.min(w / VB_W, h / VB_FIT_H);
fit = { s, oy: box.offsetTop + (h - VB_FIT_H * s) / 2 };
const H = stage.clientHeight;
scene?.setHorizon((fit.oy + (GROUND_Y - VB_Y) * s) / H);
}
/* ---------- render: everything is a function of p ---------- */
function renderDom() {
const s0 = clamp01(p);
troy.hidden = s0 >= 1;
if (!troy.hidden) {
const flat = ease(s0 / 0.32);
troyParts.figs.setAttribute("transform", `translate(0,${GROUND_Y}) scale(1,${(1 - flat).toFixed(4)}) translate(0,${-GROUND_Y})`);
troyParts.figs.style.opacity = String(1 - ease((s0 - 0.1) / 0.24));
const part = easeInOut((s0 - 0.12) / 0.5);
troyParts.top.setAttribute("transform", `translate(0,${(-part * (GROUND_Y + 700)).toFixed(1)})`);
troyParts.bottom.setAttribute("transform", `translate(0,${(part * (VB_H - GROUND_Y + 700)).toFixed(1)})`);
troyParts.horizon.style.opacity = String(1 - ease((s0 - 0.5) / 0.22));
}
// "You are here" on the route chart (Paths tab) follows the ship from open sea to the island.
const [hx, hy] = hereAt(ease((p - 1.1) / 0.8));
here.setAttribute("transform", `translate(${hx.toFixed(1)},${hy.toFixed(1)})`);
cyclops.hidden = p <= 2;
if (!cyclops.hidden) {
const drop = easeInOut(p - 2);
// the curtain falls from fully above the stage: its glaze reaches 600 units below the viewBox
const travel = fit.oy + (VB_H + 600 - VB_Y) * fit.s + 24;
cyclops.style.transform = `translateY(${((drop - 1) * travel).toFixed(1)}px)`;
const z = clamp01(p - 3);
cyParts.mouth.style.opacity = String(1 - ease(z / 0.28));
const scale = 1 + 15 * easeIn((z - 0.08) / 0.92);
cyParts.zoom.setAttribute("transform", `translate(${MOUTH.x},${MOUTH.y}) scale(${scale.toFixed(4)}) translate(${-MOUTH.x},${-MOUTH.y})`);
cyclops.style.opacity = String(1 - ease((z - 0.72) / 0.26));
}
stage.dataset.world = p < 0.5 ? "frieze" : p < 2.5 ? "sea" : p < 3.5 ? "frieze" : "cave";
// Captions dip and swap at each transition's midpoint; the panel and threads follow the same beat.
const near = Math.round(p);
const d = Math.abs(p - near);
capEl.style.opacity = String(clamp01(1 - d * 3.2));
if (near !== shownBeat) {
shownBeat = near;
const b = BEATS[near]!;
$("cap-eyebrow").textContent = b.eyebrow;
$("cap-title").textContent = b.caption;
backBtn.hidden = near !== 4;
if (b.songId !== song.id || !panelReady) { panelReady = true; setSong(songById(b.songId)); }
}
const target = anim ? anim.to : Math.round(p);
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((btn, i) => {
if (i === target) btn.setAttribute("aria-current", "step");
else btn.removeAttribute("aria-current");
});
($("prev") as HTMLButtonElement).disabled = target <= 0;
($("next") as HTMLButtonElement).disabled = target >= 4;
// the ship markers on the strips (A in the panel, C at the bottom) 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})`);
}
placeLabels();
}
let panelReady = false;
/* ---------- navigation ---------- */
function go(to: number) {
if (freezeT !== null || cutting) return;
to = Math.min(4, Math.max(0, to));
if (anim) { p = anim.to; anim = null; }
if (to === p) return;
if (reduced || Math.abs(to - p) !== 1) crossCut(to);
else anim = { from: p, to, start: performance.now(), dur: SEG_DUR[Math.min(p, to)]! * 1000 };
}
function crossCut(to: number) {
cutting = true;
cut.classList.add("is-on");
window.setTimeout(() => {
p = to;
renderDom();
cut.classList.remove("is-on");
window.setTimeout(() => { cutting = false; holdUntil = performance.now() + HOLD * 1000; }, 380);
}, reduced ? 260 : 300);
}
function setPlaying(on: boolean) {
playing = on;
playBtn.textContent = on ? "Pause" : "Play";
playBtn.setAttribute("aria-pressed", String(on));
if (on) {
if (Math.round(p) >= 4 && !anim) go(0);
holdUntil = performance.now() + 1200;
}
}
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);
measureFit();
renderTimeline();
applyPick(stage, pick);
renderDom();
}
playBtn.addEventListener("click", () => setPlaying(!playing));
$("prev").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) - 1); });
$("next").addEventListener("click", () => { setPlaying(false); go((anim ? anim.to : p) + 1); });
backBtn.addEventListener("click", () => { setPlaying(false); go(1); });
document.querySelectorAll<HTMLButtonElement>("#dots button").forEach((b) =>
b.addEventListener("click", () => { setPlaying(false); go(Number(b.dataset.beat) - 1); }));
document.querySelectorAll<HTMLButtonElement>(".ptoggle button").forEach((b) =>
b.addEventListener("click", () => setPaths(b.dataset.paths as Paths)));
window.addEventListener("keydown", (e) => {
// Keys inside the panel (tabs, audio, links) belong to the panel.
if (e.target instanceof Element && e.target.closest(".panel")) return;
if (e.key === "ArrowRight") { setPlaying(false); go((anim ? anim.to : p) + 1); }
else if (e.key === "ArrowLeft") { setPlaying(false); go((anim ? anim.to : p) - 1); }
else if (e.key === " " && !(e.target instanceof HTMLButtonElement)) { e.preventDefault(); setPlaying(!playing); }
});
/* ---------- loop ---------- */
const t0 = performance.now();
function frame(now: number) {
if (anim) {
const k = clamp01((now - anim.start) / anim.dur);
p = anim.from + (anim.to - anim.from) * k;
if (k >= 1) { p = anim.to; anim = null; holdUntil = now + HOLD * 1000; }
} else if (playing && !cutting && now >= holdUntil) {
if (Math.round(p) >= 4) setPlaying(false);
else go(Math.round(p) + 1);
}
scene?.render(p, (now - t0) / 1000 + 2.2);
renderDom();
requestAnimationFrame(frame);
}
new ResizeObserver(() => { measureFit(); renderTimeline(); applyPick(stage, pick); }).observe(stage);
setPaths(paths);
requestAnimationFrame(frame);
if (params.get("play") === "1" && freezeT === null) setPlaying(true);
+278
View File
@@ -0,0 +1,278 @@
// The persistent right-side companion: About · Paths · Lyrics · Fan works for the song of the current beat.
// In paths A a compact thread strip is pinned between the song header and the tabs, whatever tab is open.
// 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 { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import { chartSvg } from "./chart";
import { fanWorksFor, thumbSvg, TYPE_LABEL } from "./fanworks";
import { applyPick, byTrack, COURSE_Y, hitsOf, hStrip, inked, routeOf } from "./threads";
export type Tab = "about" | "paths" | "lyrics" | "fan";
export const TABS: { id: Tab; label: string }[] = [
{ id: "about", label: "About" },
{ id: "paths", label: "Paths" },
{ id: "lyrics", label: "Lyrics" },
{ id: "fan", label: "Fan works" },
];
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const STRIP = { width: 372, labelW: 150, rowH: 22, titles: false } as const;
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 (paths A). */
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;
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;
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 · Paths · Lyrics · Fan works</span>
</button>
<div class="panel__body" id="panel-body">
<header class="panel__head">
<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}</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");
// 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="lbl panel__eyebrow">Now playing on the voyage</p>
<h2 class="panel__title"><span class="panel__n">${song.track}</span>${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="lbl pin__head">Motif paths <span>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>
<h3 class="sec">Listen</h3><div class="slot-audio"></div>
<h3 class="sec">Watch</h3><div class="slot-video"></div>`;
el.querySelector(".slot-audio")!.append(createAudioSlot(song));
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 = 32, 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;
}
function lyrics(): HTMLElement {
const el = document.createElement("div");
el.className = "lyrics";
el.innerHTML = `<p class="note">Placeholder lyrics. The real lyrics are not reproduced on this fan site.</p>
${song.parts.map((p) => {
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-ms="${p.motifIds.join(" ")}" style="--c:${c}">
<header class="part__head"><h3>${esc(p.name)}</h3><span class="part__who">${p.singers.map((s) => `<span class="singer">${esc(s)}</span>`).join("")}</span></header>
<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("")}`;
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="lbl 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;
}
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 === "lyrics" ? lyrics() : 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;
}
/* ----- wiring ----- */
host.addEventListener("click", (e) => {
const b = (e.target as Element).closest<HTMLElement>("[data-pick]");
if (b) opts.onPick(pick === b.dataset.pick ? null : b.dataset.pick!);
});
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;
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;
const pad = 12, step = (STRIP.width - STRIP.labelW - pad * 2) / (byTrack.length - 1);
m.setAttribute("transform", `translate(${(pad + idx * step).toFixed(1)},${COURSE_Y})`);
},
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;
}
+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 };
});
}
}
+709
View File
@@ -0,0 +1,709 @@
// 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;
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; }
/* ----- 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.camera.aspect = this.caveCam.aspect = w / h;
this.camera.updateProjectionMatrix();
this.caveCam.updateProjectionMatrix();
}
/** 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));
const tanHalf = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2));
const pitchLock = Math.atan((this.horizonAt - 0.5) * 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();
}
}
+593 -1
View File
@@ -1 +1,593 @@
body { margin: 0; --stub-15: 1; }
/* 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. */
@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-600.css";
:root {
/* black-figure (04): the frieze, and now the accents of the whole interface */
--clay: #c46a33;
--clay-deep: #9a4520;
--glaze: #1d140e;
--added-white: #efe3c8;
/* vellum (12): the reading surfaces */
--vellum: #e3d6b4;
--vellum-hi: #ece2c6;
--vellum-lo: #e0d1ab;
--iron: #2b2118;
--iron-soft: #5a4836;
--line: rgb(43 33 24 / 0.2);
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
--panel-w: 420px;
--panel-slim: 56px;
/* the frieze's key band, as a strip: shared by the frieze, the panel's top edge and the HUD */
--meander: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28'%3E%3Crect width='28' height='28' fill='%23c46a33'/%3E%3Cpath d='M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7' fill='none' stroke='%231d140e' stroke-width='3.2'/%3E%3C/svg%3E");
/* where the fitted frieze sits on the stage: between the caption and the HUD */
--fz-top: 116px;
--fz-bot: 92px;
}
body[data-paths="c"] { --fz-bot: 218px; }
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
height: 100dvh;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
overflow: hidden;
background: var(--glaze);
color: var(--iron);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--clay); outline-offset: 2px; }
[hidden] { display: none !important; }
/* 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; }
/* the shared ornament: a meander strip between two glaze rules */
.meander-edge::before, .cap::before, .hud::before, .panel::before {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 14px;
background: var(--meander) 0 0 / 14px 14px repeat-x;
border-top: 2px solid var(--glaze);
border-bottom: 2px solid var(--glaze);
pointer-events: none;
}
/* ---------- shell: stage | panel, credits below both ---------- */
.shell {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--panel-w);
transition: grid-template-columns 0.42s cubic-bezier(0.22, 0.8, 0.2, 1);
}
.shell[data-panel="closed"] { grid-template-columns: minmax(0, 1fr) var(--panel-slim); }
[data-fan-credits] {
position: relative;
z-index: 5;
padding: 0.8rem 1.5rem;
background: var(--glaze);
color: var(--added-white);
opacity: 1;
border-top: 2px solid var(--clay);
}
/* ---------- stage ---------- */
.stage {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--glaze);
isolation: isolate;
}
.stage__three, .cut, .stage__fallback { position: absolute; inset: 0; }
.stage__three { z-index: 0; }
.stage__canvas { display: block; width: 100%; height: 100%; }
.stage__fallback { display: grid; place-items: center; background: linear-gradient(#a9c3c0, #f4dfb4 60%, #1d5a6e 61%); color: var(--iron); }
/* ---------- friezes: fitted between caption and HUD ---------- */
.frieze-host, .frieze-box { position: absolute; left: 0; right: 0; top: var(--fz-top); bottom: var(--fz-bot); }
.frieze-box { visibility: hidden; pointer-events: none; }
.frieze-host { z-index: 2; pointer-events: none; will-change: transform, opacity; }
.frieze { display: block; width: 100%; height: 100%; overflow: visible; }
.frieze-host--cyclops { z-index: 3; }
.f-surround { fill: var(--glaze); }
.f-band { fill: var(--clay); }
.ink { fill: var(--glaze); }
.clay { fill: var(--clay); }
.inc { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.limb { fill: none; stroke: var(--glaze); stroke-linecap: round; stroke-linejoin: round; }
.limb-edge { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.oar { stroke: var(--glaze); stroke-width: 3.2; stroke-linecap: round; }
.rig { fill: none; stroke: var(--glaze); stroke-width: 1.4; }
.wave { fill: none; stroke: var(--glaze); stroke-linecap: round; }
.ground { stroke: var(--glaze); stroke-width: 3.5; }
.rule { fill: none; stroke: var(--glaze); stroke-width: 2.6; }
.vol { fill: none; stroke: var(--glaze); stroke-width: 3; stroke-linecap: round; }
.mean { fill: none; stroke: var(--glaze); stroke-width: 3; }
.scroll { fill: none; stroke: var(--glaze); stroke-width: 3.4; stroke-linecap: round; }
.frame-rule { display: none; }
.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); }
.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 */
.f-threads { display: none; }
body[data-paths="b"] .f-threads { display: inline; }
body[data-paths="b"] .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.is-on .ft__line { stroke-width: 6; }
.ft.is-on .ft__edge { stroke-width: 10; }
.ft.is-dim { opacity: 0.28; }
.cut {
z-index: 6;
background: var(--glaze);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.cut.is-on { opacity: 1; }
/* ---------- caption card ---------- */
.cap {
position: absolute;
z-index: 7;
top: 22px;
left: 24px;
max-width: 440px;
padding: 22px 20px 12px;
background: var(--vellum-hi);
color: var(--iron);
border: 2px solid var(--glaze);
border-radius: 2px;
box-shadow: 0 10px 28px -14px rgb(0 0 0 / 0.6);
overflow: hidden;
}
.cap::before { border-top: 0; }
.cap__eyebrow { margin: 0; color: var(--clay-deep); }
.cap__title { margin: 4px 0 0; font: 600 2rem/1.1 var(--serif); }
.back {
margin-top: 8px;
padding: 5px 14px;
border: 0;
border-radius: 999px;
background: var(--glaze);
color: var(--added-white);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
/* ---------- HUD: the step bar (A, B) or the bottom timeline (C) ---------- */
.hud {
position: absolute;
z-index: 7;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
padding: 20px 12px 8px;
background: var(--vellum-hi);
color: var(--iron);
border: 2px solid var(--glaze);
border-radius: 2px;
box-shadow: 0 12px 30px -14px rgb(0 0 0 / 0.65);
white-space: nowrap;
overflow: hidden;
}
.hud::before { border-top: 0; height: 12px; background-size: 12px 12px; }
.hud__row { display: flex; align-items: center; gap: 8px; }
.hud button { cursor: pointer; }
.hud__play {
min-width: 74px;
padding: 7px 14px;
border: 0;
border-radius: 999px;
background: var(--glaze);
color: var(--added-white);
font-weight: 600;
font-size: 0.875rem;
}
.hud__step {
width: 32px; height: 32px;
border: 1.5px solid var(--glaze);
border-radius: 50%;
background: transparent;
}
.hud__step:disabled { opacity: 0.3; cursor: default; }
.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;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--iron-soft);
}
.dots__label { font-size: 0.8125rem; letter-spacing: 0.1em; }
.dots__n {
display: inline-grid;
place-items: center;
width: 21px; height: 21px;
border: 1.5px solid currentColor;
border-radius: 50%;
font-size: 0.7rem;
font-weight: 600;
}
.dots button[aria-current] { color: var(--iron); }
.dots button[aria-current] .dots__label { font-weight: 700; }
.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%;
background: transparent;
font: 700 0.95rem var(--serif);
}
.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; }
/* 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; }
/* ---------- horizontal thread strips (A in the panel, C at the bottom) ---------- */
.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__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;
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: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__svg { display: block; overflow: visible; }
.hs__here-band { fill: rgb(196 106 51 / 0.14); }
.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__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__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); }
.thread { transition: opacity 0.2s ease; }
.thread__line { fill: none; stroke: var(--c); stroke-width: 2.4; stroke-linecap: round; opacity: 0.85; }
.thread__knot { fill: var(--vellum-hi); stroke: var(--c); stroke-width: 2.2; }
.thread__knot.is-here { fill: var(--c); stroke: var(--glaze); stroke-width: 1.6; }
.thread.is-on .thread__line { stroke-width: 3.8; opacity: 1; }
.thread.is-on .thread__knot { fill: var(--c); }
.thread.is-dim { opacity: 0.2; }
/* ---------- B: floating labels over the 3D ribbons ---------- */
.wlabels { position: absolute; inset: 0; z-index: 5; pointer-events: none; transition: opacity 0.2s linear; }
.wl {
position: absolute;
left: 0; top: 0;
translate: -50% calc(-100% - 14px);
display: grid;
gap: 0;
padding: 4px 10px 5px;
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);
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; }
/* ---------- the panel ---------- */
.panel {
position: relative;
z-index: 8;
min-height: 0;
min-width: 0;
overflow: hidden;
background:
repeating-linear-gradient(90deg, rgb(91 65 40 / 0.03) 0 1px, transparent 1px 7px),
linear-gradient(180deg, var(--vellum-hi), #e6d9b8);
color: var(--iron);
border-left: 2px solid var(--glaze);
box-shadow: -14px 0 30px -18px rgb(0 0 0 / 0.7);
font-size: 15px;
line-height: 1.5;
}
.panel::before { z-index: 3; height: 18px; background-size: 18px 18px; border-top: 0; }
.panel__body {
position: absolute;
inset: 0;
width: calc(var(--panel-w) - 2px);
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
padding-top: 18px;
transition: opacity 0.25s ease, transform 0.42s cubic-bezier(0.22, 0.8, 0.2, 1);
}
.panel[data-open="false"] .panel__body { opacity: 0; transform: translateX(40px); pointer-events: none; }
.panel__head { display: flex; align-items: flex-start; gap: 12px; padding: 16px 22px 10px 24px; }
.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__n, .panel__tab-n {
flex: none;
display: inline-grid;
place-items: center;
width: 30px; height: 30px;
border: 1.5px solid var(--glaze);
border-radius: 50%;
background: var(--clay);
color: var(--glaze);
font: 700 0.9375rem var(--sans);
}
.panel__n { transform: translateY(-3px); }
.panel__sub { margin: 0; color: var(--iron-soft); }
.panel__hide {
flex: none;
margin-top: 2px;
padding: 5px 12px;
border: 1.5px solid var(--line);
border-radius: 999px;
background: transparent;
font-size: 0.875rem;
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); }
.tabs { display: flex; gap: 4px; padding: 0 22px 0 24px; border-top: 1px solid var(--line); border-bottom: 1.5px solid var(--glaze); }
.panel[data-pinned="false"] .tabs { border-top: 0; }
.tabs button {
position: relative;
padding: 11px 9px 10px;
border: 0;
background: transparent;
color: var(--iron-soft);
font: 600 0.9375rem var(--serif);
letter-spacing: 0.1em;
white-space: nowrap;
text-transform: uppercase;
cursor: pointer;
}
.tabs button[aria-selected="true"] { color: var(--iron); font-weight: 700; }
.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;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.panel.is-swapping .panel__view, .panel.is-swapping .panel__song, .panel.is-swapping .pin { opacity: 0; transform: translateY(6px); }
/* the slim tab, shown when collapsed */
.panel__tab {
position: absolute;
inset: 18px 0 0;
width: var(--panel-slim);
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 22px 0;
border: 0;
background: transparent;
color: var(--iron);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease 0.1s;
}
.panel[data-open="false"] .panel__tab { opacity: 1; pointer-events: auto; }
.panel__tab-arrow { font-size: 1.5rem; line-height: 1; color: var(--clay-deep); }
.panel__tab-label { writing-mode: vertical-rl; font: 600 1rem var(--serif); letter-spacing: 0.16em; text-transform: uppercase; }
/* ---------- panel content ---------- */
.sec { margin: 22px 0 8px; font: 600 1.3rem/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-radius: 4px;
color: var(--iron-soft);
font-size: 0.875rem;
}
.hint { margin: 0 0 10px; color: var(--iron-soft); font-size: 0.875rem; }
/* 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 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); }
.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; }
.panel .video-cards { gap: 8px; }
.panel .video-card {
position: relative;
padding: 9px 14px 9px 36px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: #f3ecd8;
color: var(--iron);
font-weight: 600;
}
.panel .video-card::before {
content: "";
position: absolute;
left: 13px; top: 50%;
border: 6px solid transparent;
border-left: 9px solid var(--clay);
transform: translateY(-50%);
}
/* Paths: route chart */
.route {
margin: 0;
padding: 8px 9px 6px;
background: linear-gradient(160deg, #ece2c6, #dccca5);
color: var(--iron);
border: 1.5px solid var(--glaze);
border-radius: 2px;
}
.route figcaption { margin-top: 4px; font: italic 500 0.95rem var(--serif); color: var(--iron-soft); }
.chart__svg { display: block; width: 100%; height: auto; }
.c-vellum { fill: #e6dab9; }
.c-frame { fill: none; stroke: var(--iron); stroke-width: 0.8; }
.c-rhumb { fill: none; stroke: rgb(154 69 32 / 0.18); stroke-width: 0.5; }
.c-land { fill: #c9b07a; stroke: var(--iron); stroke-width: 0.9; }
.c-ink { fill: none; stroke: var(--iron); stroke-width: 0.7; }
.c-course { fill: none; stroke: var(--clay-deep); stroke-width: 1.3; stroke-dasharray: 3 2.4; }
.c-port { fill: #f3ebd6; stroke: var(--iron); stroke-width: 0.9; }
.c-num { 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-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; }
/* Paths: motif picks */
.mlist { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; }
.mpick {
width: 100%;
display: grid;
gap: 1px;
padding: 8px 12px 9px;
border: 1px solid var(--line);
border-left: 4px solid var(--c);
border-radius: 2px;
background: transparent;
text-align: left;
cursor: pointer;
transition: opacity 0.2s ease;
}
.mpick:hover { background: var(--vellum-lo); }
.mpick[aria-pressed="true"] { background: var(--vellum-lo); border-color: var(--c); }
.mpick.is-dim { opacity: 0.5; }
.mpick__name { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--c); }
.mpick__name i { width: 10px; height: 10px; border-radius: 50%; background: var(--c); }
.mpick__path { color: var(--iron-soft); font-size: 0.875rem; }
/* Paths: the vertical thread strip */
.vstrip { position: relative; display: grid; grid-template-columns: 1fr auto; align-items: start; }
.vstrip__rows { grid-column: 1 / -1; grid-row: 1; margin: 0; padding: 0; list-style: none; }
.vstrip__row {
width: 100%;
height: 32px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 calc(var(--cols) * 26px + 30px) 0 8px;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--iron-soft);
text-align: left;
font-size: 0.9375rem;
cursor: pointer;
}
.vstrip__row:hover { background: var(--vellum-lo); }
.vstrip__row.is-current { color: var(--iron); font-weight: 600; background: rgb(196 106 51 / 0.14); }
.vstrip__row.is-picked { outline: 2px solid var(--clay); outline-offset: -2px; color: var(--iron); }
.vstrip__n { width: 18px; text-align: right; font-size: 0.8125rem; 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__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__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__by { font-size: 0.875rem; color: var(--iron-soft); }
.fw__credit { margin-top: 4px !important; font-size: 0.8125rem; color: var(--iron-soft); text-decoration: underline dotted; text-underline-offset: 3px; }
.fw__credit span { text-decoration: none; display: inline-block; }
@media (prefers-reduced-motion: reduce) {
.cut { transition-duration: 0.25s; }
.shell, .panel__body, .panel__view, .panel__song, .pin { transition: none; }
}
+146
View File
@@ -0,0 +1,146 @@
// Motif threads (12's chosen style: soft coloured threads, knotted where a motif plays), shared by the
// three placements of 15: A the strip pinned in the panel, B the frieze register and 3D ribbons, C the
// bottom timeline. 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 = 24;
/** In the timeline (C) the course sits lower, so saga names fit above the ship marker. */
export const COURSE_Y_C = 36;
export interface StripOpts {
song: Song;
pick: string | null;
/** Total width in px of the strip (labels + threads). */
width: number;
labelW: number;
rowH: number;
/** Show song titles under the course (C) or just track numbers (A). */
titles: boolean;
}
/**
* 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 {
const ms = songMotifs(o.song);
const cur = indexOf(o.song);
const n = byTrack.length;
const w = o.width - o.labelW;
const pad = o.titles ? 44 : 12;
const step = (w - pad * 2) / (n - 1);
const X = (i: number) => pad + i * step;
const courseH = o.titles ? 82 : 50;
const courseY = o.titles ? COURSE_Y_C : COURSE_Y;
const H = courseH + ms.length * o.rowH + 4;
const wav = (x: number, k: number) => Math.sin(x / 26 + k * 1.7) * 2;
// the course: a dashed line through every song, saga boundaries as small ticks
let svg = `<rect class="hs__here-band" x="${(X(cur) - step / 2).toFixed(1)}" y="0" width="${step.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)},${o.titles ? 2 : courseY - 9}V${courseY + 9}"/>`;
if (o.titles) svg += `<text class="hs__saga-name" x="${(x + 5).toFixed(1)}" y="9">${esc(sagas.find((g) => g.id === s.sagaId)!.name.replace(/^The /, "").toUpperCase())}</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 ? 7 : 5}"/>
${o.titles ? "" : `<text class="hs__num${isCur ? " is-current" : ""}" x="${X(i).toFixed(1)}" y="${courseY + 19}">${s.track}</text>`}</g>`;
if (o.titles) {
const words = s.title.split(" ");
const lines: string[] = [];
for (const wd of words) {
const last = lines[lines.length - 1];
if (last && (last + " " + wd).length <= 12) lines[lines.length - 1] = last + " " + wd;
else lines.push(wd);
}
svg += `<text class="hs__title${isCur ? " is-current" : ""}" x="${X(i).toFixed(1)}" y="${courseY + 21}">${lines.slice(0, 2).map((l, j) => `<tspan x="${X(i).toFixed(1)}" dy="${j ? 13 : 0}">${esc(l)}</tspan>`).join("")}</text>`;
}
});
// the ship marker, with "you are here": moved by the stage as the voyage sails between songs
const flip = cur > n * 0.6;
svg += `<g class="hs__ship" data-ship transform="translate(${X(cur).toFixed(1)},${courseY})">
<path class="hs__ship-hull" d="M-8,-9 Q0,-2 8,-9 Z"/><path class="hs__ship-mast" d="M0,-9 V-22"/><path class="hs__ship-sail" d="M0.5,-21 L7,-13 H0.5 Z"/>
<text class="hs__here" x="${flip ? -12 : 12}" y="-10"${flip ? ' text-anchor="end"' : ""}>you are here</text></g>`;
// 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.2}"><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" style="--label-w:${o.labelW}px;--course-h:${courseH}px">
<div class="hs__labels"><span class="hs__course-label">${o.titles ? "The voyage" : "Songs"}</span>${labels}</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" | "titles">, idx: number): number {
const w = o.width - o.labelW;
const pad = o.titles ? 44 : 12;
return pad + idx * ((w - pad * 2) / (byTrack.length - 1));
}
/** 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));
});
}