From bd84e177e676ff2ea899f76bc1cc6e03a0449986 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 02:26:27 +0100 Subject: [PATCH] feat(mockups): 15 voyage look 2 (cohesive look 2, paths A strip / B in world / C timeline) Co-Authored-By: Claude Opus 5.5 --- docs/TODO.md | 3 + docs/design/002-direction-round-notes.md | 6 + .../15-voyage-look2-report.md | 123 +++ src/mockups/15-voyage-look2/chart.ts | 65 ++ src/mockups/15-voyage-look2/fanworks.ts | 72 ++ src/mockups/15-voyage-look2/figures.ts | 269 +++++++ src/mockups/15-voyage-look2/frieze.ts | 226 ++++++ src/mockups/15-voyage-look2/main.ts | 377 ++++++++++ src/mockups/15-voyage-look2/panel.ts | 278 +++++++ src/mockups/15-voyage-look2/ribbons.ts | 190 +++++ src/mockups/15-voyage-look2/scene.ts | 709 ++++++++++++++++++ src/mockups/15-voyage-look2/style.css | 594 ++++++++++++++- src/mockups/15-voyage-look2/threads.ts | 146 ++++ 13 files changed, 3057 insertions(+), 1 deletion(-) create mode 100644 docs/design/002-sea-chart-spikes/15-voyage-look2-report.md create mode 100644 src/mockups/15-voyage-look2/chart.ts create mode 100644 src/mockups/15-voyage-look2/fanworks.ts create mode 100644 src/mockups/15-voyage-look2/figures.ts create mode 100644 src/mockups/15-voyage-look2/frieze.ts create mode 100644 src/mockups/15-voyage-look2/panel.ts create mode 100644 src/mockups/15-voyage-look2/ribbons.ts create mode 100644 src/mockups/15-voyage-look2/scene.ts create mode 100644 src/mockups/15-voyage-look2/threads.ts diff --git a/docs/TODO.md b/docs/TODO.md index 03c5c83..582a863 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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). diff --git a/docs/design/002-direction-round-notes.md b/docs/design/002-direction-round-notes.md index b951185..51d6e8e 100644 --- a/docs/design/002-direction-round-notes.md +++ b/docs/design/002-direction-round-notes.md @@ -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). diff --git a/docs/design/002-sea-chart-spikes/15-voyage-look2-report.md b/docs/design/002-sea-chart-spikes/15-voyage-look2-report.md new file mode 100644 index 0000000..9f50c4e --- /dev/null +++ b/docs/design/002-sea-chart-spikes/15-voyage-look2-report.md @@ -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=`** (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. diff --git a/src/mockups/15-voyage-look2/chart.ts b/src/mockups/15-voyage-look2/chart.ts new file mode 100644 index 0000000..c883b8e --- /dev/null +++ b/src/mockups/15-voyage-look2/chart.ts @@ -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) => `${i + 1}`).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 += ``; + return ` + + + + + + + + + + ${spiral} + + ${ports} + Troy + Cyclops' Island + two years at sea + + + + + + You are here + + + `; +} diff --git a/src/mockups/15-voyage-look2/fanworks.ts b/src/mockups/15-voyage-look2/fanworks.ts new file mode 100644 index 0000000..2b1a8a0 --- /dev/null +++ b/src/mockups/15-voyage-look2/fanworks.ts @@ -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 = { + "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 = { 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 = ` + + + `; + } else if (type === "animatic") { + let frames = ""; + for (let i = 0; i < 3; i++) frames += ``; + let holes = ""; + for (let x = 6; x < 160; x += 12) holes += ``; + art = `${holes}${frames}`; + } else if (type === "cover") { + let bars = ""; + for (let i = 0; i < 18; i++) { const h = 10 + r(i) * 46; bars += ``; } + art = `${bars}`; + } else { + art = ` + + + `; + } + return ` + ${art}`; +} diff --git a/src/mockups/15-voyage-look2/figures.ts b/src/mockups/15-voyage-look2/figures.ts new file mode 100644 index 0000000..b21b4a7 --- /dev/null +++ b/src/mockups/15-voyage-look2/figures.ts @@ -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) => `${body}`; +export const inc = (d: string, w = 1.6) => ``; +export const ink = (d: string) => ``; +// 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) => + ``; + +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 += ``; + 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 += ``; + } 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 += ``; + upper += ``; + upper += ``; + } + // 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 += `` + 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 += ``; + 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 += ``; + b += inc("M-6,-150 h28 v22 h-28 Z", 1.4); + for (const wx of [-66, 56]) { + b += ``; + 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 += ``; + } + b += ``; + 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 += ``; + 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 += ``; + 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 ``; +} + +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") + + ``); +} + +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) => ``; + +export const PANELS: Record = { + "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") + + `` + + 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 + ? `${escXml(title.toUpperCase())}` + : ""; + return ``; +} + +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 ``; +} + +export function palmette(cx: number, cy: number, flip = false): string { + let leaves = ""; + for (let i = -3; i <= 3; i++) { + leaves += ``; + } + const volutes = ``; + return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `` + volutes); +} + +/** A saga boundary: palmettes framing the saga's name, painted vertically. */ +export function sagaSvg(name: string, gap: number): string { + const tally = gap > 0 + ? `${gap} ${gap === 1 ? "year" : "years"} later` + : ""; + return ``; +} + +/** 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 ``; +} diff --git a/src/mockups/15-voyage-look2/frieze.ts b/src/mockups/15-voyage-look2/frieze.ts new file mode 100644 index 0000000..d027462 --- /dev/null +++ b/src/mockups/15-voyage-look2/frieze.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!); + +function defs(id: string): string { + const meander = (key: string, y: number) => + ``; + return ` + ${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)} + + + + + + ${g("translate(21,60) scale(0.66)", palmette(0, 0))}${g("translate(63,18) scale(0.66)", palmette(0, 0, true))} + + + + + + + + + `; +} + +/** + * 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) => ``; + const frame = (y: number) => ``; + const top = ` + + + ${rule(BAND_TOP - NECK_H + 4)} + + ${frame(BAND_TOP - 12)}${rule(BAND_TOP + 8)} + + ${rule(BAND_TOP + 46)} + + `; + const bottom = ` + + + + ${threads} + ${rule(BOT_Y + 2)} + + ${rule(BOT_Y + 40)} + + ${frame(BAND_BOT + 6)} + `; + 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 += ` + ${esc(m.name)}: ${esc(r.from)} · ${esc(r.next)} + + ${inFrieze.map((st) => ``).join("")} + + ${esc(m.name.toUpperCase())} + `; + }); + 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") => + `${esc(text.toUpperCase())}`; + +/** A painted saga column (palmettes framing the saga's name), as in 04. */ +function sagaColumn(x: number, name: string): string { + return placed(x, `${palmette(66, 52)}${palmette(66, 248, true)} + ${esc(name.toUpperCase())}`); +} +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, ``); +}; + +/** 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 ``; +} + +/* ---------- 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 += ``; + 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 += ``; + 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 ``; +} diff --git a/src/mockups/15-voyage-look2/main.ts b/src/mockups/15-voyage-look2/main.ts index 88ded7e..6c962fe 100644 --- a/src/mockups/15-voyage-look2/main.ts +++ b/src/mockups/15-voyage-look2/main.ts @@ -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 = { 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!); +app.className = "shell"; +app.innerHTML = ` +
+
+ + +
${troyFrieze(songById(BEATS[0]!.songId))}
+
${cyclopsFrieze(songById(BEATS[3]!.songId))}
+
+ +
+

+

+ +
+ +
+ `; + +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(".f-figs")!, + top: troy.querySelector(".f-top")!, + bottom: troy.querySelector(".f-bottom")!, + horizon: troy.querySelector(".horizon-line")!, +}; +const cyParts = { + zoom: cyclops.querySelector(".f-zoom")!, + mouth: cyclops.querySelector(".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("[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 ``; + }).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(".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("#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("[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(".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("#dots button").forEach((b) => + b.addEventListener("click", () => { setPlaying(false); go(Number(b.dataset.beat) - 1); })); +document.querySelectorAll(".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); diff --git a/src/mockups/15-voyage-look2/panel.ts b/src/mockups/15-voyage-look2/panel.ts new file mode 100644 index 0000000..bf49a3d --- /dev/null +++ b/src/mockups/15-voyage-look2/panel.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 = ` + +
+
+
+ +
+
+
+ ${TABS.map((t) => ``).join("")} +
+
+
`; + const $ = (id: string) => host.querySelector(`#${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()}
The ship's course so far, on 12's general chart.
`; + const here = chartFig.querySelector("#chart-here")!; + + /* ----- head and pinned strip ----- */ + function renderHead() { + const saga = sagaOf(song); + songEl.innerHTML = `

Now playing on the voyage

+

${song.track}${esc(song.title)}

+

${esc(saga.name)} · ${esc(song.storyTime.label)}

`; + $("panel-tab-n").textContent = String(song.track); + } + let shipIdx = 0; + function renderPin() { + pin.innerHTML = `

Motif paths heard before · here · returns

${hStrip({ ...STRIP, song, pick })}`; + api.setShip(shipIdx); + } + + /* ----- tabs ----- */ + const motifTag = (m: Motif) => ``; + + function about(): HTMLElement { + const el = document.createElement("div"); + el.className = "about"; + const saga = sagaOf(song); + const o = song.odyssey; + const homer = o ? `Book ${o.book}, lines ${esc(o.lines.replace("-", "–"))}` : "No counterpart in Homer"; + el.innerHTML = ` +
+
Track
${song.track} of ${songs.length} in this sample
+
Saga
${esc(saga.name)}
+
Story time
${song.storyTime.yearsAfterTroy === 0 ? "Year 0" : `Year ${song.storyTime.yearsAfterTroy}`} · ${esc(song.storyTime.label)}
+
The Odyssey
${homer}
+
+ ${song.facts.map((f) => `

${esc(f)}

`).join("")} +

Parts and singers

+
    ${song.parts.map((p) => `
  1. ${esc(p.name)}${p.singers.map(esc).join(", ")}
  2. `).join("")}
+

Listen

+

Watch

`; + 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 = `

Motif threads

+

Each thread is a motif; a knot is a song where it plays. Pick a motif to follow it everywhere.

+
    ${ms.map((m) => { + const r = routeOf(song, m.id); + return `
  • `; + }).join("")}
`; + 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 `
  • `; + }).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 += `${ys.map((y) => ``).join("")}`; + }); + wrap.innerHTML = `
      ${rows}
    + `; + wrap.querySelectorAll(".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 = `

    Placeholder lyrics. The real lyrics are not reproduced on this fan site.

    + ${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 `
    +

    ${esc(p.name)}

    ${p.singers.map((s) => `${esc(s)}`).join("")}
    +
    ${p.lines.map((l) => `

    ${esc(l)}

    `).join("")}
    + ${ms.length ? `

    ${ms.map(motifTag).join("")}

    ` : ""} +
    `; + }).join("")}`; + return el; + } + + function fan(): HTMLElement { + const el = document.createElement("div"); + el.className = "fan"; + const accent = inked(motifsInSong(song)[0]?.id ?? "odysseus"); + el.innerHTML = `

    Placeholder fan works — real ones will be credited and linked to their creators.

    +
      ${fanWorksFor(song.id).map((w, i) => `
    • + ${thumbSvg(w.type, accent, song.track * 7 + i)} +

      ${TYPE_LABEL[w.type]}

      +

      ${esc(w.title)}

      +

      by ${esc(w.handle)}

      +

      Credit + link to the original (placeholder)

      +
    • `).join("")}
    `; + return el; + } + + function reflectPick() { + applyPick(host, pick); + view.querySelectorAll(".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("[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("[data-pick]"); + if (b) opts.onPick(pick === b.dataset.pick ? null : b.dataset.pick!); + }); + host.querySelectorAll("[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("[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; +} diff --git a/src/mockups/15-voyage-look2/ribbons.ts b/src/mockups/15-voyage-look2/ribbons.ts new file mode 100644 index 0000000..434cafe --- /dev/null +++ b/src/mockups/15-voyage-look2/ribbons.ts @@ -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 ", "#include \nattribute float aU;\nvarying float vU;\nvarying float vDepth;") + .replace("#include ", "#include \nvU = aU;") + .replace("#include ", "#include \nvDepth = -mvPosition.z;"); + sh.fragmentShader = sh.fragmentShader + .replace("#include ", "#include \nuniform float uSplit;\nuniform float uNear;\nvarying float vU;\nvarying float vDepth;") + .replace("#include ", "#include \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 ", `#include + // 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 }; + }); + } +} diff --git a/src/mockups/15-voyage-look2/scene.ts b/src/mockups/15-voyage-look2/scene.ts new file mode 100644 index 0000000..520ace1 --- /dev/null +++ b/src/mockups/15-voyage-look2/scene.ts @@ -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(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 + }`, + }); + 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 ", `#include \n${WAVE_GLSL}\nvarying float vH;\nvarying vec2 vWp;`) + .replace("#include ", `#include + 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 ", `#include \nuniform float uTime;\nuniform vec3 uDeep;\nuniform vec3 uMid;\nuniform vec3 uCrest;\nuniform vec3 uContour;\nvarying float vH;\nvarying vec2 vWp;`) + .replace("#include ", `#include + // 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(); + } +} diff --git a/src/mockups/15-voyage-look2/style.css b/src/mockups/15-voyage-look2/style.css index 720efad..40c8682 100644 --- a/src/mockups/15-voyage-look2/style.css +++ b/src/mockups/15-voyage-look2/style.css @@ -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; } +} diff --git a/src/mockups/15-voyage-look2/threads.ts b/src/mockups/15-voyage-look2/threads.ts new file mode 100644 index 0000000..e8db73d --- /dev/null +++ b/src/mockups/15-voyage-look2/threads.ts @@ -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 = { + 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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 = ``; + svg += ``; + byTrack.forEach((s, i) => { + if (i > 0 && s.sagaId !== byTrack[i - 1]!.sagaId) { + const x = (X(i) + X(i - 1)) / 2; + svg += ``; + if (o.titles) svg += `${esc(sagas.find((g) => g.id === s.sagaId)!.name.replace(/^The /, "").toUpperCase())}`; + } + const isCur = i === cur; + svg += `${s.track}. ${esc(s.title)} + + ${o.titles ? "" : `${s.track}`}`; + 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 += `${lines.slice(0, 2).map((l, j) => `${esc(l)}`).join("")}`; + } + }); + // the ship marker, with "you are here": moved by the stage as the voyage sails between songs + const flip = cur > n * 0.6; + svg += ` + + you are here`; + + // one thread per motif + ms.forEach((m, k) => { + const y = courseH + k * o.rowH + o.rowH / 2; + const hits = hitsOf(m.id); + const x0 = X(hits[0]!), x1 = X(hits[hits.length - 1]!); + let d = ""; + for (let x = x0; x < x1; x += 5) d += `${d ? "L" : "M"}${x.toFixed(1)},${(y + wav(x, k)).toFixed(1)}`; + d += `${d ? "L" : "M"}${x1.toFixed(1)},${(y + wav(x1, k)).toFixed(1)}`; + svg += ` + + ${hits.map((i) => `${esc(m.name)} in ${esc(byTrack[i]!.title)}`).join("")} + `; + }); + + const labels = ms.map((m) => { + const r = routeOf(o.song, m.id); + return ``; + }).join(""); + return `
    +
    ${o.titles ? "The voyage" : "Songs"}${labels}
    + +
    `; +} + +/** Where the ship marker sits on a strip of this width, for a fractional song index. */ +export function stripX(o: Pick, 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("[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)); + }); +}