41 Commits
Author SHA1 Message Date
Lucas Winther f6cc2ecc69 docs(design): pushed; scene fixes next 2026-10-30 15:36:45 +01:00
Lucas Winther d1dc784b6c docs(design): note v1 built 2026-10-30 15:30:20 +01:00
Lucas WintherandClaude Opus 5.5 0456683d1b fix(app): controller cancels instead of queueing; Go ashore; focus, narrow and lyrics fixes
From the final review: a request cancelled mid-drop no longer sails under
the wrong frieze; new requests cancel running animations; Play voyage
no longer sticks during the first scene and stops on any other
navigation; the landing intro is revealed by the lift; Go ashore enters
a landing through the frieze; Back to sea keeps keyboard focus; the
timeline is compact below 900px; lyrics re-scroll after a tab switch;
the shared toon gradient is no longer disposed with a landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:25:45 +01:00
Lucas WintherandClaude Opus 5.5 a196c09b82 docs: the voyage app in DESIGN, PRODUCT, AGENTS, ARCHITECTURE, DECISIONS, TODO
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:12:53 +01:00
Lucas WintherandClaude Opus 5.5 d355b3a41f feat(app): wire the voyage together
Controller drives world and curtain; caption with Back to sea; play,
previous and next in the timeline bar; keyboard; no-WebGL frieze stage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:11:43 +01:00
Lucas WintherandClaude Opus 5.5 ac017787b5 feat(app): 3D world, landings and friezes
Nine stylised places on one route, ship and ribbons, the cave, arena and
hall landings, nine saga friezes and the curtain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:08:11 +01:00
Lucas WintherandClaude Opus 5.5 27d624fa44 feat(app): voyage transitions and the stage controller
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:58:59 +01:00
Lucas WintherandClaude Opus 5.5 b3385185b5 feat(app): bottom timeline; drop the Odysseus thread from the album data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:57:55 +01:00
Lucas Winther ea728176ae docs(design): note resume after restart 2026-10-30 14:52:25 +01:00
Lucas WintherandClaude Opus 5.5 2fbf3a6980 feat(app): content client and side panel
About, Listen & lyrics (real player, synced lyrics, Watch), Paths (strip
when the timeline is hidden, motif rows, nine-place chart), Fan works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:09:22 +01:00
Lucas WintherandClaude Opus 5.5 38c4614726 feat(app): app shell, design tokens and URL state
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:57:36 +01:00
Lucas WintherandClaude Opus 5.5 288425501d feat(data): album data for all 9 sagas and 40 songs
Odyssey links point at Butcher & Lang (#1728), whose Greek names the
keywords were checked against.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:56:32 +01:00
Lucas WintherandClaude Opus 5.5 3235df8bea feat(app): app page at /, mockup gallery moved to /mockups/
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:50:56 +01:00
Lucas WintherandClaude Opus 5.5 c11c2ef079 feat(content): prefer Greek-name Homer translations; voyage app spec, design record and plan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:50:11 +01:00
Lucas Winther 9a0350c82f docs(design): direction approved 2026-10-30 04:46:14 +01:00
Lucas WintherandClaude Opus 5.5 93e9165a88 feat(mockups): 17 voyage with real content (FLAC, synced lyrics, Odyssey passage)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:45:08 +01:00
Lucas WintherandClaude Opus 5.5 6db056fba2 feat(mockups): slot 17 voyage with real content
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:34:35 +01:00
Lucas WintherandClaude Opus 5.5 4ea8049b56 docs(design): pause notes before 17
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:29:42 +01:00
Lucas WintherandClaude Opus 5.5 b500115a8b feat(mockups): 16 voyage readable (Inter labels, listen & lyrics tab, combined paths)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:29:27 +01:00
Lucas WintherandClaude Opus 5.5 27b1d2fe26 feat(content): serve the mounted content folder under /content (spec 003)
Audio with range requests, library.json, Homer books; LRC parser. Content
stays out of git and the image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:46:43 +01:00
Lucas Winther 9d063fcbe0 docs(design): record the real content folder 2026-10-30 02:42:19 +01:00
Lucas WintherandClaude Opus 5.5 27d1cbfa8e feat(mockups): slot 16 voyage readable; record 14 vs 15 feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:37:19 +01:00
Lucas Winther 7e3fe798a3 docs(design): note comparison request 2026-10-30 02:31:59 +01:00
Lucas WintherandClaude Opus 5.5 bd84e177e6 feat(mockups): 15 voyage look 2 (cohesive look 2, paths A strip / B in world / C timeline)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:26:27 +01:00
Lucas WintherandClaude Opus 5.5 50c54c1949 feat(mockups): slot 15 voyage look 2; record verdict on 14
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:06:45 +01:00
Lucas WintherandClaude Opus 5.5 ada8fa4f94 feat(mockups): 14 voyage with side panel (stylised 3D, about/paths/lyrics/fan works, look 2/3)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:54:57 +01:00
Lucas WintherandClaude Opus 5.5 3cda08c32e feat(mockups): slot 14 voyage with side panel; record verdict on 13
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:53:44 +01:00
Lucas WintherandClaude Opus 5.5 e3fd97a78c feat(mockups): 13 curtain call (frieze → sea → landfall → frieze → cave, look toggle 1/2/3)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:42:37 +01:00
Lucas WintherandClaude Opus 5.5 df8ab8d174 feat(mockups): slot 13 curtain call; allow three.js in 13
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:15:33 +01:00
Lucas WintherandClaude Opus 5.5 b511d67f4e docs(design): threads chosen for motifs in plates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:13:26 +01:00
Lucas WintherandClaude Opus 5.5 09310ff254 docs(design): 12 finished, preview rebuilt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:06:56 +01:00
Lucas WintherandClaude Opus 5.5 298efca711 feat(mockups): 12 vellum atlas (11's navigation in 10's style, motif toggle A/B/C)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:06:43 +01:00
Lucas WintherandClaude Opus 5.5 89a30e5479 wip(mockups): 12 vellum atlas paused at usage limit; pause report and notes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:58:08 +01:00
Lucas WintherandClaude Opus 5.5 c7d8b8c38a feat(mockups): slot 12 vellum atlas; record verdict on sea-chart spikes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:46:11 +01:00
Lucas WintherandClaude Opus 5.5 65b5aa0d00 feat(mockups): 11 atlas with insets spike; round notes updated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:50:33 +01:00
Lucas WintherandClaude Opus 5.5 a422234970 feat(mockups): 09 schematic sea chart spike
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:34:37 +01:00
Lucas WintherandClaude Opus 5.5 e880a69177 feat(mockups): 10 unrolling chart spike; unique stub css for 11
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:31:50 +01:00
Lucas WintherandClaude Opus 5.5 42b51983c1 feat(mockups): 08 sea chart + log line spike; fix registry count test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:31:33 +01:00
Lucas WintherandClaude Opus 5.5 cab220d3e2 docs(design): resume sea-chart round, add missing spike 10 plan
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:23:21 +01:00
Lucas WintherandClaude Opus 5.5 7503f55a17 wip(mockups): pause sea-chart round — saved spike plans and 08 geo module
Agents paused at the usage limit. Resume notes live in
docs/design/002-direction-round-notes.md and docs/design/002-sea-chart-spikes/.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:30:13 +02:00
Lucas WintherandClaude Opus 5.5 52feed514d chore(mockups): add sea-chart round slots 08–11 and direction-round notes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:25:26 +02:00
165 changed files with 30046 additions and 92 deletions
+4
View File
@@ -5,3 +5,7 @@ dist
.worktrees
screenshots
.env*
epic-the-musical-content
content
**/*.flac
**/*.lrc
+6
View File
@@ -6,3 +6,9 @@ CLAUDE.local.md
screenshots/
.superpowers/
.claude/settings.local.json
# Real content is mounted at runtime, never committed (spec 003)
epic-the-musical-content/
/content/
*.flac
*.lrc
+19 -12
View File
@@ -1,7 +1,8 @@
# EPIC: The Musical Timeline
Unofficial fan site: an interactive timeline of *EPIC: The Musical* songs. Currently 7 design-direction
mockups. Bun 1.3.14 (server, bundler, test runner), TypeScript, three.js (mockups 06/07 only), Docker.
Unofficial fan site: an interactive timeline of *EPIC: The Musical* songs as a 3D sea voyage (the app at
`/`, `src/app/`), plus the design-round mockups at `/mockups/`. Bun 1.3.14 (server, bundler, test runner),
TypeScript, three.js (the app; mockups 06/07/13–17), Docker.
`AGENTS.md` is the single source of truth for agents here. `CLAUDE.md` and `GEMINI.md` only point here.
Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TODO.md`.
@@ -9,11 +10,11 @@ Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TOD
## Commands
Install: `bun install --frozen-lockfile`
Dev: `bun run dev` (`PORT` env, default 3000)
Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 7"`
Dev: `bun run dev` (`PORT` env, default 3000; `CONTENT_DIR=~/epic-the-musical-content` for real audio/lyrics)
Test: `bun test` | Single file: `bun test tests/data.test.ts` | Single case: `bun test -t "registry has 17"`
Typecheck: `bun run typecheck`
Build: `bun run build` | Run the build: `bun run start`
Docker: `docker build -t epic-timeline .` then `docker run --rm -p 3000:3000 epic-timeline`
Docker: `docker build -t epic-timeline .` then `docker run --rm -p 3000:3000 -v ~/epic-the-musical-content:/content:ro -e CONTENT_DIR=/content epic-timeline`
Run the production bundle **from inside `dist/`**. Bun resolves bundled assets relative to the cwd:
```sh
@@ -27,16 +28,18 @@ bun dist/server.js # wrong: crashes with 'Bundled file "./index-….js" no
and the `../../shared/credits.css` link in the page's HTML, and call `mountCredits()` from
`src/shared/credits.ts`. Never write your own notice text.
- Mockup agents edit only their own `src/mockups/NN-slug/` folder. `src/data/`, `src/shared/`,
`src/server.ts`, `src/index.html` and `src/mockups/registry.ts` are the shared contract; change them
`src/server.ts`, `src/mockups/index.html` and `src/mockups/registry.ts` are the shared contract; change them
only when the plan says so.
- No two mockups may have byte-identical `style.css` — the build fails with
"Multiple files share the same output path". Give each one its own styles from the start.
- Sample data (`src/data/sample.ts`): lyrics are placeholders, never real lyrics. Any fact, story time
- Real audio, lyrics and cover art are Jorge Rivera-Herrans' copyrighted work: they live only in the
mounted content folder (`CONTENT_DIR`, served under `/content/`), never in git or the Docker image.
- Album data (`src/data/album/`) and sample data (`src/data/sample.ts`): never real lyrics. Any fact, story time
or Odyssey reference you can't confirm gets `// TODO(verify)`.
- Assets are bundled, never loaded from a CDN: fonts from `@fontsource/*`, three.js from npm.
YouTube videos are link cards (`createVideoCards`), not embeds.
- Adding a mockup route means editing `src/server.ts`, `src/mockups/registry.ts` and the cards in
`src/index.html` together; `tests/server.test.ts` checks every registry slug is served and that the index shows each entry's name, pitch and tryThis.
`src/mockups/index.html` together; `tests/server.test.ts` checks every registry slug is served and that the index shows each entry's name, pitch and tryThis.
## Workflow
@@ -50,7 +53,7 @@ Tick plan checkboxes as you go so an interrupted session is resumable.
Trivial changes (typo, version bump, one-line config): say "trivial, skipping spec" and proceed.
Use impeccable for user-facing UI — `craft` for new, `audit`/`polish` on existing, `harden` before
shipping. Design context: `PRODUCT.md`, `DESIGN.md`. No visual direction is chosen yet (see `DESIGN.md`).
shipping. Design context: `PRODUCT.md`, `DESIGN.md` (the chosen direction: tokens, type, layout, 3D style).
Mockup rounds — any UI change beyond a trivial tweak, while brainstorming and before the spec:
- Round 1: about three genuinely different alternatives, each a clickable mockup with realistic
@@ -66,9 +69,13 @@ Mockup rounds — any UI change beyond a trivial tweak, while brainstorming and
## Architecture
`src/server.ts` -> `Bun.serve` with HTML-import routes: `/` and `/mockups/NN-slug/`
`src/data/` -> `types.ts` (shapes), `sample.ts` (shared sample data), `query.ts` (helpers)
`src/shared/` -> credits, audio slot (direct MP3, empty state), YouTube link cards
`src/server.ts` -> `Bun.serve` with HTML-import routes: `/` (the app), `/mockups/` (gallery), `/mockups/NN-slug/`
`src/app/` -> the voyage app: `state.ts` (URL state), `controller.ts` + `voyage.ts` (scene changes),
`world/` (three.js world, landings, ribbons), `frieze/` (friezes, curtain), `panel/`, `timeline/`, `content.ts`
`src/data/album/` -> the app's data: 9 sagas, 40 songs, motifs (`// TODO(verify)` where unconfirmed)
`src/data/` (other files) -> the mockups' shapes and sample data; frozen
`src/shared/` -> credits, audio slot (direct MP3, empty state), YouTube link cards, LRC parser
`src/content/` -> `/content/*` routes over the mounted content folder: library.json, files (ranges), Homer books
`src/mockups/NN-slug/` -> one design direction each: `index.html`, `main.ts`, `style.css`, plus optional helper modules (e.g. `scene.ts`, `layout.ts`)
`tests/` -> data invariants, shared DOM modules (happy-dom), route smoke tests
+48 -23
View File
@@ -1,29 +1,54 @@
# Design
## Status: visual direction open
No design system, palette or type scale is chosen yet. Seven mockups under `src/mockups/` each commit fully
to a different direction; the maintainer will pick one, or combine several, and this file then records the
chosen direction as tokens and rules. Until then, do not treat any single mockup's palette or fonts as the
house style.
## Status: direction chosen (2026-10-30)
The app at `/` (`src/app/`) is the house style. Its record, with every chosen and deselected idea and the
user's words: `docs/design/002-voyage-app.md`. The mockups under `src/mockups/` are history; don't take
styling from the dropped ones. New options vary structure, flow and behaviour within this direction —
never a new palette or font family.
## The candidate directions
| # | Folder | Direction |
## The direction: "two worlds" (look 2)
A stylised 3D sea voyage, bright and naturalistic in colour, framed by a Greek black-figure frieze that
drops like a curtain between scenes. The interface sits on parchment and borrows the frieze's terracotta,
black and meander band so that frieze, world and panels read as one piece.
## Tokens (`src/app/style.css` `:root`)
| Token | Value | Use |
|---|---|---|
| 1 | `01-editorial` | Editorial scroll: horizontal line, parchment and serif, motifs as thin threads |
| 2 | `02-vertical-story` | Vertical story: downward scroll with a sticky detail panel |
| 3 | `03-transit-map` | Transit map: motifs as metro lines, songs as stations |
| 4 | `04-greek-frieze` | Greek frieze: an unrolled black-figure vase frieze |
| 5 | `05-score` | Score / waveform: staff or waveform, parts as segments |
| 6 | `06-constellation` | Constellation (3D, three.js): songs as stars |
| 7 | `07-sea-voyage` | Sea voyage (3D, three.js): ship, sea, saga islands |
| `--clay` | `#c46a33` | frieze ground; accents, active tab, play button |
| `--clay-deep` | `#9a4520` | eyebrows, hover, small accent text |
| `--glaze` | `#1d140e` | frieze figures; rules, borders, dark buttons, page frame |
| `--added-white` | `#efe3c8` | text on glaze |
| `--vellum` / `--vellum-hi` / `--vellum-lo` | `#e3d6b4` / `#ece2c6` / `#e0d1ab` | panel, cards, timeline |
| `--iron` / `--iron-soft` | `#2b2118` / `#5a4836` | body text / secondary text |
| `--line` | `rgb(43 33 24 / 0.2)` | hairlines |
| `--meander` | SVG data URI | the key band: 14px strip between two 2px glaze rules (`.meander-edge`) on top of every panel, card and bar |
Details per direction: `docs/specs/001-timeline-mockups.md`.
Motif colours come from the data (`src/data/album/motifs.ts`) and are the same everywhere: timeline,
panel, ribbons and chips. On parchment, text in a motif colour is darkened to 4.5:1
(`src/app/timeline/ink.ts`); dots and knots keep the bright colour.
## Constraints that hold for every direction
- Fonts are self-hosted from `@fontsource/*` (installed: Cormorant Garamond, Inter); nothing from a CDN.
- three.js only where a direction is 3D.
- Motif colours come from the data (`Motif.color` in `src/data/sample.ts`), so threads match across views.
- The fan-credits footer (`src/shared/credits.css`) is present on every page; a direction may restyle it
but not remove or hide it.
- Avoid generic, templated "AI-looking" UI; a direction that is just another layout in different colours
has failed.
## Type
- **Inter** for all UI, labels and controls: 13px minimum (`--fs-label`), 14px small, 15px panel body,
weights 400–600, tracking ≤0.06em. Never small wide-tracked capitals (they were "very hard to read").
- **Cormorant Garamond** only for large display titles (`--fs-title` 2rem), lyric lines, Odyssey reading
text, and frieze inscriptions (≥19px on screen).
## Layout
- Desktop reference 1440×900: stage (3D canvas + frieze layer + caption card + bottom timeline) | right
panel 420px (collapsible to a 56px tab) | credits footer across the bottom.
- Timeline ≤190px tall by default; the current saga expanded with titles, the others compact.
- Below 900px: the panel becomes a bottom sheet; the timeline stays over the stage.
## 3D
Stylised, not realistic: toon/flat shading, ink outlines, flat colour-band water with drawn wave strokes,
a small flat sun, graphic silhouettes. Black-figure cut-outs (the giant, Athena) inside landings.
Pixel ratio ≤2; distant places are not drawn.
## Motion
Curtain drop ≈1.2s, parting lift ≈1.5s, doorway zoom ≈1.6s, fade ≈0.45/0.65s; ease-out curves.
`prefers-reduced-motion`: every scene change is a 200ms crossfade.
## Constraints
- Fonts self-hosted from `@fontsource/*`; three.js from npm; nothing from a CDN.
- The fan-credits footer is on every page and never hidden.
- Avoid generic, templated "AI-looking" UI.
+9 -7
View File
@@ -19,16 +19,18 @@ recur across the songs and how the story maps onto the *Odyssey*. Desktop first
(`src/shared/credits.ts`), never retyped.
- **Accuracy is visible:** content that isn't verified is marked as such (`// TODO(verify)` in data) and never
presented as fact-checked.
- **Lyrics:** mockups use placeholder lyric lines only. Whether and how real lyrics are shown is an open
copyright question, deferred by the maintainer.
- **Media:** videos are YouTube link cards (no embedded player); audio supports direct MP3 URLs. The app
works offline apart from following those links.
- **Lyrics and audio:** real recordings, lyrics and cover art come only from a content folder mounted at
runtime (spec 003); they are never committed or baked into the image. The broader copyright question is
deferred by the maintainer.
- **Media:** videos are YouTube link cards (no embedded player). The app works without the content folder
and offline apart from following those links.
## Tone
Polished and smooth; may use 3D (three.js). It should feel like a crafted piece made by a fan, not a generic
template — see `DESIGN.md`.
## Current stage
Spec `docs/specs/001-timeline-mockups.md`: 7 design-direction mockups over shared sample data (Troy and
Cyclops sagas plus later callback songs). The maintainer picks or combines a direction; the real app gets
its own spec after that.
v1 of the app (spec `docs/specs/002-voyage-app.md`): the whole album, 9 sagas and 40 songs, as a stylised
3D sea voyage with the frieze as a curtain between scenes, three landings, a side panel and a bottom
timeline of motif threads. The design rounds that led here (mockups 01–17) are recorded in
`docs/design/002-direction-round-notes.md`.
+33 -11
View File
@@ -3,16 +3,18 @@
How `epic-the-musical-timeline` is built today. Update in the same commit as any structural change.
## Overview
A Bun server (`Bun.serve`) with HTML-import routes serves an index page and seven mockup pages. Bun's bundler
turns each HTML page and its imported TS/CSS into hashed assets. `bun run build` bundles the server and every
page into `dist/`; the Docker image runs that bundle. All pages read one typed sample dataset and mount one
shared credits snippet.
A Bun server (`Bun.serve`) with HTML-import routes serves the voyage app at `/`, the mockup gallery at
`/mockups/` and the design-round mockups at `/mockups/NN-slug/`, plus `/content/*` from an optional mounted
content folder. Bun's bundler turns each HTML page and its imported TS/CSS into hashed assets. `bun run
build` bundles the server and every page into `dist/`; the Docker image runs that bundle. The app reads the
album data (`src/data/album/`); the mockups read the frozen sample dataset. Every page mounts one shared
credits snippet.
## Map
| Path | Responsibility |
|---|---|
| `src/server.ts` | `startServer(port)`: routes `/`, `/mockups/NN-slug/` (+ 302 from the no-slash form), 404 otherwise. Runs when `import.meta.main`; `PORT` env, default 3000. |
| `src/index.html`, `src/index.ts`, `src/index.css` | Index page: static cards linking each mockup, then `mountCredits()`. |
| `src/server.ts` | `startServer(port, { contentDir? })`: routes `/`, `/mockups/NN-slug/` (+ 302 from the no-slash form), `/content/*` via `src/content/routes.ts`, 404 otherwise. Runs when `import.meta.main`; `PORT` env, default 3000. |
| `src/mockups/index.html`, `index.ts`, `index.css` | Mockup gallery at `/mockups/`: static cards linking each mockup, then `mountCredits()`. |
| `src/mockups/registry.ts` | `mockups: MockupInfo[]` — slug, number, name, pitch, what to try. |
| `src/mockups/NN-slug/` | One design direction: `index.html` (links `../../shared/credits.css`, has `<footer data-fan-credits>`), `main.ts`, `style.css`, plus optional helper modules (e.g. `scene.ts`, `layout.ts`). |
| `src/data/types.ts` | `Saga`, `Song`, `Part`, `Motif`, `StoryTime`, `OdysseyRef`, `Video`. |
@@ -22,25 +24,45 @@ shared credits snippet.
| `src/shared/audio-slot.ts` | `createAudioSlot(song)`: `<audio>` for `song.audio`, else an empty-state slot. |
| `src/shared/video-cards.ts` | `createVideoCards(song)`: YouTube link cards, no embeds. |
| `src/shared/credits.css` | Styles for credits, audio slot, video cards. |
| `tests/` | `data.test.ts` (dataset invariants), `shared.test.ts` (DOM modules under happy-dom), `server.test.ts` (every route 200s, index links every registry slug, each mockup has the credits hook). |
| `src/content/routes.ts` | `contentResponse(req, dir)`: `/content/library.json`, `/content/files/<path>` (traversal-guarded, HTTP ranges), `/content/homer/<odyssey\|iliad>/<book>.json`. All 404 when no folder is mounted. |
| `src/content/library.ts` | `scanLibrary(dir)`: sagas (story order) and songs from `Songs and Lyrics/EPIC - <Saga> (<year>)/`; `.lrc` preferred over `.txt`. |
| `src/content/homer.ts` | `splitBooks(text)`: Gutenberg Butler text → books (heading, summary, paragraphs). |
| `src/shared/lrc.ts` | `parseLrc(text)`: timed lyric lines, sorted. |
| `src/app/index.html`, `main.ts`, `style.css` | The app page: stage (canvas, frieze layer, caption, timeline) and panel. `main.ts` wires store → controller → world/curtain, panel, timeline, play/prev/next, keyboard (←/→, Esc), the no-WebGL frieze stage. `style.css` holds the design tokens (see `DESIGN.md`). |
| `src/app/state.ts` | `AppState` (`songId`, `tab`, `motif`, `timeline`, `ribbons`, `read`), `parseState`/`stateToSearch` (URL), `createStore` (push on song change, replace otherwise, `popstate` restores). |
| `src/app/voyage.ts` | `transitionFor(from, to)`: same saga → sail; adjacent saga → that saga's curtain; further → fade; plus the target's landing. |
| `src/app/controller.ts` | A new request starts at once (world and curtain cancel their running animation; older steps stop at their next await). Tracks what is on screen (`at`, `cover`) so a step never sails under the wrong saga's frieze. Under a curtain: cover → instant sail → start the landing → horizon on the frieze ground line → lift. Play voyage (3.4s per song, silent; any other navigation stops it), Go ashore, Back to sea. |
| `src/app/world/` | `createWorld(canvas)` (null without WebGL 2): `route.ts` (9 places, 40 stops, pure), stylised islands, ship, ribbons ("threads on the sea"), `landings/` cave, arena, hall. |
| `src/app/frieze/` | `friezeSvg(sagaId)` for the 9 sagas from a figure kit; `createCurtain(layer)`: drop, lift (part along the ground line, or zoom through a doorway), fade, `groundY()`. |
| `src/app/panel/` | `mountPanel`: About (facts, Odyssey reading view), Listen & lyrics (real player, line-synced lyrics, Watch), Paths (strip when the timeline is hidden, motif rows, nine-place chart), Fan works. |
| `src/app/timeline/` | `layoutTimeline` (pure: current saga expanded, others compact) and `mountTimeline`: stops, you-are-here, threads, hide/show, "Threads on the sea". |
| `src/app/content.ts` | Client for `/content/*`: library match by normalised title, lyrics (`.lrc` timed / `.txt`), Homer books, passage start by keyword. |
| `src/data/album/` | `sagas`, `songs` (40, album order, `landing`), `motifs` (character themes derived from singers, except Odysseus), `fanWorks` (empty in v1), query helpers. |
| `tests/` | `data.test.ts` (dataset invariants), `shared.test.ts` (DOM modules under happy-dom), `server.test.ts` (every route 200s, index links every registry slug, each mockup has the credits hook), `content.test.ts` (LRC, library scan, Homer split, `/content/*` routes on a temp fixture), `album.test.ts`, `state.test.ts`, `voyage.test.ts` (transitions, controller with fakes), `route.test.ts`, `frieze.test.ts`, `timeline.test.ts`, `panel.test.ts`, `app-content.test.ts`. |
| `Dockerfile` | Two-stage `oven/bun:1.3`: install + build, then copy `dist/` and run from `WORKDIR /app/dist`. |
## Data flow
1. Request hits `Bun.serve`; an HTML-import route returns the bundled page (dev: bundled on demand with HMR
via `bun --hot`; production: prebuilt assets from `dist/`).
2. The page's `main.ts` imports `src/data/*` and `src/shared/*` (bundled in; no runtime API).
3. `main.ts` renders the direction and calls `mountCredits()` into `<footer data-fan-credits>`.
2. The page's `main.ts` imports `src/data/*` and `src/shared/*` (bundled in) and calls `mountCredits()`.
3. App: the URL becomes `AppState`; every change goes through the store. The controller moves the stage
(curtain + world), and the panel and timeline re-render from the store. The panel fetches
`/content/library.json`, the song's audio and lyrics, and Homer books on demand; all of it is optional.
## External dependencies
None at runtime. Fonts (`@fontsource/*`) and three.js are bundled from npm. Outbound links only: YouTube
Optional at runtime: the content folder named by `CONTENT_DIR` (real audio, lyrics, cover art, the Odyssey and
Iliad: Butcher & Lang / Lang, Leaf & Myers preferred, Butler as fallback). It is mounted read-only, never committed or baked into the image (spec 003). Without it
the site works and `/content/*` answers 404. Otherwise none. Fonts (`@fontsource/*`) and three.js are bundled from npm. Outbound links only: YouTube
(video cards) and Project Gutenberg (Odyssey references) — the site works without them.
## Invariants
- Every page renders `FAN_NOTICE` via `mountCredits()`.
- `src/server.ts` routes, `src/mockups/registry.ts` and the cards in `src/index.html` list the same slugs
- `src/server.ts` routes, `src/mockups/registry.ts` and the cards in `src/mockups/index.html` list the same slugs
(`tests/server.test.ts`).
- Every motif id used by a part exists; every motif appears in at least one song (`tests/data.test.ts`).
- No two mockups ship byte-identical `style.css` (the build fails otherwise).
- The album has 40 songs whose titles and order match the content folder (`tests/album.test.ts`).
- No lyric text in the repo; lyrics are fetched from `/content/` at runtime only.
## Known rough edges
- The production bundle only runs with `dist/` as the cwd (`bun run start`, Docker `WORKDIR /app/dist`);
+57
View File
@@ -48,3 +48,60 @@ merge request. Both override the superpowers default paths.
the repo every session — measurably slower and more tokens).
**Consequences:** Rule changes happen in one file; pointer files must stay thin. Plans are working
artefacts: kept after merge they go stale and contradict the code, so the spec is the record.
## 2026-10-30 — three.js allowed in mockup 13
**Decision:** mockup 13 (Curtain call) may use three.js, like 06 and 07. It mocks the frieze → 3D sea →
frieze → cave transition, which needs the 3D sea. three.js is already a dependency; nothing new is added.
## 2026-10-30 — three.js allowed in mockup 14
**Decision:** mockup 14 (Voyage with side panel) may use three.js; it converges 13's 3D flow with a
persistent side panel. No new dependency.
## 2026-10-30 — three.js allowed in mockup 15
**Decision:** mockup 15 (Voyage, look 2) may use three.js; it iterates on 14. No new dependency.
## 2026-10-30 — three.js allowed in mockup 16
**Decision:** mockup 16 (Voyage, readable) may use three.js; it iterates on 15. No new dependency.
## 2026-10-30 — Real content is mounted, never committed (spec 003)
**Decision:** the real audio, timed lyrics, cover art and Homer texts stay in a folder outside the repo
(`~/epic-the-musical-content` on the dev host), served under `/content/` from `CONTENT_DIR` and mounted
read-only into the container. **Why:** the audio, lyrics and art are Jorge Rivera-Herrans' copyrighted
work; committing them would push them to Gitea and bake 1.5 GB into the image. The user chose this
("Mount it, never commit"). Range requests are handled in `src/content/routes.ts` so audio can seek.
## 2026-10-30 — three.js allowed in mockup 17
**Decision:** mockup 17 (Voyage with real content) may use three.js; it iterates on 16. No new dependency.
## 2026-10-30 — Homer in translations with Greek names
**Decision:** the Odyssey and Iliad are served from Butcher & Lang (Gutenberg #1728) and Lang, Leaf &
Myers (#3059) when present, falling back to Butler (#1727, #2199). **Why:** the user disliked Butler's
Roman names (Ulysses, Minerva, Jove); EPIC uses the Greek ones. Both are public domain.
## 2026-10-30 — No thread for Odysseus himself
**Decision:** the album data has no Odysseus character theme. **Why:** character themes are derived from
singers, and Odysseus sings in 35 of 40 songs, so his thread marked almost every stop and crowded the
timeline ("+28 more"). His story is carried by the musical motifs (Warrior of the Mind, Just a Man, …).
## 2026-10-30 — The voyage app (spec 002): structure and v1 choices
- **Album data is separate from the mockups' data** (`src/data/album/` vs `src/data/types.ts`/`sample.ts`),
so the 17 mockups keep working unchanged.
- **App at `/`, gallery at `/mockups/`.** The mockups stay served as the design record.
- **Odyssey references are a book plus keywords**, not line numbers: the public-domain prose translations
have no line numbers. Links point at Butcher & Lang (Gutenberg #1728).
- **Landings:** cave = Polyphemus, Survive; arena = God Games; hall = all five Ithaca songs.
- **Transitions:** same saga sails, the adjacent saga drops its frieze, further jumps fade. Last request
wins; a curtain left down by a cancelled step is lifted by the next one.
- **Play voyage is silent** (3.4s per song): starting audio without a click is close to autoplay.
- **Fan works are empty in v1** with an empty state; no invented works are shown.
- **Without WebGL** the frieze is the stage (dropped, never lifted), so the album stays browsable.
- **Back to sea** leaves a landing but keeps the song.
+1 -1
View File
@@ -22,7 +22,7 @@ or `AGENTS.md` Commands.
| `bun install --frozen-lockfile` | worked (no changes) |
| `bun test` | worked — 34 pass, 0 fail, 3 files |
| `bun test tests/data.test.ts` | worked — 10 pass |
| `bun test -t "registry has 7"` | worked — 1 pass |
| `bun test -t "registry has 17"` | worked — 1 pass |
| `bun run typecheck` | worked (`tsc --noEmit`, exit 0) |
| `bun run build` | worked — `dist/server.js` + hashed page assets |
| `bun run dev` (`PORT=3113`) | worked — `/` and all mockup routes 200, no-slash form 302 |
+19
View File
@@ -82,3 +82,22 @@ 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).
- Homer books: with Butler's fallback text, the last book (Odyssey XXIV) also swallows the translator's
notes after it. The preferred Butcher & Lang text does not have this problem.
- App v1 (spec 002) follow-ups:
- 97 album values are `// TODO(verify)` (story years, some singers, Odyssey books/keywords, facts).
- Parts and motifs are not time-aligned to the recordings; needs a mapping file in the content folder
(part → first lyric line). Then the lyrics can show part headings and motif colours per line.
- Passage starts in the Odyssey reading view are keyword guesses.
- Fan works: decide sourcing, credit and permission process; the data type exists, the list is empty.
- Mobile (<900px) is usable but not polished; no favicon yet (one 404 per page load).
- Some frieze figures are small at five songs per saga; Charybdis reads mainly as a ring from the ship.
- Timeline motif inks on parchment can look alike (Warrior / Thunder Bringer / Zeus).
- `tests/frieze.test.ts` covers the curtain only on the reduced-motion (crossfade) path; the parting
and doorway-zoom animations are checked by screenshots, not tests.
- Ribbon labels on the sea are not clickable (the world has no click callback).
+418
View File
@@ -0,0 +1,418 @@
---
status: in-progress (brainstorming, round 2)
---
# 002 — Direction round notes (after the 7 mockups)
A running record of the user's words and the decisions they lead to, kept so that nothing gets lost
between sessions. **User quotes are verbatim.** Lines marked *(Claude's proposal)* are suggestions
the user has not confirmed yet. Newest entries go at the bottom.
Context: spec `docs/specs/001-timeline-mockups.md` produced mockups 01–07 (merged to `main`,
2026-10-23). This round picks the direction for the real app.
## 2026-10-23
### 1. Mockups that were dropped
> "First off, a lot of good stuff. Lets start with the easy ones. Vertical story, constellation and
> score/waveform is too uninteresting."
- **Dropped:** 02 Vertical story, 05 Score/waveform, 06 Constellation.
- **Still in play:** 01 Editorial scroll, 03 Transit map, 04 Greek frieze, 07 Sea voyage.
- The dropped mockups' code stays in place until the direction is settled.
### 2. A 2D version alongside a 3D version
> "They are all very nice. I see both a 2D version, and an accompanying 3D version, The editorial
> scroll combined with the transit map in some way could be nice fo9r a 2D layout / map, while using
> the sea voyage in 3d together with the greek frieze in some way could be nice as well. The sea
> voyage is nice to start off, buth could then in the different islands and places have sublevels,
> eg. landings where we see the cave of the cyclops, we see athenas fight in god games, and odysseus
> ithica when he comes home."
- **2D:** editorial scroll combined with transit map, as a layout/map.
- **3D:** sea voyage combined with the Greek frieze.
- **The 3D starts on the sea voyage.**
- **Sublevels at islands and places ("landings"), with the user's examples:**
- the Cyclops' cave;
- Athena's fight in God Games;
- Odysseus coming home to Ithaca.
- **Implication:** the sample data must grow beyond Troy, Cyclops and Storm (Wisdom saga / God Games,
Ithaca saga) so that those landings have content.
### 3. The frieze as the transition between 2D and 3D
> "Hmm. Maybe we can have the freize's look be the one that tries the different stuff together, like
> a curtain call from the first section to go over in the second, where it starts off 2d with the
> freize, then slowly morphs into the sea voytage, then when he lands on cyclops islands, it goes
> back to the freize, then into the cave? For the looks, im not sure. On one hand I want the 2D map to
> be a browsable and perhaps simple display, yet I also want it to be a kind of map in the 3D one."
- **The frieze is the connecting device**, "like a curtain call" between sections.
- **The flow:**
1. The experience starts in 2D, on the frieze.
2. The frieze morphs into the 3D sea voyage.
3. At a landing (e.g. the Cyclops' island) it returns to the frieze.
4. From the frieze it goes into the 3D sublevel (the cave).
- **Looks:** undecided.
- **The 2D map has two roles:**
- a browsable, perhaps simple display;
- a map used *inside* the 3D experience.
### 4. The sea chart
*(Claude's proposal)* A single map serves both uses. On its own it is the browsable 2D page. Inside
3D it is the same chart, unrolled as an overlay on the captain's table. Clicking a port sails or
jumps there.
> "Yeah i did think sea chart, im just not sure how we have it easy to navigate and look at as with
> the editorial scroll and transit map."
- **Agreed:** the 2D map is a sea chart.
- **Open problem:** keeping the chart as easy to navigate and read as 01 and 03.
### 5. Sea-chart mockup round
> "Hmm sure. Lets do 3 or 4 mockups and see."
- **Four throwaway spikes on branch `feat/sea-chart-round`:**
- 08 Sea chart + log line
- 09 Schematic sea chart
- 10 Unrolling chart
- 11 Atlas with insets
- **Each spike also shows the chart as the compact in-3D overlay.**
- **Spike rules:** no formal review loop; the user picks from what they see.
### 6. Process requests
> "Remember to write my words and thoughts down, such that if context is autocompacted, we dont lose
> anything"
This file exists because of that request. It gets updated after every user message in this round.
### 7. Paused at the usage limit
> "we have almost reached usage limit. Make the agents save their work for now, so we can continue
> without losing progress."
**State when paused (branch `feat/sea-chart-round`, not pushed):**
| Spike | State |
|---|---|
| 08 | Only `src/mockups/08-chart-logline/geo.ts` is written: chart geography, route and log-line maths. Nothing imports it yet, and it has not been typechecked. `main.ts` and `style.css` are still stubs. |
| 09 | No code yet; the stub is unchanged. The design plan and resume checklist are written. |
| 10 | No code yet; the stub is unchanged. The design plan and resume checklist are written. |
| 11 | Not started. It is still a stub. Idea: an overview chart of the whole sea; clicking an island opens a zoomed inset chart with that saga's songs as ports. |
- **Brief and per-spike plans:** `docs/design/002-sea-chart-spikes/`. It holds `round2-brief.md` and one `<slug>-report.md` per spike, each with a design plan, a what's-left checklist and the next step. Agents should keep writing reports to `.superpowers/brainstorm/`, and the controller copies them into this folder.
- **Preview:** http://10.20.20.2:8099/ still runs the `main` build (01–07). It does not show 08–11 yet.
**To resume:**
1. Read this file and the brief.
2. Dispatch one agent per spike, at most 3 at a time. Point each at its report file and the brief, and tell it to continue from the checklist.
3. Build 11.
4. Rebuild the preview container from this branch:
`docker build -t epic-timeline . && docker rm -f epic-timeline-preview && docker run -d --restart unless-stopped -e PORT=3000 -e CONTENT_DIR=/content -v /home/user/epic-the-musical-content:/content:ro -p 10.20.20.2:8099:3000 --name epic-timeline-preview epic-timeline`
(the content mount was added 2026-10-30, spec 003)
5. Ask the user which chart approach reads best.
## 2026-10-29
### 8. Resumed
> "continue."
- Resumed from section 7. Spikes 08, 09 and 10 were redispatched (3 concurrent), each continuing from its saved report.
- Spike 11 follows when a slot frees.
- **All four spikes are finished and committed on `feat/sea-chart-round`.** Each final report is in `docs/design/002-sea-chart-spikes/<slug>-report.md`.
- 08 Sea chart + log line: a full Admiralty-style chart on top, synced to an editorial log line below.
- 09 Schematic sea chart: an octilinear transit diagram over a faint chart, with a slider that morphs it into rough true geography.
- 10 Unrolling chart: the editorial scroll drawn on a vellum portolan, with a ship marker and a minimap.
- 11 Atlas with insets: an overview of the whole voyage on one screen; each island opens an atlas plate.
- The preview at http://10.20.20.2:8099/ was rebuilt from this branch so the user can compare them.
### 9. Verdict on the sea-chart spikes
> "8 and 9 doesnt work too well. 11 work ssurprisingly well, but the mtro map when you zoom into
> stuff doesnt hit as well. The style of 10 is much cooler."
- **Dropped:** 08 Sea chart + log line, 09 Schematic sea chart.
- **Keep from 11:** the atlas *navigation*: the whole voyage on one overview, islands opening inset plates.
- **Not working in 11:** the transit-style ("metro map") motif lines inside the zoomed plates.
- **Keep from 10:** the *style*: vellum portolan, rods, red dashed route, ship, compass rose.
- *(Claude's proposal)* Converge into one mockup, **12 Vellum atlas**: 11's overview → plate
navigation drawn in 10's vellum style. The metro band inside plates is replaced, and the plate
motif treatment is a toggle inside the mockup with three options to compare:
- **A. Threads under the sea** (10/01-style): soft threads below the route, knots at ports.
- **B. Pennants at ports**: no lines; each port flies small coloured pennants for its motifs, and
a selected motif draws a faint sea-current curve linking its ports.
- **C. Ink only**: no motif marks on the chart until a motif is picked; then its ports are ringed
and named parts are written beside them in the motif's ink colour.
### 10. Paused again at the usage limit
> "We are going to hit 5 hour limit soon, wind down and wait for my go to continue."
- 12 Vellum atlas was being built by one agent from `docs/design/002-sea-chart-spikes/12-vellum-atlas-brief.md`.
It was told to stop and write a pause report (what's done / checklist / next step) to
`docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md`; its partial work is in
`src/mockups/12-vellum-atlas/` and committed as WIP on `feat/sea-chart-round` (not pushed).
- **To resume (only on the user's go):** read this file, the 12 brief and the 12 report; dispatch one
agent to continue from the report's checklist; check its screenshots; rebuild the preview
container (command in §7); tell the user 12 is live and ask A/B/C.
### 11. Resumed
> "All right, usage limits back. Continue."
- Resumed from §10: one agent continues 12 from its pause report.
- **12 is finished and committed** (`feat/sea-chart-round`, not pushed); final report in
`docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md`. Preview rebuilt with 01–12.
- *(Agent's recommendation, unconfirmed)* A as default with more room on busy plates, borrowing C's
hand-lettered part names for the picked theme. Known weak spot: A's faded labels on the 7-theme
Cyclops plate are ~9px.
### 12. Motifs inside a plate: threads
> "Threads, definetly."
- **Chosen:** treatment **A, Threads under the sea**, for motifs inside a plate. B (pennants) and C
(ink only) are deselected.
- Known weak spot to fix when it is built for real: faded thread labels on busy plates (~9px on the
7-theme Cyclops plate) need more room.
- *(Claude's proposal)* The 2D sea chart is now 12 Vellum atlas with threads. Next open questions are
the look of the whole and how the frieze/3D layers attach to this chart.
### 13. A short mockup of the transition
*(Claude's question)* The look of the whole: 1 vellum throughout, 2 two worlds (vellum chart and 3D,
terracotta frieze only at transitions; Claude's recommendation), 3 frieze-led. Offered a short
mockup of the transition.
> "Short mockup please :)"
- The user did not pick 1/2/3 yet, so mockup **13 Curtain call** shows the transition sequence with
the look as a toggle (1/2/3), defaulting to 2.
- Sequence: frieze (2D) → morphs into the 3D sea → ship reaches Cyclops' Island → back to the frieze
→ into the cave. The vellum chart (12) appears as the map on the captain's table at sea.
- three.js is allowed in 13 (it is already a dependency; it reuses 07's sea).
- **13 is finished and committed** (`feat/sea-chart-round`, not pushed); report in
`docs/design/002-sea-chart-spikes/13-curtain-call-report.md`. Preview rebuilt with 01–13.
Known rough spots: a small sideways camera shuffle at landfall, sun glare in looks 1/2, a boxy giant.
### 14. Verdict on 13 Curtain call
> "Yes it looks pretty good! Small design issues here and there but these can be fixed. 1, 2 and 3 all
> look decent. The UI of 2 and 3 tend to look a bit better though. For the models/3d, its better to go
> stylistic than realistic. While going through the 3D experience, its still important to have the
> right side window open, to show information, paths, lyrics, and fan works."
- **Accepted:** the curtain-call flow (frieze → sea → landfall → frieze → landing) works; small design
issues are fixable later.
- **Look:** all three are "decent"; the UI of **2 (two worlds)** and **3 (frieze-led)** looks better.
Look 1 (vellum throughout) is effectively out; 2 vs 3 is not decided.
- **3D models: stylistic, not realistic.**
- **New requirement:** during the 3D experience a **right-side window stays open**, showing:
- information (about the current song/place);
- paths (motif threads / the route);
- lyrics;
- fan works.
- *(Claude's proposal)* Converge into **14 Voyage with side panel**: 13's flow with stylised models and
a persistent right panel (tabs: About · Paths · Lyrics · Fan works), with a 2/3 look toggle.
Fan works and lyrics are placeholders (no real lyrics, no real fan artists' names or links).
- **14 is finished and committed** (`feat/sea-chart-round`, not pushed); report in
`docs/design/002-sea-chart-spikes/14-voyage-panel-report.md`. Preview rebuilt with 01–14.
Agent's choices: the panel stays open in all five beats (frieze beats too); the small vellum chart
moved into the Paths tab. Rough spots: empty space around the frieze band with the panel open, light
sea patches that read like lily pads, heavy outlines on the giant.
### 15. Verdict on 14: leaning look 2, paths too hidden
> "This is actually surprisingly really good work. The design for 3 really makes the whole site feels
> like it works together, both in terms of frieze, the side panel and the world. However, im leaning a
> lot more towards 2. The colors pop a lot more. The paths feel a bit too hidden away now. All in all
> though, its looking better and better"
- **Look:** leaning strongly to **2 (two worlds)**: "the colors pop a lot more".
- **What 3 does better:** it makes frieze, side panel and world feel like one site. Worth carrying
that cohesion into 2.
- **Problem:** the paths (motif threads) are "a bit too hidden away" inside the Paths tab.
- Not yet a final approval ("looking better and better").
- *(Claude's proposal)* Mockup **15 Voyage, look 2**: look 2 only, borrowing 3's cohesion (shared
frieze border/ornament and type across frieze, panel and HUD), and paths brought out of the tab, with
a toggle of three placements:
- **A. Pinned strip** — a compact thread strip always visible at the top of the panel, above the tabs.
- **B. Threads in the world** — the current song's motifs as coloured ribbons in the 3D sea,
trailing the ship and running ahead to where they return.
- **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.
- User asked to compare with 14: "can you open mockup 14 again so i can compare?" (14 is still served
unchanged at /mockups/14-voyage-panel/.)
### 16. 14 vs 15 compared
> "Thanks. First off, 15's font is very hard to read. 15 Accents with the freisa patterns on all the
> menus are very nice though, and the colors on the "between sagas a year at sea" looks nice. The hard
> question is paths A, B and C, cause they all have their strengths. In terms of menu, listen should be
> easier to do, and perhaps closer tied to the lyrics tab."
- **Fix:** 15's typography is "very hard to read" (the small, wide-tracked Cormorant capitals used for
labels, and small SVG text). Go back to readable labels (14's style) at comfortable sizes.
- **Keep:** the frieze-pattern accents on all menus/panels (15).
- **Keep:** the colours of the "Between sagas · a year at sea" beat (15's beat 2).
- **Paths A/B/C:** undecided, "they all have their strengths".
- **Listen:** should be easier, and tied more closely to the Lyrics tab.
- *(Claude's proposal)* Mockup **16** from 15:
- readable type: Inter for all labels/UI (no wide-tracked small caps), Cormorant only for large titles;
nothing under 13px;
- **Listen + Lyrics as one tab**: a player at the top of the tab (play, scrub bar with part markers),
lyrics by part below; clicking a part jumps there, the current part is highlighted as it plays;
plus a play button always visible in the panel header;
- **paths combined rather than chosen**: C's timeline is the default home and can be collapsed to a
slim bar; when collapsed, A's strip appears in the panel; B's ribbons in the sea are an on/off
switch ("Threads on the sea").
### 17. Real content added outside the repo
> "We have also added audio files, as well as a full version of Homer's Oddysey and Illiad to the
> epic-the-musical-content directory in home."
What is in `~/epic-the-musical-content/` (1.5 GB, checked 2026-10-30):
- `Songs and Lyrics/EPIC - The <Saga> Saga (<year>)/`: all 9 sagas, **40 songs as FLAC**, **37 `.lrc`
timed-lyrics files** (3 songs have none), plus `album.nfo` (saga blurb, fandom-wiki credited) and
`folder.jpg` cover art. File names: `Jorge Rivera-Herrans - EPIC - The <Saga> Saga - NN - <Title>.flac|.lrc`.
- `Homers the Odyssey Book/The Odyssey.txt` and `The Illiad.txt`: Project Gutenberg #1727 and #2199,
**Samuel Butler's prose translations** (public domain). Being prose, they have books and paragraphs,
not line numbers, so the sample data's "Book 9, lines 105–460" references will not map onto them.
*(Claude's notes)*
- The `.lrc` files make **synced lyrics** possible: the Listen & lyrics player can highlight the
current line, not just the current part.
- The Odyssey text makes the "real place in the Odyssey" link able to open the actual passage.
- The audio, lyrics and cover art are Jorge Rivera-Herrans' copyrighted work. AGENTS.md currently says
"lyrics are placeholders, never real lyrics". Proposed: keep this folder **out of git and out of the
Docker image**; the server reads it from a `CONTENT_DIR` path, mounted read-only into the container.
Needs the user's OK (it touches `src/server.ts`, the Dockerfile and the lyrics rule).
- **User's answers (2026-10-30):** content access: **"Mount it, never commit"**; next step: **"Yes, in 17
after 16"** (16 finishes with its simulated player, then 17 uses real FLAC playback, line-synced
`.lrc` lyrics and the actual Odyssey passage). Implemented as spec `docs/specs/003-content-mount.md`.
- Note: Butler's translation uses Roman names (Ulysses, Minerva, Jove). Worth a line in the UI.
- **16 is finished and committed**; report in `docs/design/002-sea-chart-spikes/16-voyage-readable-report.md`.
Preview rebuilt with 01–16 and the content folder mounted. Known: timeline at its height budget with
3 motif rows; the panel strip still shows numbers only; ribbon labels sit on the mountain face.
### 18. Paused at the usage limit (third time)
> "Wind down current session and wait for usage limit reset."
**State (branch `feat/sea-chart-round`, not pushed, working tree clean):**
- 01–16 committed; preview at http://10.20.20.2:8099/ runs 01–16 with the content folder mounted
(`/content/library.json`, audio with seeking, Homer books all verified 200/206; no content in the image).
- Spec 003 (content mount) implemented and committed; `docs/plans/003-content-mount.md` must be `git rm`'d
before a merge request.
- **Not started:** 17 (real audio, line-synced `.lrc` lyrics, the actual Odyssey passage), agreed by the
user as "Yes, in 17 after 16". No agents are running.
- The user has not yet reacted to 16.
**To resume (only on the user's go):** read this file; show the user 16
(http://10.20.20.2:8099/mockups/16-voyage-readable/) and ask for their reaction; then wire slot 17
(server route, registry, index card, count test 16→17, AGENTS/PERMISSIONS count refs, three.js note)
and brief one agent from a copy of 16, using `/content/library.json`, `parseLrc` from
`src/shared/lrc.ts` and `/content/homer/odyssey/<book>.json`. Its test server must run with
`CONTENT_DIR=/home/user/epic-the-musical-content`.
### 19. Resumed
> "Continue."
- Resumed from §18. 17 (real content) is started now, as agreed; the user's reaction to 16 is asked
in parallel.
- **17 is finished and committed**; report in `docs/design/002-sea-chart-spikes/17-voyage-real-report.md`.
Real FLAC playback with seeking, line-synced lyrics (click a line to seek), the Butler Odyssey passage
in a reading view, fallback to 16 without the folder. Agent's choices: "Play voyage" stays silent (no
excerpts, avoids near-autoplay); part buttons dropped in real mode (sample part times don't match the
recordings). Open: passage start points are keyword guesses; parts/motifs aren't timed to the
recordings yet (report proposes a small mapping file in the content folder).
### 20. Direction approved
*(Claude's question)* "If this is the direction, say so. I'll then write it up and build the real app
(full album, all sagas) without stopping for more reviews…"
> "Yes. But listen wit hthe lyrics and watch should probably both be in the listen & lyrics tab. Threads
> on the sea toggle works nicely. If hide timeline is off, maybe just put it into paths or something."
- **Approved:** the direction of 16/17 (look 2, frieze accents, curtain-call voyage, stylised 3D,
side panel, bottom timeline with threads, real content) → build the real app. Per the user's global
rule: spec → plan → execution with no extra review stops; report decisions at the end.
- **Changes on top of 17:**
- "Listen with the lyrics" and **Watch** both live in the **Listen & lyrics** tab (not in About).
- **"Threads on the sea"** toggle: keep as is ("works nicely").
- When the timeline is hidden, the motif strip goes **into the Paths tab** (not pinned above the tabs).
### 21. Greek names, not Roman
> "I dont like that Samuel Butler uses Roman names instead, is there a version with original names we
> can download on Project Gutenberg?"
- Yes. Downloaded into the content folder (Butler's files kept as a fallback):
- `The Odyssey (Butcher and Lang).txt`: Gutenberg #1728, S. H. Butcher & Andrew Lang (1879): Odysseus,
Athene, Poseidon, Zeus.
- `The Iliad (Lang, Leaf and Myers).txt`: Gutenberg #3059, Lang, Leaf & Myers: same Greek names.
- Checked by counting names: Butcher & Lang has 718× "Odysseus", 0× "Ulysses"; Pope's and Cowper's
translations also use Roman names.
- The server now prefers these (`src/content/routes.ts`); the app drops the Roman-names note.
### 22. Resumed after a PC restart
> "Continue work. We had to restart the pC."
- Build state (branch `feat/voyage-app`, not pushed): Task 1 album data, Task 2 shell/state, Tasks 3+4
content client + panel are committed. Tasks 5 (timeline) and 6 (world/landings/friezes) were
interrupted mid-way; their partial files were on disk and both agents were resumed from their
transcripts. Next: Task 7 (controller), Task 8 (docs, verify, deploy).
### 23. v1 of the app built
- Spec `docs/specs/002-voyage-app.md` implemented on `feat/voyage-app` (not pushed): the app at `/`,
gallery at `/mockups/`, 40 songs / 9 sagas, 3D voyage with nine places, three landings, nine friezes,
side panel, timeline, real content. A fresh whole-branch review found controller race bugs and gaps
(Go ashore, focus, narrow timeline); all fixed and re-verified in the browser.
- Preview rebuilt from `feat/voyage-app` at http://10.20.20.2:8099/.
### 24. Pushed; next: fixing the scenes
> "yes. Its a really good start and foundation, but there are a lot of things we can start to fix in the
> different scenes"
- `feat/voyage-app` pushed to Gitea (it includes the unpushed `feat/sea-chart-round` commits it is
based on).
- Next round: scene-by-scene fixes. The user has specific issues in mind; collect them per scene.
## Open questions (in priority order)
1. ~~Which sea-chart approach (08–11) reads best, or which mix?~~ 11's navigation + 10's style (§9).
Then A/B/C inside plates: **A threads** (§12).
2. What is the look of the whole? Leaning strongly to look 2, with 3's cohesion (§15). Where do paths live (15's A/B/C)?
3. How do 2D and 3D relate as products? Partly answered: in 3D a right-side panel stays open with
info, paths, lyrics and fan works (§14). Whether the chart also stands alone is unconfirmed.
3b. Fan works: what are they (art, covers, animatics?), where do they come from, and how are they
credited? No data model for them yet.
4. Which landings are in scope for the first build? The user's examples are the Cyclops' cave, God
Games and Ithaca.
5. Earlier, still open from 001: lyrics copyright (deferred), branching policy, linter, and what
happens to dropped mockups.
@@ -0,0 +1,69 @@
# 08 Sea chart + log line: report
**Status:** done. Not committed.
## The navigation idea
The screen is split in two:
- **Top two thirds: an Admiralty-style chart that shows *where*.** It has buff land with hachure and shallow-water bands, soundings, a graticule and a compass rose. The course is drawn in chart magenta, and songs are numbered ports.
- **Bottom third: a horizontal log line in the style of 01 that shows *when*.** Gap length tracks in-story years (156px per song plus 300px per year). Years at sea are drawn as a magenta swell with a "1 year at sea" label. The year boundaries are marked "year 0/1/2/3" along the top. Seven motif lanes run underneath.
The two halves are synced:
- Hovering or dragging along the log moves the ship along the course, and the chart zooms to 1.5× around the ship. The cursor shows a readout such as "year 1, Warrior of the Mind" or "year 2, at sea". 600ms after you leave the log, the chart eases back out.
- Clicking a port selects the song and smooth-scrolls the log to it.
- Clicking a log entry selects the song and focuses the chart on that port. The port sits at about 36% of the chart width, so the 380px detail panel doesn't cover it.
**Motifs** are selected from a sticky legend or from the chips in the detail panel.
- Course legs where both songs carry the motif turn solid in the motif's colour. Legs where the motif skips a port are dotted.
- Ports that carry the motif get a ring, and the rest fade.
- The other lanes in the log fade.
- Each lane shows one box per part, filled where the motif plays, so motifs read at part level.
**Overlay** (`?overlay=1`): the same chart and log sit in a compact 1180×740 panel over a dark sea gradient, under a "You are here: Survive" bar.
- Clicking a port opens a popover beside it, with the port, title, the time ahead or back, and a **Sail here** button. The button animates the ship along the course and updates "You are here".
- Scrubbing the log shows a dashed probe ring instead of moving the ship.
- The credits are fixed at the bottom of the viewport.
URL state is kept in `?song`, `?motif` and `?overlay` via `replaceState`.
## What works
- **Where and when are both readable at a glance, with no hunting.** The chart shows all 10 port titles and the saga sea-areas. The log shows every title, story label, saga bracket and year boundary.
- **Scrubbing is the strongest part.** It plays the voyage back like a film strip, and the long swell between Open Arms and Warrior of the Mind physically shows the lost year.
- **Motifs read well on both halves.** For example, Warrior of the Mind is solid only on Warrior→Polyphemus and dotted elsewhere.
- **The overlay proves that one chart serves both uses.** I verified "Sail here" from Survive to My Goodbye end to end.
- **Every sync path was verified in headless Chrome over CDP:**
- log scrub → chart `viewBox` and ship;
- port click → `?song=`, log `scrollLeft`, and the detail panel;
- overlay port → popover → sail.
## What doesn't work (yet)
- **The log is a scroll strip,** so only about 6 of the 10 songs are visible at once. "When" is never in full view the way "where" is. A minimap or a squeeze-to-fit mode would help.
- **Saga bracket names scroll under the sticky legend** and get clipped (for example "…Cyclops Saga" in the overlay). They should stick to the left edge.
- **Labels on steep course legs are hard to read.** The "1 year at sea" label on the chart follows the leg, so it reads at a steep angle.
- **The ship rides offset away from each port's label,** so at Survive it sits on the island's shore band.
- **The overlay has no idea of 3D camera context.** It is a gradient stand-in only.
- **Desktop 1440×900 only.** Nothing is responsive.
- **The port label offsets in `geo.ts` are hand-tuned.** Real data with more songs would need a label-placement pass.
## Screenshots
- `screenshots/08-chart-logline-default.png`
- `screenshots/08-chart-logline-motif.png` (`?motif=warrior`)
- `screenshots/08-chart-logline-song.png` (`?song=polyphemus`)
- `screenshots/08-chart-logline-overlay.png` (`?overlay=1&song=my-goodbye`)
- `screenshots/08-chart-logline-scrub.png` (extra: mid-scrub, chart zoomed onto the ship)
## Checks
- `bun run typecheck`: passes.
- `bun test`: 41 pass and 1 fail. The failure is `registry has 7 mockups` in `tests/server.test.ts`. It already fails at HEAD, because commit 52feed5 grew the registry to 11 without updating the test. That is outside this spike's folder.
- **Build:** a scratch outdir build of the real tree succeeds.
- Midway through, the build failed with "Multiple files share the same output path" because spikes 09, 10 and 11 had identical in-progress CSS.
- While that was happening, I built from a scratch copy, adding a marker rule to their CSS in the copy only.
- That collision has since cleared.
@@ -0,0 +1,97 @@
# 09 — Schematic sea chart (`09-chart-transit`): report
Status: **done**. Built from the saved plan. `bun test` (42 pass) and `bun run typecheck` pass. The build
goes to the scratchpad outdir `r2-09-chart-transit`, served on port 4109. Nothing is committed.
Files: `src/mockups/09-chart-transit/layout.ts` (new), `main.ts`, `style.css`. `index.html` is unchanged.
## The navigation idea
The ship's course is straightened into a transit-map schematic, like mockup 03:
- x is story time: a hop of 92 between songs, +100 per year, +70 when the saga changes.
- Each saga is a horizontal row, and the rows are joined by single 45° diagonals in the gaps between
ports. Every port therefore sits on a horizontal, and every label is level and unrotated.
- Under the course is a faint Admiralty-style chart: pale water, rhumb lines from a compass rose, buff
"saga islands" with a shoal band and dashed depth contours, and a dashed storm cell at Storm.
Motifs run as coloured lanes parallel to the magenta course:
- Character themes run on the left of the course and musical motifs on the right, each in a fixed lane.
- A lane is solid where the motif plays in consecutive songs. A single song gets a ~30px stub.
- A dotted lane means the theme is silent there and returns later.
- Each port is a capsule across the course and every lane that calls there.
A slider (with "Schematic" and "True geography" end buttons that animate) morphs every sample of the
course to a rough equirectangular Aegean–Ionian map. Land crossfades to coastlines, a graticule fades
in, lanes pull closer to the course, and names give way to track numbers.
## What works
- **Readable at a glance (the round's question).** At the default 1440×900, all ten song titles,
three saga names, every in-story time label, the year tags (Year 0, 1, 3) and the gap notes ("a year
passes", "two years pass") are visible together with no hunting. Straightening the course is what
makes this possible.
- **Motif tracing is very clear.**
- Selecting a theme fades the other lanes to 13% and rings its ports in the theme colour.
- Ports that don't carry it are greyed.
- The key prints "plays in 5 songs: …".
- The dotted "returns later" stretches show when a theme goes silent and comes back. 03 couldn't
show that.
- **One chart serves both uses.** At `?overlay=1` the same SVG renders inside a ~1180×650 card over a
dimmed sea gradient:
- the viewBox is cropped to the course;
- time labels are hidden and the key becomes chips;
- a magenta ship marker reads "You are here: Survive";
- clicking a port opens a popover with **Sail here** (the ship animates along the course and the
pill reads "Sailing to Storm", then "You are here: Storm") and **Song details**.
- **The morph earns its place.** At `?morph=1` you see that the voyage really doubles back westward,
and the slider makes the cost of geography obvious: ports 06–09 cluster at Messina and their names
can't fit.
- **Detail drawer.**
- Contents: track and saga, title, story time with years after Troy, Odyssey link, theme chips (which
select the motif), parts with singers and motif dots, `createAudioSlot` and `createVideoCards`.
- The chart pans left when the open port would sit under the drawer, e.g. Storm.
- **Interactions and accessibility.**
- URL state: `?song=`, `?motif=`, `?overlay=1`, plus `?morph=0..1` and `?pop=<id>` for screenshots.
- Escape closes the popover, then the drawer, then the motif.
- Ports are keyboard-focusable.
- Reduced motion is respected.
- **Credits.** The credit is fixed at the bottom and fully visible in every state.
- **Verification.** Click flows were checked over Chrome DevTools: port → drawer, key → motif,
geography button → morph=1, overlay popover → Sail here → arrival, and popover → details. No console
errors.
## What doesn't work (or is weak)
- **Geo mode is a demo of the trade-off, not a usable view.**
- The four Troy ports and the four Cyclops ports bunch up.
- Capsules overlap at the turnarounds, and you only get track numbers.
- The midpoint of the morph (~0.5) is messy: big rotated capsules and two land layers crossfading.
- **The coastlines are hand-drafted and rough.** Hellas and Thrace are one blob, and the Peloponnese
is crude.
- **Song positions are illustrative.** Geo positions per song are rough guesses: Troy and Ismarus,
off Cape Malea, the cave on Sicily's east coast, and Storm north-west of Sicily. They are not
checked against the musical.
- **Interchanges were redefined.** In the sample data every song has 2 or more themes, so I made an
"interchange" mean **3 or more** themes meet. Otherwise every port would be one.
- **Horizontal space is tight.** The hop of 92 only works because titles wrap onto two balanced lines.
The full album (~40 songs) won't fit one screen at this density, so it would need horizontal
panning like 03, or rows that wrap per saga.
- **Many dotted lanes.** Seven lanes plus dotted returns along the whole course get busy. Without a
motif selected, the dotted returns compete a little.
- **Small chart details.**
- In overlay mode, the "N" of the compass is cropped and the ship marker covers the Survive capsule.
- The Odysseus colour was pushed to a brown (contrast 4.6) so it doesn't collide with Warrior gold.
It is no longer the data colour `#f2e3b3`.
- **The key covers some land.** The key panel covers the south-west corner of the Troy island, and the
western lon labels in geo mode.
## Screenshots
- `screenshots/09-chart-transit-default.png`
- `screenshots/09-chart-transit-motif.png` (`?motif=warrior`)
- `screenshots/09-chart-transit-song.png` (`?song=storm`, chart panned clear of the drawer)
- `screenshots/09-chart-transit-overlay.png` (`?overlay=1`)
- Extras:
- `screenshots/09-chart-transit-overlay-pop.png` (Sail here popover)
- `screenshots/09-chart-transit-geo.png` (`?morph=1`)
- `screenshots/09-chart-transit-mid.png` (`?morph=0.5`)
@@ -0,0 +1,56 @@
# 10-chart-scroll: Unrolling chart (final report)
Status: done. The spike is built and was checked in headless Chrome at 1440×900. Nothing is committed.
## Navigation idea
This spike uses mockup 01's reading model, drawn as a portolan sea chart on a vellum roll.
- **The route.** Songs sit as ports on one dashed red route that runs left to right. Each song is 220 units after the last, and every in-story year adds 600 more, so the length of open water stands for elapsed time.
- **The route curve.** The route is a cubic Hermite spline, y = f(x). It curves gently but can never double back.
- **Time labels.** "A year passes" and "two years pass at sea" are written along the water. A nautical scale bar doubles as the time axis, with labels such as "The year Troy falls" and "Year 1 after Troy".
- **Motif threads.** Threads sit beneath the sea, as in 01. A fixed legend on the left has rows aligned to the threads.
- **The ship.** The ship rides the route at the scroll position, so x = first port + span × scroll fraction, and it turns with the route's tangent. At any given scroll position the ship is always in view and sits exactly on a port.
- **The minimap.** A thin minimap at the top shows the land, the route, the saga folds, a viewport frame and the ship marker. Click or drag it to jump.
- **Unrolling.** Rods sit at both edges and cast shadows, and the chart unrolls once from the west on load.
## What works
- **Reading at a glance.** In the default view, the titles, the italic story-time labels, the saga names and the year scale can all be read without hunting.
- **Time as distance.** Elapsed time reads as distance sailed. The two-year gap before Storm is a long, empty stretch of open sea with rocks, a compass rose and the storm swirl.
- **Saying where you are.** The ship and the caption ("Moored at Polyphemus. The island of the Cyclopes, in The Cyclops Saga.") answer "where am I?" more clearly than a scrollbar does. The minimap ship marker carries the same answer to the whole-voyage view.
- **Selecting a motif.** Choosing a motif from the legend, or with `?motif=`, does the following:
- lights its thread and labels each knot with its part names (for example "Verse 1, Verse 2");
- rings the matching ports;
- thickens the route around each port where it appears;
- tints those ports on the minimap;
- scrolls to the motif's first appearance.
- **One chart, two uses.** The `?overlay=1` view is the same component at scale 0.62, in a frame over a dimmed CSS sea. It shows a "You are here: Survive" tag, and clicking a port opens a popover with "Sail here", which animates the ship along the route.
- **Input.** Dragging, the vertical wheel (it scrolls sideways), the left and right arrow keys (they step from port to port) and Enter (it opens the port) all work.
## What doesn't
- **Overlay text is too small.** At 0.62 scale, the overlay's port titles and story times are about 11–12px. They are legible but cramped, so a real in-3D overlay would need labels that don't scale down with the chart, or a shorter crop.
- **Little of the voyage fits on screen.** Only about 4 to 5 ports fit across the viewport at a time. A big story gap, such as the one before Storm, can fill most of the screen with empty sea. That is honest about time, but you see little at once, and the minimap has to do the work of the overview.
- **The ship's speed isn't constant.** The ship moves linearly with scroll, so it seems to "lag" or "lead" the visible centre at the ends of the roll. That makes sense, but it surprises people at first.
- **The drawer covers part of the chart.** The song drawer covers the right third of the chart. The ship stays visible because it moors at the port, but ports to the east are hidden while it's open.
- **The unroll animation isn't reliable everywhere.**
- In headless Chrome it sometimes catches mid-unroll, or it doesn't play at all inside the overlay frame. For that reason the overlay skips the animation.
- Use `--virtual-time-budget=4000` for screenshots.
- In a real browser it should behave, but I didn't verify that interactively.
- **Unverified geography.** The geography is decorative, as in 07. The islands are seeded blobs and the coast of Troy is a wave function. That makes it portolan-flavoured, not accurate.
## Screenshots
- `screenshots/10-chart-scroll-default.png`: the Troy Saga, with the ship moored at the first port.
- `screenshots/10-chart-scroll-motif.png`: `?motif=warrior`.
- `screenshots/10-chart-scroll-song.png`: `?song=polyphemus`, with the drawer open.
- `screenshots/10-chart-scroll-overlay.png`: `?overlay=1`, with "You are here: Survive".
- `screenshots/10-chart-scroll-overlay-sail.png`: `?overlay=1&song=remember-them`, showing the "Sail here" popover. This one is extra.
- `screenshots/10-chart-scroll-storm.png`: `?song=storm`, showing the open-sea end and the storm swirl. This one is extra.
## Files
- `src/mockups/10-chart-scroll/chart.ts`: the geometry, the route spline and the coastlines.
- `src/mockups/10-chart-scroll/main.ts`: the page.
- `src/mockups/10-chart-scroll/style.css`
- `index.html` is unchanged and keeps the credits footer and the `credits.css` link.
## Checks
- `bun run typecheck` passes.
- `bun test` gives 41 passing and 1 failing test. The failure is `registry has 7 mockups`. It predates this spike: the registry already had 11 entries at HEAD (commit 52feed5 added slots 08–11), and the test lives in a shared file I'm not allowed to touch.
- **The full build fails, but not because of this spike.** The repo-wide `bun build` currently fails with "Multiple files share the same output path". The cause is that the 09 and 11 `style.css` stubs minify to identical output, since only their comments differ. To verify this spike, I built from a scratchpad copy where only those stubs were disambiguated. The 10 stylesheet is unique.
@@ -0,0 +1,60 @@
# 11-chart-atlas: Atlas with insets (final report)
Status: done. Built into my own outdir, served on port 4111, checked in headless Chrome at 1440×900 and driven with Playwright (clicks, arrow keys, Esc, Sail here). `bun test` (42 pass) and `bun run typecheck` pass. The style.css is unique even with comments stripped. Nothing is committed.
## Navigation idea
Two-level navigation, like a bound atlas: a general chart of the whole voyage, then one inset plate per saga. This mirrors the planned 3D landings.
- **Look.** Slate book cloth around a cool paper plate. The plate has a checkered degree neatline, engraved water-lining around the coasts, and hand-tinted islands (Troy rose, Cyclops' Island sap green, the storm a prussian wash). It deliberately avoids 08's pale Admiralty look, 09's white schematic and 10's brown scroll.
- **General chart (overview).** The whole voyage is on screen at once, with no scrolling: the Troy mainland, Cyclops' Island, and the open sea with its storm spiral, joined by a dashed course with numbered ports.
- Each place has a label block with the place name, saga and plate number, song count and year span ("5 songs, years 0 to 1"), plus a numbered track list. That way every song title can be read from the overview.
- "A year passes" and "two years at sea" are written on the crossings.
- A cartouche reads "The voyage from Troy … Choose an island to open its plate."
- **Motif at overview level.** Islands where the selected motif plays get a halo in the motif colour and a count badge ("Polyphemus plays in 2 of 4"). Places where it never plays dim and get a dashed "… is silent here" badge. Matching ports and track rows are tinted.
- **Inset plates.** Clicking an island animates the SVG viewBox into that island (about 0.5s, skipped under reduced motion), then swaps in its plate.
- Each plate has a title cartouche ("Plate II / Cyclops' Island / The Cyclops Saga, tracks 6 to 9 / Year 1") and a locator key map of the general chart, top right, with the plate's area boxed. Clicking the locator takes you back out.
- The **scale bar is a scale of story time**: alternating bar segments sit under the ports, with year ticks where the year changes and "a year passes" written on the gap.
- Songs are ports on a straight local course, each with its track number, title and italic story time. A new year drops the course by one diagonal step, and the year gap is stretched.
- **Motif lines, transit-style.** Each theme that plays on a plate runs parallel to the course. A line is solid next to the ports where it plays and dotted where it is quiet between them. Dotted stubs follow the course to the plate edge when the theme plays on another plate. A station stem with coloured dots shows which themes play at each port. Selecting a theme thickens its line and dims the other lines and the ports that lack it. The themes panel shows counts as "on this plate / in the whole voyage", and themes absent from the plate are greyed.
- **Navigation.**
- Breadcrumbs ("Aegean › Cyclops' Island") take you back out.
- Prev and next buttons in the header are labelled with the plate ("‹ I. Troy", "III. Open sea and storm ›").
- ←/→ turn plates with a slide; → from the overview opens plate I.
- Esc closes a song, then returns to the Aegean.
- The course runs off the plate edge with "From plate I, Troy" and "Continued on plate III, Open sea and storm", and both notes are clickable.
- **Song detail.** A bottom "slip" with three columns (identity, parts with singers and motif ticks, then theme chips, `createAudioSlot` and `createVideoCards`) slides up over the lower third of the inset. The course and ports stay visible above it.
- **Overlay (`?overlay=1`).**
- The overview sits compact on a wooden captain's table over a dimmed CSS sea.
- The header carries a "You are here | Survive" tag, and a ship marker on the chart reads "You are here: Survive".
- Clicking an island opens its inset inside the table.
- Clicking a port opens a popover with "Sail here" and "Open song". Sailing moves the ship and the tag, and shows "Course set for Storm. In the 3D voyage the ship sails there now."
- **URL state.** `?saga=`, `?song=` (opens its plate and the slip), `?motif=` and `?overlay=1` are supported, and the URL stays in sync as you navigate.
## What works
- **The overview answers "what is in this voyage?" in one look.** It shows three places, ten titles, year spans and the passage of time, with no scrolling and no hunting. This is the strongest part of the spike.
- **Motif selection at two levels.** On the overview you see which sagas a theme touches and how often. On a plate you see exactly which songs, transit-style. The two levels complement each other rather than repeat.
- **The atlas furniture is real wayfinding, not decoration.**
- The locator key map, the breadcrumbs, the "continued on plate" notes and the plate numbers all say where you are and how to move.
- The scale bar doubles as the time axis.
- **Insets map well to 3D landings.** Each plate is the 2D twin of a landing. The overlay reuses the same component with an island click leading to a plate, which is the flow the 3D would need.
- **Legibility.** Port titles on the plates are 23px Cormorant, and story times are 16.5px italic.
## What doesn't
- **Uneven plates.** The Ocean plate holds a single song (Storm), so it is mostly empty sea. Sagas vary a lot in size, and with the full album some plates would hold 6 or more songs. The inset spacing caps out at about 6 ports per plate before titles crowd.
- **Most motif lines read as dotted.** With the sample data, few themes play in consecutive songs, so most of the band is quiet. The half-solid treatment helps, but the band is busier and less striking than mockup 03.
- **The slip covers the island art.** The slip hides the lower half of the plate (the land shape and the scale bar). The course stays visible, but the plate loses its "map" feel while a song is open.
- **Overlay text is small.** On the table the overview's track lists are about 12px: readable but cramped. The plates read fine in the overlay.
- **One collision left.** On the overview, the "a year passes" note brushes the course loop between ports 5 and 6. The decorative geography (seeded blobs) is not accurate.
- **Two clicks to reach any song from the overview** (island, then port). This is the cost of the two-level model. The overview track list could be made directly clickable to skip a step; I did not do that.
- **Zoom transition not seen live.** The zoom was only verified headless (the end state is correct); I did not judge the motion's feel in a real browser.
## Screenshots
- `screenshots/11-chart-atlas-default.png`: the general chart.
- `screenshots/11-chart-atlas-motif.png`: the general chart with `?motif=polyphemus` (halos, counts, the silent badge on Troy).
- `screenshots/11-chart-atlas-song.png`: the Cyclops' Island plate with the Survive slip open.
- `screenshots/11-chart-atlas-overlay.png`: the captain's table, "You are here: Survive".
- Extra:
- `screenshots/11-chart-atlas-inset.png`: plate I, Troy.
- `screenshots/11-chart-atlas-ocean.png`: plate III with `?motif=warrior`.
- `screenshots/11-chart-atlas-overlay-inset.png`: the Cyclops plate inside the overlay.
- `screenshots/11-chart-atlas-overlay-sail.png`: the Sail here popover.
- `screenshots/11-chart-atlas-overlay-sailed.png`: after sailing to Storm.
@@ -0,0 +1,60 @@
# 12 Vellum atlas — converge brief
## Why this mockup exists
The user's verdict on spikes 08–11 (verbatim):
> "8 and 9 doesnt work too well. 11 work ssurprisingly well, but the mtro map when you zoom into
> stuff doesnt hit as well. The style of 10 is much cooler."
So 12 combines:
- **11's navigation**, unchanged in structure: the whole voyage on one overview, each island opening
an inset plate, plate furniture (cartouche, locator key map, breadcrumbs, prev/next, "continued on
plate" notes, the scale of story time), ←/→/Esc keys, the bottom song slip, `?saga= ?song= ?motif=
?overlay=1` and URL sync, the overlay with "You are here: Survive" and "Sail here".
- **10's look**: the warm vellum portolan — paper texture, rods/rolled edges, the red dashed route,
the ship marker, compass rose and rhumb lines, its typography and palette. Read
`src/mockups/10-chart-scroll/style.css` and `chart.ts` and copy freely.
- **Not** 11's slate book cloth, cool paper, prussian/sap-green tints, and **not** 11's transit-style
motif band inside plates — that is the part that "doesn't hit".
## The open question: motifs inside a plate
Add a small, always-visible toggle on plates ("Themes on the plate: A · B · C", also `?motifs=a|b|c`,
default `a`) with three treatments:
- **A. Threads under the sea** (10/01-style): soft, slightly wavy threads drawn beneath the route in
each motif's colour, with knots at the ports where it plays. No dotted "quiet" segments, no
station stems. Selecting a motif lights its thread and labels its knots with part names; the rest fade.
- **B. Pennants at ports**: no lines at rest. Each port flies small coloured pennants (one per motif it
carries). Selecting a motif hides other pennants and draws one faint sea-current curve linking that
motif's ports, continuing off the plate edge if it plays on other plates.
- **C. Ink only**: no motif marks until a motif is picked. Then its ports are ringed in the motif's ink
colour and the part names are written beside each port in a hand-lettered italic, like a navigator's note.
The overview's motif behaviour from 11 (halo + "plays in 2 of 4" badges, "silent here") stays, restyled.
## Also fix from 11's report (cheap ones only)
- Make the overview track-list rows directly clickable (open the plate *and* the song: one click).
- The Ocean plate is near-empty: let the open-sea plate fill with 10's open-water dressing (rhumb
lines, compass rose, storm swirl, "two years at sea") so it feels intentional rather than empty.
- The slip should not hide the whole island: keep it compact (max ~38% height) or let the plate
shift up while it is open.
## Rules
Same as `docs/design/002-sea-chart-spikes/round2-brief.md` (read it), with these specifics:
- Edit ONLY `src/mockups/12-vellum-atlas/`. Its `index.html` already links credits and calls
`mountCredits()` via the stub `main.ts`; the stub `style.css` must be replaced with unique content.
Start by copying 11's `geo.ts`/`main.ts`/`style.css` into your folder, then restyle and change.
- Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r3-12`,
serve with `cd <outdir> && PORT=4112 bun server.js`. Kill only your own server.
- Screenshots (1440×900, `--virtual-time-budget=4000`): `screenshots/12-vellum-atlas-default.png`,
`-motif.png` (`?motif=polyphemus`), `-plate-a.png`, `-plate-b.png`, `-plate-c.png` (Cyclops plate with
`?saga=cyclops&motif=polyphemus&motifs=a|b|c` — check the actual saga id in the data),
`-song.png`, `-overlay.png`, `-ocean.png`. Look at every one before reporting; fix what looks wrong.
- `bun test` and `bun run typecheck` must pass. Do not commit. Do not spawn subagents.
- Report to `docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md` (what you built, what
works, what doesn't, screenshots, a one-paragraph honest comparison of A/B/C). Reply with status,
screenshot paths and concerns only.
@@ -0,0 +1,76 @@
# 12-vellum-atlas: report
Status: **done** (spike quality). Nothing is committed. Code: `src/mockups/12-vellum-atlas/` (`geo.ts`, `main.ts`, `style.css`).
`bun test` (44 pass) and `bun run typecheck` pass; `style.css` is unique among the mockups.
## What was built
- **11's navigation, unchanged in structure.** It has these parts:
- the general chart (overview) with a zoom into an inset plate per island;
- plate furniture: a cartouche, a locator "Key to the general chart", breadcrumbs, prev/next buttons, "From plate I" and "Continued on plate III" notes, and a scale of story time;
- the ←/→/Esc keys and the bottom song slip;
- `?saga= ?song= ?motif= ?overlay=1`, kept in sync with the URL;
- an overlay (the "Captain's table") with a "You are here: Survive" chip, a ship on the chart, and a "Sail here" popover when you click a port.
- **10's vellum portolan look.** It has these parts:
- grain and foxing on the paper, rods with curl shading at the plate edges, and the red dashed course;
- rhumb lines and compass roses, tan land with a green coast-wash and dashed water-lines;
- 10's storm swirl and ship, the iron-gall motif inks, Cormorant and Inter type, and the dark desk.
- None of 11's slate cloth, cool paper or transit-style motif band is used.
- **Plates have the land along the top.** Below it come the titles, the course and the story time, then the motif marks, then the scale bar. As a result, the song slip (29% of the plate height) covers only open sea, never the island.
- **Motif toggle on plates.** "Themes on the plate: A Threads · B Pennants · C Ink only" sits at the top of the side panel. It also accepts `?motifs=a|b|c`, defaults to `a`, and the key changes per mode.
- **A, threads under the sea:** soft wavy threads in each theme's ink, knotted at the ports where the theme plays, with labels at the left. A thread runs off the plate edge when the theme continues on another plate. Picking a theme lights its thread, labels its knots with part names ("Verse 1") and fades the rest.
- **B, pennants at ports:** small bunting under each port, one pennant per theme. Picking a theme hides the other pennants and draws one faint current with chevrons, tethered to its ports. The current says "from earlier plates" or "on to later plates" at the edges.
- **C, ink only:** nothing shows at rest. Picking a theme rings its ports in hand-drawn ink and writes the part name below each one in a hand-lettered italic, with a flourish.
- **The overview's motif behaviour is kept from 11 and restyled.** It has the halo, the "Polyphemus plays in 2 of 4" badges and "silent here".
- **Fixes taken from 11's report:**
- Overview track rows are clickable, so one click opens the plate and the song.
- The Ocean plate is dressed as open water, with "The open sea", rhumb roses, a big rose, the storm with rain, rocks, and "two years at sea" written along the incoming course.
- The slip is compact.
### Fixes in this session
- The overview's "a year passes" note sits right under Troy's loop at port 5 instead of floating low.
- The overview's "two years at sea" note no longer crosses the course between ports 9 and 10.
- On the Ocean plate, the storm moved down and left so it no longer collides with the "Key to the general chart" caption.
- On the overlay plate, the ship moved left of the port so its mast no longer overlaps the "Survive" title.
- The overlay's "Sail here" popover now sits below the port's story-time caption instead of covering it.
- I re-checked these, and they are fine: no thread row is clipped by the slip on the Cyclops (7 themes) or Troy plates, the pennant icon in the B key renders, and the credits footer is fully visible in every shot.
## Screenshots (1440×900)
Required:
- `screenshots/12-vellum-atlas-default.png`: the general chart.
- `screenshots/12-vellum-atlas-motif.png`: the general chart with `?motif=polyphemus`.
- `screenshots/12-vellum-atlas-plate-a.png`, `-plate-b.png`, `-plate-c.png`: the Cyclops plate with `?saga=cyclops&motif=polyphemus&motifs=a|b|c`.
- `screenshots/12-vellum-atlas-song.png`: `?song=survive`, the plate with the slip open.
- `screenshots/12-vellum-atlas-overlay.png`: `?overlay=1`.
- `screenshots/12-vellum-atlas-ocean.png`: `?saga=ocean`.
Extra:
- `screenshots/12-vellum-atlas-troy-song.png`: the Troy plate with a slip and 5 thread rows.
- `screenshots/12-vellum-atlas-overlay-plate.png`: the overlay on the Cyclops plate, showing the ship and the "You are here" tag.
- `screenshots/12-vellum-atlas-overlay-sail.png`: the overlay with the "Sail here" popover (`?overlay=1&song=remember-them`).
## What works
- The structure reads instantly: general chart, then plate, then song. The plate furniture carries the atlas metaphor well, and 10's vellum makes it feel like one object rather than a UI.
- With the land at the top, the slip, the threads and the scale never fight the island.
- The Ocean plate no longer looks empty. It reads as a deliberate stretch of open water.
- The overlay reuses the same chart. The ship, the "You are here" tag and "Sail here" all read clearly on the plates.
## What doesn't (or is weak)
- **On the Cyclops plate, A packs 7 thread rows** into about 90 px of plate. The faded labels are small (about 9 px on screen) and low-contrast at rest. That is fine as texture, but too small for reading.
- **With the slip open, the lower plate (the scale of story time) is hidden.** The threads stay visible, but the bottom of the plate is lost. The lower area is also fairly empty on plates without a slip.
- **The "Sail here" popover can cover thread labels**, for example on the Polyphemus port. It is transient, so this is acceptable for a spike.
- **On the overview, the motif halo around the storm overlaps the "two years at sea" note** slightly.
- **This is desktop 1440×900 only.** It was not tested at other sizes or with a screen reader beyond the basic labels and keys.
## A / B / C, honestly
**A (threads)** is the only one that works as a *system*. At a glance you see which themes run through a plate, where they enter and where they carry on, and it keeps 10's identity. The cost is density: with 7 themes the band becomes a striped ribbon whose labels are too small, and it competes with the course. **B (pennants)** is the most charming and the calmest at rest, and it looks like it belongs on a portolan. But a pennant's colour means nothing without the key, and the linking current only appears once you pick a theme, so B shows *which* themes are at a port better than how they *travel*. **C (ink only)** is the cleanest and the most "navigator's note"-like, and it is lovely once a theme is picked. At rest it shows nothing, so the chart never invites you to explore themes. My recommendation is **A as the default with B's restraint**: A already draws only the themes that play on the current plate, but a busy plate like Cyclops still needs more vertical room or lighter, slower rows. Borrow C's hand-lettered ink notes for the selected theme's part names.
## Rerun
Build and serve: `bun build src/server.ts --target=bun --production --outdir <scratchpad>/r3-12`, then
`cd <scratchpad>/r3-12 && PORT=4112 bun server.js`. Open `http://localhost:4112/mockups/12-vellum-atlas/`.
@@ -0,0 +1,60 @@
# 13 Curtain call — short transition mockup brief
## Why
The user's idea (verbatim, from `docs/design/002-direction-round-notes.md` §3):
> "Maybe we can have the freize's look be the one that tries the different stuff together, like a
> curtain call from the first section to go over in the second, where it starts off 2d with the
> freize, then slowly morphs into the sea voytage, then when he lands on cyclops islands, it goes
> back to the freize, then into the cave?"
The 2D chart is decided: **12 Vellum atlas with threads**. The open question is **the look of the
whole**, so the look is a toggle in this mockup:
1. **Vellum throughout** — the frieze is redrawn as ink on vellum (an illustrated border of the chart);
the 3D sea is tinted warm, parchment-like.
2. **Two worlds** (default) — chart and 3D are vellum/naturalistic; the frieze keeps 04's terracotta
and black and appears only at transitions, like a curtain dropping between scenes.
3. **Frieze-led** — terracotta/black sets the whole tone; the 3D sea is stylised in that palette
(black-figure silhouettes, terracotta sky), and the vellum chart is one object on the table.
Toggle: visible control "Look: 1 · 2 · 3", also `?look=1|2|3`.
## The sequence (short — about 20–30 s total with Play)
Five beats, each a step. Controls: Play/Pause, ← / → step, step dots with labels, `?beat=1..5`.
1. **Frieze.** 04's frieze band: Troy figures, the horse. Caption "Troy falls".
2. **Morph to sea.** The frieze band's ground line becomes the horizon; figures flatten away / the band
unrolls and the camera tilts down into 07's 3D sea with the ship. The vellum chart (12's general
chart, small, static is fine — a simplified SVG redraw is fine) lies as the captain's-table map in a
corner with "You are here".
3. **Landfall.** The ship sails to Cyclops' Island; the island rises from the sea.
4. **Back to the frieze.** The curtain drops: the frieze band returns with Cyclops-saga figures
(Polyphemus, sheep, the ship's crew), caption "Polyphemus".
5. **Into the cave.** The frieze parts / zooms through a cave mouth into a simple 3D cave interior
(dark rock, firelight, the giant's silhouette is enough). A "Back to sea" control returns to beat 2.
The transitions are the point. Make them smooth, honest and short (≈1–2 s each); respect
`prefers-reduced-motion` with crossfades. Content is light: a caption per beat, a song title chip
(e.g. "Survive") that opens nothing. No song detail panel needed.
## Rules
- Edit ONLY `src/mockups/13-curtain-call/` (stub files exist: index.html links credits; main.ts calls
`mountCredits()`; replace the stub style.css with unique content). three.js is allowed here (already a
dependency). Reuse by copying from `src/mockups/04-greek-frieze/` (figures.ts, glyphs.ts) and
`src/mockups/07-sea-voyage/` (scene.ts, route.ts) into your folder — don't import across mockup folders.
- Look at screenshots/04-*, 07-*, 12-* before designing. Credits footer fully visible in every beat.
- Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r3-13`,
serve with `cd <outdir> && PORT=4113 bun server.js`. Kill only your own server.
- Screenshots 1440×900, `--use-gl=swiftshader --virtual-time-budget=6000` (WebGL headless):
`screenshots/13-curtain-call-beat{1..5}.png` at look 2, plus `-beat2-look1.png`, `-beat2-look3.png`,
`-beat4-look1.png`, `-beat4-look3.png`. If possible also capture a mid-transition frame
(`-morph.png`, e.g. `?beat=2&t=0.5` to freeze a transition at 50%). Look at every one; fix what's off.
- `bun test` and `bun run typecheck` must pass. Don't commit. Don't spawn subagents.
- If you run low on budget, stop and write a pause report (done / checklist / next step) instead.
- Report to `docs/design/002-sea-chart-spikes/13-curtain-call-report.md`: what you built, what works,
what doesn't, screenshots, and an honest paragraph comparing looks 1/2/3. Reply with status,
screenshot paths and concerns only.
@@ -0,0 +1,85 @@
# 13 Curtain call — report
Status: **done** (not committed). Built from `13-curtain-call-brief.md`.
## What was built
`src/mockups/13-curtain-call/` is a five-beat scripted transition. All of it is a pure function of one
number, the beat position `p` (0 = frieze … 4 = cave), so every transition can be played forwards or
backwards, or frozen with `?beat=N&t=0..1`.
| Beat | What you see | Transition into it |
|---|---|---|
| 1 Frieze | 04's black-figure band: Troy saga column, the horse and the infant, Just a Man, Full Speed Ahead. Caption "Troy falls". | — |
| 2 To sea | 07's low-poly sea and ship; a small redraw of 12's general chart lies bottom left as the table map, with "You are here". | 1.8 s. The figures flatten into the ground line. The band splits along that line: the top half slides up, the bottom half slides down. The 3D horizon starts **exactly on the painted ground line**: the camera pitch is solved from the layout. The line fades, and only then does the camera rise over the ship's shoulder. The chart slides in last. |
| 3 Landfall | Cyclops' Island with a cave mouth facing the camera; the ship lies off the shore. | 2.0 s. The ship sails a gentle S, the island rises from under the sea, the camera swings to frame both, and "You are here" moves to port 6 on the chart. |
| 4 Curtain | The Cyclops frieze: crew (Odysseus with the cup), the cave mouth in the rock, Polyphemus with his club, sheep. Caption "Polyphemus". | 1.2 s. The frieze drops over the sea like a curtain (ease-in-out); the chart is put away as it falls. |
| 5 Cave | 3D cave interior: firelit rock, the giant's silhouette with a glinting eye, the fire, the flock. "Back to sea" in the caption. | 1.6 s. The glow in the cave mouth fades and opens. The mouth is a real hole, masked through the whole frieze, so the 3D cave shows through it. The frieze zooms through the mouth (×16) while the cave camera moves in. |
Controls: Play/Pause (holds each beat for 3.4 s, about 25 s in total), ← / → (also arrow keys, Space
to play), five labelled step dots, "Look: 1 · 2 · 3" with the look's name, and `?beat=1..5`,
`?look=1|2|3`, `?t=` (freeze) and `?play=1`. A jump that skips a beat, such as a dot two steps away
or "Back to sea", fades through the curtain colour in about 0.7 s instead of scrubbing through the
beats in between. Under `prefers-reduced-motion`, **every** step is that fade, and the waves slow
down. The credits footer is a grid row of its own, outside the stage, so it is visible in every beat
and every look.
Files: `main.ts` (beats, timing, DOM choreography), `frieze.ts` (the two full-stage frieze SVGs,
split at the ground line), `figures.ts` (copied from 04, helpers exported), `scene.ts` (trimmed from 07:
sky, sea, island, ship, plus a new cave scene, palettes per look), `chart.ts` (simplified 12 chart).
## Screenshots (1440×900)
Look 2 (default): `screenshots/13-curtain-call-beat1.png` … `-beat5.png`.
Looks 1/3: `-beat2-look1.png`, `-beat2-look3.png`, `-beat4-look1.png`, `-beat4-look3.png`.
Mid-transition (frozen with `?t=`): `-morph.png` (1→2 at 40 %), `-morph-look1.png`, `-morph-look3.png`,
`-mid-landfall.png` (2→3 at 50 %), `-mid-curtain.png` (3→4 at 50 %), `-mid-cave.png` (4→5 at 50 %).
**Capture note:** with the brief's `--use-gl=swiftshader`, headless Chrome often captured the WebGL
canvas blank, which looked like a black stage. `--use-angle=swiftshader --use-gl=angle
--enable-unsafe-swiftshader --virtual-time-budget=6000` captures reliably. The renderer also sets
`preserveDrawingBuffer`.
## What works
- **The morph is honest.** The ground line really becomes the horizon: the split happens on the line,
and the sea's horizon sits on it pixel-exact before the camera moves (`-morph*.png`).
- **The cave zoom** reads well: through the mouth you see the real 3D cave, then the frieze falls away
around you (`-mid-cave.png`).
- The curtain drop is simple and clear. All transitions sit at 1.2–2.0 s and run in reverse with ←.
## What doesn't, or is rough
- **Landfall framing:** in the middle of 2→3 the island rises to the *right* of the ship, then the camera
swings and it ends up behind it. That reads as a slight sideways shuffle; a fixed camera would be calmer.
- **The sun** is a large glare in the morph frames of looks 1 and 2, because the camera looks level
at the horizon there.
- **The giant** is blocky boxes. As a silhouette it is enough, but it is not pretty.
- **The 3D does not match 1440×900 exactly:** the stage is 1440×~857 and the frieze uses SVG `slice`.
It is tuned for desktop only, as briefed. Other sizes still line the horizon up, because it is
computed, but the crops will differ.
- The Play hold times were only checked in code; headless virtual time does not run the rAF loop
faithfully, and one headless Play run showed the state switching (Pause, dot 2) but not the frames.
- Beat 3 has no song chip (only an eyebrow, "year 1"). Beats 3 and 4 both point at Polyphemus, so I left
that chip off beat 3.
## The three looks, honestly
**Look 2, two worlds** (default) is the most legible. The terracotta frieze is a strong, clearly
"other" curtain, and the naturalistic sea is a real place, so each change of world feels like an event.
But the morph (`-morph.png`) shows the seam: clay band → blue-gold sea is a jump in palette, carried
only by the geometry of the horizon line.
**Look 3, frieze-led** makes the morph almost seamless, because terracotta sky meets terracotta band
(`-morph-look3.png`). The sea becomes a black-figure painting come to life, which is the most
*distinctive* of the three. The cost is that the 3D sea is dark and moody, the ship is a silhouette
with little detail, and the vellum chart then looks like a foreign object (which the brief allows:
"one object on the table").
**Look 1, vellum throughout** is the most unified and the quietest. The pen-and-wash frieze as an
illustrated chart border is charming (`-beat4-look1.png`), and the sepia sea sits right next to the
chart. But the sepia 3D loses depth and contrast (`-beat2-look1.png`), and nothing feels like a
"curtain" any more: the transitions are less of an event.
My read: 2 if the frieze is the punctuation, 3 if the frieze is the identity. 1 is the safest, but it
undersells the 3D.
@@ -0,0 +1,76 @@
# 14 Voyage with side panel — converge brief
## Why
The user's verdict on 13 Curtain call (verbatim):
> "Yes it looks pretty good! Small design issues here and there but these can be fixed. 1, 2 and 3 all
> look decent. The UI of 2 and 3 tend to look a bit better though. For the models/3d, its better to go
> stylistic than realistic. While going through the 3D experience, its still important to have the
> right side window open, to show information, paths, lyrics, and fan works."
14 starts from a **copy of 13** (`src/mockups/13-curtain-call/`) and changes three things:
1. **Persistent right-side panel** (the main new thing).
2. **Stylised models**, not realistic.
3. **Look toggle 2 / 3 only** (`?look=2|3`, default 2). Drop look 1.
Keep 13's five beats, transitions, Play/step controls, `?beat=` `?t=` `?play=`, and the small vellum
chart. Fix 13's known rough spots while you're there: the sideways camera shuffle at landfall, the big
sun glare.
## 1. The side panel
- A right-hand panel, about 400–440px wide, **open throughout the 3D beats** (2 sea, 3 landfall,
5 cave). The 3D view fills the remaining width; frame the camera so the ship and island sit in the
visible area, not behind the panel. During the frieze beats (1, 4) the panel may stay open too
(preferred — it should feel like one persistent companion) — your call, but say what you chose.
- It can be collapsed to a slim tab, but starts open. `?panel=0` starts collapsed.
- It follows the voyage: each beat has a current song (beat 1 The Horse and the Infant, 2 Full Speed
Ahead, 3 Polyphemus, 4 Polyphemus, 5 Survive — check ids in `src/data/sample.ts`), and the panel
updates with a gentle transition when the beat changes.
- Tabs, `?tab=about|paths|lyrics|fan`:
- **About** — song title, track number, saga, story time, the Odyssey reference (book/lines from the
data), parts with singers, `createAudioSlot` and `createVideoCards` from `src/shared/`.
- **Paths** — the route and the motifs: a compact vertical **thread** strip (the user chose threads
in 12: soft coloured threads with knots where a motif plays) showing this song's motifs and where
each comes from / goes next ("Polyphemus — first heard here · returns in Survive"). Clicking a
motif highlights its thread; clicking a song in it is just a highlight (no navigation needed).
The small vellum chart may move into this tab or stay on the table — your call.
- **Lyrics** — the song's lyrics split by part with singer labels, using the **placeholder** lyrics
from the sample data (never real lyrics). Highlight motif lines with the motif colour. A current-part
marker is a nice touch, not required.
- **Fan works** — 3–4 placeholder cards per song: type (art / animatic / cover / cosplay), a
stylised placeholder thumbnail (CSS/SVG, no external images), a made-up clearly-placeholder title,
"by @fan-artist" placeholder handle, and a "credit + link to the original" line. Add a visible note
"Placeholder fan works — real ones will be credited and linked to their creators." Keep this data
local to your folder (e.g. `fanworks.ts`); do not change `src/data/`.
- The panel follows the look: look 2 parchment/vellum panel; look 3 dark/terracotta frieze panel.
Text must stay comfortably legible (≥15px body, good contrast) in both.
## 2. Stylised models
"Stylistic rather than realistic." Make the 3D read like illustration, consistent with the frieze:
flat or toon shading (e.g. `MeshToonMaterial` or flat `MeshLambertMaterial` with a stepped gradient
map), a limited palette per look, ink outlines (inverted-hull or `EdgesGeometry` lines), simple
graphic shapes. Stylised water (flat bands/patterned waves, not a realistic glare). The island a bold
graphic shape. **The giant** should become a strong stylised silhouette (black-figure inspired, one
eye) rather than boxes. Ship: graphic, silhouette-first.
## Rules
- Edit ONLY `src/mockups/14-voyage-panel/` (stub files exist; index.html links credits; main.ts calls
`mountCredits()`; replace the stub style.css with unique content). three.js is allowed. Copy from 13
(and 04/07/12 if useful) into your folder; don't import across mockup folders.
- Credits footer fully visible in every beat and with the panel open.
- Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r3-14`,
serve with `cd <outdir> && PORT=4114 bun server.js`. Kill only your own server.
- Screenshots 1440×900 with `--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader
--virtual-time-budget=6000` (plain `--use-gl=swiftshader` often captures a black stage):
`screenshots/14-voyage-panel-beat{1..5}.png` (look 2, tab about),
`-paths.png`, `-lyrics.png`, `-fan.png` (beat 3), `-beat2-look3.png`, `-beat5-look3.png`,
`-lyrics-look3.png`, `-collapsed.png`. Look at every one; fix what's off.
- `bun test` and `bun run typecheck` must pass. Don't commit. Don't spawn subagents.
- If you run low on budget, stop and write a pause report (done / checklist / next step) instead.
- Report to `docs/design/002-sea-chart-spikes/14-voyage-panel-report.md`: what you built, what works,
what doesn't, screenshots, and a short honest comparison of looks 2 and 3 with the panel.
@@ -0,0 +1,124 @@
# 14 Voyage with side panel — report
Status: **done** (not committed). Built from `14-voyage-panel-brief.md`, starting from a copy of 13.
## What was built
`src/mockups/14-voyage-panel/` is 13's five-beat curtain call, with the same beats, timings, Play/step
controls, `?beat=` `?t=` `?play=`, the fade-through for jumps, and reduced motion. Three things
changed, as the brief asked:
1. **A persistent right-side panel** (420px). It is **open in every beat, including the frieze beats 1
and 4** (my call: it reads as one companion that stays through the whole voyage). The stage, with
the 3D canvas, the friezes, the caption and the control bar, fills the remaining ~1020px. So the
ship and island are framed in the visible area by construction, not hidden behind the panel.
- Song per beat: 1 The Horse and the Infant, 2 Full Speed Ahead, 3 and 4 Polyphemus, 5 Survive.
The panel switches at each transition's midpoint, together with the caption. The title and the
tab content dip and settle (180 ms); under reduced motion they swap instantly.
- Head: "Now playing on the voyage", track badge, title, saga · story time, and a **Hide ›** button.
Collapsed, it becomes a 56px slim tab (‹, the track number, "About · Paths · Lyrics · Fan works"
written vertically), and the stage widens. `?panel=0` starts collapsed. The grid column animates.
- Tabs (`?tab=about|paths|lyrics|fan`, with a proper tablist and arrow keys; arrow keys inside the
panel no longer step the beats):
- **About**: track, saga, story time, the Odyssey book/lines (linked), the fact, parts with singers,
`createAudioSlot` (empty state) and `createVideoCards`, restyled for the panel.
- **Paths**: first the motif threads. Each of this song's motifs is a button with "comes from … /
first heard here · returns in … (+n more)". Under them, a compact vertical **thread strip**: all
10 songs as rows, one soft wavy thread per motif, knotted where it plays, with the current song
marked "now". Clicking a motif highlights its thread and dims the others. Clicking a song row only
highlights that row. Below that is **the route**: the small vellum chart moved here from the
table, with "You are here" still moving with the ship.
- **Lyrics**: placeholder lines from `sample.ts`, split by part, with singer chips. Each part has a
left rule in its motif colour and motif tags. I left out the current-part marker: the beats carry
no timing, so any marker would have been made up.
- **Fan works**: the note "Placeholder fan works — real ones will be credited and linked to their
creators.", then 3–4 cards per song in two columns: type (art / animatic / cover / cosplay), a
stylised SVG thumbnail, a "Placeholder: …" title, "by @fan-artist", and "Credit + link to the
original (placeholder)". The data is local, in `fanworks.ts`.
- Look 2 panel: a vellum sheet with a wooden roller edge (12). Look 3 panel: glaze black, with a
terracotta tongue band along the top (from the frieze) and clay accents. Motif colours are
re-inked darker on vellum (12's `INK`) and kept light on black. Body text is 15px, with good
contrast in both looks.
2. **Stylised 3D** (`scene.ts`, rewritten from 13's):
- Toon shading: `MeshToonMaterial` with a three-step gradient map. Ink outlines are inverted hulls
(smoothed normals, back faces), plus faint facet lines on the islands and the cave dome.
- The sky is posterised into four flat bands with a slight wave. **The sun is a small flat disc with
one ring, with no glare** (fixes 13's rough spot).
- The sea is unlit flat colour bands by wave height, with a hand-drawn-looking contour line on the
crest band and a small ripple term, so the crests read as drawn patches, not lit water.
- Island: a few-faceted bold shape in three flat bands (sand, ochre rock, olive scrub), inked, with a
black arched cave mouth. Ship: silhouette first, with a dark hull, a terracotta gunwale band,
striped sail, painted bow eyes, a curled stern post and a ram.
- **Polyphemus**: an extruded black-figure profile cut-out. He strides, with his club raised, a heavy
nose and a pointed beard. **One big painted eye** catches the firelight. His outline and incised
lines (belt, zigzag hem, knees, chest, brow, ear) are in clay, as in the frieze. Flat layered
flame shapes; woolly sheep made of outlined puffs.
- Look 3: black-figure ship and island with clay outlines, a clay-lit cave mouth, a glaze sea with
clay contour lines, and a terracotta sky.
- **Landfall camera**: it stays behind the ship's right shoulder the whole way and only pulls back
and up. The island rises ahead-left and stays there, so 13's sideways shuffle is gone
(`-mid-landfall.png`).
3. **Look toggle 2 · 3 only** (`?look=2|3`, default 2). Look 1 is removed, along with its CSS.
Frieze scaling: with the stage now ~1020px wide, the frieze SVGs use `meet` instead of `slice`, so the
whole band shows (saga column to Full Speed Ahead), with the glaze surround above and below.
`syncHorizon` uses the matching `min()` scale, so the morph still starts exactly on the painted ground
line (`-morph.png`).
Files: `main.ts` (beats, DOM choreography, params), `panel.ts` (the companion), `fanworks.ts`
(placeholder data and thumbnails), `scene.ts` (stylised 3D), `frieze.ts`, `figures.ts` and `chart.ts`
(copied from 13; only the frieze scaling changed), `style.css`.
## Screenshots (1440×900, `screenshots/`)
- Look 2, About tab: `14-voyage-panel-beat1.png` … `-beat5.png`
- Beat 3 tabs: `-paths.png`, `-lyrics.png`, `-fan.png`
- Look 3: `-beat2-look3.png`, `-beat5-look3.png`, `-lyrics-look3.png`
- Collapsed panel: `-collapsed.png`
- Extra: `-mid-landfall.png` (2→3 at 50 %), `-morph.png` (1→2 at 40 %)
The credits footer is visible in every one, with the panel open or collapsed.
## What works
- The panel feels like a companion, not a modal. It is always there, and it changes song at the same
moment as the caption. The 3D has its own uncluttered frame.
- Paths gives a lot of information at a glance: where each motif comes from and goes next, in words
and as threads, plus the route chart.
- The stylised 3D is consistent with the frieze, especially the giant and look 3's black-figure
island. The sea pattern reads as illustration.
- Landfall is calm, and the sun no longer glares.
- Checked: `bun test` (48 pass), `bun run typecheck`, and a production build to the scratchpad. A
throwaway happy-dom check (not kept) confirmed the motif highlight, song highlight, tab callback,
fan cards and collapse.
## What doesn't, or is rough
- **Frieze beats with the panel open** show a lot of empty glaze above and below the band (`-beat1`,
`-beat4`). In the Troy band, the Full Speed Ahead ship touches the stage edge. A 14-specific,
narrower frieze layout (fewer panels) would fill the stage better.
- The **sea crest patches** near the camera are large, flat shapes, a bit like lily pads. They are
stylised but could be finer.
- The giant's beard and the flock's outlines are a little heavy at the cave's end framing. The club
tip nearly touches the top of the frame.
- Beats 3 and 4 share Polyphemus, so the panel does not change between them (on purpose).
- Play hold times were not checked in a live browser; headless virtual time does not run rAF
faithfully (same caveat as 13).
- The panel width is fixed at 420px. It is tuned only for desktop 1440×900, as briefed.
## Looks 2 and 3 with the panel, honestly
**Look 2 (two worlds)** suits the panel best. The vellum panel sits beside a cream-and-teal poster sea
and reads like the captain's notes next to the view. The terracotta frieze is still a clear
"curtain". The panel has three materials around it (vellum, teal sea, clay frieze), but they belong
together.
**Look 3 (frieze-led)** is the more distinctive 3D: the black-figure island with its clay-lit mouth
(`-lyrics-look3.png`) and the giant are the strongest images in the mockup. The dark panel matches
it, so the whole screen feels like one painted vase. The cost: the sea is very dark, so there is
less depth. The pale vellum route chart in Paths becomes a bright foreign object in the dark panel.
And dark-on-dark makes the screen heavy over a long session.
My read: look 2 for reading (the panel is where people spend time), and look 3 for the 3D moments.
A hybrid (look 3's black-figure models and look 2's vellum panel) may be worth one toggle in a next
round.
@@ -0,0 +1,59 @@
# 15 Voyage, look 2 — iteration brief
## Why
The user's verdict on 14 (verbatim):
> "This is actually surprisingly really good work. The design for 3 really makes the whole site feels
> like it works together, both in terms of frieze, the side panel and the world. However, im leaning a
> lot more towards 2. The colors pop a lot more. The paths feel a bit too hidden away now. All in all
> though, its looking better and better"
15 starts from a **copy of 14** (`src/mockups/14-voyage-panel/`). Keep everything that works (five
beats, transitions, stylised toon/ink 3D, the side panel and its tabs, placeholders, URL params).
Change:
1. **Look 2 only.** Remove the look toggle and look 3 code paths. Keep look 2's colours — they "pop".
2. **Borrow look 3's cohesion.** What made 3 feel like one site: the frieze, the panel and the world
share one visual language. Bring that into look 2 *without* dulling its colours, e.g.:
- a shared ornament: the frieze's meander/key band as the panel's top border and the HUD's edge;
- the same type pairing and label styles across frieze captions, panel, step bar and caption card;
- the panel and HUD use the frieze's terracotta + black as accents on look 2's parchment, so the
frieze beats and the sea beats feel like the same book;
- fix 14's rough spots: empty space above/below the frieze band with the panel open (fit the
frieze to the stage), sea patches that read like lily pads, heavy giant outlines.
3. **Paths out in the open.** They are "too hidden away" in the Paths tab. Add a visible toggle
"Paths: A · B · C" (also `?paths=a|b|c`, default `a`) with three placements. In all three, the
current song's motifs are visible without clicking anything, and clicking a motif highlights it
everywhere (panel, threads, lyrics chips). Use the thread style the user chose in 12 (soft coloured
threads with knots where a motif plays) — see `src/mockups/12-vellum-atlas/`.
- **A. Pinned strip** — a compact thread strip pinned at the top of the panel, between the song
header and the tabs, always visible whatever tab is open: the voyage's songs as knots along a
line, the current song marked "you are here", one thread per motif of the current song showing
where it was heard before and where it returns. Keep it compact (≈120–160px tall).
- **B. Threads in the world** — the current song's motifs drawn in the 3D sea as coloured ribbons
(stylised, flat, ink-edged) trailing behind the ship and running ahead toward where they return,
with small floating labels. Also shown in the frieze beats as coloured threads along the frieze's
ground line.
- **C. Timeline at the bottom** — the step bar grows into a horizontal song timeline across the
bottom of the stage (the user's original "big horizontal line"): all songs as stops, "you are
here", motif threads underneath with knots. Play/step/look controls fold into it.
The Paths tab can stay for the full detail, but must not be the only place paths appear.
## Rules
- Edit ONLY `src/mockups/15-voyage-look2/` (stub files exist; index.html links credits; main.ts calls
`mountCredits()`; replace the stub style.css with unique content). three.js allowed. Copy from 14
(and 12) into your folder; don't import across mockup folders.
- Credits footer fully visible in every state.
- Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r3-15`,
serve with `cd <outdir> && PORT=4115 bun server.js`. Kill only your own server.
- Screenshots 1440×900 with `--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader
--virtual-time-budget=6000`: `screenshots/15-voyage-look2-beat{1..5}.png` (paths a),
`-paths-a.png`, `-paths-b.png`, `-paths-c.png` (beat 3, and with `?motif=` highlighted if you add
that param), `-beat1-paths-b.png`, `-beat5-paths-c.png`, `-lyrics.png`. Look at every one and fix
what's off.
- `bun test` and `bun run typecheck` must pass. Don't commit. Don't spawn subagents.
- If you run low on budget, stop and write a pause report (done / checklist / next step) instead.
- Report to `docs/design/002-sea-chart-spikes/15-voyage-look2-report.md`: what you built, what works,
what doesn't, screenshots, and an honest comparison of paths A/B/C.
@@ -0,0 +1,123 @@
# 15 Voyage, look 2 — report
Status: **done** (not committed). Built from `15-voyage-look2-brief.md`, starting from a copy of 14.
## What was built
`src/mockups/15-voyage-look2/` is 14's five-beat voyage with its side panel, tabs, placeholders and URL
params (`?beat=` `?t=` `?play=` `?tab=` `?panel=0`). New params: **`?paths=a|b|c`** (default `a`) and
**`?motif=<id>`** (starts with that motif highlighted).
1. **Look 2 only.** The look toggle, look 3's palette, sail and panel code and the `Look` type are gone.
Look 2's colours are unchanged: teal and cream sea, cream sky, terracotta frieze, bright motif colours.
2. **Look 3's cohesion, on look 2's colours.**
- **One ornament.** The frieze's meander key band, drawn as a strip between glaze rules, runs along
the top of the panel (18px), the caption card and the step bar or timeline (12–14px). It is the
same tile as in the frieze.
- **One type pairing.** All labels use the frieze inscriptions' style: Cormorant, capitals, wide
tracking. That covers the caption eyebrow, the panel eyebrow, tabs, fact labels, beat labels,
"Paths", strip headings, fan-work types and the B labels. Titles stay in Cormorant and body text in
Inter.
- **One accent pair.** Terracotta and glaze black replace 14's rhumb-red. Track badges and the
current beat are terracotta discs with black numerals, as on the frieze. Play and Back are black
pills. Cards have black 2px rules. The current tab has a terracotta tongue. The footer is glaze with
a terracotta rule. The vellum roller edge is replaced by a black rule, so the panel reads as the
same book as the frieze.
- **Fitted frieze.** The viewBox is now cropped to the band, and the band sits in a box between the
caption and the HUD. Two registers fill the height: a **palmette chain** above (a vase neck) and a
**running-wave scroll** under the ground line. The horizon sync and the curtain drop were rewritten
for the new box. The morph still starts on the painted ground line (`-morph.png`).
- **Sea.** The filled crest patches (14's "lily pads") are gone. The sea is now two flat swell bands
plus thin cream wave strokes on the upper swells, which thin out with distance.
- **Giant.** The outline hulls are half as thick (0.32 → 0.15). The giant is scaled 0.8 → 0.72, so the
club stays well inside the frame. The flock's outlines are halved too.
3. **Paths out in the open.** "Paths A · B · C" sits in the HUD with a short name (In the panel / In the
world / Timeline). In all three modes, the current song's motifs show without any click. **One pick
highlights everywhere**: the pinned strip, the Paths tab (buttons and vertical strip), lyrics (the
matching parts light up, the others dim, and the chips are buttons), frieze threads, 3D ribbons and
their labels, and the timeline. The pick is kept across songs when the new song has that motif, and
is mirrored in `?motif=`. The Paths tab stays as the full detail view.
- **A. Pinned strip** (`threads.ts` `hStrip`, about 150px). It sits between the song header and the
tabs, whatever tab is open. All 10 songs are knots on a dashed course, numbered, with saga ticks
and a terracotta "here" column. A small ship with "you are here" **sails between songs as the beats
play**. One soft thread per motif, knotted where it plays (12's style). The motif names are buttons
with a from/returns tooltip.
- **B. Threads in the world** (`ribbons.ts`). Every song has a fixed place on the route: two behind
the start, Full Speed Ahead at the start, Polyphemus at the anchorage, Survive at the cave mouth,
and the rest beyond the island. Each motif is a flat ribbon with a black edge that rides the waves.
It is **solid behind the ship and dashed ahead**, with open-ring knots. Each ribbon has a floating
vellum label on its next knot ahead ("POLYPHEMUS · next: 7. Survive"), and labels stack when they
would overlap. Ribbons fade out near the camera so they don't become roads. In the cave, ribbons run
along the floor between the flock and the giant. In the frieze beats, the wave-scroll register
becomes the **thread register**: ink-edged threads in the bright colours, knotted under each painted
song, running off the band where the motif continues, with names on the left (clickable).
- **C. Timeline at the bottom.** The HUD becomes a full-width vellum timeline. Its top row holds Play,
the steps, the beats and the paths toggle. Below are all songs as stops with titles, saga names,
the "here" column, the sailing ship with "you are here", and the motif threads underneath. The
frieze box shrinks so the band still fits above it.
Files: `main.ts` (beats, paths mode, motif pick, B labels, C timeline, fitted frieze), `panel.ts`,
`threads.ts` (new: shared thread data and the horizontal strip), `ribbons.ts` (new: 3D ribbons),
`scene.ts` (one palette, sea strokes, thinner outlines, ribbons and label projection), `frieze.ts`
(cropped viewBox, chain and wave or thread registers, tighter mouth mask), `figures.ts`, `chart.ts`,
`fanworks.ts` (copied), `style.css`.
## Screenshots (1440×900, `screenshots/`)
- Paths A: `15-voyage-look2-beat1.png` … `-beat5.png`
- Beat 3 with `?motif=polyphemus`: `-paths-a.png`, `-paths-b.png`, `-paths-c.png`
- `-beat1-paths-b.png` (frieze threads), `-beat5-paths-c.png`, `-lyrics.png` (A, `?motif=odysseus`)
- Extra: `-beat2-paths-b.png`, `-beat4-paths-b.png`, `-beat5-paths-b.png` (cave ribbons), `-paths-tab.png`,
`-collapsed.png` (panel collapsed, C), `-morph.png` (1→2 at 40%), `-mid-curtain.png` (3→4 at 50%, B),
`-mid-cave.png` (4→5 at 60%)
The credits footer is fully visible in every one.
## What works
- **Cohesion.** The meander strip, the capitals labels and the terracotta/black accents tie the
caption, HUD and panel to the frieze, and the sea and sky keep look 2's pop. It now reads as one site.
- **The frieze fills the stage.** The band sits between the caption and the HUD with only a small
margin of glaze (`-beat1`, `-beat4`). The new registers look native to the vase.
- **The sea** reads as drawn strokes, not pads. The giant is lighter and fully in frame.
- **A** is always visible without changing the panel's job, and the sailing ship makes it feel alive.
- **B** is the most striking: the threads literally run ahead of the ship to the cave mouth
(`-paths-b`). In the frieze, the thread register is a natural vase band (`-beat1-paths-b`).
- **C** is the clearest overview. It is the user's "big horizontal line", with every song titled.
- Checked: `bun test` (50 pass), `bun run typecheck`, and a production build to the scratchpad.
## What doesn't, or is rough
- **B labels on the island** can stack over the cave mouth, as Odysseus and Polyphemus both return in
Survive (`-paths-b`). They are readable, but the leader line of the upper label points at nothing.
- **B ribbons behind the ship** sweep towards the camera at the lower left in beat 2. They now fade, but
still cross under the HUD. The far ribbons (tracks 8–10, beyond the island) are only small dashes.
- **B's frieze labels** are small (about 12px on screen at this stage width).
- **A at 372px is dense.** The 10 knots are about 22px apart, so the numbers carry the meaning and song
titles appear only in tooltips. "Warrior of the Mind" just fits the label column.
- **C costs about 220px of stage.** The landfall and cave are framed higher, and in the cave the flock
sits behind the timeline (`-beat5-paths-c`).
- The morph frame shows a large flat sun with a ring. This is 14's camera at horizon lock, not changed.
- Play timing was not checked in a live browser (headless virtual time, same caveat as 13 and 14).
Desktop 1440×900 only.
## Paths A / B / C, honestly
**A (pinned strip)** is the safest. It is always there and compact, it keeps the stage clean, and the
sailing ship makes "you are here" concrete. But it is small and abstract: numbers, not names, in a busy
panel. It fixes "too hidden" without making paths feel like part of the voyage.
**B (threads in the world)** is the most "EPIC". The motifs become something the ship sails along, and
you see a theme run ahead into the Cyclops' cave before the song gets there. The frieze version is
lovely. The costs: it depends on camera framing (labels stack, ribbons near the camera need fading),
and it shows only the current song's motifs, with no overview of the whole voyage. It is the hardest
to build well for every landing.
**C (timeline at the bottom)** is the clearest. Every song is named, the threads are long enough to
read as journeys, and the step controls sit with it naturally. It costs stage height, and the
interface feels more like a tool and less like a picture.
My read: **C as the home for paths, with B's ribbons as an optional flourish in the 3D** (the frieze
thread register in particular is cheap and beautiful). A works as a fallback when the panel is the
main surface, for example on smaller screens. Each needs one more pass: C could collapse to a slim
course line while the 3D plays, and B labels need smarter placement.
@@ -0,0 +1,74 @@
# 16 Voyage, readable — iteration brief
## Why
The user compared 14 and 15 (verbatim):
> "Thanks. First off, 15's font is very hard to read. 15 Accents with the freisa patterns on all the
> menus are very nice though, and the colors on the "between sagas a year at sea" looks nice. The hard
> question is paths A, B and C, cause they all have their strengths. In terms of menu, listen should be
> easier to do, and perhaps closer tied to the lyrics tab."
16 starts from a **copy of 15** (`src/mockups/15-voyage-look2/`). Keep: look 2, the five beats and
transitions, stylised toon/ink 3D, the frieze-pattern accents on every menu/panel/HUD, the colours of
beat 2 ("Between sagas · a year at sea"), placeholders, URL params.
## 1. Readable type (the top complaint)
- Stop using small, wide-tracked Cormorant capitals for labels (`.lbl`, tab names, step-dot labels,
"PATHS", panel eyebrows, data-row labels, saga names, frieze-thread labels, timeline labels).
- **Inter** for all UI text and labels: sentence case or modest caps with ≤0.06em tracking, weight
500–600. **Cormorant** only for large display titles (caption title, panel song title, frieze
inscriptions at ≥19px).
- **Nothing under 13px on screen**, body text in the panel ≥15px, timeline song titles ≥12.5px with
room. Check contrast (≥4.5:1 for text) on parchment.
- Compare against 14's panel, which the user found readable.
## 2. Listen + Lyrics, together
- Merge the Lyrics tab and the Listen section into **one tab, "Listen & lyrics"** (About keeps facts,
Odyssey, parts and singers, and Watch).
- At the top of that tab, a **player**: big play/pause button, elapsed / total time, a scrub bar with
**part markers** (Verse 1, Chorus…) you can click. Below it the lyrics, by part with singer labels and
motif chips. Clicking a part's heading jumps the player there. While playing, the **current part is
highlighted** and scrolled into view.
- There are no real audio files. Simulate playback with a timer over a made-up duration per song
(spread part start times across it; use `part.startSec` if present), and say so in a small note
("Preview player: no audio file yet"). If `song.audio` exists, use a real `<audio>` element instead.
Do not change `src/shared/audio-slot.ts` — build this player in your folder.
- **Play button always visible in the panel header** next to the song title: one click starts the
current song and switches to the Listen & lyrics tab.
## 3. Paths: combine A, B and C instead of choosing
Remove the A/B/C toggle. Instead:
- **C is the default home**: the bottom timeline with all songs, saga dividers, "you are here", and
threads underneath. It gets a **collapse** control (chevron) that shrinks it to a slim bar (≈56px:
just the song stops and you-are-here, no threads).
- **When the timeline is collapsed, A's strip appears** pinned in the panel above the tabs, so threads
are never hidden. When expanded, the panel strip hides (no duplication).
- **B's ribbons are an on/off switch** in the controls: "Threads on the sea" (default off). Fix 15's
B issue: labels stacking over the cave mouth — offset/stack them so they never overlap each other or
the cave.
- Clicking a motif anywhere still highlights it everywhere (timeline, strip, ribbons, lyrics chips).
- URL: `?timeline=open|closed`, `?ribbons=1`, `?motif=`, `?tab=about|listen|paths|fan`.
- Make sure the expanded timeline doesn't eat the stage: keep it ≤190px tall and frame the 3D camera
so the ship and island sit above it.
## Rules
- Edit ONLY `src/mockups/16-voyage-readable/` (stub files exist; index.html links credits; main.ts calls
`mountCredits()`; replace the stub style.css with unique content). three.js allowed. Copy from 15
(and 14 for its readable panel type); don't import across mockup folders.
- Credits footer fully visible in every state.
- Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r3-16`,
serve with `cd <outdir> && PORT=4116 bun server.js`. Kill only your own server.
- Screenshots 1440×900 with `--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader
--virtual-time-budget=6000`: `screenshots/16-voyage-readable-beat{1..5}.png`, `-listen.png`
(beat 3, Listen & lyrics tab, mid-playback if you can freeze it e.g. `?t_play=40`), `-collapsed.png`
(timeline closed, strip in panel), `-ribbons.png` (beat 3 with ribbons on, `?motif=polyphemus`),
`-fan.png`. Look at every one, read every label at 100% zoom, and fix anything small or cramped.
- `bun test` and `bun run typecheck` must pass. Don't commit. Don't spawn subagents.
- If you run low on budget, stop and write a pause report (done / checklist / next step) instead.
- Report to `docs/design/002-sea-chart-spikes/16-voyage-readable-report.md`: what you built, what works,
what doesn't, screenshots.
@@ -0,0 +1,123 @@
# 16 Voyage, readable — report
Status: **done** (not committed). Built from `16-voyage-readable-brief.md`, starting from a copy of 15.
## What was built
`src/mockups/16-voyage-readable/` is 15 (look 2, five beats, toon/ink 3D, meander accents on every
menu, panel and HUD, beat 2's colours, placeholders) with three changes. URL params: `?beat=` `?t=`
`?play=` `?panel=0` as before, plus **`?tab=about|listen|paths|fan`**, **`?timeline=open|closed`**
(default open), **`?ribbons=1`**, **`?motif=<id>`** and **`?t_play=<s>`**. `t_play` starts the
first song's player at that second, playing, so a screenshot can show playback.
1. **Readable type.** 15's small Cormorant capitals with wide tracking are gone from every label.
- **Inter** (400/500/600) is used for all UI: caption eyebrow (15px), HUD (14px), tabs (15px,
sentence case), fact labels, panel eyebrow, strip and timeline labels, saga names, "you are
here", B labels and fan-work types. Tracking is 0 everywhere except the frieze's vertical saga
name (0.06em).
- **Cormorant** is kept only for big display text: the caption title, the panel song title, section
headings (22px), part headings in the lyrics, italic lyric lines (19px), the About fact (20px) and
the frieze inscriptions. These are now 27 units, which is about 19px on screen (15 used 19 units,
about 13px).
- **Nothing under 13px on screen.** This includes the SVG text: timeline titles, stop numbers and
saga names are 13px. The frieze's saga column and thread names are Inter at about 14px. The route
chart's 7px port numbers are hidden, and its place names were enlarged. Panel body text is 15px.
Text uses iron `#2b2118`, iron-soft `#5a4836` and clay-deep `#9a4520` on vellum, all about 5:1 or
better.
2. **Listen & lyrics, one tab** (`player.ts`, `panel.ts`). Tabs: About · Listen & lyrics · Paths · Fan
works. About keeps the facts, the Odyssey, parts and singers, and Watch. It also gains a "Listen
with the lyrics" button.
- **Player card** at the top of the tab, with a meander edge. It has a 56px play/pause button, the
current part and its singers, elapsed / total time, and a scrub bar with ticks at the part starts
(click or drag it; ←/→ seek 5s). Below the bar is one button per part ("Verse 2 0:50"), and the
current part is filled terracotta. The note under it reads "Preview player: no audio file yet.
Playback is simulated."
- **Lyrics by part** below the card. Each part heading is a button (▶ Verse 2 0:50) that jumps the
player there and plays. The current part gets a terracotta wash, an ink outline and a "Now
playing" chip, and it scrolls into view when the part changes. Singer pills and motif chips stay;
motif picks still dim and highlight parts.
- **Simulated playback.** A rAF clock runs over a made-up, stable duration per song (2:30–4:10).
Part starts use `part.startSec` when the data has it, and are otherwise spread evenly across the
song. If `song.audio` exists, a real `<audio>` element drives the same UI instead. The shared
`audio-slot.ts` is untouched.
- **Header play button.** A 52px terracotta disc beside the song title, always visible. One click
starts the song and switches to Listen & lyrics; a second click pauses. While a song plays, the
header eyebrow reads "Playing · Verse 1 · 0:41 / 3:52". Moving to a new song on the voyage loads
that song's player, stopped at 0:00.
3. **Paths combined.** The A/B/C toggle is removed.
- **The timeline at the bottom is home, open by default.** It shows all songs titled in Inter 13px,
saga dividers and names, the here-band, the sailing ship with "you are here", and the current
song's motif threads. Titles are measured and split into the narrowest one or two lines, and the
stops are spaced by title width, so neighbouring titles never touch. The HUD is **182px** tall,
under the 190px budget.
- **Collapse.** A "Hide timeline ⌄" pill at the right of the HUD row collapses it to a slim course:
the beat row plus numbered stop discs, saga ticks, and the ship with "you are here · Polyphemus".
It is about 113px tall in total.
- **The panel strip appears only when collapsed.** 15's A strip is pinned between the header and the
tabs. It now uses 13px numbers and motif names and gets the "Motif paths" heading. When the
timeline is open, the strip is hidden.
- **"Threads on the sea" switch** (role=switch, off by default). It turns on the 3D ribbons and their
labels, and the frieze's thread register in beats 1 and 4.
- **The label fix for 15's B.** Labels are grouped by the knot they point at. In landfall, the two
motifs returning in Survive now share one label, "Next: 7. Survive", with each motif as its own
button. Each label is lifted 96px above its knot on an ink leader line with a ring at the knot, so
it sits on the mountain above the cave mouth, not over it. Labels stack against each other using
measured sizes, and stay below the caption card.
- One motif pick still highlights everywhere: timeline, panel strip, Paths tab, lyrics chips and
parts, frieze threads, ribbons and labels.
- **Framing.** The stage lifts the 3D picture by part of the HUD height. Both cameras render a window
into a taller frame (`setViewOffset` with a compensated fov), so nothing zooms and the image only
shifts up. The frieze box's bottom inset follows the HUD's measured height. The morph's horizon
lock accounts for the shift.
Files: `main.ts` (HUD row, switch, timeline open/closed, label grouping and placement, framing),
`panel.ts` (tabs, header play, Listen & lyrics), `player.ts` (new), `threads.ts` (measured timeline
layout, slim strip, readable sizes), `scene.ts` (`setFrameShift`), `frieze.ts` (thread rows and names),
`style.css` (new type system and new components), and `chart.ts`, `fanworks.ts`, `figures.ts`,
`ribbons.ts` copied from 15.
## Screenshots (1440×900, `screenshots/`)
- `16-voyage-readable-beat1.png` … `-beat5.png`: timeline open, ribbons off
- `-listen.png`: beat 3, Listen & lyrics, `?t_play=40`, playing Verse 1
- `-collapsed.png`: timeline closed, strip pinned in the panel
- `-ribbons.png`: beat 3, ribbons on, `?motif=polyphemus`
- `-fan.png`: Fan works tab
- Extra: `-beat1-ribbons.png` (frieze thread register, `?motif=warrior`), `-beat2-ribbons.png`,
`-beat5-ribbons.png` (cave), `-paths-tab.png` (Paths tab, collapsed), `-panel-closed.png`
The credits footer is fully visible in every one.
## What works
- **It reads.** At 100%, every label in every screenshot reads without effort: the HUD, the timeline
titles, the tabs, the fact rows, the strip, the B labels and the frieze inscriptions. The meander
bands, the terracotta/black accents and beat 2's sea and sky keep 15's cohesion. Only the type
changed.
- **Listen is one click.** The header button is always there, and playing lands you in the lyrics with
the current part lit. The parts are the player's chapters, so listening and lyrics are one thing.
- **Paths no longer have to be chosen.** The timeline gives the overview. Collapsing it moves the
threads into the panel, so they are never hidden. The sea ribbons are an opt-in flourish, and their
labels no longer pile up on the cave (`-ribbons`, `-beat2-ribbons`, `-beat5-ribbons`).
- The landfall and cave are framed above the open timeline. In the cave the flock and the giant are
fully visible (`-beat5`).
- Checked: `bun test` (62 pass), `bun run typecheck` (clean) and a production build to the scratchpad. A scratch happy-dom run
(deleted afterwards) checked that the header play opens Listen & lyrics and plays, a part heading
jumps to 0:50 and highlights that part and chip, and pause works.
## What doesn't, or is rough
- **The timeline is still 182px of stage.** With three motif rows it is at the limit, and a song with
four motifs would need about 16px more. The thread rows are 16px apart, which is readable but snug.
- **Timeline spacing is uneven by design.** Stops are spaced by title width, so the voyage's rhythm is
not to scale; Survive and Remember Them sit closer than the others. Slim mode is evenly spaced.
- **The frieze thread names in beat 1** sit just under the ground line, close to the saga column's
rules. When a motif is picked, the other names dim to low contrast on clay, as intended.
- **B labels are lifted 96px on leader lines.** In landfall the label sits on the mountain's face. In
the open sea, ribbons near the camera still sweep under the HUD.
- **The pinned strip in the panel is dense.** Stops are 21px apart, so it shows numbers, not titles;
the titles are in the tooltips and the Paths tab.
- **The simulated player is a timer,** and real audio was not tested. With `?t_play` under headless
virtual time the clock advances a few seconds (40 → 0:41 in the shot). Desktop 1440×900 only.
- With the panel closed, a ring from 14's sun shows at the stage's right edge (`-panel-closed`). This is
inherited and unchanged.
@@ -0,0 +1,70 @@
# 17 Voyage with real content — brief
## Why
The user added the real content (`~/epic-the-musical-content`, see round notes §17) and agreed:
"Yes, in 17 after 16" — 17 uses **real FLAC playback, line-synced lyrics from the `.lrc` files, and
the actual Odyssey passage**. Spec `docs/specs/003-content-mount.md` added the server side; read it.
17 starts from a **copy of 16** (`src/mockups/16-voyage-readable/`). Keep everything from 16 (look 2,
readable Inter labels, frieze accents, the five beats, stylised 3D, panel, combined paths, fan works
placeholders). Change only the content side.
## What the server gives you (only when `CONTENT_DIR` is set)
- `GET /content/library.json` → `{ sagas: [{ name, year, cover, songs: [{ track, title, audio,
lyrics: { url, timed } | null }] }] }` (types in `src/content/library.ts`; import the types only).
- `GET <audio url>` → FLAC with HTTP range support (seeking works in `<audio>`).
- `GET <lyrics url>` → `.lrc` (timed) or `.txt` (plain). Parse `.lrc` with `parseLrc` from
`src/shared/lrc.ts` → `{ time, text }[]`.
- `GET /content/homer/odyssey/<book>.json` → `{ book, heading, summary, paragraphs[] }` (Butler's prose
translation; Roman names: Ulysses, Minerva, Jove, Neptune).
- Without the folder every `/content/*` is 404 → 17 must **fall back gracefully** to 16's behaviour
(placeholder lyrics, simulated player) with a small note "Content folder not mounted".
## Changes
1. **Match sample songs to the library** by normalised title (case/punctuation-insensitive). Log, don't
crash, on no match.
2. **Real player** in the Listen & lyrics tab and the header play button: a real `<audio>` with the
FLAC URL; play/pause, elapsed/total from the audio, the scrub bar seeks. Drop the "Preview player"
note when real audio is used. Saga cover art (`cover`) as a small square in the player.
3. **Line-synced lyrics**: render the LRC lines; the current line is highlighted and kept in view
(smooth scroll, not jumpy; don't fight the user if they scroll manually — resume after ~4 s).
Clicking a line seeks to it. Plain `.txt` lyrics: show them unsynced with a note. Part headings:
LRC has no parts, so drop part headings in the lyrics list; keep the sample's parts + motif chips in
a compact summary above the lyrics ("Parts: Verse 1 · Polyphemus …") — and say in the report how
parts/motifs could be time-aligned later.
Real lyrics are copyrighted: they are shown only from the mounted folder at runtime — never copy any
lyric text into source files, tests, reports or screenshots' file names. Screenshots showing them are
fine (screenshots/ is gitignored).
4. **The Odyssey passage**: the About tab's Odyssey reference opens the real text. The sample data has
book numbers; fetch that book and show heading, summary, and the paragraphs, starting at the first
paragraph that mentions a keyword for the song (e.g. Polyphemus → "Polyphemus"/"Cyclops";
fall back to the book start), in a readable reading view (a drawer over the panel or a "Read" sub-view
with back). Add a one-line note: "Samuel Butler's translation (public domain) uses Roman names:
Ulysses = Odysseus, Minerva = Athena …". Replace "lines 105–460" displays with "Book IX" since the
prose has no lines.
5. **Voyage play**: when "Play voyage" runs and a beat's song has audio, it may play a short excerpt
— optional, your call; say what you chose. Never autoplay on page load.
## Rules
- Edit ONLY `src/mockups/17-voyage-real/` (stubs exist; index.html links credits; main.ts calls
`mountCredits()`; replace the stub style.css with unique content). You may import from `src/shared/`
and types from `src/content/` but don't change them. three.js allowed. No new dependencies.
- Credits footer fully visible in every state.
- Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r3-17`,
serve with `cd <outdir> && PORT=4117 CONTENT_DIR=/home/user/epic-the-musical-content bun server.js`.
Kill only your own server.
- Screenshots 1440×900 with `--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader
--virtual-time-budget=6000 --autoplay-policy=no-user-gesture-required`:
`screenshots/17-voyage-real-listen.png` (beat 3, playing, mid-song e.g. `?t_play=60`, current line
highlighted), `-odyssey.png` (the passage view for Polyphemus), `-txt.png` (if a sample song maps to a
`.txt` lyric; otherwise skip), `-nocontent.png` (served WITHOUT `CONTENT_DIR`, fallback), `-beat1.png`.
Also verify with Playwright or a script that the audio element's `currentTime` advances and seeking
works against the real server. Look at every screenshot; fix what's off.
- `bun test` and `bun run typecheck` must pass. Don't commit. Don't spawn subagents.
- If you run low on budget, stop and write a pause report (done / checklist / next step) instead.
- Report to `docs/design/002-sea-chart-spikes/17-voyage-real-report.md` (no lyric text in it): what you
built, what works, what doesn't, screenshots, concerns.
@@ -0,0 +1,133 @@
# 17 Voyage with real content — report
Status: **done** (not committed). Built from `17-voyage-real-brief.md`, starting from a copy of 16.
## What was built
`src/mockups/17-voyage-real/` is 16 unchanged on the stage (look 2, Inter labels, frieze accents, five
beats, stylised 3D, combined paths, fan works placeholders). Only the content side changed. URL params
are as in 16, plus **`?read=1`**, which opens About's Odyssey reading view for the first song (used for
screenshots).
- **`content.ts`** (new) reads the mounted folder at runtime:
- `loadLibrary()` fetches `/content/library.json` before the panel is built (top-level await, 4 s
timeout). A 404 or a failure means "not mounted".
- `realFor(song)` matches a sample song to the library by normalised title. Case, accents,
apostrophes and punctuation are ignored. A miss is logged once and does not crash.
- `loadLyrics()` parses `.lrc` files with `parseLrc` (`src/shared/lrc.ts`) and splits `.txt` files
into plain lines. `loadBook()` fetches `/content/homer/odyssey/<n>.json`. Both are cached.
- It also holds the keywords that mark each song's passage, `passageStart()`, and a footnote-marker
stripper for Gutenberg's inline note numbers.
- Only types are imported from `src/content/`.
1. **Real player** (`player.ts`, `panel.ts`).
- The `<audio>` element is real and sits hidden in the panel's DOM. Play/pause, elapsed time and
total time all come from the element, and the scrub bar seeks it (click, drag, ←/→ ±5 s).
- While the song plays, the clock is read every animation frame, so the highlight moves smoothly.
`timeupdate` alone fires only about 4 times a second.
- A seek made before the metadata has loaded (`?t_play`) sets the start position and is applied
again once the metadata arrives.
- The saga cover is a 56 px square in the player card. The card is sticky at the top of the tab, so
the controls stay put while the lyrics scroll under it.
- With real audio, the "Preview player" note, the part segment buttons and the part ticks are gone,
because the sample's part times are invented and would be wrong against the recording.
- The header play button and its eyebrow ("Playing · 1:00 / 3:36") use the real clock.
- If the file fails to load, the player drops back to the simulated clock and the tab re-renders
as 16.
2. **Line-synced lyrics.**
- The `.lrc` lines are shown in serif italic, with empty LRC lines drawn as a faint `· · ·` break.
- The current line has a terracotta wash, an ink outline and bold type, and lines already sung turn
iron-soft. The match leads the timestamp by 0.15 s.
- **Keeping the line in view.** The current line is smoothly scrolled to about a third of the way
down the space below the sticky player, once per line change.
- **The reader's own scrolling wins.** A wheel, touch, scrollbar drag or keyboard scroll in the view
pauses following for 4 s. After that the lyrics catch up on the next line. Seeking or clicking a
line resumes following at once.
- **Click-to-seek.** Clicking a line seeks to its timestamp and plays. The timestamp appears in the
gutter on hover or focus.
- **Plain `.txt` lyrics** are shown unsynced, with the note "These lyrics are not timed, so they do
not follow the song".
- **Missing or failed lyrics** each get a one-line note.
- **Parts and motifs.** Part headings are gone from the lyrics. A compact summary above them reads
"Parts: Verse 1 · Verse 2", followed by the motif chips (the motif pick works as before) and "From
the sample data, not yet timed to the recording".
3. **The Odyssey passage.**
- About's Odyssey row now reads "Book IX". The line ranges are dropped because the prose has no line
numbers.
- The **Read the passage →** button replaces About with a reading view, which has "← Back to About",
the book heading, Butler's summary line, the Roman-names note and the paragraphs.
- **Where the passage starts.** The view opens at the first paragraph that mentions the song's
keyword and puts a meander rule before it: "The passage for Polyphemus begins here · first mention
of 'Cyclops'". Keywords are highlighted in the text.
- **Earlier paragraphs.** "Show the 13 paragraphs before this passage" expands the whole book and
scrolls to the rule. If no keyword is found, the view starts at the beginning of the book.
- The text ends with "End of Book IX · Project Gutenberg #1727".
- **Keywords (Butler's spelling).**
- Horse and the Infant: horse (VIII, Demodocus).
- Full Speed Ahead: Cicons / Ismarus.
- Warrior of the Mind: Minerva (XIII).
- Polyphemus: Polyphemus / Cyclops.
- Survive: olive (the stake).
- Remember Them: rams.
- My Goodbye: "put your eye out" (the taunt).
- Storm: Aeolus.
- These are content guesses, the same as the sample's `TODO(verify)` references.
4. **Fallback.** Without `CONTENT_DIR` the page behaves like 16: the simulated player, placeholder
lyrics by part, and the Gutenberg link. The player note starts with **"Content folder not mounted."**,
and the end of About has a small "Content folder not mounted" note. `body[data-content]` is
`mounted` or `missing`.
5. **Voyage play.** It stays **silent: no excerpts**. Beats hold for 3.4 s, so an excerpt would be a
jarring audio stab, and starting sound without a click is close to the autoplay the brief rules out.
As in 16, moving to a new song loads that song stopped at 0:00. The old `<audio>` element is removed.
A song that is playing keeps playing while the beat changes but the song stays the same (Landfall →
Curtain).
## Screenshots (1440×900, `screenshots/`)
- `17-voyage-real-listen.png`: beat 3, Listen & lyrics, `?t_play=60`. It is playing at 1:00 / 3:36,
with the cover, the current line lit and kept in view under the sticky player.
- `17-voyage-real-odyssey.png`: Polyphemus, Book IX reading view, opened at the first "Cyclops"
paragraph.
- `17-voyage-real-nocontent.png`: served without `CONTENT_DIR`. It shows the simulated player, the
"Content folder not mounted" note and the placeholder lyrics.
- `17-voyage-real-nocontent-about.png`: About without content (Gutenberg link and the note).
- `17-voyage-real-beat1.png`: the Troy frieze, with About's "Book VIII · Read the passage →".
- `-txt.png` was skipped. No sample song (tracks 1–10) maps to a `.txt` lyric; in the library only
Scylla and two others outside the sample do.
The credits footer is fully visible in every one.
## Verified
- `bun test` (64 pass), `bun run typecheck` (clean) and `bun run build`. The scratchpad build was
served on port 4117 with and without `CONTENT_DIR`.
- A CDP script drove headless Chrome against the real server, with no virtual time:
- The real FLAC loads (`readyState` 4, duration 216 s).
- After the header play click, `currentTime` advances (2.94 → 4.95 s over 2 s).
- A scrub click at 50 % seeks to 109.4 s.
- Clicking a lyric line stamped 1:08 seeks there, and that line becomes current.
- A range request on the audio URL returns `206 bytes 1000000-1000099/42425888`.
- Following keeps the current line 249 px down a 520 px view.
- After a user scroll to the top, the view stays at 0 for 2.5 s, then follows again by 7.5 s.
- Moving to beat 5 swaps the element to Survive, stopped.
- The reading view (Survive → "olive", 20 earlier paragraphs) expands to all 44 paragraphs, and Back
returns to About.
- The console has no errors; the only message is a three.js warning inherited from 16.
## What doesn't, or is rough
- **Parts and motifs are not time-aligned.** Later, each part could get a `startSec` (the type already
has it), or better, an LRC line index, by marking the first line of each part once per song. That
could be a small JSON sidecar in the content folder, keyed by song, mapping part → first line index;
the line's timestamp then gives the time. Motifs could be stamped the same way, with a line range
per motif. With that, the player could show "Verse 2" again, put the part ticks on the scrub bar, and
light motif chips as they sound.
- **Keyword starts are approximate.** The Polyphemus passage opens where the Cyclops comes home, not
at the landing on the island (the first "Cyclopes", 8 paragraphs earlier). My Goodbye's Homer
counterpart is debatable. The keywords should be confirmed with the content work.
- Butler's summary line is in capitals from the source, and contains a doubled dash ("—-").
- Headless virtual time does not run the audio clock, so `?t_play` screenshots show the seek point,
not real progress. Real progress was checked over CDP instead.
- Library loading delays the panel by one request (fast locally). A slow server would hold the panel
for up to 4 s before it falls back.
- Desktop 1440×900 only. The `.txt` path is implemented but has not been seen on screen.
@@ -0,0 +1,55 @@
# Round 2 brief: sea-chart spikes (08–11)
## The question these spikes answer
**How can a sea chart be as easy to navigate and read as mockup 01 (editorial scroll) and mockup 03 (transit map)?**
The user's own words:
> "Yeah i did think sea chart, im just not sure how we have it easy to navigate and look at as with the editorial scroll and transit map."
Read `docs/design/002-direction-round-notes.md` for the full context. In short, the planned app has two layers:
- A 2D sea chart that you can browse on its own as a simple display.
- A 3D sea voyage (see mockup 07) with landings, where the chart is reused as an in-3D map overlay. The Greek frieze (mockup 04) acts as the transition between them.
Before designing, look at mockups 01, 03, 04 and 07: their code is in `src/mockups/`, and their screenshots are in `screenshots/01-*`, `03-*`, `04-*` and `07-*`. Your chart should borrow their best ideas:
- From 01: legible editorial typography, time-proportional spacing, and the motif-thread idea.
- From 03: crisp motif lines, interchanges, and the legend.
- From 07: the geography of the route and its islands.
## What every spike must show
- **The chart itself.** Use stylised geography: islands for the sagas (Troy, the Cyclops' island, and the open sea and storm) and the ship's route. Each song is a port or waypoint on the route.
- **Readable at a glance.** Song titles, saga names and the passage of in-story time must be easy to see without hunting. This is the whole point of the round.
- **Motifs as coloured routes or lines.** Selecting a motif highlights every song and part where it plays.
- **Opening a song.** Use a simple detail panel with the title, story time, Odyssey link, parts with singers, motif chips, and the shared `createAudioSlot` and `createVideoCards`. Keep it lighter than in 01–07, because the navigation is what's being tested here.
- **The in-3D overlay state.** At `?overlay=1`, show the same chart as a compact overlay sitting on a dimmed backdrop. A static image or a CSS gradient standing in for the 3D sea is fine. Mark the ship's current position ("You are here: Survive"), and make clicking a port show a "Sail here" affordance. This shows how one chart serves both uses.
- **Credits.** The fan notice and the credit to Jorge Rivera-Herrans must be fully visible in every state, via `mountCredits()`, which the stub already calls.
## Rules
- **This is a throwaway spike.** It doesn't need to be bulletproof and no unit tests are needed. Desktop 1440×900 only.
- **Edit ONLY `src/mockups/<your-slug>/`.** Never touch shared files, and add no new dependencies.
- **Fonts.** Use the bundled `@fontsource/cormorant-garamond` and `@fontsource/inter`, or system stacks. No CDN.
- **No three.js.** These are 2D: SVG, Canvas 2D, or plain DOM.
- **Unique CSS.** Your `style.css` must not be byte-identical to any other mockup's.
- **Build.** Build into your own outdir, never `dist/`:
`bun build src/server.ts --target=bun --production --outdir /tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r2-<slug>`
Then serve it with `cd <outdir> && PORT=<port> bun server.js`. Ports: 08→4108, 09→4109, 10→4110, 11→4111. Kill only your own server; don't use `pkill` patterns.
- **Screenshots.**
- Command: `google-chrome --headless=new --disable-gpu --hide-scrollbars --window-size=1440,900 --screenshot=<file> <url>`
- Support `?song=<id>`, `?motif=<id>` and `?overlay=1` so the states can be screenshotted.
- Save `screenshots/<slug>-default.png`, `-motif.png`, `-song.png` and `-overlay.png`.
- Look at them before reporting.
- **Checks before reporting.** `bun test` and `bun run typecheck` must pass.
- **Committing.** Don't commit; the controller commits.
- **Subagents.** Never spawn subagents.
## Report
- Write a short report to `.superpowers/brainstorm/<slug>-report.md` covering:
- what navigation idea you tried;
- what works;
- what doesn't;
- your screenshot paths.
- Then reply with only: status, one line on the idea, screenshot paths, and concerns.
+65
View File
@@ -0,0 +1,65 @@
---
status: approved
---
# 002 — The voyage app: design record
Approved by the user on 2026-10-30 ("Yes. But listen wit hthe lyrics and watch should probably both be
in the listen & lyrics tab. Threads on the sea toggle works nicely. If hide timeline is off, maybe just
put it into paths or something."). Full history and verbatim quotes: `002-direction-round-notes.md`.
Reference mockups: **17 Voyage with real content** (with **16** and **12** for details). This file is
written so the app can be built without opening the mockups.
## The chosen ideas
1. **A 3D sea voyage is the main experience.** The ship sails Odysseus' route; every song is a stop
along it, in album order. Moving between songs moves the ship.
2. **The Greek frieze is the curtain call between scenes.** A black-figure-on-terracotta frieze band
drops like a curtain at scene changes: at the start (Troy), when entering a new saga, and before a
landing. The frieze's ground line becomes the 3D horizon when it lifts.
3. **Landings are sublevels.** At three places the voyage goes ashore through the frieze into a small 3D
scene: the **Cyclops' cave** (Cyclops Saga), **Athena's fight in God Games** (Wisdom Saga), and
**Odysseus' Ithaca homecoming** (Ithaca Saga). "Back to sea" returns to the voyage.
4. **Look 2, "two worlds".** The world is bright and naturalistic in colour (blue sea, warm sky,
green-ochre islands); the frieze keeps terracotta and black. The UI ties them together with the
frieze's meander band as the top edge of every panel/card/bar and terracotta + black accents on
parchment. "The colors pop."
5. **Stylised, not realistic, 3D.** Toon/flat shading, ink outlines, flat colour-band water with drawn
wave strokes, a small flat sun, graphic silhouettes (ship, island, the black-figure giant).
6. **Readable type.** Inter for all UI and labels, nothing under 13px, panel body ≥15px, minimal
tracking. Cormorant Garamond only for large display titles, lyric lines and frieze inscriptions.
7. **A right-side panel stays open** throughout (collapsible to a tab), following the current song.
Tabs:
- **About**: track, saga, story time, facts, parts and singers, the Odyssey reference with a
"Read the passage" reading view (Greek names: Butcher & Lang).
- **Listen & lyrics**: the player (real audio, seek bar, cover art), the line-synced lyrics with the
current line highlighted and click-to-seek, and **Watch** (YouTube link cards). The "listen with the
lyrics" entry and Watch both live here.
- **Paths**: each of the song's motifs with where it was heard before and where it returns, the
thread detail, the chart; and **the motif strip when the timeline is hidden**.
- **Fan works**: credited, linked fan works per song.
A play button in the panel header starts the song and opens Listen & lyrics.
8. **Paths are always visible.** The **bottom timeline** (the "big horizontal line") shows the songs,
saga dividers, "you are here", and motif threads underneath with knots where a motif plays. It can be
hidden; then the strip lives in the Paths tab. **"Threads on the sea"** is a toggle that draws the
current song's motifs as ink-edged ribbons on the sea (behind the ship solid, ahead dashed, with
merged labels such as "Next: Survive").
9. **Motifs as threads** (chosen over pennants and ink-only in 12): soft coloured threads with knots,
one colour per motif, the same colour everywhere (timeline, panel, ribbons, lyric chips).
10. **Real content from the mounted folder** (spec 003): FLAC playback with seeking, `.lrc` synced
lyrics, the Odyssey passage (Butcher & Lang's translation, which keeps the Greek names). Never committed; the app works without it.
11. **Fan project, credited.** The fan notice and the thanks to Jorge Rivera-Herrans on every screen.
## Deselected, and why
- **02 Vertical story, 05 Score/waveform, 06 Constellation**: "too uninteresting".
- **08 Sea chart + log line, 09 Schematic sea chart**: "doesnt work too well".
- **11's metro-map lines inside plates**: "doesnt hit as well". 11's navigation survives as the idea of
places opening into sublevels; 10's vellum style survives in the chart.
- **Pennants (B) and ink-only (C)** motif treatments in 12: threads chosen "definetly".
- **Look 1 (vellum throughout)**: decent but the UI of 2 and 3 looked better.
- **Look 3 (frieze-led)**: made the site feel unified, but "im leaning a lot more towards 2. The colors
pop a lot more." Its cohesion (frieze accents on menus) was carried into look 2.
- **15's inscription-style labels** (small wide-tracked Cormorant capitals): "very hard to read".
- **Choosing one paths placement**: A/B/C "all have their strengths", so they were combined.
- **Simulated playback and placeholder lyrics**: replaced by the real content.
+196
View File
@@ -0,0 +1,196 @@
---
status: in-progress
---
# The Voyage App (v1) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the approved voyage design (mockup 17 + the user's last changes) as the real app at `/` for all 9 sagas / 40 songs.
**Architecture:** A new page `src/app/` (plain TS + DOM + three.js, bundled by Bun's HTML import) with pure, unit-tested modules for state, route geometry, transitions and timeline layout, and DOM/three modules ported from `src/mockups/17-voyage-real/`. New album data lives in `src/data/album/` so the mockups' `src/data/types.ts`/`sample.ts` stay untouched. Real content comes from `/content/*` (spec 003).
**Tech Stack:** Bun 1.3.14 (serve, bundler, test + happy-dom), TypeScript, three.js 0.186, @fontsource Inter + Cormorant Garamond.
**Spec:** `docs/specs/002-voyage-app.md` · design record `docs/design/002-voyage-app.md` · content `docs/specs/003-content-mount.md`
## Global Constraints
- Every page keeps `<footer data-fan-credits>`, links `../shared/credits.css` (path relative to the page) and calls `mountCredits()`. Never write notice text.
- Real lyrics/audio/art only from `/content/*` at runtime. Never put lyric text in source, tests, docs or commit messages.
- Fonts/three.js bundled from npm; no CDN; no new dependencies.
- Inter for UI/labels, **nothing under 13px**, panel body ≥15px; Cormorant only for big titles, lyric lines, frieze inscriptions.
- Look 2 palette: bright world, terracotta + black frieze, parchment UI with the meander band on top of panels/cards/bars.
- Facts you cannot confirm get `// TODO(verify)` (album data only).
- Mockup folders `src/mockups/**` are not modified (copy code from 17 instead).
- Desktop 1440×900 is the reference; below 900px: panel as bottom sheet, compact timeline.
## Review Focus
1. **Content folder missing or partial** (no `CONTENT_DIR`, or a song without `.lrc`, or `.txt` lyrics) → the app still navigates; Listen shows a clear note; nothing throws. Test in Task 4.
2. **No WebGL** (`canvas.getContext` returns null) → frieze + panel + timeline still work and a note explains. Test in Task 7.
3. **Deep link to any of 40 songs / bad `?song=`** → valid songs open directly (landing songs open in their landing); unknown ids fall back to the first song. Test in Task 2.
4. **Rapid navigation** (holding → or clicking stops during a transition) → transitions are cancelled/queued, the final state matches the last request, no stuck curtain. Test in Task 7.
5. **Long titles** ("Would You Fall in Love with Me Again", "Not Sorry for Loving You") → no overlap in the expanded timeline saga, panel title wraps. Test in Task 5.
---
### Task 1: Album data
**Files:**
- Create: `src/data/album/types.ts`, `src/data/album/motifs.ts`, `src/data/album/songs.ts`, `src/data/album/sagas.ts`, `src/data/album/query.ts`, `src/data/album/index.ts`
- Test: `tests/album.test.ts`
**Interfaces — Produces:**
```ts
export type LandingId = "cave" | "arena" | "hall";
export type PlaceId = "troy" | "cyclops" | "ocean" | "circe" | "underworld" | "thunder" | "wisdom" | "vengeance" | "ithaca";
export interface Motif { id: string; name: string; kind: "motif" | "character"; color: string; description: string }
export interface AlbumSaga { id: PlaceId; name: string; order: number; year: number; place: string; blurb: string }
export interface OdysseyRef { book: number; keywords: string[]; url: string } // url: Gutenberg #1727 book anchor
export interface AlbumPart { name: string; singers: string[]; motifIds: string[] }
export interface AlbumSong {
id: string; title: string; sagaId: PlaceId; track: number /*1..40 album order*/; sagaTrack: number;
storyTime: { yearsAfterTroy: number; label: string }; odyssey: OdysseyRef | null;
singers: string[]; motifIds: string[]; parts: AlbumPart[]; facts: string[];
videos: { title: string; url: string }[]; landing: LandingId | null;
}
export interface FanWork { songId: string; type: "art" | "animatic" | "cover" | "cosplay" | "other"; title: string; creator: string; url: string; permission: string }
// index.ts re-exports: sagas, songs, motifs, fanWorks (= [] in v1), and query helpers:
export function songById(id: string): AlbumSong | undefined;
export function songsInSaga(sagaId: PlaceId): AlbumSong[];
export function sagaOf(song: AlbumSong): AlbumSaga;
export function songsWithMotif(motifId: string): AlbumSong[];
export function prevNextWithMotif(song: AlbumSong, motifId: string): { prev: AlbumSong | null; next: AlbumSong | null };
```
Values pinned by the spec: titles and order exactly as the content folder (Troy: The Horse and the Infant, Just a Man, Full Speed Ahead, Open Arms, Warrior of the Mind · Cyclops: Polyphemus, Survive, Remember Them, My Goodbye · Ocean: Storm, Luck Runs Out, Keep Your Friends Close, Ruthlessness · Circe: Puppeteer, Wouldn’t You Like, Done For, There Are Other Ways · Underworld: The Underworld, No Longer You, Monster · Thunder: Suffering, Different Beast, Scylla, Mutiny, Thunder Bringer · Wisdom: Legendary, Little Wolf, We'll Be Fine, Love in Paradise, God Games · Vengeance: Not Sorry for Loving You, Dangerous, Charybdis, Get in the Water, Six Hundred Strike · Ithaca: The Challenge, Hold Them Down, Odysseus, I Can't Help but Wonder, Would You Fall in Love with Me Again). Landings: `cave` = Polyphemus, Survive; `arena` = God Games; `hall` = all five Ithaca songs. Ids are kebab-case titles (`wouldnt-you-like`). Motif colours: reuse `src/data/sample.ts` colours for motifs that exist there. Odyssey `url`: `https://www.gutenberg.org/cache/epub/1728/pg1728-images.html#chap{NN}` (Butcher & Lang) (two digits).
Content guidance: use what is well established about EPIC (motifs such as Warrior of the Mind, Open Arms, Just a Man/My Goodbye, Ruthlessness, Monster, Little Wolf, Love in Paradise/Penelope's theme, Thunder Bringer, character themes for Odysseus, Athena, Poseidon, Zeus, Penelope, Telemachus, Polyphemus, Circe, Hermes…). The `.lrc` files in `/home/user/epic-the-musical-content` may be *read* to check where phrases recur, but no lyric text may be written anywhere. Mark every uncertain value `// TODO(verify)`.
- [x] **Step 1: Write failing tests** in `tests/album.test.ts`: 9 sagas ordered troy…ithaca; `songs.length === 40`; tracks 1..40 contiguous; `songs.map(s=>s.title)` equals the pinned list; each saga's `sagaTrack` 1..n; every `motifIds`/part motif exists in `motifs` and every motif is used ≥2 songs; `storyTime.yearsAfterTroy` non-decreasing by track; landing mapping as pinned; `fanWorks` is `[]`; `prevNextWithMotif` on a motif's middle occurrence returns both neighbours; if `/home/user/epic-the-musical-content/Songs and Lyrics` exists, `scanLibrary` titles (normalised: lowercase, strip non-alphanumerics) equal the album titles in order.
- [x] **Step 2:** `bun test tests/album.test.ts` → FAIL (modules missing).
- [x] **Step 3:** Implement the files above.
- [x] **Step 4:** `bun test` and `bun run typecheck` → all pass.
- [x] **Step 5:** Commit `feat(data): album data for all 9 sagas and 40 songs`.
### Task 2: App shell, routes and URL state
**Files:**
- Create: `src/app/index.html`, `src/app/main.ts`, `src/app/style.css` (tokens: colours, type, meander band, layout grid: stage | panel 420px, timeline bottom), `src/app/state.ts`
- Move: `src/index.html`, `src/index.ts`, `src/index.css` → `src/mockups/index.html`, `src/mockups/index.ts`, `src/mockups/index.css` (fix relative paths: `../shared/credits.css` → `../shared/credits.css` stays valid? — it becomes `../shared/` from `src/mockups/`; card hrefs stay absolute)
- Modify: `src/server.ts` (`"/"` → app, `"/mockups/"` → gallery, `"/mockups"` → 302 `/mockups/`), `tests/server.test.ts` (gallery tests hit `/mockups/`; new test: `/` contains `data-fan-credits` and `id="voyage-app"`)
- Test: `tests/state.test.ts`
**Interfaces — Produces:**
```ts
// src/app/state.ts
export type Tab = "about" | "listen" | "paths" | "fan";
export interface AppState { songId: string; tab: Tab; motif: string | null; timeline: "open" | "closed"; ribbons: boolean; read: boolean }
export const DEFAULT_STATE: AppState; // songId "the-horse-and-the-infant", tab "about", motif null, timeline "open", ribbons false, read false
export function parseState(search: string): AppState; // unknown song/tab/motif → defaults
export function stateToSearch(s: AppState): string; // omits defaults; "" for DEFAULT_STATE
export function createStore(initial: AppState): { get(): AppState; set(p: Partial<AppState>, opts?: { push?: boolean }): void; subscribe(fn: (s: AppState, prev: AppState) => void): () => void };
// store.set writes history (push for song changes, replace otherwise); popstate restores.
```
`index.html` has `<main id="voyage-app">` with regions `#stage` (canvas + frieze layer + caption), `#panel`, `#timeline`, and the credits footer.
- [x] **Step 1:** Failing tests: `parseState("?song=survive&tab=listen&motif=polyphemus&timeline=closed&ribbons=1&read=1")` round-trips via `stateToSearch`; `parseState("?song=nope&tab=x")` equals `DEFAULT_STATE`; `stateToSearch(DEFAULT_STATE) === ""`; server tests as above.
- [x] **Step 2:** Run → FAIL.
- [x] **Step 3:** Implement; `main.ts` boots the store, mounts credits, renders a placeholder stage showing the current song title (replaced in Task 7).
- [x] **Step 4:** `bun test`, `bun run typecheck`, `bun run build` pass; `bun run dev` → `/` and `/mockups/` load.
- [x] **Step 5:** Commit `feat(app): app shell at /, gallery at /mockups/, URL state`.
### Task 3: Content client
**Files:** Create `src/app/content.ts` (port of `src/mockups/17-voyage-real/content.ts` to album types). Test: `tests/app-content.test.ts`.
**Interfaces — Produces:**
```ts
export function loadLibrary(timeoutMs?: number): Promise<boolean>; // false when /content 404s or times out (default 4000)
export function realFor(song: AlbumSong): { audio: string; cover: string | null; lyrics: { url: string; timed: boolean } | null } | null;
export type Lyrics = { timed: true; lines: LrcLine[] } | { timed: false; lines: string[] };
export function loadLyrics(ref: { url: string; timed: boolean }): Promise<Lyrics | null>;
export function loadBook(book: number): Promise<HomerBook | null>;
export function passageStart(book: HomerBook, keywords: string[]): { index: number; word: string | null };
```
- [x] Steps: failing tests with a mocked `fetch` (library 404 → `false`; match by normalised title incl. curly apostrophe "Wouldn’t You Like"; `.txt` lyrics → `{timed:false}`; `passageStart` finds first keyword paragraph, else 0) → implement → pass → commit `feat(app): content client`.
### Task 4: Side panel
**Files:** Create `src/app/panel/panel.ts`, `about.ts`, `listen.ts` (player + synced lyrics + Watch), `paths.ts`, `fanworks.ts`, `reading.ts`; styles appended to `src/app/style.css` under a `/* panel */` section. Port from 17's `panel.ts`, `player.ts`. Test: `tests/panel.test.ts` (happy-dom).
**Interfaces:**
- Consumes: Task 1 album API, Task 2 store, Task 3 content client.
- Produces: `mountPanel(root: HTMLElement, store: Store, opts: { timelineHidden: () => boolean }): { refresh(): void; destroy(): void }`.
Behaviour pinned by the user: **Listen & lyrics** contains the player, the lyrics and **Watch** (video cards via `createVideoCards`); About contains no Watch and no listen button. **Paths** shows the motif strip at its top **only when `state.timeline === "closed"`**, then per-motif "heard before / here / returns" rows, then the nine-place chart. **Fan works**: empty state "No fan works collected yet. Real ones will be credited and linked to their creators." when `fanWorks` has none for the song. Header play button: starts audio and switches to `listen`. Reading view (`state.read`) shows the Odyssey book (Butcher & Lang, Greek names — no Roman-names note) from the passage start, "Show the N paragraphs before", Back.
- [x] Steps: failing tests — About has no `.watch`; Listen has `.player`, `.lyrics`, `.watch`; Paths has `.motif-strip` iff timeline closed; fan empty state text; without content (`loadLibrary` false) Listen shows "Content folder not mounted" and no `<audio>`; long title wraps (no `white-space: nowrap` on `.panel__title`) → implement → pass → commit `feat(app): side panel`.
### Task 5: Timeline
**Files:** Create `src/app/timeline/layout.ts` (pure), `src/app/timeline/timeline.ts` (DOM/SVG). Test: `tests/timeline.test.ts`.
**Interfaces — Produces:**
```ts
export interface TimelineStop { songId: string; x: number; label: string | null; current: boolean }
export interface TimelineLayout { stops: TimelineStop[]; sagas: { sagaId: PlaceId; x0: number; x1: number; expanded: boolean }[]; width: number }
export function layoutTimeline(currentSongId: string, width: number, measure: (text: string) => number): TimelineLayout;
export function mountTimeline(root: HTMLElement, store: Store): { refresh(): void };
```
Layout rule: the current saga is expanded (labels, spacing by measured label width + 16px gap, min 64px); other sagas are compact (no labels, 18px per stop, saga name above); remaining width distributed to gaps between sagas; if it still doesn't fit, the expanded saga scrolls into view (the strip scrolls horizontally). Threads: one row per motif of the current song, knots at songs that use it, across all 40. Hide/show control writes `state.timeline`. "Threads on the sea" switch writes `state.ribbons`.
- [x] Steps: failing tests — 40 stops; stops strictly increasing x; labels only in the current saga; in every saga expanded (try each saga's first song, width 980, measure = 7.2px/char) no two labelled stops overlap; collapse sets `timeline=closed` → implement → pass → commit `feat(app): bottom timeline`.
### Task 6: The 3D world, landings and friezes
**Files:**
- Create `src/app/world/route.ts` (pure), `world.ts`, `materials.ts`, `ship.ts`, `islands.ts`, `ribbons.ts`, `landings/cave.ts`, `landings/arena.ts`, `landings/hall.ts`
- Create `src/app/frieze/kit.ts` (figure primitives from 17's `figures.ts`), `src/app/frieze/sagas.ts`, `src/app/frieze/curtain.ts`
- Test: `tests/route.test.ts`, `tests/frieze.test.ts`
**Interfaces — Produces:**
```ts
// route.ts
export interface Place { id: PlaceId; x: number; z: number; radius: number }
export const PLACES: Place[]; // 9, in saga order, along a winding route
export interface Stop { songId: string; placeId: PlaceId; x: number; z: number; heading: number; s: number /* 0..1 along route */ }
export function stops(): Stop[]; // 40, s strictly increasing
// world.ts
export interface World {
sailTo(songId: string, opts?: { instant?: boolean }): Promise<void>;
setLanding(id: LandingId | null): Promise<void>;
setRibbons(motifIds: string[] | null, highlight: string | null): void;
horizonFromFrieze(groundYpx: number): void; // camera pitch so horizon sits on the frieze ground line
resize(): void; dispose(): void;
}
export function createWorld(canvas: HTMLCanvasElement): World | null; // null when WebGL is unavailable
// frieze
export function friezeSvg(sagaId: PlaceId): string; // saga name + its song titles as inscriptions
export function createCurtain(layer: HTMLElement): { drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise<void>; lift(): Promise<void>; fade(): Promise<void>; groundY(): number };
```
Style per the design record (toon/ink, flat bands, small sun). Each place gets a distinct stylised silhouette (Troy walls; Cyclops' island + cave mouth; storm spiral at sea; Aeaea with a palace; Underworld dark gate; thunder sea with Scylla's cliffs; Olympus peak; Charybdis whirlpool; Ithaca with palace). Landings: cave (port 17's), arena (Athena vs. Odysseus as black-figure cut-outs on a sandy ring with columns), hall (Ithaca hall with a bow and a hearth). Friezes: one per saga from the shared kit; reuse 17's Troy and Cyclops friezes.
- [x] Steps: failing tests — `PLACES.length===9` in saga order; `stops().length===40`, `s` strictly increasing, each stop within 1.5×radius of its place; `friezeSvg(id)` contains the saga name and every song title of that saga (escaped) for all 9 → implement → `bun test` pass → screenshots of each place, each landing and each frieze → commit `feat(app): 3D world, landings and friezes`.
### Task 7: Voyage controller and integration
**Files:** Create `src/app/voyage.ts` (pure `transitionFor`) and `src/app/controller.ts`; modify `src/app/main.ts`. Test: `tests/voyage.test.ts`.
**Interfaces — Produces:**
```ts
export type Transition = { kind: "sail" } | { kind: "curtain"; sagaId: PlaceId } | { kind: "fade" } ;
export interface Step { transition: Transition; landing: LandingId | null }
export function transitionFor(fromSongId: string, toSongId: string): Step;
// same saga → sail; adjacent saga → curtain(to's saga); ≥2 sagas apart → fade; landing = to.landing.
```
Controller: subscribes to the store; runs `transitionFor` → curtain/fade → `world.sailTo` → `world.setLanding`; a newer request cancels the running one (generation counter) and the last request wins; Play voyage advances every 3.4s (silent) and stops on user input; ←/→ prev/next song; Esc closes reading view, else leaves a landing; caption card shows saga · story time and song title; no WebGL → `createWorld` null → frieze of the current saga stays as the stage with a note "3D needs WebGL — browsing with the frieze"; `prefers-reduced-motion` → every transition is a 200ms crossfade.
- [x] Steps: failing tests — `transitionFor` cases (polyphemus→survive sail + cave; full-speed-ahead→polyphemus? (troy→cyclops adjacent) curtain cyclops; storm→god-games fade + arena; survive→remember-them sail + null); controller with a fake world: five rapid `set({songId})` calls end with the world at the last song and the curtain lifted; no-WebGL path renders the note → implement → pass → full-app screenshots (below) → commit `feat(app): voyage controller`.
### Task 8: Docs, verification, deploy
- [x] `DESIGN.md`: replace "visual direction open" with the chosen direction as tokens and rules (palette, type scale, meander band, motif colours from data, 3D style, panel/timeline layout).
- [x] `docs/ARCHITECTURE.md` (app modules, routes `/` and `/mockups/`, album data), `docs/DECISIONS.md` (album data separate from mockup data; landing mapping; silent Play voyage; fan works empty in v1), `AGENTS.md` (overview line, architecture, rules for `src/app/`), `PRODUCT.md` if it describes the site as mockups, `docs/TODO.md` (parts/motif time alignment, mobile polish, fan-work sourcing, Odyssey passage confirmation).
- [x] Screenshots at 1440×900 (`--use-angle=swiftshader --use-gl=angle --enable-unsafe-swiftshader --virtual-time-budget=6000`) into `screenshots/app-*.png`: first load; one song per saga; each landing; Listen playing with lyrics; Paths with timeline closed; ribbons on; reading view; no content (server without `CONTENT_DIR`); 800×900 narrow. Look at every one.
- [x] `bun test`, `bun run typecheck`, `bun run build`; rebuild the preview container (command in `docs/design/002-direction-round-notes.md` §7); curl `/`, `/mockups/`, `/content/library.json`.
- [ ] Final whole-branch review by a fresh agent; fix findings; `git rm docs/plans/002-voyage-app.md docs/plans/003-content-mount.md` before any merge request; spec 002 and 003 → `status: implemented`.
+26
View File
@@ -0,0 +1,26 @@
---
status: in-progress
---
# Plan 003 — content mount
Spec: `docs/specs/003-content-mount.md`. TDD: write the test, see it fail, implement, see it pass.
- [x] 1. `src/shared/lrc.ts` — `parseLrc(text): { time: number; text: string }[]`. Tests in
`tests/content.test.ts`: basic lines, `[mm:ss]` and `[mm:ss.xx]`/`[mm:ss.xxx]`, multiple stamps on
one line, `[ar:…]`-style metadata skipped, CRLF, sorted output.
- [x] 2. `src/content/library.ts` — `scanLibrary(dir)`: saga folders `EPIC - <Name> (<year>)`, song files
`… - NN - <Title>.flac`, sagas in story order (Troy, Cyclops, Ocean, Circe, Underworld, Thunder,
Wisdom, Vengeance, Ithaca; unknown names after, by year then name), lyrics `.lrc` preferred else `.txt`, `folder.jpg` cover. URLs under
`/content/files/` with each path segment URI-encoded. Fixture folder built in a temp dir by the test.
- [x] 3. `src/content/homer.ts` — `splitBooks(text)`: between the Gutenberg START/END markers, headings
`BOOK <roman>` (optional trailing dot), CRLF-safe; returns `{ book, heading, summary, paragraphs }[]`.
Test with a small fixture string.
- [x] 4. `src/content/routes.ts` — `contentResponse(req, dir | undefined): Response | null` handling the
three routes, traversal guard, range requests (206 with Content-Range). Wire into `src/server.ts`
`fetch()`. Route tests via `startServer` with `CONTENT_DIR` pointing at the fixture, and without it.
- [x] 5. `.gitignore` / `.dockerignore`: ignore `epic-the-musical-content/`, `content/`, `*.flac`, `*.lrc`.
- [x] 6. Docs: AGENTS.md (lyrics rule, content line in Architecture/Commands), `docs/ARCHITECTURE.md`,
`docs/DECISIONS.md`, `docs/design/002-direction-round-notes.md` §7 run command.
- [x] 7. Verify: `bun test`, `bun run typecheck`, `bun run build`; rebuild the preview container with the
mount; curl library.json (9 sagas, 40 songs), a range request on a FLAC, an Odyssey book.
+71
View File
@@ -0,0 +1,71 @@
---
status: implemented
---
# 002 — The voyage app (v1)
Status: approved by user 2026-10-30 (direction approval; the user's standing rule: carry an approved
design through spec → plan → execution without review stops). Design record:
`docs/design/002-voyage-app.md`. Content source: `docs/specs/003-content-mount.md`.
## Intent
Turn the approved mockup direction (17) into the real site at `/`: the whole album (9 sagas, 40 songs)
as a stylised 3D sea voyage with the frieze as the curtain call between scenes, three landing
sublevels, a persistent side panel, and a bottom timeline of motif threads, using the real audio,
synced lyrics and the Odyssey text from the mounted content folder.
## Goals
1. **Routes.** The app is served at `/`. The mockup gallery moves to `/mockups/` (mockups stay at
`/mockups/NN-slug/`). Every page keeps the fan notice and credit.
2. **Data for the whole album.** `src/data/` describes 9 sagas and 40 songs in album order (titles and
track numbers as in the content folder) with: story time (years after Troy + a label), the Odyssey
reference as a **book number and passage keywords** (Butler's prose has no line numbers), singers,
motifs per song (and per part where known), facts, and video links. A curated motif set (musical
motifs and character themes) with stable colours. Anything not certain is marked `// TODO(verify)`.
Fan works: a typed, credited list (title, creator, type, URL, licence/permission note), **empty in
v1** — no invented works.
3. **The voyage.** One 3D world: a route past nine places (Troy, the Cyclops' island, the open sea/storm,
Aeaea, the Underworld, Scylla/the thunder sea, Olympus/the wisdom sea, Charybdis/vengeance, Ithaca).
Each song is a stop on the route; selecting a song sails the ship there. Stylised per the design
record.
4. **Curtain call.** Entering a saga (from the previous saga or by jumping) drops that saga's frieze
band (black-figure on terracotta, the saga's name and its song titles as inscriptions), which then
lifts into the sea with the horizon on the frieze's ground line. Within a saga, songs change without
the curtain. Jumps across several sagas fade through the curtain colour.
5. **Three landings.** Cyclops' cave, the God Games arena (Athena), Ithaca's hall. Each is entered from
its saga's frieze ("Go ashore") or automatically when its song is chosen (Survive; God Games; the
Ithaca songs from The Challenge on — exact song→landing mapping in the plan), and left with
"Back to sea".
6. **Side panel** with About, Listen & lyrics (player + synced lyrics + Watch), Paths (motif detail +
strip when the timeline is hidden + chart of the nine places), Fan works (list or empty state).
Collapsible; header play button.
7. **Bottom timeline** for 40 songs: all sagas visible as groups; the current saga expanded with
titles, other sagas compact; you-are-here; motif threads for the current song's motifs across the
whole album. Hideable (strip moves into Paths). "Threads on the sea" toggle.
8. **Navigation and state.** Previous/next song (buttons, ←/→), click any stop, Play voyage (steps
through songs, silent), deep links (`?song=`, `?tab=`, `?motif=`, `?timeline=`, `?ribbons=`), browser
back/forward restore state. Esc closes the reading view / leaves a landing.
9. **Robustness.** Works without the content folder (no audio/lyrics, a clear note). Works without
WebGL (the frieze and panel still navigate; a note explains). Respects `prefers-reduced-motion`
(crossfades). Keyboard reachable with visible focus. Desktop first (1280–1920 wide); below 900px
the panel becomes a bottom sheet and the timeline compact — usable, not polished.
## Non-goals (v1)
- Bespoke frieze art for every song; per-saga friezes may reuse a shared figure kit.
- Time-aligning parts and motifs to the recordings (needs a content mapping file; see 17's report).
- Collecting real fan works; mobile polish; i18n; removing the mockups.
## Acceptance criteria
- `bun test` covers: the dataset (40 songs matching the content folder's titles and order, every motif
used and defined, story times non-decreasing in album order, every song in exactly one saga),
the route/stop geometry (one stop per song, ordered along the route), URL state round-trip, timeline
layout (40 stops, no title overlaps in the expanded saga), and the `/` and `/mockups/` routes.
- With the content folder mounted, at 1440×900: every saga's frieze, each landing, the panel tabs,
playback with synced lyrics, and the timeline/ribbons render correctly in headless screenshots that
were looked at; without it, the fallback renders.
- `bun run typecheck`, `bun run build` pass; the preview container serves the app at `/`.
- Docs: ARCHITECTURE, DECISIONS, DESIGN.md (the chosen direction as tokens and rules), AGENTS.md, TODO.
+53
View File
@@ -0,0 +1,53 @@
---
status: implemented
---
# Real content from a mounted folder (audio, timed lyrics, Homer)
Status: approved by user 2026-10-30 · Date: 2026-10-30
## Intent
The user added the real content in `~/epic-the-musical-content/`: all 40 *EPIC* songs as FLAC, timed
lyrics (`.lrc`) for 37 of them and plain-text lyrics for 3, saga cover art, and Samuel Butler's
public-domain prose translations of the *Odyssey* and the *Iliad* (Project Gutenberg #1727, #2199).
Mockups (starting with 17) and later the real app should use this content: real playback, line-synced
lyrics, and the actual Odyssey passage for a song.
The audio, lyrics and cover art are Jorge Rivera-Herrans' copyrighted work. The user chose (2026-10-30):
**"Mount it, never commit"** — the folder stays out of git and out of the Docker image; the server reads
it from a path given by `CONTENT_DIR`, mounted read-only into the container.
## Goals
1. **Server serves the mounted folder** when `CONTENT_DIR` is set:
- `GET /content/library.json` — every saga (name, year, cover URL) with its songs (track, title,
audio URL, lyrics URL and whether they are timed), built by scanning the folder.
- `GET /content/files/<path>` — the file at that path inside `CONTENT_DIR`, with the right content
type and HTTP range support so audio can seek. Paths outside `CONTENT_DIR` are refused.
- `GET /content/homer/<odyssey|iliad>/<book>.json` — one book: number, heading, summary line,
paragraphs. Prefers the Greek-names translations (Butcher & Lang's Odyssey, #1728; Lang, Leaf &
Myers' Iliad, #3059) when present in the folder, else Butler's (#1727, #2199).
- Without `CONTENT_DIR` (or with a missing folder), all `/content/*` routes answer 404 and every
other route works as before.
2. **Shared helpers** for clients: an LRC parser (timed lines, sorted, multi-stamp lines, metadata tags
skipped) and the library types.
3. **Nothing copyrighted is committed or baked into the image.** `.gitignore` and `.dockerignore`
guard against a copy inside the repo.
4. **Docs**: AGENTS.md lyrics rule updated (real lyrics only from the mounted folder, never in the
repo); ARCHITECTURE, DECISIONS; the preview container run command mounts the folder.
## Non-goals
- Changing existing mockups 01–16 to use real content.
- Transcoding FLAC (browsers play FLAC); caching or a CDN.
- Mapping songs to Odyssey passages (that is content work for the real app).
## Acceptance criteria
- `bun test` covers: library scan of a fixture folder (sagas in story order, songs by track, `.lrc`
preferred over `.txt`), LRC parsing, Homer book splitting, the `/content/*` routes with and without `CONTENT_DIR`,
path-traversal refusal, and a range request returning 206.
- The preview container, run with `-v ~/epic-the-musical-content:/content:ro -e CONTENT_DIR=/content`,
serves `/content/library.json` listing 9 sagas and 40 songs, and an audio file plays with seeking.
- `git status` shows no content files; the Docker image contains none.
+109
View File
@@ -0,0 +1,109 @@
// The real content, read at runtime from the server's mounted folder (spec 003): the library (FLAC audio,
// timed or plain lyrics, saga covers) and the Odyssey by book (Butcher & Lang, Greek names). Nothing here
// is copied into the repo: without the folder every /content/ route is 404 and the app runs without it.
// Ported from src/mockups/17-voyage-real/content.ts to the album types.
import type { HomerBook } from "../content/homer";
import type { Library, LibrarySong } from "../content/library";
import type { AlbumSong } from "../data/album";
import { parseLrc, type LrcLine } from "../shared/lrc";
export type Lyrics = { timed: true; lines: LrcLine[] } | { timed: false; lines: string[] };
export type LyricsRef = NonNullable<LibrarySong["lyrics"]>;
export interface RealSong { audio: string; cover: string | null; lyrics: LyricsRef | null }
/** Case-, accent- and punctuation-insensitive title key ("Wouldn’t You Like" = "wouldnt you like"). */
export const normTitle = (s: string) =>
s.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[’'`]/g, "").replace(/[^a-z0-9]+/g, " ").trim();
const byTitle = new Map<string, RealSong>();
let pending: Promise<boolean> | null = null;
let generation = 0;
/** Loads /content/library.json; false when the folder is not mounted (404), the request fails or times out. */
export function loadLibrary(timeoutMs = 4000): Promise<boolean> {
byTitle.clear();
const gen = ++generation;
const p = (async () => {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
const res = await fetch("/content/library.json", { signal: ctl.signal });
if (!res.ok) return false;
const lib = (await res.json()) as Library;
if (gen !== generation) return false; // a newer load replaced this one
for (const saga of lib.sagas) for (const song of saga.songs) {
byTitle.set(normTitle(song.title), { audio: song.audio, cover: saga.cover, lyrics: song.lyrics });
}
return byTitle.size > 0;
} catch {
return false;
} finally {
clearTimeout(timer);
}
})();
pending = p;
return p;
}
/** The library load in flight or done, starting one if none was asked for yet. */
export const libraryReady = (): Promise<boolean> => pending ?? loadLibrary();
/** Whether the library has loaded with at least one song. */
export const contentMounted = () => byTitle.size > 0;
/** The library entry for an album song, matched by normalised title; null if not mounted or no match. */
export function realFor(song: AlbumSong): RealSong | null {
return byTitle.get(normTitle(song.title)) ?? null;
}
const lyricCache = new Map<string, Promise<Lyrics | null>>();
/** Fetches and parses a song's lyrics (.lrc → timed lines, .txt → plain lines); null if it fails. */
export function loadLyrics(ref: LyricsRef): Promise<Lyrics | null> {
const key = `${ref.timed}:${ref.url}`;
let p = lyricCache.get(key);
if (!p) {
p = fetch(ref.url)
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
.then((text): Lyrics => ref.timed
? { timed: true, lines: parseLrc(text) }
: { timed: false, lines: text.trim().split(/\r?\n/).map((l) => l.trim()) })
.catch(() => { lyricCache.delete(key); return null; });
lyricCache.set(key, p);
}
return p;
}
const bookCache = new Map<number, Promise<HomerBook | null>>();
/** One book of the Odyssey from the content folder; null if it is not there. */
export function loadBook(book: number): Promise<HomerBook | null> {
let p = bookCache.get(book);
if (!p) {
p = fetch(`/content/homer/odyssey/${book}.json`)
.then((r) => (r.ok ? (r.json() as Promise<HomerBook>) : null))
.catch(() => null)
.then((b) => { if (!b) bookCache.delete(book); return b; });
bookCache.set(book, p);
}
return p;
}
const reEsc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
/** A whole-word, case-insensitive pattern for any of the keywords; null for none. */
export const keywordRe = (words: string[]) => (words.length ? new RegExp(`\\b(${words.map(reEsc).join("|")})\\b`, "gi") : null);
/** Index of the first paragraph that mentions a keyword, and which one; paragraph 0 if none does. */
export function passageStart(book: HomerBook, keywords: string[]): { index: number; word: string | null } {
const re = keywordRe(keywords);
if (re) for (let i = 0; i < book.paragraphs.length; i++) {
re.lastIndex = 0;
const m = re.exec(book.paragraphs[i]!);
if (m) return { index: i, word: m[1]! };
}
return { index: 0, word: null };
}
/** Gutenberg's footnote markers sit glued to words ("the place.114"); the reading view drops them. */
export const stripFootnotes = (s: string) => s.replace(/([A-Za-z.,;:!?’”)])\d{1,3}(?=[\s,;:.’”]|$)/g, "$1");
const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX", "XXI", "XXII", "XXIII", "XXIV"];
export const roman = (n: number) => ROMAN[n] ?? String(n);
+191
View File
@@ -0,0 +1,191 @@
import { songById, songs, type LandingId, type PlaceId } from "../data/album";
import type { Store } from "./state";
import { transitionFor } from "./voyage";
/** The parts of the 3D world the controller drives (src/app/world/world.ts implements them). */
export interface ControllerWorld {
/** A new call resolves the running sail at once. */
sailTo(songId: string, opts?: { instant?: boolean }): Promise<void>;
/** Resolves when the landing's arrival has played; `null` returns to sea at once. */
setLanding(id: LandingId | null): Promise<void>;
setRibbons(motifIds: string[] | null, highlight: string | null): void;
horizonFromFrieze(groundYpx: number): void;
}
/** The parts of the frieze curtain the controller drives (src/app/frieze/curtain.ts). Any call cancels the running one. */
export interface ControllerCurtain {
drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise<void>;
lift(): Promise<void>;
fade(): Promise<void>;
groundY(): number;
}
export interface Controller {
/** The song the stage has finished moving to. */
settledSongId(): string;
busy(): boolean;
play(): void;
stop(): void;
playing(): boolean;
onPlayingChange(fn: (playing: boolean) => void): void;
/** The landing the stage is in, if any. */
landing(): LandingId | null;
/** "Back to sea": leave the landing, keep the song. */
leaveLanding(): Promise<void>;
/** "Go ashore": enter the current song's landing through its saga's frieze. */
goAshore(): Promise<void>;
onLandingChange(fn: (landing: LandingId | null) => void): void;
}
const PLAY_HOLD_MS = 3400;
/**
* Moves the stage when the song changes. A newer request starts at once: the world and the curtain cancel
* their running animation on the next call, and the older step stops at its next await (generation
* check). The controller tracks what is on screen (the song the ship is headed for, and which frieze, if
* any, covers the stage), so a step that starts under a frieze of the wrong saga swaps it first and never
* sails visibly under it. Order under a curtain: cover → sail (instant) → start the landing → horizon on
* the frieze's ground line → lift, so the lift reveals the landing's arrival.
*/
export function createController(opts: {
store: Store;
world: ControllerWorld | null;
curtain: ControllerCurtain;
reducedMotion: boolean;
/** How long Play voyage holds each song (default 3.4s). */
holdMs?: number;
}): Controller {
const { store, world, curtain, reducedMotion } = opts;
const holdMs = opts.holdMs ?? PLAY_HOLD_MS;
let settled = store.get().songId;
/** The song the ship is at or sailing to. */
let at = settled;
/** What covers the stage: a saga's frieze, the fade, or nothing. */
let cover: PlaceId | "fade" | null = null;
let landed: LandingId | null = null;
let gen = 0;
let running = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
let playingNow = false;
let voyageNav = false;
const playListeners: ((p: boolean) => void)[] = [];
const landingListeners: ((l: LandingId | null) => void)[] = [];
const setLanded = (l: LandingId | null) => { landed = l; landingListeners.forEach((fn) => fn(l)); };
const ribbons = () => {
const s = store.get();
world?.setRibbons(s.ribbons ? songById(s.songId)!.motifIds : null, s.motif);
};
/** Covers the stage with `sagaId`'s frieze (or the fade), unless it is already covered by it. */
async function coverWith(kind: "curtain" | "fade", sagaId: PlaceId, landing: LandingId | null) {
if (kind === "fade" || reducedMotion) {
if (cover === "fade") return;
cover = "fade";
await curtain.fade();
} else {
if (cover === sagaId) return;
cover = sagaId;
await curtain.drop(sagaId, landing ? { landing } : undefined);
}
}
async function uncover(my: number) {
world?.horizonFromFrieze(curtain.groundY());
await curtain.lift();
if (my === gen) cover = null;
}
async function step(to: string, my: number) {
const target = songById(to)!;
const { transition, landing } = transitionFor(at, to);
if (transition.kind === "curtain") await coverWith("curtain", transition.sagaId, landing);
else if (transition.kind === "fade") await coverWith("fade", target.sagaId, landing);
else if (cover !== null && cover !== "fade" && cover !== target.sagaId) await coverWith("curtain", target.sagaId, landing);
if (my !== gen) return;
const covered = cover !== null;
if (world) {
if (landed && landed !== landing) { await world.setLanding(null); setLanded(null); }
at = to;
await world.sailTo(to, { instant: covered });
if (my !== gen) return;
if (landing && landed !== landing) { setLanded(landing); void world.setLanding(landing); }
} else {
at = to;
}
if (covered) await uncover(my);
if (my !== gen) return;
settled = to;
ribbons();
}
function track(p: Promise<void>) {
running++;
return p.catch((e) => console.error(e)).finally(() => { running--; maybeSchedule(); });
}
function maybeSchedule() {
if (!playingNow || timer !== null || running > 0) return;
timer = setTimeout(() => {
timer = null;
if (!playingNow) return;
const next = songs.find((x) => x.track === songById(settled)!.track + 1);
if (!next) return setPlaying(false);
voyageNav = true;
store.set({ songId: next.id });
voyageNav = false;
}, holdMs);
}
function setPlaying(p: boolean) {
if (playingNow === p) return;
playingNow = p;
if (!p && timer) { clearTimeout(timer); timer = null; }
playListeners.forEach((fn) => fn(p));
maybeSchedule();
}
store.subscribe((s, prev) => {
if (s.songId !== prev.songId) {
if (!voyageNav) setPlaying(false);
void track(step(s.songId, ++gen));
} else if (s.ribbons !== prev.ribbons || s.motif !== prev.motif) ribbons();
});
// First paint: put the ship at the deep-linked song without a transition.
if (world) {
const first = songById(settled)!;
void track(world.sailTo(settled, { instant: true }).then(async () => {
ribbons();
if (first.landing) { setLanded(first.landing); await world.setLanding(first.landing); }
}));
}
return {
settledSongId: () => settled,
busy: () => running > 0,
play: () => setPlaying(true),
stop: () => setPlaying(false),
playing: () => playingNow,
onPlayingChange(fn) { playListeners.push(fn); },
landing: () => landed,
leaveLanding() {
if (!world || !landed) return Promise.resolve();
++gen;
return track(world.setLanding(null).then(() => setLanded(null)));
},
goAshore() {
const song = songById(settled)!;
if (!world || !song.landing || landed) return Promise.resolve();
const my = ++gen;
return track((async () => {
await coverWith("curtain", song.sagaId, song.landing);
if (my !== gen) return;
setLanded(song.landing);
void world.setLanding(song.landing);
await uncover(my);
})());
},
onLandingChange(fn) { landingListeners.push(fn); },
};
}
+215
View File
@@ -0,0 +1,215 @@
// The curtain call (design record idea 2): a saga's frieze drops over the stage like a curtain, then lifts
// by parting along its painted ground line, which is where the 3D horizon sits (the world is told with
// horizonFromFrieze(groundY())). Dropped for a landing, it lifts instead by zooming through the doorway.
// fade() covers the stage in the curtain's colour for long jumps. Every call cancels the running one
// (its promise resolves at once) and starts from what is on screen, so rapid navigation never sticks.
// With prefers-reduced-motion every move is a 200ms crossfade.
import type { LandingId, PlaceId } from "../../data/album";
import { DOOR_SAGA, GROUND_Y, VB_FIT_H, VB_H, VB_W, VB_Y, friezeLayout, friezeSvg } from "./sagas";
export interface Curtain {
/** Drop the saga's frieze over the stage; resolves when it covers the stage. */
drop(sagaId: PlaceId, opts?: { landing?: LandingId }): Promise<void>;
/** Uncover the stage: part along the ground line, zoom through the doorway (after a landing drop), or fade out a fade(). */
lift(): Promise<void>;
/** Cover the stage in the curtain colour (for jumps across several sagas); lift() uncovers it. */
fade(): Promise<void>;
/** The frieze's ground line, in px from the top of the layer: where the 3D horizon must sit as it lifts. */
groundY(): number;
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
const ease = (x: number) => { const t = clamp01(x); return t * t * (3 - 2 * t); };
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; };
const DUR = { drop: 1200, split: 1500, zoom: 1600, fadeIn: 450, fadeOut: 650, reduced: 200 };
export function createCurtain(layer: HTMLElement): Curtain {
const doc = layer.ownerDocument;
const box = doc.createElement("div");
box.className = "fz-box";
box.setAttribute("aria-hidden", "true");
const host = doc.createElement("div");
host.className = "fz-host";
host.hidden = true;
const cover = doc.createElement("div");
cover.className = "fz-cover";
cover.hidden = true;
layer.append(box, host, cover);
const reduced = () => !!doc.defaultView?.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const now = () => doc.defaultView?.performance?.now() ?? Date.now();
const raf = (f: () => void) => {
const w = doc.defaultView;
if (w?.requestAnimationFrame) w.requestAnimationFrame(() => f());
else setTimeout(f, 16);
};
let saga: PlaceId | null = null;
let landing: LandingId | null = null;
/** 0 = above the stage, 1 = covering it. */
let drop = 0;
let coverK = 0;
let parts: { zoom: SVGGElement | null; top: SVGGElement | null; bottom: SVGGElement | null; figs: SVGGElement | null; line: SVGElement | null; doors: SVGElement[]; masked: SVGGElement | null; mask: string } =
{ zoom: null, top: null, bottom: null, figs: null, line: null, doors: [], masked: null, mask: "" };
/* ---------- the running animation; a new call cancels it ---------- */
let token = 0;
let finish: (() => void) | null = null;
function animate(dur: number, frame: (k: number) => void): Promise<void> {
finish?.();
const mine = ++token;
return new Promise((resolve) => {
finish = () => { finish = null; resolve(); };
const t0 = now();
const step = () => {
if (mine !== token) return;
const k = dur > 0 ? clamp01((now() - t0) / dur) : 1;
frame(k);
if (k >= 1) finish?.();
else raf(step);
};
step();
});
}
const cancel = () => { token++; finish?.(); };
/* ---------- layout ---------- */
function fit(): { s: number; oy: number } {
const w = box.clientWidth, h = box.clientHeight;
if (!w || !h) return { s: 0, oy: 0 };
const s = Math.min(w / VB_W, h / VB_FIT_H);
return { s, oy: box.offsetTop + (h - VB_FIT_H * s) / 2 };
}
/** How far up the frieze must sit to be fully above the stage (its glaze runs 600 units below the band). */
const travel = () => { const f = fit(); return f.oy + (VB_H + 600 - VB_Y) * f.s + 24 || layer.clientHeight || 1000; };
function mount(id: PlaceId) {
if (saga === id && parts.zoom) return;
saga = id;
host.innerHTML = friezeSvg(id);
const q = <T extends Element>(sel: string) => host.querySelector<T>(sel);
parts = {
zoom: q<SVGGElement>(".f-zoom"), top: q<SVGGElement>(".f-top"), bottom: q<SVGGElement>(".f-bottom"),
figs: q<SVGGElement>(".f-figs"), line: q<SVGElement>(".horizon-line"), doors: [...host.querySelectorAll<SVGElement>(".door-fill")],
masked: q<SVGGElement>("g[mask]"), mask: q<SVGGElement>("g[mask]")?.getAttribute("mask") ?? "",
};
host.dataset.saga = id;
}
/** Put every part back where it is painted. */
function reset() {
parts.zoom?.removeAttribute("transform");
parts.top?.removeAttribute("transform");
parts.bottom?.removeAttribute("transform");
parts.figs?.removeAttribute("transform");
if (parts.figs) parts.figs.style.opacity = "";
if (parts.line) parts.line.style.opacity = "";
parts.doors.forEach((d) => (d.style.opacity = ""));
if (parts.masked && parts.mask) parts.masked.setAttribute("mask", parts.mask);
host.style.opacity = "";
}
const place = () => { host.style.transform = drop >= 1 ? "" : `translateY(${((drop - 1) * travel()).toFixed(1)}px)`; };
const showCover = () => { cover.hidden = coverK <= 0; cover.style.opacity = String(coverK); };
function hideFrieze() {
host.hidden = true;
drop = 0;
landing = null;
reset();
place();
}
/* ---------- the three moves ---------- */
async function dropFn(id: PlaceId, opts: { landing?: LandingId } = {}): Promise<void> {
const wantDoor = opts.landing && DOOR_SAGA[opts.landing] === id ? opts.landing : null;
const covered = !host.hidden && drop >= 1 && saga === id;
cancel();
if (saga !== id || host.hidden) { mount(id); reset(); }
else if (!covered) reset();
landing = wantDoor;
host.hidden = false;
if (covered) { reset(); place(); host.style.opacity = "1"; return; }
if (reduced()) {
drop = 1;
place();
const from = host.style.opacity === "" ? 0 : Number(host.style.opacity);
await animate(DUR.reduced, (k) => { host.style.opacity = String(from + (1 - from) * k); });
} else {
host.style.opacity = "1";
const from = drop;
await animate(DUR.drop * (1 - from), (k) => { drop = from + (1 - from) * easeInOut(k); place(); });
}
if (saga === id && !host.hidden && drop >= 1) {
// once covered, any fade() underneath is no longer needed
coverK = 0;
showCover();
}
}
async function liftFn(): Promise<void> {
cancel();
if (host.hidden && coverK <= 0) return;
if (reduced()) {
const h0 = host.hidden ? 0 : Number(host.style.opacity || 1), c0 = coverK;
await animate(DUR.reduced, (k) => {
host.style.opacity = String(h0 * (1 - k));
coverK = c0 * (1 - k);
showCover();
});
if (host.style.opacity === "0") hideFrieze();
return;
}
if (coverK > 0) {
// a fade(): the frieze (if any) goes at once, the colour fades out over the new scene
hideFrieze();
const c0 = coverK;
await animate(DUR.fadeOut, (k) => { coverK = c0 * (1 - ease(k)); showCover(); });
return;
}
const door = landing ? friezeLayout(saga!).door : null;
if (door) {
// zoom through the doorway into the landing (13/17's cave zoom)
await animate(DUR.zoom, (z) => {
parts.doors.forEach((d) => (d.style.opacity = String(1 - ease(z / 0.28))));
const scale = 1 + 15 * easeIn((z - 0.08) / 0.92);
parts.zoom?.setAttribute("transform", `translate(${door.x},${door.y}) scale(${scale.toFixed(4)}) translate(${-door.x},${-door.y})`);
host.style.opacity = String(1 - ease((z - 0.72) / 0.26));
if (z >= 1) hideFrieze();
});
return;
}
// part along the ground line, which is the horizon: figures sink into the line, the halves slide apart
// the doorway stays shut: no hole travels with the parting halves
parts.doors.forEach((d) => (d.style.opacity = "0"));
parts.masked?.removeAttribute("mask");
await animate(DUR.split, (s0) => {
const flat = ease(s0 / 0.24);
parts.figs?.setAttribute("transform", `translate(0,${GROUND_Y}) scale(1,${(1 - flat).toFixed(4)}) translate(0,${-GROUND_Y})`);
if (parts.figs) parts.figs.style.opacity = String(1 - ease((s0 - 0.08) / 0.18));
const part = easeInOut((s0 - 0.1) / 0.55);
parts.top?.setAttribute("transform", `translate(0,${(-part * (GROUND_Y + 700)).toFixed(1)})`);
parts.bottom?.setAttribute("transform", `translate(0,${(part * (VB_H - GROUND_Y + 700)).toFixed(1)})`);
if (parts.line) parts.line.style.opacity = String(1 - ease((s0 - 0.5) / 0.3));
if (s0 >= 1) hideFrieze();
});
}
async function fadeFn(): Promise<void> {
cancel();
const c0 = coverK;
cover.hidden = false;
await animate((reduced() ? DUR.reduced : DUR.fadeIn) * (1 - c0), (k) => { coverK = c0 + (1 - c0) * k; showCover(); });
}
function groundY(): number {
const f = fit();
return f.oy + (GROUND_Y - VB_Y) * f.s;
}
return { drop: dropFn, lift: liftFn, fade: fadeFn, groundY };
}
+296
View File
@@ -0,0 +1,296 @@
// The black-figure figure kit for the saga friezes. Copied from mockup 17's figures.ts (via 04 and 13;
// mockups are never imported) and extended with the props the nine sagas need: tridents, bows, wings,
// long hair, pigs, cattle, trees, columns, cliffs, a whirlpool and Scylla's heads.
// Glaze is `fill: var(--glaze)` (.ink); "incised" detail is scratched back to the clay (.inc).
// Every vignette shares one 300-unit-tall panel space with the ground line at GROUND.
export const PANEL_H = 300;
export const GROUND = 284;
/** Escapes text for SVG/HTML markup, apostrophes included. */
export const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
export const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
export const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
export const ink = (d: string) => `<path class="ink" d="${d}"/>`;
/** A limb: a glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze. */
export const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw" | "bow" | "bolt";
export 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;
/** Long hair down the back (women, goddesses). */
hair?: boolean; wings?: boolean; crown?: boolean; trident?: boolean; bag?: boolean; staff?: boolean;
}
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 = "";
if (p.wings) {
upper += ink("M-8,-150 Q-60,-196 -78,-150 Q-62,-152 -66,-132 Q-50,-138 -50,-118 Q-34,-126 -10,-118 Z");
upper += inc("M-14,-142 Q-44,-160 -66,-150 M-14,-130 Q-38,-142 -58,-132", 1.4);
}
if (p.hair) upper += ink("M-8,-176 Q-20,-150 -14,-128 L-2,-132 Q-6,-152 2,-168 Z");
// the 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");
if (arms === "bow") upper += limb("M-8,-148 L6,-150 L18,-152");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (p.crown) {
upper += ink("M-9,-176 L-7,-190 L-2,-180 L2,-194 L6,-180 L11,-190 L13,-176 Z");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5);
upper += p.trident
? ink("M72,-222 L80,-246 L74,-224 Z M64,-226 L58,-248 L70,-228 Z M80,-218 L96,-238 L82,-214 Z") + limb("M62,-228 L84,-214", 3)
: ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
case "bow":
upper += limb("M10,-148 L38,-150 L60,-152");
upper += `<path class="limb" d="M54,-206 Q86,-152 54,-98" stroke-width="4.5"/>`;
upper += `<path class="rig" d="M54,-206 L18,-152 L54,-98"/>` + `<path class="rig" d="M18,-152 L92,-152" stroke-width="2.2"/>`;
upper += ink("M92,-152 L82,-157 L84,-152 L82,-147 Z");
break;
case "bolt":
upper += limb("M10,-148 L22,-182 L26,-210");
upper += g("translate(14,-262) scale(0.62)", ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.staff) upper += limb("M14,-100 L22,-236", 3.5) + `<circle class="ink" cx="22" cy="-240" r="6"/>`;
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
if (p.bag) {
upper += ink("M54,-146 Q46,-138 50,-116 Q58,-96 76,-104 Q88,-118 76,-138 Q70,-146 60,-146 Z M55,-150 h8 l-2,6 h-5 Z");
upper += inc("M58,-130 q8,6 16,0 M56,-118 q10,6 20,-2", 1.3);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
export function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
export function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1; broken?: boolean } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
if (o.broken) {
b += limb("M0,-50 L0,-150", 5) + limb("M0,-150 L40,-196", 5);
} else {
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
}
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
export function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
export function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
export 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"));
export function sheep(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-14,-14 L-16,0", 4) + limb("M-4,-14 L-3,0", 4) + limb("M12,-14 L13,0", 4) + limb("M20,-14 L22,0", 4);
b += ink("M-24,-30 Q-26,-46 -10,-48 Q2,-58 14,-48 Q30,-50 28,-32 Q32,-16 16,-14 L-14,-14 Q-30,-14 -24,-30 Z");
b += inc("M-14,-38 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 M-16,-26 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0", 1.3);
b += ink("M26,-40 L40,-44 Q46,-38 40,-30 L28,-30 Z");
b += `<circle class="clay" cx="38" cy="-39" r="1.6"/>`;
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** Circe's work: a pig, snout forward, curly tail. */
export function pig(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-16,-12 L-18,0", 5) + limb("M-6,-12 L-5,0", 5) + limb("M12,-12 L13,0", 5) + limb("M20,-12 L22,0", 5);
b += ink("M-30,-26 Q-30,-46 -6,-48 Q22,-50 30,-36 L42,-34 Q46,-28 42,-22 L30,-20 Q22,-10 -6,-10 Q-30,-10 -30,-26 Z");
b += ink("M22,-46 L30,-58 L32,-44 Z");
b += `<path class="limb" d="M-30,-30 q-10,-2 -8,-10 q2,-6 6,-2" stroke-width="2.4"/>`;
b += `<circle class="clay" cx="30" cy="-38" r="1.8"/>` + inc("M40,-30 v4", 1.4) + inc("M-14,-22 Q4,-16 18,-24", 1.3);
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** Helios' cattle: a broad bull with lyre horns. */
export function bull(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-30,-24 L-32,0", 7) + limb("M-16,-24 L-14,0", 7) + limb("M22,-24 L24,0", 7) + limb("M34,-24 L36,0", 7);
b += ink("M-44,-46 Q-44,-70 -14,-72 L30,-72 Q48,-70 50,-52 L62,-60 Q74,-58 72,-44 L60,-36 Q50,-22 30,-22 L-30,-22 Q-44,-24 -44,-46 Z");
b += `<path class="limb" d="M-44,-54 q-12,10 -8,30" stroke-width="3"/>`;
b += `<path class="limb" d="M62,-60 q-4,-14 6,-20 M66,-58 q10,-10 18,-6" stroke-width="3.2"/>`;
b += `<circle class="clay" cx="64" cy="-50" r="2"/>` + inc("M-30,-60 Q0,-52 34,-60 M-24,-36 Q4,-30 30,-38", 1.4);
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** A tree: Calypso's palm or Ithaca's olive. */
export function tree(x: number, s: number, kind: "palm" | "olive"): string {
let b = "";
if (kind === "palm") {
b += limb("M0,0 Q-6,-80 6,-170", 9);
for (let i = -3; i <= 3; i++) b += `<ellipse class="ink" cx="6" cy="-200" rx="7" ry="36" transform="rotate(${i * 28} 6 -170)"/>`;
b += inc("M-2,-30 h8 M-3,-60 h8 M-2,-90 h8 M0,-120 h8 M3,-150 h7", 1.4);
} else {
b += limb("M0,0 Q-10,-50 4,-100 M2,-90 Q-24,-110 -34,-128 M4,-100 Q26,-112 34,-134", 8);
b += ink("M-58,-130 Q-64,-170 -30,-176 Q-16,-206 16,-196 Q50,-200 54,-166 Q72,-140 44,-124 Q20,-112 -4,-122 Q-40,-108 -58,-130 Z");
b += inc("M-40,-150 q8,-6 14,0 M-10,-170 q8,-6 14,0 M20,-156 q8,-6 14,0 M-24,-132 q8,-6 14,0 M28,-136 q8,-6 14,0", 1.4);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
/** A Doric column, base on the ground line. */
export function column(x: number, h: number, w = 26): string {
const half = w / 2;
let b = ink(`M${-half - 6},0 h${w + 12} v-8 h${-w - 12} Z`);
b += ink(`M${-half},-8 L${-half + 2},${-h + 14} h${w - 4} L${half},-8 Z`);
b += ink(`M${-half - 8},${-h + 14} h${w + 16} v-8 h${-w - 16} Z M${-half - 4},${-h + 6} h${w + 8} v-6 h${-w - 8} Z`);
b += inc(`M${-half / 2},-12 V${-h + 18} M0,-12 V${-h + 18} M${half / 2},-12 V${-h + 18}`, 1.3);
return g(`translate(${x},${GROUND})`, b);
}
/** A jagged sea cliff rising from the ground line. */
export function cliff(x: number, w: number, h: number, f: 1 | -1 = 1): string {
const b = ink(`M0,0 L0,${-h * 0.7} L${w * 0.18},${-h} L${w * 0.42},${-h * 0.86} L${w * 0.56},${-h * 0.62} L${w * 0.8},${-h * 0.5} L${w},0 Z`) +
inc(`M${w * 0.14},${-h * 0.62} q${w * 0.1},${-h * 0.12} ${w * 0.2},${-h * 0.1} M${w * 0.3},${-h * 0.34} q${w * 0.16},${-h * 0.08} ${w * 0.3},0 M${w * 0.62},${-h * 0.4} q${w * 0.1},${h * 0.04} ${w * 0.16},${h * 0.16}`, 1.8);
return g(`translate(${x},${GROUND}) scale(${f},1)`, b);
}
/** Scylla: long necks out of a cave in the cliff, each ending in a snapping head. */
export function scyllaHeads(x: number, y: number, s: number): string {
let b = "";
const necks: [string, number, number, number][] = [
["M0,0 Q40,-60 90,-40", 90, -40, -10], ["M0,0 Q60,-10 110,24", 110, 24, 20], ["M0,0 Q20,-110 70,-110", 70, -110, -24], ["M0,0 Q70,40 120,80", 120, 80, 34],
];
for (const [d, hx, hy, r] of necks) {
b += limb(d, 9);
b += g(`translate(${hx},${hy}) rotate(${r})`, ink("M-6,-9 L22,-6 L30,-1 L16,2 L28,7 L-6,9 Z") + `<circle class="clay" cx="8" cy="-3" r="2"/>`);
}
return g(`translate(${x},${y}) scale(${s})`, b);
}
/** Charybdis: a spiral whirlpool seen from the side, as stacked scrolls. */
export function whirlpool(cx: number, y: number, r: number): string {
let d = "";
for (let k = 0; k <= 64; k++) {
const a = (k / 64) * Math.PI * 6;
const rr = r * (1 - k / 70);
d += `${k ? "L" : "M"}${(cx + Math.cos(a) * rr).toFixed(1)},${(y - 30 + Math.sin(a) * rr * 0.32).toFixed(1)}`;
}
return `<path class="wave" d="${d}" stroke-width="4"/>` + ink(`M${cx - 10},${y - 34} L${cx},${y - 6} L${cx + 10},${y - 34} Z`) + waves(cx - r - 20, cx + r + 20, y, 1.1);
}
export function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A shade of the dead: a robed, veiled figure with mourning arms. */
export const shade = (x: number, s: number, f: 1 | -1 = 1) => person({ x, s, f, arms: "mourn", robe: true, hair: true });
+300
View File
@@ -0,0 +1,300 @@
// One frieze per saga: the saga's name in a painted column, then one vignette per song with its title
// inscribed above it, on the chrome of mockup 17 (palmette neck, meander, tongues, wave scroll, rays).
// The Troy and Cyclops vignettes are 17's; the rest are composed from the same kit. The three landing
// sagas have a doorway (cave mouth, arena gate, palace door) masked out of the frieze, so the landing's 3D
// scene shows through it as the curtain zooms in.
import { sagas, songsInSaga } from "../../data/album";
import type { LandingId, PlaceId } from "../../data/album";
import {
GROUND, PANEL_H, bolt, bull, cliff, column, esc, g, inc, ink, palmette, person, pig, scyllaHeads, shade,
sheep, ship, stele, tree, trojanHorse, waves, whirlpool,
} from "./kit";
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: also the 3D horizon when the curtain lifts. */
export const GROUND_Y = FIELD_Y + GROUND * SCALE;
const NECK_H = 96;
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;
/** Inscriptions: font size in frieze units (CSS sets the same), line height, and an estimate of a capital's advance. */
export const INSCR_SIZE = 28;
const LINE_H = 30;
const CHAR_W = 18;
/** Which saga frieze has the doorway for each landing. */
export const DOOR_SAGA: Record<LandingId, PlaceId> = { cave: "cyclops", arena: "wisdom", hall: "ithaca" };
interface Door { path: string; glow: LandingId }
interface Vignette { songId: string; w: number; art: string; door?: Door }
/* ---------- the chrome (from 17) ---------- */
function defs(id: string): string {
const meander = (key: string, y: number) =>
`<pattern id="${id}-${key}" y="${y}" width="28" height="28" patternUnits="userSpaceOnUse"><path class="mean" d="M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7" /></pattern>`;
return `<defs>
${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)}
<pattern id="${id}-tongues" y="${BAND_TOP + 50}" width="18" height="22" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 0H16V13A7 7 0 0 1 2 13Z" />
</pattern>
<pattern id="${id}-chain" x="0" y="${BAND_TOP - NECK_H + 10}" width="84" height="78" patternUnits="userSpaceOnUse">
<path class="vol" d="M0 39 Q10.5 62 21 62 Q31.5 62 42 39 Q52.5 16 63 16 Q73.5 16 84 39"/>
${g("translate(21,60) scale(0.66)", palmette(0, 0))}${g("translate(63,18) scale(0.66)", palmette(0, 0, true))}
</pattern>
<pattern id="${id}-scroll" x="0" y="${GROUND_Y + 14}" width="56" height="52" patternUnits="userSpaceOnUse">
<path class="scroll" d="M0 44 C20 44 34 36 34 20 C34 9 24 5 18 11 C13 16 18 23 24 19" />
<path class="scroll" d="M34 20 C34 34 44 44 56 44" />
</pattern>
<pattern id="${id}-rays" y="${BOT_Y + 46}" width="32" height="54" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 54L16 6L30 54Z" />
</pattern>
<radialGradient id="${id}-glow" cx="0.5" cy="0.92" r="0.75">
<stop offset="0" class="glow-a"/><stop offset="0.55" class="glow-b"/><stop offset="1" class="glow-c"/>
</radialGradient>
</defs>`;
}
/** Split at the ground line into a top half and a bottom half, so the lift can part them along the horizon. */
function chrome(id: string) {
const X0 = -600, W = VB_W + 1200;
const rule = (y: number) => `<path class="rule" d="M${X0} ${y}H${X0 + W}"/>`;
const top = `<g class="f-top">
<rect class="f-surround" x="${X0}" y="-600" width="${W}" height="${GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${BAND_TOP - NECK_H}" width="${W}" height="${GROUND_Y - BAND_TOP + NECK_H}"/>
${rule(BAND_TOP - NECK_H + 4)}
<rect x="${X0}" y="${BAND_TOP - NECK_H + 10}" width="${W}" height="78" fill="url(#${id}-chain)"/>
${rule(BAND_TOP + 8)}
<rect x="${X0}" y="${BAND_TOP + 14}" width="${W}" height="28" fill="url(#${id}-mt)"/>
${rule(BAND_TOP + 46)}
<rect x="${X0}" y="${BAND_TOP + 50}" width="${W}" height="22" fill="url(#${id}-tongues)"/>
</g>`;
const bottom = `<g class="f-bottom">
<rect class="f-surround" x="${X0}" y="${GROUND_Y}" width="${W}" height="${VB_H - GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${GROUND_Y}" width="${W}" height="${BAND_BOT - GROUND_Y}"/>
<rect x="${X0}" y="${GROUND_Y + 14}" width="${W}" height="52" fill="url(#${id}-scroll)"/>
${rule(BOT_Y + 2)}
<rect x="${X0}" y="${BOT_Y + 8}" width="${W}" height="28" fill="url(#${id}-mb)"/>
${rule(BOT_Y + 40)}
<rect x="${X0}" y="${BOT_Y + 46}" width="${W}" height="54" fill="url(#${id}-rays)"/>
</g>`;
return { top, bottom };
}
/** The painted saga column: palmettes framing the place's word of the saga's name ("UNDERWORLD"), written
* vertically (as in 04); the full name is the frieze's title. One word keeps it at 20 units (14px). */
function sagaColumn(name: string): string {
const short = name.replace(/^The /, "").replace(/ Saga$/, "").toUpperCase();
return g(`translate(22,${FIELD_Y}) scale(${SCALE})`,
`<path class="rule" d="M4,0 V${PANEL_H} M128,0 V${PANEL_H}"/>${palmette(66, 52)}${palmette(66, 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${esc(short)}</text>`);
}
const COLUMN_W = 132 * SCALE + 22 + 14;
/** A narrow divider between two songs: a double rule with a run of dots, as between vase panels. */
function divider(x: number): string {
let dots = "";
for (let y = FIELD_Y + 16; y < GROUND_Y - 8; y += 22) dots += `<circle class="ink" cx="${x}" cy="${y}" r="3"/>`;
return `<path class="rule" d="M${x - 8} ${FIELD_Y}V${GROUND_Y}M${x + 8} ${FIELD_Y}V${GROUND_Y}"/>${dots}`;
}
const DIVIDER_W = 26;
/** Break an uppercase title into lines of at most `max` characters, at spaces. */
export function wrap(title: string, max: number): string[] {
const lines: string[] = [];
for (const word of title.split(" ")) {
const last = lines.at(-1);
if (last !== undefined && (last + " " + word).length <= max) lines[lines.length - 1] = last + " " + word;
else lines.push(word);
}
return lines;
}
/* ---------- doorways ---------- */
/** The cave mouth in Polyphemus' rock (17's caveRock, narrowed to one vignette). */
function caveDoor(cx: number): { art: string; door: string } {
const W = 66, H = 132, r = W + 40;
const door = `M${cx - W},${GROUND} V${GROUND - 70} Q${cx - W},${GROUND - H} ${cx},${GROUND - H} Q${cx + W},${GROUND - H} ${cx + W},${GROUND - 70} V${GROUND} Z`;
let art = ink(`M${cx - r - 30},${GROUND} Q${cx - r - 24},${GROUND - 120} ${cx - r + 16},${GROUND - 160} Q${cx - 50},${GROUND - 196} ${cx + 8},${GROUND - 190} Q${cx + r - 10},${GROUND - 198} ${cx + r + 14},${GROUND - 146} Q${cx + r + 34},${GROUND - 80} ${cx + r + 30},${GROUND} Z`);
art += `<path class="inc" d="${door}" stroke-width="5" transform="translate(${cx},${GROUND}) scale(1.07) translate(${-cx},${-GROUND})"/>`;
art += inc(`M${cx - r - 14},${GROUND - 90} q14,-24 36,-30 M${cx - r + 6},${GROUND - 140} q26,-18 54,-20 M${cx + r - 24},${GROUND - 160} q24,4 34,30 M${cx + r + 8},${GROUND - 60} q6,-24 3,-46 M${cx - 30},${GROUND - 178} q30,-6 64,2`, 2);
return { art, door };
}
/** The God Games gate: a stone pylon between two columns with an arched opening onto the arena. */
function gateDoor(cx: number): { art: string; door: string } {
const W = 62, H = 150;
const door = `M${cx - W},${GROUND} V${GROUND - H + W} A${W} ${W} 0 0 1 ${cx + W},${GROUND - H + W} V${GROUND} Z`;
let art = ink(`M${cx - 96},${GROUND} V${GROUND - 176} H${cx + 96} V${GROUND} Z`);
art += ink(`M${cx - 132},${GROUND - 186} H${cx + 132} V${GROUND - 200} H${cx - 132} Z`);
art += inc(`M${cx - 96},${GROUND - 40} H${cx - W - 6} M${cx + W + 6},${GROUND - 40} H${cx + 96} M${cx - 96},${GROUND - 84} H${cx - W - 6} M${cx + W + 6},${GROUND - 84} H${cx + 96} M${cx - 96},${GROUND - 128} H${cx - 50} M${cx + 50},${GROUND - 128} H${cx + 96} M${cx - 80},${GROUND - 166} H${cx + 80}`, 1.8);
art += `<path class="inc" d="${door}" stroke-width="4" transform="translate(${cx},${GROUND}) scale(1.08) translate(${-cx},${-GROUND})"/>`;
art += column(cx - 116, 186, 22) + column(cx + 116, 186, 22);
return { art, door };
}
/** Odysseus' palace: a façade under a pediment with the door to the hall. */
function palaceDoor(cx: number): { art: string; door: string } {
const W = 46, H = 128;
const door = `M${cx - W},${GROUND} V${GROUND - H} H${cx + W} V${GROUND} Z`;
let art = ink(`M${cx - 120},${GROUND} V${GROUND - 160} H${cx + 120} V${GROUND} Z`);
art += ink(`M${cx - 136},${GROUND - 160} H${cx + 136} L${cx},${GROUND - 204} Z`);
art += inc(`M${cx - 112},${GROUND - 166} H${cx + 112} M${cx - 96},${GROUND - 170} L${cx},${GROUND - 196} L${cx + 96},${GROUND - 170}`, 1.8);
for (const x of [cx - 100, cx - 70, cx + 70, cx + 100]) art += inc(`M${x},${GROUND - 6} V${GROUND - 150} M${x - 8},${GROUND - 150} h16`, 2.2);
art += `<path class="inc" d="${door}" stroke-width="4" transform="translate(${cx},${GROUND}) scale(1.08) translate(${-cx},${-GROUND})"/>`;
return { art, door };
}
/* ---------- the vignettes ---------- */
const P = person;
function vignettes(sagaId: PlaceId): Vignette[] {
switch (sagaId) {
case "troy": return [
{ songId: "the-horse-and-the-infant", w: 300, art: trojanHorse(100, 0.62) + P({ x: 238, f: -1, s: 0.85, arms: "reach", helmet: true, infant: true }) },
{ songId: "just-a-man", w: 220, art: P({ x: 76, s: 0.88, arms: "mourn", helmet: true, beard: true }) + `<path class="limb" d="M168,284 L180,104" stroke-width="3.5"/>` + ink("M180,104 L174,84 L188,100 Z") + `<circle class="ink" cx="160" cy="252" r="30"/><circle class="inc" cx="160" cy="252" r="24" stroke-width="1.6"/>` + inc("M147,239 l26,26 M173,239 l-26,26", 1.4) },
{ songId: "full-speed-ahead", w: 320, art: waves(0, 320, 290) + ship(160, 276, 0.72, { sail: true }) },
{ songId: "open-arms", w: 240, art: P({ x: 76, s: 0.86, arms: "open", beard: true }) + P({ x: 178, f: -1, s: 0.86, arms: "reach", robe: true }) },
{ songId: "warrior-of-the-mind", w: 250, art: P({ x: 70, s: 0.82, arms: "spear", helmet: true, shield: true, robe: true, hair: true }) + P({ x: 190, f: -1, s: 0.82, arms: "spear", helmet: true, shield: true, beard: true }) },
];
case "cyclops": {
const cave = caveDoor(150);
return [
{ songId: "polyphemus", w: 270, art: P({ x: 56, s: 0.66, arms: "reach", beard: true, cup: true }) + P({ x: 196, f: -1, s: 0.86, arms: "club", cyclops: true, beard: true }) },
{ songId: "survive", w: 300, art: cave.art + sheep(272, 0.62, -1), door: { path: cave.door, glow: "cave" } },
{ songId: "remember-them", w: 250, art: stele(125) + P({ x: 48, s: 0.82, arms: "mourn", kneel: true, beard: true }) + P({ x: 206, f: -1, s: 0.82, arms: "mourn", robe: true }) },
{ songId: "my-goodbye", w: 300, art: waves(130, 300, 290) + P({ x: 60, s: 0.8, arms: "throw", cyclops: true, beard: true }) + ship(232, 276, 0.42, { sail: true }) },
];
}
case "ocean": return [
{ songId: "storm", w: 290, art: bolt(36, 96, 0.8) + bolt(240, 104, 0.6) + ship(150, 262, 0.62, { tilt: -9 }) + waves(0, 290, 278, 1.2) + waves(14, 290, 296, 0.8) },
{ songId: "luck-runs-out", w: 270, art: ship(130, 268, 0.62, { broken: true, tilt: 7 }) + waves(0, 270, 284, 1.3) + waves(20, 270, 298, 0.8) },
{ songId: "keep-your-friends-close", w: 250, art: P({ x: 66, s: 0.84, arms: "reach", robe: true, beard: true, crown: true, bag: true }) + P({ x: 196, f: -1, s: 0.84, arms: "reach", beard: true }) },
{ songId: "ruthlessness", w: 290, art: P({ x: 84, s: 0.86, arms: "spear", trident: true, robe: true, beard: true, crown: true }) + ship(226, 276, 0.4) + waves(0, 290, 290, 1.1) },
];
case "circe": return [
{ songId: "puppeteer", w: 280, art: P({ x: 64, s: 0.84, arms: "raise", robe: true, hair: true, crown: true }) + pig(170, 0.85) + pig(240, 0.7, -1) },
{ songId: "wouldnt-you-like", w: 260, art: P({ x: 76, s: 0.82, arms: "open", wings: true, staff: true }) + P({ x: 196, f: -1, s: 0.82, arms: "reach", beard: true }) },
{ songId: "done-for", w: 250, art: P({ x: 66, s: 0.82, arms: "open", robe: true, hair: true }) + P({ x: 186, f: -1, s: 0.82, arms: "spear", helmet: true, beard: true }) },
{ songId: "there-are-other-ways", w: 250, art: P({ x: 66, s: 0.82, arms: "reach", robe: true, hair: true }) + palmette(126, 250) + P({ x: 190, f: -1, s: 0.82, arms: "reach", beard: true }) },
];
case "underworld": return [
{ songId: "the-underworld", w: 320, art: column(36, 200) + column(284, 200) + ink(`M14,${GROUND - 200} H306 V${GROUND - 214} H14 Z`) + shade(120, 0.7) + shade(170, 0.66, -1) + shade(220, 0.7, -1) },
{ songId: "no-longer-you", w: 250, art: P({ x: 70, s: 0.86, arms: "reach", robe: true, beard: true, staff: true }) + P({ x: 190, f: -1, s: 0.82, arms: "mourn", kneel: true, beard: true }) },
{ songId: "monster", w: 290, art: P({ x: 76, s: 0.88, arms: "spear", helmet: true, shield: true, beard: true }) + shade(200, 0.64, -1) + shade(256, 0.6, -1) },
];
case "thunder": return [
{ songId: "suffering", w: 300, art: ink(`M0,${GROUND} V${GROUND - 64} Q30,${GROUND - 92} 74,${GROUND - 70} L84,${GROUND} Z`) + P({ x: 38, y: GROUND - 74, s: 0.56, arms: "open", wings: true, hair: true, robe: true }) + ship(196, 276, 0.52, { sail: true }) + waves(90, 300, 290) },
{ songId: "different-beast", w: 260, art: P({ x: 76, s: 0.86, arms: "spear", helmet: true, shield: true, beard: true }) + P({ x: 206, f: -1, s: 0.72, arms: "reach", wings: true, hair: true, robe: true }) },
{ songId: "scylla", w: 310, art: cliff(0, 124, 196) + scyllaHeads(84, 150, 0.9) + ship(236, 276, 0.42) + waves(110, 310, 290) },
{ songId: "mutiny", w: 300, art: bull(66, 0.72) + P({ x: 170, s: 0.8, arms: "spear", helmet: true }) + P({ x: 254, f: -1, s: 0.8, arms: "spear", helmet: true, beard: true, shield: true }) },
{ songId: "thunder-bringer", w: 290, art: P({ x: 76, s: 0.8, arms: "bolt", crown: true, beard: true, robe: true }) + ship(214, 266, 0.44, { tilt: 12 }) + waves(110, 290, 290, 1.1) },
];
case "wisdom": {
const gate = gateDoor(150);
return [
{ songId: "legendary", w: 240, art: P({ x: 76, s: 0.8, arms: "spear" }) + P({ x: 184, f: -1, s: 0.82, arms: "raise", beard: true }) },
{ songId: "little-wolf", w: 250, art: P({ x: 66, s: 0.84, arms: "spear", robe: true, hair: true, helmet: true, shield: true }) + P({ x: 186, f: -1, s: 0.76, arms: "spear" }) },
{ songId: "well-be-fine", w: 240, art: P({ x: 66, s: 0.84, arms: "open", robe: true, hair: true, helmet: true }) + P({ x: 176, f: -1, s: 0.76, arms: "open" }) },
{ songId: "love-in-paradise", w: 270, art: tree(40, 0.78, "palm") + P({ x: 128, s: 0.82, arms: "reach", robe: true, hair: true }) + P({ x: 230, f: -1, s: 0.82, arms: "mourn", beard: true }) },
{ songId: "god-games", w: 300, art: gate.art, door: { path: gate.door, glow: "arena" } },
];
}
case "vengeance": return [
{ songId: "not-sorry-for-loving-you", w: 250, art: P({ x: 70, s: 0.82, arms: "mourn", robe: true, hair: true }) + P({ x: 176, s: 0.82, arms: "down", beard: true }) },
{ songId: "dangerous", w: 270, art: P({ x: 66, s: 0.8, arms: "reach", wings: true, staff: true }) + ship(196, 276, 0.42, { sail: true }) + waves(110, 270, 290) },
{ songId: "charybdis", w: 300, art: whirlpool(150, 288, 112) + ship(150, 226, 0.34, { tilt: -18 }) },
{ songId: "get-in-the-water", w: 270, art: P({ x: 70, s: 0.82, arms: "spear", beard: true }) + P({ x: 196, f: -1, s: 0.9, arms: "spear", trident: true, robe: true, crown: true, beard: true }) + waves(0, 270, 292, 1.1) },
{ songId: "six-hundred-strike", w: 290, art: ship(96, 276, 0.5, { sail: true }) + bolt(150, 104, 0.7) + P({ x: 232, f: -1, s: 0.86, arms: "spear", trident: true, robe: true, crown: true, beard: true }) + waves(0, 290, 292) },
];
case "ithaca": {
const palace = palaceDoor(150);
return [
{ songId: "the-challenge", w: 300, art: palace.art, door: { path: palace.door, glow: "hall" } },
{ songId: "hold-them-down", w: 290, art: P({ x: 64, s: 0.82, arms: "spear", helmet: true, shield: true, beard: true }) + P({ x: 166, s: 0.76, arms: "spear" }) + P({ x: 262, f: -1, s: 0.7, arms: "mourn", kneel: true }) },
{ songId: "odysseus", w: 270, art: P({ x: 60, s: 0.86, arms: "bow", beard: true }) + P({ x: 220, f: -1, s: 0.72, arms: "mourn", kneel: true, beard: true }) },
{ songId: "i-cant-help-but-wonder", w: 240, art: P({ x: 70, s: 0.84, arms: "open", beard: true }) + P({ x: 176, f: -1, s: 0.78, arms: "open" }) },
{ songId: "would-you-fall-in-love-with-me-again", w: 270, art: tree(135, 0.72, "olive") + P({ x: 48, s: 0.82, arms: "reach", beard: true }) + P({ x: 226, f: -1, s: 0.82, arms: "reach", robe: true, hair: true }) },
];
}
}
}
/* ---------- assembly ---------- */
export interface FriezeLayout {
/** Centre of the doorway in frieze units (viewBox space), when the saga has one. */
door: { x: number; y: number; landing: LandingId } | null;
}
function build(sagaId: PlaceId): { svg: string; layout: FriezeLayout } {
const saga = sagas.find((s) => s.id === sagaId)!;
const songs = songsInSaga(sagaId);
const vs = vignettes(sagaId);
const id = `fz-${sagaId}`;
const x0 = COLUMN_W;
const room = VB_W - 18 - x0 - (vs.length - 1) * DIVIDER_W;
const slot = room / vs.length;
let figs = sagaColumn(saga.name);
let doorMask = "";
let doorFill = "";
let door: FriezeLayout["door"] = null;
vs.forEach((v, i) => {
const song = songs.find((s) => s.id === v.songId);
if (!song) throw new Error(`frieze ${sagaId}: unknown song ${v.songId}`);
const sx = x0 + i * (slot + DIVIDER_W);
if (i) figs += divider(sx - DIVIDER_W / 2);
const k = Math.min(1, slot / (v.w * SCALE));
const left = sx + (slot - v.w * k * SCALE) / 2;
const t = `translate(${left.toFixed(1)},${FIELD_Y}) scale(${SCALE}) translate(0,${(GROUND * (1 - k)).toFixed(1)}) scale(${k.toFixed(4)})`;
figs += `<g class="f-song" data-song="${esc(song.id)}"><title>${esc(song.title)}</title>${g(t, v.art)}</g>`;
const lines = wrap(song.title.toUpperCase(), Math.floor(slot / CHAR_W));
const cx = sx + slot / 2;
figs += `<text class="inscr" x="${cx.toFixed(1)}" y="${FIELD_Y + 34}" text-anchor="middle">${lines
.map((l, j) => `<tspan x="${cx.toFixed(1)}" dy="${j ? LINE_H : 0}">${esc(l)}</tspan>`).join("")}</text>`;
if (v.door) {
doorMask += `<path d="${v.door.path}" transform="${t}" fill="#000"/>`;
doorFill += `<path class="door-fill door-fill--${v.door.glow}" d="${v.door.path}" transform="${t}" fill="url(#${id}-glow)"/>`;
// the door's centre: half-way up a vignette whose tallest door is ~140 units
door = { x: left + 150 * k * SCALE, y: FIELD_Y + (GROUND * (1 - k) + (GROUND - 62) * k) * SCALE, landing: v.door.glow };
}
});
const c = chrome(id);
const body = `${c.top}${c.bottom}<g class="f-figs">${figs}</g><path class="ground horizon-line" d="M-600 ${GROUND_Y}H${VB_W + 600}"/>`;
const masked = doorMask
? `<mask id="${id}-door" maskUnits="userSpaceOnUse" x="-610" y="-610" width="${VB_W + 1220}" height="${VB_H + 1220}">
<rect x="-610" y="-610" width="${VB_W + 1220}" height="${VB_H + 1220}" fill="#fff"/>${doorMask}
</mask><g mask="url(#${id}-door)">${body}</g>${doorFill}`
: body;
const svg = `<svg class="frieze frieze--${sagaId}" viewBox="0 ${VB_Y} ${VB_W} ${VB_FIT_H}" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">
<title>${esc(saga.name)}</title>
${defs(id)}
<g class="f-zoom">${masked}</g>
</svg>`;
return { svg, layout: { door } };
}
const cache = new Map<PlaceId, { svg: string; layout: FriezeLayout }>();
const built = (id: PlaceId) => {
let b = cache.get(id);
if (!b) cache.set(id, (b = build(id)));
return b;
};
/** The saga's frieze as an SVG string: the saga's name and its song titles as inscriptions. */
export function friezeSvg(sagaId: PlaceId): string {
return built(sagaId).svg;
}
export function friezeLayout(sagaId: PlaceId): FriezeLayout {
return built(sagaId).layout;
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EPIC: The Musical Timeline — an unofficial fan voyage</title>
<link rel="stylesheet" href="../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
<link rel="stylesheet" href="./world/world.css" />
<link rel="stylesheet" href="./panel/panel.css" />
<link rel="stylesheet" href="./timeline/timeline.css" />
</head>
<body>
<main id="voyage-app" class="shell" data-panel="open">
<section id="stage" class="stage" aria-label="The voyage">
<canvas id="world" class="stage__canvas" aria-hidden="true"></canvas>
<div id="frieze-layer" class="frieze-layer"></div>
<div id="caption" class="cap meander-edge" aria-live="polite"></div>
<nav id="timeline" class="timeline meander-edge" aria-label="Songs and motif threads"></nav>
</section>
<aside id="panel" class="panel meander-edge" aria-label="Song details"></aside>
</main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
import { mountCredits } from "../shared/credits";
import { sagaOf, songById, type LandingId, type PlaceId } from "../data/album";
import { createStore, parseState } from "./state";
import { mountPanel } from "./panel/panel";
import { mountTimeline } from "./timeline/timeline";
import { createWorld } from "./world/world";
import { createCurtain } from "./frieze/curtain";
import { createController, type ControllerCurtain } from "./controller";
mountCredits();
const $ = (id: string) => document.getElementById(id)!;
const store = createStore(parseState(location.search));
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const narrow = matchMedia("(max-width: 900px)");
const world = createWorld($("world") as HTMLCanvasElement);
const curtain = createCurtain($("frieze-layer"));
document.body.dataset.webgl = world ? "yes" : "no";
/**
* Without WebGL the frieze is the stage: it is dropped for the current saga and never lifted, so the
* panel and the timeline still browse the whole album (spec 002, goal 9).
*/
const stageCurtain: ControllerCurtain = world
? curtain
: {
drop: (sagaId: PlaceId, opts?: { landing?: LandingId }) => curtain.drop(sagaId, opts),
fade: () => curtain.drop(sagaOf(songById(store.get().songId)!).id),
lift: () => Promise.resolve(),
groundY: () => curtain.groundY(),
};
if (!world) {
void curtain.drop(sagaOf(songById(store.get().songId)!).id);
const note = document.createElement("p");
note.className = "stage__note";
note.textContent = "3D needs WebGL — browsing with the frieze.";
$("stage").append(note);
}
const controller = createController({ store, world, curtain: stageCurtain, reducedMotion });
const panel = mountPanel($("panel"), store, { timelineHidden: () => narrow.matches });
const timeline = mountTimeline($("timeline"), store);
// Below 900px the timeline starts compact (its slim course) and the motif strip lives in Paths.
const compactIfNarrow = () => { if (narrow.matches && store.get().timeline === "open") store.set({ timeline: "closed" }, { push: false }); };
compactIfNarrow();
narrow.addEventListener("change", () => { compactIfNarrow(); panel.refresh(); timeline.refresh(); });
/* ---------- caption card: saga · story time, song title, Go ashore / Back to sea ---------- */
const LANDING_NAME: Record<LandingId, string> = { cave: "The Cyclops' cave", arena: "God Games", hall: "The hall of Ithaca" };
const caption = $("caption");
function renderCaption(focusAction = false) {
const song = songById(store.get().songId)!;
const landing = controller.landing();
caption.innerHTML = `<p class="cap__eyebrow"></p><h1 class="cap__title" tabindex="-1"></h1>`;
caption.querySelector(".cap__eyebrow")!.textContent = landing
? `Ashore · ${LANDING_NAME[landing]}`
: `${sagaOf(song).name} · ${song.storyTime.label}`;
caption.querySelector(".cap__title")!.textContent = song.title;
const action = landing ? "Back to sea" : world && song.landing ? `Go ashore: ${LANDING_NAME[song.landing]}` : null;
if (action) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "cap__back";
btn.textContent = action;
btn.addEventListener("click", () => void (landing ? controller.leaveLanding() : controller.goAshore()));
caption.append(btn);
}
// the button that had focus was just replaced: keep keyboard users on the caption's action
if (focusAction) (caption.querySelector<HTMLElement>(".cap__back") ?? caption.querySelector<HTMLElement>(".cap__title"))!.focus();
}
store.subscribe((s, prev) => { if (s.songId !== prev.songId) renderCaption(); });
controller.onLandingChange(() => renderCaption(caption.contains(document.activeElement)));
renderCaption();
/* ---------- play / previous / next, in the timeline's controls slot ---------- */
const controls = $("timeline").querySelector(".timeline__controls")!;
controls.innerHTML = `
<button type="button" class="vc vc--play" data-act="play" aria-pressed="false">Play voyage</button>
<button type="button" class="vc" data-act="prev" aria-label="Previous song">←</button>
<button type="button" class="vc" data-act="next" aria-label="Next song">→</button>`;
const playBtn = controls.querySelector<HTMLButtonElement>("[data-act=play]")!;
function step(delta: 1 | -1) {
controller.stop();
const track = songById(store.get().songId)!.track + delta;
const next = [...document.querySelectorAll<HTMLElement>("#timeline [data-song]")]
.map((el) => el.dataset.song!)
.find((id) => songById(id)?.track === track);
if (next) store.set({ songId: next });
}
controls.addEventListener("click", (e) => {
const act = (e.target as HTMLElement).closest<HTMLElement>("[data-act]")?.dataset.act;
if (act === "play") (controller.playing() ? controller.stop() : controller.play());
else if (act === "prev") step(-1);
else if (act === "next") step(1);
});
controller.onPlayingChange((p) => {
playBtn.textContent = p ? "Pause voyage" : "Play voyage";
playBtn.setAttribute("aria-pressed", String(p));
});
/* ---------- keyboard: ←/→ songs, Esc closes the reading view or leaves a landing ---------- */
addEventListener("keydown", (e: KeyboardEvent) => {
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
const t = e.target as HTMLElement;
if (t.closest("input, textarea, select, [contenteditable], [role=slider], #timeline, .lyrics")) return;
if (e.key === "ArrowRight") { step(1); e.preventDefault(); }
else if (e.key === "ArrowLeft") { step(-1); e.preventDefault(); }
else if (e.key === "Escape") {
if (store.get().read) store.set({ read: false });
else if (controller.landing()) void controller.leaveLanding();
}
});
for (const ev of ["pointerdown", "wheel"] as const) $("stage").addEventListener(ev, () => controller.stop(), { passive: true });
// (any song change the voyage didn't make itself also stops it: see createController)
/* ---------- layout ---------- */
addEventListener("resize", () => { world?.resize(); timeline.refresh(); });
+30
View File
@@ -0,0 +1,30 @@
// About: track, saga, story time, the Odyssey reference (with "Read the passage" when the content folder is
// mounted), the facts, and the parts and singers. Watch and the listen entry live in Listen & lyrics.
import { sagaOf, songs, type AlbumSong } from "../../data/album";
import { roman } from "../content";
import { esc } from "./util";
export function aboutView(song: AlbumSong, mounted: boolean): HTMLElement {
const el = document.createElement("div");
el.className = "about";
const saga = sagaOf(song);
const o = song.odyssey;
const homer = !o ? "No counterpart in Homer"
: mounted
? `<span class="odref">Book ${roman(o.book)}</span><button type="button" class="readbtn" data-act="read">Read the passage <span aria-hidden="true">→</span></button>`
: `<span class="odref">Book ${roman(o.book)}</span> · <a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Butcher &amp; Lang on Gutenberg</a>`;
const parts = song.parts.length
? song.parts.map((p) => `<li><span class="parts__name">${esc(p.name)}</span><span class="parts__who">${p.singers.map(esc).join(", ")}</span></li>`).join("")
: song.singers.map((s) => `<li><span class="parts__name">${esc(s)}</span></li>`).join("");
el.innerHTML = `
<dl class="facts">
<div><dt>Track</dt><dd>${song.track} of ${songs.length} · ${song.sagaTrack} in the saga</dd></div>
<div><dt>Saga</dt><dd>${esc(saga.name)} <span class="facts__soft">(${saga.year})</span></dd></div>
<div><dt>Story time</dt><dd>${esc(song.storyTime.label)}</dd></div>
<div><dt>The Odyssey</dt><dd>${homer}</dd></div>
</dl>
${song.facts.map((f) => `<p class="about__fact">${esc(f)}</p>`).join("")}
<h3 class="sec">${song.parts.length ? "Parts and singers" : "Singers"}</h3>
<ol class="parts">${parts}</ol>`;
return el;
}
+90
View File
@@ -0,0 +1,90 @@
// The route chart in Paths: a small vellum sea chart of the nine places of the voyage, Troy to Ithaca, with
// "You are here" on the current song. Redrawn from 17's chart (which had two places) for the whole album.
// Stylised, not geographic: the course wanders west from Troy and loops back east to Ithaca.
import { sagas, songsInSaga, type AlbumSong, type PlaceId } from "../../data/album";
type Pt = [number, number];
/** Each place: its port on the 320×210 card, its short name and where the name sits. */
const PLACES: Record<PlaceId, { at: Pt; name: string; dx: number; dy: number; anchor?: "start" | "middle" | "end" }> = {
troy: { at: [286, 44], name: "Troy", dx: 0, dy: -9, anchor: "middle" },
cyclops: { at: [238, 86], name: "Cyclops' isle", dx: 0, dy: 19, anchor: "middle" },
ocean: { at: [186, 42], name: "Open sea", dx: 0, dy: -9, anchor: "middle" },
circe: { at: [132, 82], name: "Aeaea", dx: 0, dy: 19, anchor: "middle" },
underworld: { at: [66, 40], name: "Underworld", dx: 0, dy: -9, anchor: "middle" },
thunder: { at: [40, 120], name: "Scylla", dx: 8, dy: 4, anchor: "start" },
wisdom: { at: [100, 166], name: "Olympus", dx: 0, dy: 19, anchor: "middle" },
vengeance: { at: [164, 128], name: "Charybdis", dx: 0, dy: -9, anchor: "middle" },
ithaca: { at: [232, 170], name: "Ithaca", dx: -10, dy: 5, anchor: "end" },
};
const ORDER = sagas.map((s) => s.id);
const pts = ORDER.map((id) => PLACES[id].at);
const f = (n: number) => n.toFixed(1);
/** A smooth course through every port (Catmull-Rom as cubic Béziers). */
function course(): string {
let d = `M${f(pts[0]![0])},${f(pts[0]![1])}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i]!, p1 = pts[i]!, p2 = pts[i + 1]!, p3 = pts[i + 2] ?? p2;
const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += ` C${f(c1[0])},${f(c1[1])} ${f(c2[0])},${f(c2[1])} ${f(p2[0])},${f(p2[1])}`;
}
return d;
}
/** Where "You are here" sits for a song: at its saga's port, edging along the course as the saga goes on. */
export function hereAt(song: AlbumSong): Pt {
const i = ORDER.indexOf(song.sagaId);
const p = pts[i]!, next = pts[i + 1];
if (!next) return p;
const n = songsInSaga(song.sagaId).length;
const k = n > 1 ? ((song.sagaTrack - 1) / n) * 0.6 : 0;
return [p[0] + (next[0] - p[0]) * k, p[1] + (next[1] - p[1]) * k];
}
export function chartSvg(song: AlbumSong): string {
const [hx, hy] = hereAt(song);
const cur = ORDER.indexOf(song.sagaId);
const ports = ORDER.map((id, i) => {
const p = PLACES[id];
const cls = i < cur ? " is-past" : i === cur ? " is-current" : "";
return `<g class="c-stop${cls}"><circle class="c-port" cx="${p.at[0]}" cy="${p.at[1]}" r="${i === cur ? 4 : 3}"/>
<text class="c-place" x="${p.at[0] + p.dx}" y="${p.at[1] + p.dy}" text-anchor="${p.anchor ?? "start"}">${p.name}</text></g>`;
}).join("");
let rhumbs = "";
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
rhumbs += `M160,105 L${(160 + Math.cos(a) * 240).toFixed(0)},${(105 + Math.sin(a) * 240).toFixed(0)} `;
}
// "You are here" sits on the side away from the current place's own name
const own = PLACES[song.sagaId];
const label = own.anchor === "end" ? { x: 10, y: -9, a: "start" }
: own.dy < 0 && own.anchor === "middle" ? { x: 0, y: 21, a: "middle" }
: { x: 0, y: -11, a: "middle" };
return `<svg class="chart__svg" viewBox="0 0 320 210" role="img" aria-label="Chart of the voyage from Troy to Ithaca; you are at ${sagas[cur]!.place}">
<defs><clipPath id="chart-clip"><rect x="6" y="6" width="308" height="198"/></clipPath></defs>
<rect class="c-vellum" x="0" y="0" width="320" height="210"/>
<g clip-path="url(#chart-clip)">
<path class="c-rhumb" d="${rhumbs}"/>
<path class="c-land" d="M262,0 Q270,26 300,30 Q312,46 320,52 V0 Z"/>
<path class="c-land" d="M320,124 Q288,140 270,168 Q262,190 284,210 H320 Z"/>
<ellipse class="c-land" cx="232" cy="171" rx="13" ry="7"/>
<ellipse class="c-land" cx="238" cy="88" rx="16" ry="9"/>
<ellipse class="c-land" cx="132" cy="84" rx="14" ry="8"/>
<ellipse class="c-land" cx="100" cy="168" rx="18" ry="9"/>
<path class="c-ink" d="M92,168 l8,-11 l8,11"/>
<circle class="c-dark" cx="66" cy="42" r="8"/>
<path class="c-land" d="M0,92 Q26,104 34,124 Q30,150 0,160 Z"/>
<ellipse class="c-ink" cx="164" cy="130" rx="11" ry="5"/><ellipse class="c-ink" cx="164" cy="130" rx="6" ry="2.6"/>
<path class="c-course" d="${course()}"/>
${ports}
<g class="c-rose" transform="translate(296,104)"><circle r="10"/><path d="M0,-14 L3,0 L0,14 L-3,0 Z M-14,0 L0,3 L14,0 L0,-3 Z"/></g>
</g>
<g class="c-here" transform="translate(${f(hx)},${f(hy)})">
<circle class="c-here__ring" r="7"/>
<circle class="c-here__dot" r="3"/>
<text class="c-here__label" x="${label.x}" y="${label.y}" text-anchor="${label.a}">You are here</text>
</g>
<rect class="c-frame" x="4" y="4" width="312" height="202"/>
</svg>`;
}
+25
View File
@@ -0,0 +1,25 @@
// Fan works: credited, linked fan works for the song; an empty state while none are collected (v1).
import { fanWorks, type AlbumSong, type FanWork } from "../../data/album";
import { esc } from "./util";
const TYPE_LABEL: Record<FanWork["type"], string> = {
art: "Art", animatic: "Animatic", cover: "Cover", cosplay: "Cosplay", other: "Fan work",
};
export function fanView(song: AlbumSong): HTMLElement {
const el = document.createElement("div");
el.className = "fan";
const works = fanWorks.filter((w) => w.songId === song.id);
el.innerHTML = works.length
? `<ul class="fw">${works.map((w) => `<li class="fw__card">
<p class="fw__type">${TYPE_LABEL[w.type]}</p>
<p class="fw__title"><a href="${esc(w.url)}" target="_blank" rel="noopener noreferrer">${esc(w.title)}</a></p>
<p class="fw__by">by ${esc(w.creator)}</p>
<p class="fw__credit">${esc(w.permission)}</p>
</li>`).join("")}</ul>`
: `<div class="fan__empty">
<svg class="fan__glyph" viewBox="0 0 72 44" aria-hidden="true"><path class="g-line" d="M36 28 V5"/><path class="g-sail" d="M37 7 L52 24 H37 Z"/><path class="g-hull" d="M12 28 Q36 40 60 28 Z"/><path class="g-line" d="M4 39 q6 -4 12 0 t12 0 t12 0 t12 0 t12 0"/></svg>
<p class="fan__lead">No fan works collected yet. Real ones will be credited and linked to their creators.</p>
</div>`;
return el;
}
+250
View File
@@ -0,0 +1,250 @@
// Listen & lyrics: the player (real audio, seek bar, cover art), the line-synced lyrics with the current
// line lit and click-to-seek, the song's parts and motifs, and Watch (YouTube link cards). Ported from 17's
// panel.ts/player.ts; without the content folder there is no player, only a note (no simulated playback).
import type { AlbumSong } from "../../data/album";
import type { Song } from "../../data/types";
import { createVideoCards } from "../../shared/video-cards";
import { loadLyrics, type Lyrics, type RealSong } from "../content";
import { AudioPlayer } from "./player";
import { dot, esc, fmt, motifsOf, PAUSE_ICON, PLAY_ICON } from "./util";
type LyricsState = Lyrics | "none" | "loading" | "failed";
export interface ListenDeps {
/** Where the hidden <audio> element lives. */
host: HTMLElement;
/** The panel's scrolling view, which the lyrics follow inside. */
view: HTMLElement;
reduced: () => boolean;
/** Called on every player change (time, play/pause), for the panel header. */
onTick: () => void;
/** Called when what Listen shows changes (lyrics arrived, the file failed): re-render it if shown. */
onContent: () => void;
}
export type Listen = ReturnType<typeof createListen>;
export function createListen(deps: ListenDeps) {
let song: AlbumSong | null = null;
let real: RealSong | null = null;
let mounted = false;
let lyrics: LyricsState = "none";
/** The lit line, the line last scrolled to, and until when the reader's own scroll wins. */
let lineNow = -1;
let lineScrolled = -1;
let userScrollUntil = 0;
const player = new AudioPlayer(deps.host, () => {
if (player.failed && real) { real = null; deps.onContent(); }
sync();
deps.onTick();
});
function setSong(next: AlbumSong, nextReal: RealSong | null, isMounted: boolean) {
mounted = isMounted;
const same = song?.id === next.id && real?.audio === nextReal?.audio;
song = next;
if (same) return;
real = nextReal;
lyrics = "none";
lineNow = -1;
lineScrolled = -1;
player.load(real?.audio ?? null, next.id);
if (real?.lyrics) {
lyrics = "loading";
const id = next.id;
loadLyrics(real.lyrics).then((l) => {
if (song?.id !== id) return;
lyrics = l ?? "failed";
deps.onContent();
});
}
}
const timed = (): Extract<Lyrics, { timed: true }> | null =>
typeof lyrics === "object" && lyrics.timed ? lyrics : null;
function render(): HTMLElement {
const s = song!;
const el = document.createElement("div");
el.className = "listen";
const ms = motifsOf(s);
const parts = s.parts.length ? s.parts.map((p) => esc(p.name)).join(" · ") : s.singers.map(esc).join(", ");
el.innerHTML = `
${player.loaded ? playerHtml(s) : noPlayerHtml()}
<section class="psum" aria-label="Parts and motifs">
<p class="psum__row"><span class="psum__k">${s.parts.length ? "Parts" : "Singers"}</span><span class="psum__v">${parts}</span></p>
${ms.length ? `<p class="psum__row"><span class="psum__k">Motifs</span><span class="psum__chips">${ms.map((m) =>
`<button type="button" class="mtag" data-m="${m.id}" data-pick="${m.id}" aria-pressed="false">${dot(m)}${esc(m.name)}</button>`).join("")}</span></p>` : ""}
<p class="psum__note">Parts and motifs are not yet timed to the recording.</p>
</section>
<section class="watch" aria-label="Watch">
<h3 class="sec">Watch</h3>
<div class="watch__cards"></div>
</section>
<section class="lyrics" aria-label="Lyrics">
<h3 class="sec">Lyrics</h3>
${lyricsHtml()}
</section>`;
// createVideoCards only reads `videos`; the album song has the same shape for it
el.querySelector(".watch__cards")!.append(createVideoCards(s as unknown as Song));
wireScrub(el);
// a fresh render starts at the top: let the next tick scroll the current line back into view
lineNow = -1;
lineScrolled = -1;
return el;
}
function playerHtml(s: AlbumSong): string {
const r = real!;
return `
<section class="player player--real" aria-label="Player">
<div class="player__top">
${r.cover ? `<img class="player__cover" src="${esc(r.cover)}" alt="" width="56" height="56" />` : ""}
<div class="player__meta">
<p class="player__label">Track ${s.track} · ${esc(s.title)}</p>
<p class="player__time"><span data-elapsed>0:00</span> <span class="player__of">/ <span data-total>…</span></span></p>
</div>
<button type="button" class="player__play" data-act="toggle" aria-pressed="false"></button>
</div>
<div class="scrub" role="slider" tabindex="0" aria-label="Position in ${esc(s.title)}" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0">
<div class="scrub__track"><div class="scrub__fill" data-fill></div><div class="scrub__head" data-head></div></div>
</div>
</section>`;
}
function noPlayerHtml(): string {
const why = !mounted
? `<b>Content folder not mounted.</b> The recording and its lyrics play here when the server has <code>CONTENT_DIR</code>.`
: player.failed ? `<b>The recording could not be loaded.</b> Check the file in the content folder.`
: `<b>This song is not in the content folder.</b> Its recording and lyrics play here once it is added.`;
return `<section class="player player--none" aria-label="Player"><p class="cnote">${why}</p></section>`;
}
function lyricsHtml(): string {
if (!real) return `<p class="note">Lyrics are shown only from the mounted content folder, with the recording.</p>`;
if (lyrics === "loading") return `<p class="hint">Loading the lyrics…</p>`;
if (lyrics === "failed") return `<p class="note">The lyrics file could not be loaded.</p>`;
if (lyrics === "none") return `<p class="note">This song has no lyrics file in the content folder.</p>`;
if (!lyrics.timed) {
return `<p class="note">These lyrics are not timed, so they do not follow the song.</p>
<div class="lyrics__plain">${lyrics.lines.map((l) => (l ? `<p>${esc(l)}</p>` : `<p class="lyrics__gap" aria-hidden="true"></p>`)).join("")}</div>`;
}
return `<p class="hint">Follows the song. Click a line to play from there.</p>
<ol class="lyrics__lines">${lyrics.lines.map((l, i) => (l.text
? `<li><button type="button" class="ly" data-line="${i}" title="Play from ${fmt(l.time)}"><span class="ly__at">${fmt(l.time)}</span><span class="ly__x">${esc(l.text)}</span></button></li>`
: `<li class="ly ly--gap" data-line="${i}" aria-hidden="true"><span class="ly__x">· · ·</span></li>`)).join("")}</ol>`;
}
function wireScrub(el: HTMLElement) {
const scrub = el.querySelector<HTMLElement>(".scrub");
if (!scrub) return;
const seekAt = (clientX: number) => {
if (!player.ready) return;
const r = scrub.getBoundingClientRect();
player.seek(((clientX - r.left) / r.width) * player.dur);
followNow();
};
scrub.addEventListener("pointerdown", (e) => {
seekAt(e.clientX);
scrub.setPointerCapture?.(e.pointerId);
const move = (ev: PointerEvent) => seekAt(ev.clientX);
scrub.addEventListener("pointermove", move);
scrub.addEventListener("pointerup", () => scrub.removeEventListener("pointermove", move), { once: true });
});
scrub.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 5 : e.key === "ArrowLeft" ? -5 : 0;
if (d) { e.preventDefault(); player.seek(player.t + d); followNow(); }
});
}
/** After a seek or a line click, the lyrics follow the song again at once. */
function followNow() { userScrollUntil = 0; lineScrolled = -1; }
/** The reader scrolled the lyrics: their scroll wins for ~4 s. */
function userScrolled() { userScrollUntil = performance.now() + 4000; lineScrolled = -1; }
/** Index of the timed line playing at time t (a small lead so the highlight lands with the voice). */
function lineAt(t: number): number {
const ly = timed();
if (!ly) return -1;
let k = -1;
for (let i = 0; i < ly.lines.length && ly.lines[i]!.time <= t + 0.15; i++) k = i;
return k;
}
/** Brings a lyric line to about a third of the way down the space under the sticky player. */
function scrollToLine(line: HTMLElement) {
const view = deps.view;
const card = view.querySelector<HTMLElement>(".player--real");
const capH = card ? card.offsetHeight : 0;
const vr = view.getBoundingClientRect();
const lr = line.getBoundingClientRect();
const top = view.scrollTop + (lr.top - vr.top) - (capH + (view.clientHeight - capH) * 0.3);
view.scrollTo?.({ top: Math.max(0, top), behavior: deps.reduced() ? "auto" : "smooth" });
}
/** Reflects the player in the Listen view, if it is shown. */
function sync() {
const lv = deps.view.querySelector<HTMLElement>(".listen");
if (!lv) { lineNow = -1; return; }
const pb = lv.querySelector<HTMLButtonElement>(".player__play");
if (!pb) return;
const on = player.playing;
const pct = player.ready && player.dur ? Math.min(100, (player.t / player.dur) * 100) : 0;
lv.querySelector<HTMLElement>("[data-fill]")!.style.width = `${pct}%`;
lv.querySelector<HTMLElement>("[data-head]")!.style.left = `${pct}%`;
lv.querySelector("[data-elapsed]")!.textContent = fmt(player.t);
lv.querySelector("[data-total]")!.textContent = player.ready ? fmt(player.dur) : "…";
// buttons are only rewritten when their state changes, so a click is never lost to a re-render
if (pb.dataset.on !== String(on)) {
pb.dataset.on = String(on);
pb.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"}</span>`;
pb.setAttribute("aria-pressed", String(on));
}
const scrub = lv.querySelector<HTMLElement>(".scrub")!;
scrub.setAttribute("aria-valuemax", String(Math.round(player.dur)));
scrub.setAttribute("aria-valuenow", String(Math.round(player.t)));
scrub.setAttribute("aria-valuetext", `${fmt(player.t)} of ${player.ready ? fmt(player.dur) : "unknown length"}`);
const k = player.t > 0 || on ? lineAt(player.t) : -1;
if (k !== lineNow) {
lineNow = k;
lv.querySelectorAll<HTMLElement>("[data-line]").forEach((n) => {
const i = Number(n.dataset.line);
n.classList.toggle("is-now", i === k);
n.classList.toggle("is-past", i < k);
if (i === k) n.setAttribute("aria-current", "true"); else n.removeAttribute("aria-current");
});
}
if (k >= 0 && k !== lineScrolled && performance.now() >= userScrollUntil) {
const el = lv.querySelector<HTMLElement>(`[data-line="${k}"]`);
if (el) { lineScrolled = k; scrollToLine(el); }
}
}
/** Handles a click inside the Listen view; true if it was one of ours. */
function click(target: Element): boolean {
const line = target.closest<HTMLElement>("button[data-line]");
const ly = timed();
if (line && ly) {
player.seek(ly.lines[Number(line.dataset.line)]!.time);
followNow();
player.play();
return true;
}
if (target.closest<HTMLElement>("[data-act=toggle]")) { player.toggle(); return true; }
return false;
}
return {
player,
setSong,
render,
sync,
click,
userScrolled,
/** Seek to t seconds and play (e.g. for a deep link into a song). */
playAt(t: number) { player.seek(t); followNow(); player.play(); },
destroy() { player.destroy(); },
};
}
+502
View File
@@ -0,0 +1,502 @@
/* The side panel (plan task 4): About · Listen & lyrics · Paths · Fan works. Ported from mockup 17's panel
styles onto the app's tokens (src/app/style.css). Generic names are scoped under .panel. */
/* ---------- frame ---------- */
.panel {
position: relative;
z-index: 8;
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);
box-shadow: -14px 0 30px -18px rgb(0 0 0 / 0.7);
font-size: var(--fs-body);
line-height: 1.5;
}
.panel.meander-edge::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 minmax(0, 1fr);
padding-top: 18px;
transition: opacity 0.25s ease, transform 0.42s var(--ease);
}
.panel[data-open="false"] .panel__body { opacity: 0; transform: translateX(40px); pointer-events: none; }
.panel__head { display: flex; align-items: flex-start; gap: 14px; padding: 18px 20px 12px 22px; }
.panel__song { flex: 1; min-width: 0; }
.panel__eyebrow { margin: 0; font-size: var(--fs-label); font-weight: 600; color: var(--clay-deep); font-variant-numeric: tabular-nums; }
.panel__eyebrow.is-live::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 7px; border-radius: 50%; background: var(--clay); box-shadow: 0 0 0 1.5px var(--glaze); vertical-align: 1px; }
.panel__title { margin: 2px 0 4px; font: 600 var(--fs-title)/1.05 var(--serif); overflow-wrap: break-word; text-wrap: balance; }
.panel__sub { margin: 0; font-size: var(--fs-body); color: var(--iron-soft); }
.pplay, .player__play {
flex: none;
display: grid; place-items: center;
width: 52px; height: 52px;
margin-top: 4px;
border: 2px solid var(--glaze);
border-radius: 50%;
background: var(--clay);
color: var(--glaze);
cursor: pointer;
box-shadow: 0 3px 0 var(--glaze);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.pplay svg, .player__play svg { width: 22px; height: 22px; fill: currentColor; }
.pplay:hover, .player__play:hover { transform: translateY(-1px); box-shadow: 0 4px 0 var(--glaze); }
.pplay:active, .player__play:active { transform: translateY(2px); box-shadow: 0 1px 0 var(--glaze); }
.pplay[aria-pressed="true"], .player__play[aria-pressed="true"] { background: var(--glaze); color: var(--added-white); }
.panel__hide {
flex: none;
margin-top: 2px;
padding: 5px 12px;
border: 1.5px solid var(--line);
border-radius: 999px;
background: transparent;
font-size: var(--fs-small);
font-weight: 500;
color: var(--iron-soft);
cursor: pointer;
}
.panel__hide:hover { color: var(--iron); border-color: var(--glaze); }
.ptabs { display: flex; gap: 2px; padding: 0 20px 0 22px; border-bottom: 1.5px solid var(--glaze); }
.ptabs button {
position: relative;
padding: 11px 9px 10px;
border: 0;
background: transparent;
color: var(--iron-soft);
font-size: var(--fs-body);
font-weight: 500;
white-space: nowrap;
cursor: pointer;
}
.ptabs button:hover { color: var(--iron); }
.ptabs button[aria-selected="true"] { color: var(--iron); font-weight: 600; }
.ptabs button[aria-selected="true"]::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1.5px; height: 4px; background: var(--clay); border: 1px solid var(--glaze); border-bottom: 0; }
.panel__view {
min-height: 0;
overflow: auto;
padding: 18px 20px 28px 22px;
scrollbar-width: thin;
scrollbar-color: var(--line) transparent;
}
.panel.is-swapped .panel__view, .panel.is-swapped .panel__song { animation: panel-settle 0.36s var(--ease); }
@keyframes panel-settle { from { 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-n {
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 var(--fs-body) var(--sans);
}
.panel__tab-label { writing-mode: vertical-rl; font-size: var(--fs-body); font-weight: 600; }
/* ---------- shared content ---------- */
.panel .sec { margin: 26px 0 8px; font: 600 1.375rem/1.2 var(--serif); }
.panel__view > div > .sec:first-child { margin-top: 0; }
.panel .note {
margin: 0 0 14px;
padding: 9px 12px;
border: 1px dashed rgb(43 33 24 / 0.35);
border-radius: 4px;
color: var(--iron-soft);
font-size: var(--fs-small);
}
.panel .hint { margin: 0 0 10px; color: var(--iron-soft); font-size: var(--fs-small); }
.panel .cnote {
margin: 0;
padding: 10px 12px;
border-left: 4px solid var(--clay);
background: var(--vellum-hi);
color: var(--iron-soft);
font-size: var(--fs-small);
}
.panel .cnote b { color: var(--iron); font-weight: 600; }
.panel .cnote code { font-size: var(--fs-label); }
.mdot { flex: none; display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
/* ---------- About ---------- */
.panel .facts { margin: 0; display: grid; }
.panel .facts div { display: grid; grid-template-columns: 112px 1fr; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.panel .facts dt { font-size: var(--fs-small); font-weight: 600; color: var(--iron-soft); }
.panel .facts dd { margin: 0; }
.facts__soft { color: var(--iron-soft); }
.panel .facts a { color: var(--clay-deep); text-underline-offset: 3px; }
.about__fact { margin: 14px 0 0; font: italic 500 1.25rem/1.35 var(--serif); }
.panel .parts { margin: 0; padding: 0; list-style: none; }
.panel .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; }
.odref { font-weight: 600; }
.readbtn {
display: inline-flex; align-items: center; gap: 6px;
margin-left: 10px;
padding: 2px 12px;
border: 1.5px solid var(--glaze);
border-radius: 999px;
background: var(--vellum-hi);
color: var(--iron);
font-size: var(--fs-small); font-weight: 600;
cursor: pointer;
}
.readbtn:hover { background: var(--clay); }
/* ---------- the reading view ---------- */
.reading__back {
display: inline-flex; gap: 6px;
margin: 0 0 12px;
padding: 4px 0;
border: 0;
background: transparent;
color: var(--clay-deep);
font-size: var(--fs-body); font-weight: 600;
cursor: pointer;
}
.reading__back:hover { text-decoration: underline; text-underline-offset: 3px; }
.reading__eyebrow { margin: 0; font-size: var(--fs-small); font-weight: 600; color: var(--iron-soft); }
.reading__title { margin: 2px 0 6px; font: 600 1.75rem/1.15 var(--serif); }
.reading__sum { margin: 0 0 14px; font-size: var(--fs-label); font-weight: 600; line-height: 1.45; letter-spacing: 0.02em; color: var(--iron-soft); }
.reading__more {
display: block;
width: 100%;
margin: 0 0 6px;
padding: 8px 12px;
border: 1px dashed rgb(43 33 24 / 0.45);
border-radius: 4px;
background: transparent;
color: var(--iron-soft);
font-size: var(--fs-small); font-weight: 600;
cursor: pointer;
}
.reading__more:hover { color: var(--iron); border-color: var(--glaze); }
.reading__mark {
position: relative;
margin: 18px 0 10px;
padding: 16px 0 0;
color: var(--clay-deep);
font-size: var(--fs-small); font-weight: 600;
scroll-margin-top: 8px;
}
.reading__mark::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 8px;
background: var(--meander) 0 0 / 8px 8px repeat-x;
border-top: 1px solid var(--glaze);
border-bottom: 1px solid var(--glaze);
}
.reading__p { margin: 0 0 12px; font: 500 1.1875rem/1.5 var(--serif); hanging-punctuation: first; }
.reading__p mark { padding: 0 2px; border-radius: 2px; background: rgb(196 106 51 / 0.3); color: inherit; }
.reading__end { margin: 18px 0 0; font-size: var(--fs-label); color: var(--iron-soft); text-align: center; }
/* ---------- Listen & lyrics: the player ---------- */
.panel .player {
position: relative;
margin: 0 0 16px;
padding: 22px 16px 14px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: #f3ecd8;
overflow: hidden;
}
.panel .player::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 10px;
background: var(--meander) 0 0 / 10px 10px repeat-x;
border-bottom: 1.5px solid var(--glaze);
}
.panel .player--none { padding: 22px 0 0; }
.panel .player--none .cnote { border-left: 0; background: transparent; padding: 10px 14px 12px; }
/* it stays pinned while the lyrics scroll under it */
.panel .player--real {
position: sticky;
top: -18px;
z-index: 2;
margin-top: -18px;
border-top-width: 0;
box-shadow: 0 10px 14px -12px rgb(29 20 14 / 0.55);
}
.player__top { display: flex; align-items: center; gap: 12px; }
.player__play { margin-top: 0; width: 56px; height: 56px; }
.player__play svg { width: 24px; height: 24px; }
.player__meta { flex: 1; min-width: 0; }
.player__cover {
flex: none;
width: 56px; height: 56px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
object-fit: cover;
background: var(--glaze);
}
.player__label { margin: 0; font-size: var(--fs-small); color: var(--iron-soft); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.player__time { margin: 0; font-size: 1.0625rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.player__of { font-weight: 400; color: var(--iron-soft); }
.scrub { position: relative; margin: 14px 0 0; padding: 8px 0; cursor: pointer; touch-action: none; }
.scrub:focus-visible { outline-offset: 0; }
.scrub__track { position: relative; height: 8px; border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo); }
.scrub__fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; border-radius: 999px; background: var(--clay); }
.scrub__head { position: absolute; top: 50%; left: 0; width: 18px; height: 18px; margin: -9px 0 0 -9px; border: 2px solid var(--glaze); border-radius: 50%; background: var(--added-white); }
/* the song's parts and motifs, compact, above the lyrics */
.psum { margin: 0 0 4px; padding: 0 0 12px; border-bottom: 1px solid var(--line); }
.psum__row { display: flex; align-items: baseline; gap: 10px; margin: 0 0 6px; }
.psum__k { flex: none; width: 62px; font-size: var(--fs-small); font-weight: 600; color: var(--iron-soft); }
.psum__v { font-size: var(--fs-body); line-height: 1.4; }
.psum__chips { display: flex; flex-wrap: wrap; gap: 6px; }
.psum__note { margin: 2px 0 0 72px; font-size: var(--fs-label); color: var(--iron-soft); }
.mtag {
display: inline-flex; align-items: center; gap: 7px;
padding: 2px 10px 2px 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgb(255 250 236 / 0.5);
color: var(--iron);
font-size: var(--fs-label); font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.mtag .mdot { width: 9px; height: 9px; }
.mtag:hover, .mtag.is-on { border-color: var(--glaze); }
.mtag.is-on { background: var(--vellum-lo); box-shadow: inset 0 -3px 0 var(--c); }
.mtag.is-dim { opacity: 0.5; }
/* Watch: YouTube link cards */
.watch .sec { margin-top: 18px; }
.panel .video-cards { display: flex; flex-wrap: wrap; 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;
text-decoration: none;
}
.panel .video-card:hover { background: var(--vellum-hi); text-decoration: underline; text-underline-offset: 3px; }
.panel .video-card::before {
content: "";
position: absolute;
left: 13px; top: 50%;
border: 6px solid transparent;
border-left: 9px solid var(--clay);
transform: translateY(-50%);
}
/* synced lyrics: the line playing now is lit; earlier lines are sung */
.lyrics .sec { margin-top: 22px; }
.lyrics__lines { margin: 0; padding: 0; list-style: none; }
.ly {
display: grid;
grid-template-columns: 46px 1fr;
align-items: baseline;
width: 100%;
margin: 0;
padding: 4px 10px 4px 0;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--iron);
text-align: left;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease, box-shadow 0.3s ease;
}
.ly__at {
justify-self: end;
padding-right: 8px;
font-size: var(--fs-label);
font-variant-numeric: tabular-nums;
color: var(--iron-soft);
opacity: 0;
transition: opacity 0.15s ease;
}
.ly__x { font: italic 500 1.1875rem/1.4 var(--serif); }
.ly:hover .ly__at, .ly:focus-visible .ly__at { opacity: 1; }
.ly:hover .ly__x { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 4px; text-decoration-color: rgb(43 33 24 / 0.4); }
.ly.is-past { color: var(--iron-soft); }
.ly.is-now { background: rgb(196 106 51 / 0.18); box-shadow: inset 0 0 0 1.5px var(--glaze); color: var(--iron); }
.ly.is-now .ly__at { opacity: 1; color: var(--glaze); font-weight: 600; }
.ly.is-now .ly__x { font-weight: 700; }
.ly--gap { cursor: default; padding-top: 2px; padding-bottom: 2px; }
.ly--gap .ly__x { grid-column: 2; font: 500 var(--fs-label)/1 var(--sans); color: rgb(90 72 54 / 0.6); letter-spacing: 0.3em; }
.ly--gap.is-now { background: transparent; box-shadow: inset 3px 0 0 var(--clay); }
.lyrics__plain p { margin: 2px 0; font: italic 500 1.1875rem/1.4 var(--serif); }
.lyrics__plain .lyrics__gap { height: 12px; }
/* ---------- Paths ---------- */
/* the motif strip: the bottom timeline in miniature, shown while the timeline is hidden */
.motif-strip {
position: relative;
margin: 0 0 6px;
padding: 18px 0 10px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: var(--vellum-hi);
overflow: hidden;
}
.motif-strip::before {
content: "";
position: absolute; left: 0; right: 0; top: 0;
height: 8px;
background: var(--meander) 0 0 / 8px 8px repeat-x;
border-bottom: 1.5px solid var(--glaze);
}
.ms__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; margin: 0 10px 2px; }
.ms__title { font-size: var(--fs-small); font-weight: 600; }
.ms__sub { font-size: var(--fs-label); color: var(--iron-soft); }
.ms__body { position: relative; width: calc(var(--w) * 1px); height: calc(var(--h) * 1px); margin: 0 auto; }
.ms__svg { display: block; width: calc(var(--w) * 1px); height: calc(var(--h) * 1px); overflow: visible; }
.ms__band { fill: rgb(196 106 51 / 0.18); }
.ms__course { fill: none; stroke: var(--clay-deep); stroke-width: 1.4; stroke-dasharray: 4 3; }
.ms__saga { stroke: var(--glaze); stroke-width: 1; opacity: 0.45; }
.ms__stop { fill: var(--iron-soft); cursor: pointer; }
.ms__stop:hover { fill: var(--clay-deep); }
.ms__stop.is-current { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.8; }
.ms__hull { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.3; stroke-linejoin: round; }
.ms__mast { stroke: var(--glaze); stroke-width: 1.5; }
.ms__thread { transition: opacity 0.2s ease; }
.ms__edge { fill: none; stroke: var(--glaze); stroke-width: 4.6; stroke-linecap: round; }
.ms__line { fill: none; stroke: var(--c); stroke-width: 2.6; stroke-linecap: round; }
.ms__thread.is-on .ms__edge { stroke-width: 6.4; }
.ms__thread.is-on .ms__line { stroke-width: 4.2; }
.ms__knot { fill: var(--c); stroke: var(--glaze); stroke-width: 1.3; }
.ms__knot.is-here { stroke-width: 2; }
.ms__thread.is-dim { opacity: 0.25; }
.ms__name {
position: absolute; left: 4px;
display: inline-flex; align-items: center; gap: 7px;
padding: 0 6px 0 4px;
border: 0; border-radius: 2px;
background: rgb(236 226 198 / 0.85);
color: var(--iron);
font-size: var(--fs-label); font-weight: 600; line-height: 1.5;
cursor: pointer;
}
.ms__name .mdot { width: 9px; height: 9px; }
.ms__name:hover, .ms__name.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.ms__name.is-dim { opacity: 0.5; }
/* per-motif rows: heard before · here · returns */
.mrows { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 8px; }
.mrow { border: 1px solid var(--line); border-radius: 2px; background: rgb(255 250 236 / 0.35); transition: opacity 0.2s ease; }
.mrow.is-on { border-color: var(--glaze); background: var(--vellum-hi); }
.mrow.is-dim { opacity: 0.55; }
.mrow__name {
width: 100%;
display: flex; align-items: center; gap: 8px;
padding: 8px 12px 6px;
border: 0;
background: transparent;
text-align: left;
font-weight: 600;
cursor: pointer;
}
.mrow__name:hover > span:first-of-type { text-decoration: underline; text-underline-offset: 3px; }
.mrow__kind { margin-left: auto; font-size: var(--fs-label); font-weight: 500; color: var(--iron-soft); }
.mrow__route { margin: 0; padding: 0 12px 8px 30px; display: grid; gap: 2px; }
.mrow__route div { display: grid; grid-template-columns: 92px 1fr; gap: 8px; align-items: baseline; }
.mrow__route dt { font-size: var(--fs-label); font-weight: 600; color: var(--iron-soft); }
.mrow__route dd { margin: 0; font-size: var(--fs-small); }
.mrow__song, .mthread__song {
display: inline-flex; align-items: baseline; gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: var(--iron);
font-size: var(--fs-small); font-weight: 500;
text-align: left;
text-decoration: underline; text-decoration-color: rgb(43 33 24 / 0.35); text-underline-offset: 3px;
cursor: pointer;
}
.mrow__song:hover, .mthread__song:hover { text-decoration-color: var(--clay-deep); color: var(--clay-deep); }
.mrow__n { font-size: var(--fs-label); font-variant-numeric: tabular-nums; color: var(--iron-soft); }
.mrow__none, .mrow__more { color: var(--iron-soft); }
/* the followed motif's whole thread */
.mthread { margin: 0 0 8px; padding: 10px 12px; border-left: 4px solid var(--glaze); background: var(--vellum-hi); }
.mthread__head { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--fs-small); }
.mthread__desc { margin: 4px 0 6px; color: var(--iron-soft); font-size: var(--fs-small); }
.mthread__songs { margin: 0; padding: 0; list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; }
.mthread__song.is-current { font-weight: 700; text-decoration: none; }
/* the route chart */
.route {
margin: 0;
padding: 8px 9px 6px;
background: linear-gradient(160deg, #ece2c6, #dccca5);
border: 1.5px solid var(--glaze);
border-radius: 2px;
}
.route figcaption { margin-top: 4px; font-size: var(--fs-small); 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-dark { fill: var(--iron); opacity: 0.75; }
.c-ink { fill: none; stroke: var(--iron); stroke-width: 0.8; }
.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-stop.is-past .c-port { fill: var(--clay); }
.c-stop.is-current .c-port { fill: var(--clay); stroke-width: 1.4; }
.c-place { font: 600 12px var(--sans); fill: var(--iron); paint-order: stroke; stroke: #e6dab9; stroke-width: 3px; stroke-linejoin: round; }
.c-stop.is-current .c-place { 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: 700 12px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: #efe6cd; stroke-width: 3px; stroke-linejoin: round; }
/* ---------- Fan works ---------- */
.fan__empty { display: grid; justify-items: center; gap: 10px; padding: 36px 18px; border: 1px dashed rgb(43 33 24 / 0.35); border-radius: 4px; text-align: center; }
.fan__glyph { width: 72px; height: 44px; }
.fan__glyph .g-hull, .fan__glyph .g-sail { fill: var(--clay); stroke: var(--glaze); stroke-width: 2; stroke-linejoin: round; }
.fan__glyph .g-line { fill: none; stroke: var(--glaze); stroke-width: 2; stroke-linecap: round; }
.fan__lead { margin: 0; max-width: 30ch; color: var(--iron-soft); }
.fw { margin: 0; padding: 0; list-style: none; display: grid; gap: 10px; }
.fw__card { padding: 10px 12px; border: 1.5px solid var(--glaze); border-radius: 2px; background: #f3ecd8; }
.fw__card p { margin: 0; }
.fw__type { font-size: var(--fs-label); font-weight: 600; color: var(--clay-deep); }
.fw__title { font-weight: 600; }
.fw__title a { color: var(--iron); text-underline-offset: 3px; }
.fw__by, .fw__credit { font-size: var(--fs-small); color: var(--iron-soft); }
@media (prefers-reduced-motion: reduce) {
.panel__body, .panel__tab { transition: none; }
.panel.is-swapped .panel__view, .panel.is-swapped .panel__song { animation: none; }
}
/* narrow screens: the panel is a bottom sheet (src/app/style.css); its body flows instead of overlaying */
@media (max-width: 900px) {
.panel { height: 50dvh; }
.shell[data-panel="closed"] .panel { height: 56px; }
.panel__body { width: auto; }
.panel__tab { inset: 18px 0 0; width: auto; flex-direction: row; justify-content: center; padding: 8px 0; }
.panel__tab-label { writing-mode: horizontal-tb; }
}
+229
View File
@@ -0,0 +1,229 @@
// The persistent right-side panel: About · Listen & lyrics · Paths · Fan works for the current song.
// It follows the store (song, tab, picked motif, timeline, reading view) and writes back to it; the play
// button in the header starts the song and opens Listen & lyrics. Collapsing the panel to its slim tab is
// local to the panel (it sets data-panel on the surrounding .shell). Ported from 17's panel.ts.
import { songById, songs, sagaOf, type AlbumSong } from "../../data/album";
import { contentMounted, libraryReady, realFor } from "../content";
import type { AppState, Store, Tab } from "../state";
import { aboutView } from "./about";
import { fanView } from "./fanworks";
import { createListen } from "./listen";
import { pathsView } from "./paths";
import { readingView } from "./reading";
import { applyPick, esc, fmt, PAUSE_ICON, PLAY_ICON } from "./util";
export const TABS: { id: Tab; label: string }[] = [
{ id: "about", label: "About" },
{ id: "listen", label: "Listen & lyrics" },
{ id: "paths", label: "Paths" },
{ id: "fan", label: "Fan works" },
];
export interface PanelOpts {
/** Whether the bottom timeline is hidden (besides `state.timeline === "closed"`), e.g. on small screens. */
timelineHidden: () => boolean;
}
export function mountPanel(root: HTMLElement, store: Store, opts: PanelOpts): { refresh(): void; destroy(): void } {
const reduced = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
let song: AlbumSong = songById(store.get().songId) ?? songs[0]!;
let mounted = contentMounted();
let readAll = false;
let destroyed = false;
root.classList.add("panel");
root.innerHTML = `
<button type="button" class="panel__tab" data-act="open" aria-controls="panel-body" aria-expanded="true" tabindex="-1">
<span class="panel__tab-arrow" aria-hidden="true">‹</span>
<span class="panel__tab-n"></span>
<span class="panel__tab-label">About · Listen &amp; lyrics · Paths · Fan works</span>
</button>
<div class="panel__body" id="panel-body">
<header class="panel__head">
<button type="button" class="pplay" aria-pressed="false"></button>
<div class="panel__song" aria-live="polite"></div>
<button type="button" class="panel__hide" data-act="close" aria-controls="panel-body" aria-expanded="true" title="Collapse the panel">Hide <span aria-hidden="true">›</span></button>
</header>
<div class="ptabs" role="tablist" aria-label="About this song">
${TABS.map((t) => `<button type="button" role="tab" id="ptab-${t.id}" aria-controls="panel-view" data-tab="${t.id}">${esc(t.label)}</button>`).join("")}
</div>
<div class="panel__view" id="panel-view" role="tabpanel" tabindex="0"></div>
</div>`;
const q = <T extends HTMLElement>(sel: string) => root.querySelector<T>(sel)!;
const view = q<HTMLElement>(".panel__view");
const songEl = q<HTMLElement>(".panel__song");
const headPlay = q<HTMLButtonElement>(".pplay");
const tabBtns = [...root.querySelectorAll<HTMLButtonElement>("[role=tab]")];
const listen = createListen({
host: root,
view,
reduced,
onTick: syncHead,
onContent: () => { if (store.get().tab === "listen") renderView(true); },
});
/* ----- header ----- */
function renderHead() {
const saga = sagaOf(song);
songEl.innerHTML = `<p class="panel__eyebrow"></p>
<h2 class="panel__title">${esc(song.title)}</h2>
<p class="panel__sub">${esc(saga.name)} · ${esc(song.storyTime.label)}</p>`;
q<HTMLElement>(".panel__tab-n").textContent = String(song.track);
headPlay.dataset.key = "";
syncHead();
}
function syncHead() {
const p = listen.player;
const on = p.playing;
const key = `${on}|${song.id}`;
if (headPlay.dataset.key !== key) {
headPlay.dataset.key = key;
headPlay.innerHTML = `${on ? PAUSE_ICON : PLAY_ICON}<span class="sr">${on ? "Pause" : "Play"} ${esc(song.title)}</span>`;
headPlay.setAttribute("aria-pressed", String(on));
headPlay.title = `${on ? "Pause" : "Play"} ${song.title}`;
}
const eyebrow = songEl.querySelector<HTMLElement>(".panel__eyebrow");
if (!eyebrow) return;
const active = on || p.t > 0;
eyebrow.textContent = active
? `${on ? "Playing" : "Paused"} · ${fmt(p.t)} / ${p.ready ? fmt(p.dur) : "…"}`
: `Track ${song.track} of ${songs.length}`;
eyebrow.classList.toggle("is-live", on);
}
/* ----- the tab view ----- */
const timelineHidden = (s: AppState) => s.timeline === "closed" || opts.timelineHidden();
const reading = (s: AppState) => s.tab === "about" && s.read && !!song.odyssey && mounted;
function renderView(keepScroll = false) {
const s = store.get();
const top = view.scrollTop;
const el = s.tab === "about" ? (reading(s) ? readingView(song, readAll) : aboutView(song, mounted))
: s.tab === "listen" ? listen.render()
: s.tab === "paths" ? pathsView(song, s.motif, timelineHidden(s))
: fanView(song);
view.replaceChildren(el);
view.setAttribute("aria-labelledby", `ptab-${s.tab}`);
tabBtns.forEach((b) => {
const on = b.dataset.tab === s.tab;
b.setAttribute("aria-selected", String(on));
b.tabIndex = on ? 0 : -1;
});
applyPick(view, s.motif);
view.scrollTop = keepScroll ? top : 0;
listen.sync();
}
function loadSong() {
listen.setSong(song, realFor(song), mounted);
readAll = false;
}
/** A gentle swap: the panel's content dips and settles when the voyage reaches a new song. */
function swapIn() {
if (reduced()) return;
root.classList.remove("is-swapped");
void root.offsetWidth;
root.classList.add("is-swapped");
}
const off = store.subscribe((s, prev) => {
if (s.songId !== prev.songId) {
song = songById(s.songId) ?? songs[0]!;
loadSong();
renderHead();
if (s.read && prev.read) { store.set({ read: false }); return; } // a new song closes the reading view
renderView();
swapIn();
return;
}
const pathsChanged = s.tab === "paths" && (s.timeline !== prev.timeline || s.motif !== prev.motif);
if (s.tab !== prev.tab || s.read !== prev.read || pathsChanged) {
if (!s.read) readAll = false;
renderView(pathsChanged);
} else if (s.motif !== prev.motif) {
applyPick(view, s.motif);
}
});
/* ----- open / collapsed ----- */
function setOpen(open: boolean) {
root.dataset.open = String(open);
const shell = root.closest<HTMLElement>(".shell");
if (shell) shell.dataset.panel = open ? "open" : "closed";
root.querySelectorAll("[aria-controls=panel-body]").forEach((b) => b.setAttribute("aria-expanded", String(open)));
q<HTMLElement>(".panel__body").inert = !open;
q<HTMLElement>(".panel__tab").tabIndex = open ? -1 : 0;
}
/* ----- wiring ----- */
root.addEventListener("click", (e) => {
const t = e.target as Element;
const pick = t.closest<HTMLElement>("[data-pick]");
if (pick) { store.set({ motif: store.get().motif === pick.dataset.pick ? null : pick.dataset.pick! }); return; }
const to = t.closest<Element>("[data-song]");
if (to && root.contains(to)) { store.set({ songId: to.getAttribute("data-song")! }); return; }
if (store.get().tab === "listen" && listen.click(t)) return;
const act = t.closest<HTMLElement>("[data-act]")?.dataset.act;
if (act === "read") store.set({ tab: "about", read: true });
else if (act === "unread") store.set({ read: false });
else if (act === "read-all") { readAll = true; renderView(true); }
else if (act === "open") { setOpen(true); headPlay.focus(); }
else if (act === "close") { setOpen(false); q<HTMLElement>(".panel__tab").focus(); }
});
root.addEventListener("keydown", (e) => {
if (e.key === "Escape" && store.get().read) { e.stopPropagation(); store.set({ read: false }); }
});
headPlay.addEventListener("click", () => {
if (listen.player.playing) { listen.player.pause(); return; }
if (store.get().tab !== "listen" || store.get().read) store.set({ tab: "listen", read: false });
listen.player.play();
});
tabBtns.forEach((b, i) => {
b.addEventListener("click", () => store.set({ tab: b.dataset.tab as Tab, read: false }));
b.addEventListener("keydown", (e) => {
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (!d) return;
e.preventDefault();
e.stopPropagation();
const nb = tabBtns[(i + d + tabBtns.length) % tabBtns.length]!;
nb.focus();
nb.click();
});
});
// the reader's own scrolling of the lyrics wins for ~4 s, then the lyrics follow the song again
view.addEventListener("wheel", listen.userScrolled, { passive: true });
view.addEventListener("touchmove", listen.userScrolled, { passive: true });
view.addEventListener("pointerdown", (e) => { if (e.target === view) listen.userScrolled(); });
view.addEventListener("keydown", (e) => {
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(e.key) && !(e.target as Element).closest(".scrub")) listen.userScrolled();
});
/** Re-reads the content folder's state (call after the library loads) and re-renders. */
function refresh() {
if (destroyed) return;
mounted = contentMounted();
loadSong();
renderHead();
renderView(true);
}
setOpen(true);
loadSong();
renderHead();
renderView();
libraryReady().then(() => { if (contentMounted() !== mounted) refresh(); });
return {
refresh,
destroy() {
destroyed = true;
off();
listen.destroy();
root.replaceChildren();
delete root.dataset.open;
},
};
}
+96
View File
@@ -0,0 +1,96 @@
// Paths: the motif strip (only while the bottom timeline is hidden), then one row per motif of the song with
// where it was heard before, where it plays here and where it returns, the followed motif's whole thread,
// and the chart of the nine places.
import { motifs, sagaOf, songs, type AlbumSong, type Motif } from "../../data/album";
import { chartSvg } from "./chart";
import { dot, esc, hitsOf, motifsOf } from "./util";
export function pathsView(song: AlbumSong, pick: string | null, showStrip: boolean): HTMLElement {
const el = document.createElement("div");
el.className = "paths";
const ms = motifsOf(song);
const followed = pick ? motifs.find((m) => m.id === pick) ?? null : null;
el.innerHTML = `
${showStrip ? motifStrip(song, ms) : ""}
<h3 class="sec">Motif threads</h3>
${ms.length
? `<p class="hint">Each thread is a motif or a character's theme. Pick one to follow it through the album.</p>
<ul class="mrows">${ms.map((m) => motifRow(song, m)).join("")}</ul>`
: `<p class="note">No motifs are recorded for this song yet.</p>`}
${followed ? threadDetail(song, followed) : ""}
<h3 class="sec">The route</h3>
<figure class="route">${chartSvg(song)}<figcaption>${esc(sagaOf(song).place)} · stop ${song.track} of ${songs.length} on the voyage.</figcaption></figure>`;
return el;
}
const songBtn = (s: AlbumSong) =>
`<button type="button" class="mrow__song" data-song="${s.id}"><span class="mrow__n">${s.track}</span>${esc(s.title)}</button>`;
function motifRow(song: AlbumSong, m: Motif): string {
const hits = hitsOf(m.id);
const before = hits.filter((s) => s.track < song.track);
const after = hits.filter((s) => s.track > song.track);
const prev = before.at(-1);
const next = after[0];
const inParts = song.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name);
const here = inParts.length && inParts.length < song.parts.length ? `in ${inParts.map(esc).join(", ")}` : "throughout";
return `<li class="mrow" data-m="${m.id}">
<button type="button" class="mrow__name" data-pick="${m.id}" aria-pressed="false">${dot(m)}<span>${esc(m.name)}</span>
<span class="mrow__kind">${m.kind === "motif" ? "motif" : "character theme"}</span></button>
<dl class="mrow__route">
<div><dt>Heard before</dt><dd>${prev ? songBtn(prev) : `<span class="mrow__none">first heard here</span>`}</dd></div>
<div><dt>Here</dt><dd>${here}</dd></div>
<div><dt>Returns</dt><dd>${next ? songBtn(next) + (after.length > 1 ? ` <span class="mrow__more">+${after.length - 1} more</span>` : "") : `<span class="mrow__none">not heard again</span>`}</dd></div>
</dl>
</li>`;
}
/** The followed motif: what it is and every song it plays in. */
function threadDetail(song: AlbumSong, m: Motif): string {
const hits = hitsOf(m.id);
return `<section class="mthread" data-m="${m.id}" aria-label="${esc(m.name)} through the album">
<p class="mthread__head">${dot(m)}<span>Following <b>${esc(m.name)}</b> · ${hits.length} song${hits.length === 1 ? "" : "s"}</span></p>
<p class="mthread__desc">${esc(m.description)}</p>
<ol class="mthread__songs">${hits.map((s) => `<li><button type="button" class="mthread__song${s.id === song.id ? " is-current" : ""}" data-song="${s.id}"${s.id === song.id ? ' aria-current="true"' : ""}>
<span class="mrow__n">${s.track}</span>${esc(s.title)}</button></li>`).join("")}</ol>
</section>`;
}
/* The motif strip: the bottom timeline in miniature, for when the timeline is hidden. The album's 40 songs
as stops on a course (saga dividers, the ship on the current song), then one thread per motif of this
song, knotted where it plays. */
const W = 356, PAD = 8, COURSE_Y = 26, ROW = 34;
const X = (i: number) => PAD + (i * (W - PAD * 2)) / (songs.length - 1);
function motifStrip(song: AlbumSong, ms: Motif[]): string {
const cur = songs.indexOf(song);
const H = COURSE_Y + 14 + ms.length * ROW;
let svg = `<rect class="ms__band" x="${(X(cur) - 5).toFixed(1)}" y="0" width="10" height="${H}" rx="2"/>`;
svg += `<path class="ms__course" d="M${PAD - 4},${COURSE_Y}H${W - PAD + 4}"/>`;
songs.forEach((s, i) => {
if (i > 0 && s.sagaId !== songs[i - 1]!.sagaId) {
const x = ((X(i) + X(i - 1)) / 2).toFixed(1);
svg += `<path class="ms__saga" d="M${x},${COURSE_Y - 9}V${H}"/>`;
}
svg += `<circle class="ms__stop${i === cur ? " is-current" : ""}" data-song="${s.id}" cx="${X(i).toFixed(1)}" cy="${COURSE_Y}" r="${i === cur ? 4.5 : 2}"><title>${s.track}. ${esc(s.title)}</title></circle>`;
});
svg += `<g class="ms__ship" transform="translate(${X(cur).toFixed(1)},${COURSE_Y - 6})"><path class="ms__hull" d="M-7,-6 Q0,0 7,-6 Z"/><path class="ms__mast" d="M0,-6 V-17"/><path class="ms__hull" d="M0.5,-16 L6,-10 H0.5 Z"/></g>`;
ms.forEach((m, k) => {
const y = COURSE_Y + 14 + k * ROW + ROW - 9;
const idx = hitsOf(m.id).map((s) => songs.indexOf(s));
const x0 = X(idx[0]!), x1 = X(idx.at(-1)!);
svg += `<g class="ms__thread" data-m="${m.id}" style="--c:${m.color}">
<path class="ms__edge" d="M${x0.toFixed(1)},${y}H${x1.toFixed(1)}"/><path class="ms__line" d="M${x0.toFixed(1)},${y}H${x1.toFixed(1)}"/>
${idx.map((i) => `<circle class="ms__knot${i === cur ? " is-here" : ""}" cx="${X(i).toFixed(1)}" cy="${y}" r="${i === cur ? 4.6 : 3.2}"><title>${esc(m.name)} in ${esc(songs[i]!.title)}</title></circle>`).join("")}
</g>`;
});
const names = ms.map((m, k) => `<button type="button" class="ms__name" data-m="${m.id}" data-pick="${m.id}" aria-pressed="false"
style="top:${COURSE_Y + 14 + k * ROW}px">${dot(m)}${esc(m.name)}</button>`).join("");
return `<section class="motif-strip" aria-label="Motif paths across the album">
<p class="ms__head"><span class="ms__title">Motif paths</span><span class="ms__sub">Track ${song.track} of ${songs.length} · heard before · here · returns</span></p>
<div class="ms__body" style="--w:${W};--h:${H}">
<svg class="ms__svg" viewBox="0 0 ${W} ${H}" aria-hidden="true">${svg}</svg>
${names}
</div>
</section>`;
}
+97
View File
@@ -0,0 +1,97 @@
// The Listen & lyrics player: one song's real FLAC through an <audio> element (kept in the DOM, hidden).
// Ported from 17's SongPlayer without the simulated clock: with no recording there is no player at all.
export class AudioPlayer {
t = 0;
dur = 0;
playing = false;
/** Whether the file's length is known yet. */
ready = false;
/** Set when the file fails to load. */
failed = false;
private audio: HTMLAudioElement | null = null;
private raf = 0;
constructor(private host: HTMLElement, private onChange: () => void) {}
get loaded() { return !!this.audio; }
get element() { return this.audio; }
/** Loads a recording, stopped at 0:00; null unloads. */
load(url: string | null, label = "") {
this.pause();
if (this.audio) { this.audio.removeAttribute("src"); this.audio.remove(); }
this.audio = null;
this.t = 0;
this.dur = 0;
this.ready = false;
this.failed = false;
if (url) {
const a = document.createElement("audio");
a.preload = "metadata";
a.hidden = true;
a.dataset.song = label;
a.src = url;
a.addEventListener("loadedmetadata", () => {
if (a !== this.audio || !Number.isFinite(a.duration)) return;
this.dur = a.duration;
this.ready = true;
// a seek asked for before the length was known is applied now
if (this.t > 0 && Math.abs(a.currentTime - this.t) > 0.5) a.currentTime = this.t;
this.onChange();
});
a.addEventListener("timeupdate", () => { if (a === this.audio && this.ready) { this.t = a.currentTime; this.onChange(); } });
a.addEventListener("pause", () => { if (a === this.audio && this.playing && !a.ended) { this.playing = false; this.onChange(); } });
a.addEventListener("ended", () => { if (a === this.audio) { this.playing = false; cancelAnimationFrame(this.raf); this.onChange(); } });
a.addEventListener("error", () => {
if (a !== this.audio) return;
this.audio = null;
this.failed = true;
this.playing = false;
a.remove();
this.onChange();
});
this.host.append(a);
this.audio = a;
}
this.onChange();
}
play() {
const a = this.audio;
if (!a || this.playing) return;
if (this.ready && this.t >= this.dur - 0.05) this.seek(0);
this.playing = true;
a.play()?.catch?.(() => { if (a === this.audio) { this.playing = false; this.onChange(); } });
this.raf = requestAnimationFrame(this.tick);
this.onChange();
}
pause() {
if (!this.playing) return;
this.playing = false;
this.audio?.pause();
cancelAnimationFrame(this.raf);
this.onChange();
}
toggle() { if (this.playing) this.pause(); else this.play(); }
seek(t: number) {
if (!this.audio) return;
// before the length is known, any time is allowed; loadedmetadata applies it
this.t = Math.max(0, this.ready ? Math.min(this.dur, t) : t);
this.audio.currentTime = this.t;
this.onChange();
}
destroy() { this.load(null); }
// read the element's clock every frame so the lyric highlight is smooth (timeupdate is ~4 Hz)
private tick = () => {
if (!this.playing || !this.audio) return;
if (this.ready) this.t = this.audio.currentTime;
this.onChange();
this.raf = requestAnimationFrame(this.tick);
};
}
+45
View File
@@ -0,0 +1,45 @@
// The reading view: the song's book of the Odyssey (Butcher & Lang, which keeps the Greek names), opened at
// the first paragraph naming one of the song's keywords, with the paragraphs before it folded away.
import type { AlbumSong } from "../../data/album";
import { keywordRe, loadBook, passageStart, roman, stripFootnotes } from "../content";
import { esc } from "./util";
/** `readAll` shows the whole book from the start, keeping the passage marker. */
export function readingView(song: AlbumSong, readAll: boolean): HTMLElement {
const o = song.odyssey!;
const el = document.createElement("div");
el.className = "reading";
el.innerHTML = `
<button type="button" class="reading__back" data-act="unread"><span aria-hidden="true">←</span> Back to About</button>
<p class="reading__eyebrow">The Odyssey · Book ${roman(o.book)} · tr. Butcher &amp; Lang</p>
<div class="reading__body" aria-busy="true"><p class="hint">Opening Book ${roman(o.book)}…</p></div>`;
const body = el.querySelector<HTMLElement>(".reading__body")!;
loadBook(o.book).then((book) => {
if (!el.isConnected) return; // the reader has moved on
body.removeAttribute("aria-busy");
if (!book) {
body.innerHTML = `<p class="note">Book ${roman(o.book)} could not be loaded from the content folder.
<a href="${esc(o.url)}" target="_blank" rel="noopener noreferrer">Read it on Project Gutenberg</a>.</p>`;
return;
}
const at = passageStart(book, o.keywords);
const re = keywordRe(o.keywords);
const para = (t: string) => {
let h = esc(stripFootnotes(t));
if (re) h = h.replace(re, "<mark>$1</mark>");
return `<p class="reading__p">${h}</p>`;
};
const from = readAll ? 0 : at.index;
const marker = `<p class="reading__mark" id="reading-mark">${at.word
? `The passage for ${esc(song.title)} begins here · first mention of “${esc(at.word)}”`
: `No keyword for ${esc(song.title)} found · Book ${roman(o.book)} from the start`}</p>`;
body.innerHTML = `
<h3 class="reading__title">${esc(book.heading.replace(/^BOOK\b/, "Book").replace(/\.$/, ""))}</h3>
${book.summary ? `<p class="reading__sum">${esc(book.summary)}</p>` : ""}
${from > 0 ? `<button type="button" class="reading__more" data-act="read-all">Show the ${from} paragraph${from === 1 ? "" : "s"} before this passage</button>` : ""}
${book.paragraphs.slice(from).map((t, i) => (from + i === at.index ? marker : "") + para(t)).join("")}
<p class="reading__end">End of Book ${roman(o.book)} · Project Gutenberg #1728</p>`;
if (readAll) el.querySelector<HTMLElement>("#reading-mark")?.scrollIntoView?.({ block: "start" });
});
return el;
}
+38
View File
@@ -0,0 +1,38 @@
// Small helpers shared by the panel's tabs.
import { motifs, songs, type AlbumSong, type Motif } from "../../data/album";
export const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
export const PLAY_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 3.5 L16.5 10 L6 16.5 Z"/></svg>`;
export const PAUSE_ICON = `<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 3.5 H8.6 V16.5 H5 Z M11.4 3.5 H15 V16.5 H11.4 Z"/></svg>`;
/** m:ss */
export const fmt = (sec: number) => {
const s = Math.max(0, Math.floor(Number.isFinite(sec) ? sec : 0));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
};
const motifById = new Map(motifs.map((m) => [m.id, m]));
/** The song's motifs and character themes, musical motifs first. */
export function motifsOf(song: AlbumSong): Motif[] {
const ms = song.motifIds.flatMap((id) => motifById.get(id) ?? []);
return [...ms.filter((m) => m.kind === "motif"), ...ms.filter((m) => m.kind === "character")];
}
/** Songs where a motif plays (song level or in a part), in album order. */
export const hitsOf = (id: string): AlbumSong[] =>
songs.filter((s) => s.motifIds.includes(id) || s.parts.some((p) => p.motifIds.includes(id)));
/** A motif's dot: its own colour with an ink edge, so even the pale ones read on parchment. */
export const dot = (m: Motif) => `<i class="mdot" style="--c:${m.color}" aria-hidden="true"></i>`;
/** Toggle is-on / is-dim on every element carrying data-m, so one pick highlights everywhere in the panel. */
export function applyPick(root: ParentNode, pick: string | null) {
root.querySelectorAll<HTMLElement>("[data-m]").forEach((el) => {
const id = el.dataset.m;
el.classList.toggle("is-on", !!pick && id === pick);
el.classList.toggle("is-dim", !!pick && id !== pick);
});
root.querySelectorAll<HTMLElement>("button[data-pick]").forEach((b) => b.setAttribute("aria-pressed", String(!!pick && b.dataset.pick === pick)));
}
+95
View File
@@ -0,0 +1,95 @@
import { motifs, songById } from "../data/album";
export type Tab = "about" | "listen" | "paths" | "fan";
export interface AppState {
songId: string;
tab: Tab;
motif: string | null;
timeline: "open" | "closed";
ribbons: boolean;
read: boolean;
}
export const DEFAULT_STATE: AppState = {
songId: "the-horse-and-the-infant",
tab: "about",
motif: null,
timeline: "open",
ribbons: false,
read: false,
};
const TABS: Tab[] = ["about", "listen", "paths", "fan"];
/** Reads the app state from a query string; anything unknown falls back to the default. */
export function parseState(search: string): AppState {
const q = new URLSearchParams(search);
const song = q.get("song");
const tab = q.get("tab") as Tab | null;
const motif = q.get("motif");
return {
songId: song && songById(song) ? song : DEFAULT_STATE.songId,
tab: tab && TABS.includes(tab) ? tab : DEFAULT_STATE.tab,
motif: motif && motifs.some((m) => m.id === motif) ? motif : null,
timeline: q.get("timeline") === "closed" ? "closed" : "open",
ribbons: q.get("ribbons") === "1",
read: q.get("read") === "1",
};
}
/** The query string for a state, leaving out defaults ("" for the default state). */
export function stateToSearch(s: AppState): string {
const q = new URLSearchParams();
if (s.songId !== DEFAULT_STATE.songId) q.set("song", s.songId);
if (s.tab !== DEFAULT_STATE.tab) q.set("tab", s.tab);
if (s.motif) q.set("motif", s.motif);
if (s.timeline === "closed") q.set("timeline", "closed");
if (s.ribbons) q.set("ribbons", "1");
if (s.read) q.set("read", "1");
const str = q.toString();
return str ? `?${str}` : "";
}
export interface Store {
get(): AppState;
/** Song changes push a history entry; everything else replaces it. `push` overrides. */
set(patch: Partial<AppState>, opts?: { push?: boolean }): void;
subscribe(fn: (s: AppState, prev: AppState) => void): () => void;
}
/** A tiny state store kept in sync with the URL (unless `history: false`, e.g. in tests). */
export function createStore(initial: AppState, opts: { history?: boolean } = {}): Store {
const useHistory = opts.history ?? true;
let state = initial;
const subs = new Set<(s: AppState, prev: AppState) => void>();
const emit = (prev: AppState) => subs.forEach((fn) => fn(state, prev));
if (useHistory) {
addEventListener("popstate", () => {
const prev = state;
state = parseState(location.search);
emit(prev);
});
}
return {
get: () => state,
set(patch, setOpts = {}) {
const prev = state;
const next = { ...state, ...patch };
if (stateToSearch(next) === stateToSearch(prev)) return;
state = next;
if (useHistory) {
const url = location.pathname + stateToSearch(state);
const push = setOpts.push ?? next.songId !== prev.songId;
if (push) history.pushState(null, "", url);
else history.replaceState(null, "", url);
}
emit(prev);
},
subscribe(fn) {
subs.add(fn);
return () => subs.delete(fn);
},
};
}
+127
View File
@@ -0,0 +1,127 @@
/* The voyage app (spec 002, design record docs/design/002-voyage-app.md): look 2 with the frieze's
accents. Tokens and the page shell live here; each region has its own stylesheet next to its code. */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-700.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
:root {
/* black-figure: the frieze, and the accents of the whole interface */
--clay: #c46a33;
--clay-deep: #9a4520;
--glaze: #1d140e;
--added-white: #efe3c8;
/* parchment: 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;
/* type scale: nothing under 13px; panel body 15px */
--fs-label: 13px;
--fs-small: 14px;
--fs-body: 15px;
--fs-title: 2rem;
--panel-w: 420px;
--panel-slim: 56px;
--timeline-h: 190px;
--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");
--ease: cubic-bezier(0.22, 0.8, 0.2, 1);
}
* { 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: 400 var(--fs-body)/1.5 var(--sans);
-webkit-font-smoothing: antialiased;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--clay); outline-offset: 2px; }
[hidden] { display: none !important; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* the shared ornament: a meander strip between two glaze rules, on top of every panel, card and bar */
.meander-edge { position: relative; }
.meander-edge::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 var(--ease);
}
.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);
border-top: 2px solid var(--clay);
}
/* stage: the 3D canvas, the frieze curtain over it, the caption card and the timeline */
.stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--glaze); isolation: isolate; }
.stage__canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.frieze-layer { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.cap {
position: absolute; z-index: 4; top: 22px; left: 24px; max-width: min(460px, 60%);
padding: 26px 20px 14px; background: var(--vellum-hi); border: 2px solid var(--glaze);
box-shadow: 0 10px 30px rgb(0 0 0 / 0.25);
}
.cap__eyebrow { margin: 0; color: var(--clay-deep); font-size: var(--fs-small); font-weight: 600; }
.cap__title { margin: 2px 0 0; font: 600 var(--fs-title)/1.1 var(--serif); }
.timeline {
position: absolute; z-index: 4; left: 16px; right: 16px; bottom: 14px;
padding-top: 16px; background: var(--vellum-hi); border: 2px solid var(--glaze);
}
.panel { min-width: 0; min-height: 0; overflow: hidden; background: var(--vellum); border-left: 2px solid var(--glaze); }
/* narrow screens: the panel becomes a bottom sheet over the stage */
@media (max-width: 900px) {
.shell, .shell[data-panel="closed"] { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
.panel { max-height: 50dvh; border-left: 0; border-top: 2px solid var(--glaze); }
.shell[data-panel="closed"] .panel { max-height: 56px; }
}
/* caption: Back to sea; the no-WebGL note */
.cap__back {
margin-top: 10px; padding: 6px 14px; border: 2px solid var(--glaze); border-radius: 999px;
background: var(--glaze); color: var(--added-white); font-size: var(--fs-small); font-weight: 600; cursor: pointer;
}
.cap__back:hover { background: var(--clay-deep); }
.stage__note {
position: absolute; z-index: 4; top: 22px; right: 24px; margin: 0; padding: 8px 12px;
background: var(--vellum-hi); border: 2px solid var(--glaze); font-size: var(--fs-small);
}
/* voyage controls in the timeline's bar */
.vc {
min-width: 36px; height: 34px; padding: 0 12px; border: 1.5px solid var(--glaze); border-radius: 999px;
background: transparent; color: var(--iron); font-size: var(--fs-small); font-weight: 600; cursor: pointer;
}
.vc:hover { background: var(--vellum); }
.vc--play { background: var(--glaze); color: var(--added-white); }
.vc--play:hover { background: var(--clay-deep); }
+33
View File
@@ -0,0 +1,33 @@
// Motif colours are tuned to be bright on the sea and the clay frieze. On the timeline's parchment the
// same hue is re-inked darker (mixed toward the iron ink) until text in it reads at 4.5:1.
const PARCHMENT = [0xec, 0xe2, 0xc6]; // --vellum-hi
const IRON = [0x2b, 0x21, 0x18]; // --iron
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const lum = (c: number[]) => {
const [r, g, b] = c.map((v) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
};
export const contrast = (a: number[], b: number[]) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi! + 0.05) / (lo! + 0.05);
};
const hex = (c: number[]) => `#${c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
const cache = new Map<string, string>();
/** The colour darkened toward iron until it has at least 4.5:1 contrast on the timeline's parchment. */
export function inkOnParchment(color: string): string {
const hit = cache.get(color);
if (hit) return hit;
const base = rgb(color);
let out = base;
for (let k = 0; k <= 50 && contrast(out, PARCHMENT) < 4.5; k++) out = base.map((v, i) => Math.round(v + (IRON[i]! - v) * (k / 50)));
const res = hex(out);
cache.set(color, res);
return res;
}
export const parchment = PARCHMENT;
export const rgbOf = rgb;
+117
View File
@@ -0,0 +1,117 @@
// The bottom timeline's geometry (plan task 5), a pure function of the current song, a width and a text
// measure. The current saga is expanded with its song titles; the others are compact (stops only, the
// saga's short name above). Coordinates are in px from the left of the strip.
import { motifs, sagas, songById, songs, songsInSaga } from "../../data/album";
import type { Motif, PlaceId } from "../../data/album";
export interface TimelineStop { songId: string; x: number; label: string | null; current: boolean }
export interface TimelineLayout {
stops: TimelineStop[];
sagas: { sagaId: PlaceId; x0: number; x1: number; expanded: boolean }[];
width: number;
}
/** Room between neighbouring titles in the expanded saga. */
export const LABEL_GAP = 16;
/** Minimum distance between stops in the expanded saga. */
export const MIN_SPACING = 64;
/** Distance between stops in a compact saga. */
export const COMPACT_STEP = 18;
/** Minimum room between two sagas (the divider sits in the middle); slack is added here. */
export const SAGA_GAP = 14;
/** Room at both ends of the strip. */
export const END_PAD = 8;
/** Room a saga name needs beyond its text (it starts 4px after the saga's left edge). */
const NAME_PAD = 8;
/** The saga's name as shown above the strip: short ("Cyclops") when compact, "Cyclops Saga" when expanded. */
export function sagaLabel(sagaId: PlaceId, expanded: boolean): string {
const name = sagas.find((g) => g.id === sagaId)!.name.replace(/^The /, "");
return expanded ? name : name.replace(/ Saga$/, "");
}
/** A title on one line, or split into the two lines that make it narrowest ("\n" between them). */
export function splitTitle(title: string, measure: (t: string) => number): { text: string; w: number } {
const words = title.split(" ");
let best = { text: title, w: measure(title) };
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
const w = Math.max(measure(a), measure(b));
if (w < best.w - 0.5) best = { text: `${a}\n${b}`, w };
}
return best;
}
interface Slot { sagaId: PlaceId; expanded: boolean; width: number; offsets: number[]; labels: (string | null)[] }
function slotFor(sagaId: PlaceId, expanded: boolean, measure: (t: string) => number): Slot {
const list = songsInSaga(sagaId);
const nameW = measure(sagaLabel(sagaId, expanded)) + NAME_PAD;
if (!expanded) {
const content = list.length * COMPACT_STEP;
const width = Math.max(content, nameW);
const start = (width - content) / 2 + COMPACT_STEP / 2;
return { sagaId, expanded, width, offsets: list.map((_, i) => start + i * COMPACT_STEP), labels: list.map(() => null) };
}
const split = list.map((s) => splitTitle(s.title, measure));
const offsets: number[] = [];
let x = split[0]!.w / 2;
split.forEach((t, i) => {
if (i > 0) x += Math.max(MIN_SPACING, (split[i - 1]!.w + t.w) / 2 + LABEL_GAP);
offsets.push(x);
});
const content = x + split.at(-1)!.w / 2;
const width = Math.max(content, nameW);
const shift = (width - content) / 2;
return { sagaId, expanded, width, offsets: offsets.map((o) => o + shift), labels: split.map((t) => t.text) };
}
export function layoutTimeline(currentSongId: string, width: number, measure: (text: string) => number): TimelineLayout {
const current = songById(currentSongId) ?? songs[0]!;
const slots = sagas.map((g) => slotFor(g.id, g.id === current.sagaId, measure));
const gaps = slots.length - 1;
const need = END_PAD * 2 + slots.reduce((a, s) => a + s.width, 0) + SAGA_GAP * gaps;
const gap = SAGA_GAP + Math.max(0, width - need) / gaps;
const stops: TimelineStop[] = [];
const spans: TimelineLayout["sagas"] = [];
let x0 = END_PAD;
for (const slot of slots) {
songsInSaga(slot.sagaId).forEach((s, i) => {
stops.push({ songId: s.id, x: x0 + slot.offsets[i]!, label: slot.labels[i]!, current: s.id === current.id });
});
spans.push({ sagaId: slot.sagaId, x0, x1: x0 + slot.width, expanded: slot.expanded });
x0 += slot.width + gap;
}
return { stops, sagas: spans, width: Math.max(width, need) };
}
/** Every motif a song uses, song-level or in a part. */
function motifIdsOf(songId: string): Set<string> {
const s = songById(songId);
return new Set(s ? [...s.motifIds, ...s.parts.flatMap((p) => p.motifIds)] : []);
}
/**
* The motifs that get a thread for this song: musical motifs before character themes (the Odysseus
* theme plays in 35 songs, so it would otherwise crowd out the rarer ones), each in the album's motif
* order. A picked motif is included even if the song does not use it, so it can be followed.
*/
export function threadMotifs(songId: string, picked: string | null): Motif[] {
const ids = motifIdsOf(songId);
if (picked) ids.add(picked);
const rank = (m: Motif) => (m.kind === "motif" ? 0 : 1);
return motifs.filter((m) => ids.has(m.id)).sort((a, b) => rank(a) - rank(b) || motifs.indexOf(a) - motifs.indexOf(b));
}
/** The most thread rows shown at once; past it, a "+N more" row takes the last place. */
export const MAX_ROWS = 4;
/** Which of `all` are shown: all of them if they fit (or `showAll`), else the first 3 with the picked one kept. */
export function visibleMotifs(all: Motif[], picked: string | null, showAll: boolean): { shown: Motif[]; hidden: number } {
if (showAll || all.length <= MAX_ROWS) return { shown: all, hidden: 0 };
let shown = all.slice(0, MAX_ROWS - 1);
const p = all.find((m) => m.id === picked);
if (p && !shown.includes(p)) shown = [...shown.slice(0, -1), p];
return { shown, hidden: all.length - shown.length };
}
+111
View File
@@ -0,0 +1,111 @@
/* The bottom timeline (plan task 5): a bar with the voyage controls slot, "Threads on the sea" and
Hide/Show, then the strip of songs and motif threads (look from mockup 17's ".hs" strip).
#timeline itself is positioned in ../style.css; the open timeline stays within --timeline-h. */
.tl { display: flex; flex-direction: column; padding-bottom: 2px; font-size: var(--fs-label); }
/* ---------- the bar ---------- */
.tl-bar {
display: flex; align-items: center; gap: 6px;
min-height: 30px;
padding: 1px 10px 1px 12px;
border-bottom: 1px solid var(--line);
}
.timeline__controls { display: flex; align-items: center; gap: 6px; margin-right: auto; min-width: 0; }
.tl-switch, .tl-toggle {
display: inline-flex; align-items: center; gap: 8px;
padding: 3px 10px 3px 6px;
border: 0; border-radius: 999px;
background: transparent;
font-size: var(--fs-small); font-weight: 600; line-height: 1.3;
cursor: pointer; white-space: nowrap;
}
.tl-switch:hover { background: rgb(43 33 24 / 0.07); }
.tl-switch__track {
position: relative; flex: none; width: 34px; height: 20px;
border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo);
transition: background-color 0.2s ease;
}
.tl-switch__knob {
position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%;
background: var(--glaze); transition: transform 0.2s var(--ease);
}
.tl-switch[aria-checked="true"] .tl-switch__track { background: var(--clay); }
.tl-switch[aria-checked="true"] .tl-switch__knob { transform: translateX(14px); }
.tl-toggle { padding: 3px 10px 3px 12px; border: 1.5px solid var(--line); }
.tl-toggle:hover { border-color: var(--glaze); }
.tl-toggle__chev {
width: 7px; height: 7px; margin: -3px 2px 0;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(45deg); transition: transform 0.2s var(--ease);
}
.tl[data-state="closed"] .tl-toggle__chev { margin-top: 3px; transform: rotate(-135deg); }
/* ---------- the strip: a fixed column of names, the songs and threads scroll beside it ---------- */
.tl-body { display: grid; grid-template-columns: var(--tl-label-w, 110px) minmax(0, 1fr); padding: 2px 0 0 12px; }
.tl-labels { display: flex; flex-direction: column; align-items: flex-start; padding-top: 76px; position: relative; }
.tl-labels__course {
position: absolute; left: 0; top: 29px;
font-size: var(--fs-label); font-weight: 600; line-height: 1.3; color: var(--iron-soft);
}
.tl[data-state="closed"] .tl-labels { padding-top: 0; }
.tl[data-state="closed"] .tl-labels__course { top: 19px; }
.tl[data-state="closed"] .tl-body { height: 44px; }
.tl-m, .tl-more {
display: flex; align-items: center; gap: 7px;
height: 14px; padding: 0 6px 0 0;
border: 0; border-radius: 3px; background: transparent;
font-size: var(--fs-label); font-weight: 600; line-height: 1; white-space: nowrap;
text-align: left; cursor: pointer;
}
.tl-m { color: var(--c); transition: opacity 0.2s ease; }
.tl-m i { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--c-bright); box-shadow: 0 0 0 1.5px var(--glaze); }
.tl-m:hover, .tl-more:hover { text-decoration: underline; text-underline-offset: 3px; }
.tl-m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.tl-m.is-dim { opacity: 0.5; }
.tl-more { color: var(--clay-deep); padding-left: 17px; }
.tl-scroll {
overflow-x: auto; overflow-y: hidden;
padding-right: 12px;
scrollbar-width: thin; scrollbar-color: var(--clay) transparent;
overscroll-behavior-x: contain;
}
.tl-scroll { --fade-l: 0px; --fade-r: 0px; mask-image: linear-gradient(to right, transparent, #000 var(--fade-l), #000 calc(100% - var(--fade-r)), transparent); }
.tl-scroll.is-more-left { --fade-l: 28px; }
.tl-scroll.is-more-right { --fade-r: 28px; }
.tl-svg { display: block; overflow: visible; }
.tl-band { fill: rgb(196 106 51 / 0.16); }
.tl-course { fill: none; stroke: var(--clay-deep); stroke-width: 1.6; stroke-dasharray: 5 3.5; }
.tl-divider { stroke: var(--glaze); stroke-width: 1.4; }
.tl-saga { font: 600 13px var(--sans); fill: var(--clay-deep); }
.tl-saga.is-current { fill: var(--iron); }
.tl-stop { cursor: pointer; outline: none; }
.tl-stop__hit { fill: transparent; }
.tl-stop circle { fill: var(--vellum-hi); stroke: var(--glaze); stroke-width: 1.6; transition: fill 0.15s ease; }
.tl-stop:hover circle { fill: var(--added-white); stroke-width: 2.2; }
.tl-stop.is-current circle { fill: var(--clay); stroke-width: 2; }
.tl-stop:focus-visible .tl-stop__hit { fill: rgb(196 106 51 / 0.12); stroke: var(--clay); stroke-width: 2.5; }
.tl-title { font: 500 13px var(--sans); fill: var(--iron-soft); }
.tl-stop:hover .tl-title { fill: var(--iron); text-decoration: underline; }
.tl-stop.is-current .tl-title { font-weight: 600; fill: var(--iron); }
.tl-ship { pointer-events: none; }
.tl-ship__hull, .tl-ship__sail { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.4; stroke-linejoin: round; }
.tl-ship__mast { stroke: var(--glaze); stroke-width: 1.6; }
.tl-here { font: 600 13px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: var(--vellum-hi); stroke-width: 4px; stroke-linejoin: round; }
.tl-thread { transition: opacity 0.2s ease; }
.tl-thread__line { fill: none; stroke: var(--c); stroke-width: 2.4; stroke-linecap: round; opacity: 0.85; }
.tl-thread__knot { fill: var(--vellum-hi); stroke: var(--c); stroke-width: 2.2; }
.tl-thread__knot.is-here { fill: var(--c-bright); stroke: var(--glaze); stroke-width: 1.6; }
.tl-thread.is-on .tl-thread__line { stroke-width: 3.6; opacity: 1; }
.tl-thread.is-on .tl-thread__knot { fill: var(--c-bright); }
.tl-thread.is-dim { opacity: 0.22; }
/* below 900px: a tighter bar */
@media (max-width: 900px) {
.tl-bar { padding-inline: 8px; gap: 2px; }
.tl-body { padding-left: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.tl *, .tl *::before { transition: none !important; }
}
+270
View File
@@ -0,0 +1,270 @@
// The bottom timeline (plan task 5, design record ideas 8 and 9; look ported from mockup 17's threads.ts).
// A bar on top (the controller's play controls go in .timeline__controls; "Threads on the sea"; Hide /
// Show timeline), then the strip: the 40 songs on a course line, the current saga expanded with titles,
// "you are here", and one thread per motif of the current song, knotted wherever that motif plays.
// Collapsed, the strip shrinks to a slim course with the ship. Reads songId and motif; writes songId,
// motif, timeline and ribbons.
import { songById, songs, songsWithMotif } from "../../data/album";
import type { Store } from "../state";
import { inkOnParchment } from "./ink";
import { layoutTimeline, MAX_ROWS, sagaLabel, threadMotifs, visibleMotifs, type TimelineLayout } from "./layout";
const NS = "http://www.w3.org/2000/svg";
/** The course line's y in the open strip; saga names sit above it, titles below. */
const COURSE_Y = 38;
/** Height of the course part of the open strip (names, ship, stops, two-line titles). */
const COURSE_H = 76;
const ROW_H = 14;
/** The slim (collapsed) strip. */
const SLIM_Y = 30;
const SLIM_H = 42;
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
const f = (n: number) => n.toFixed(1);
/* Titles and names are laid out by their measured width (13px Inter, semibold so the current one fits). */
let ctx: CanvasRenderingContext2D | null | undefined;
const widths = new Map<string, number>();
function measure(t: string): number {
const hit = widths.get(t);
if (hit !== undefined) return hit;
if (ctx === undefined) {
try { ctx = document.createElement("canvas").getContext("2d"); } catch { ctx = null; }
}
let w = t.length * 7.2;
if (ctx) {
ctx.font = '600 13px "Inter", system-ui, sans-serif';
const m = ctx.measureText(t).width;
if (m > 0) w = m;
}
widths.set(t, w);
return w;
}
const pickCls = (id: string, pick: string | null) => (pick === id ? " is-on" : pick ? " is-dim" : "");
function ship(x: number, y: number, label: string, flip: boolean): string {
return `<g class="tl-ship" transform="translate(${f(x)},${y})">
<path class="tl-ship__hull" d="M-8,-8 Q0,-1 8,-8 Z"/><path class="tl-ship__mast" d="M0,-8 V-20"/>
<path class="tl-ship__sail" d="M0.5,-19 L7,-11.5 H0.5 Z"/>
<text class="tl-here" x="${flip ? -12 : 12}" y="-11"${flip ? ' text-anchor="end"' : ""}>${esc(label)}</text></g>`;
}
const stopAttrs = (id: string, cur: boolean) => {
const s = songById(id)!;
return `data-song="${id}" role="link" tabindex="${cur ? 0 : -1}" aria-label="${s.track}. ${esc(s.title)}"${cur ? ' aria-current="step"' : ""}`;
};
/** The open strip's SVG: course, sagas, stops with titles in the expanded saga, ship, threads. */
function openSvg(l: TimelineLayout, songId: string, shown: ReturnType<typeof threadMotifs>, pick: string | null, rows: number): string {
const H = COURSE_H + rows * ROW_H + 2;
const xOf = new Map(l.stops.map((s) => [s.songId, s.x]));
const ci = l.stops.findIndex((s) => s.current);
const cur = l.stops[ci]!;
const prevX = l.stops[ci - 1]?.x, nextX = l.stops[ci + 1]?.x;
const half = cur.label ? Math.max(...cur.label.split("\n").map(measure)) / 2 + 8 : 9;
const bandL = Math.max(cur.x - half, prevX !== undefined ? (prevX + cur.x) / 2 : 0);
const bandR = Math.min(cur.x + half, nextX !== undefined ? (nextX + cur.x) / 2 : l.width);
let svg = `<rect class="tl-band" x="${f(bandL)}" y="${COURSE_Y - 26}" width="${f(bandR - bandL)}" height="${H - COURSE_Y + 26}" rx="3"/>`;
svg += `<path class="tl-course" d="M${f(l.stops[0]!.x - 8)},${COURSE_Y}H${f(l.stops.at(-1)!.x + 8)}"/>`;
l.sagas.forEach((g, i) => {
if (i > 0) {
const x = (l.sagas[i - 1]!.x1 + g.x0) / 2;
svg += `<path class="tl-divider" d="M${f(x)},0V${H}"/>`;
}
svg += `<text class="tl-saga${g.expanded ? " is-current" : ""}" x="${f(g.x0 + 4)}" y="12">${esc(sagaLabel(g.sagaId, g.expanded))}</text>`;
});
for (const s of l.stops) {
const song = songById(s.songId)!;
const lines = s.label?.split("\n") ?? [];
const w = lines.length ? Math.max(...lines.map(measure)) + 8 : 18;
const h = lines.length ? COURSE_H - COURSE_Y + 12 : 26;
svg += `<g class="tl-stop${s.current ? " is-current" : ""}" ${stopAttrs(s.songId, s.current)}>
<title>${song.track}. ${esc(song.title)}</title>
<rect class="tl-stop__hit" x="${f(s.x - w / 2)}" y="${COURSE_Y - 13}" width="${f(w)}" height="${h}"/>
<circle cx="${f(s.x)}" cy="${COURSE_Y}" r="${s.current ? 6.5 : 5}"/>
${lines.length ? `<text class="tl-title" x="${f(s.x)}" y="${COURSE_Y + 19}" text-anchor="middle">${lines.map((t, j) => `<tspan x="${f(s.x)}" dy="${j ? 14 : 0}">${esc(t)}</tspan>`).join("")}</text>` : ""}
</g>`;
}
// "you are here" reads to the right of the ship unless that would cross into the next saga
const label = "you are here";
const sagaEnd = l.sagas.find((g) => g.expanded)!.x1;
svg += ship(cur.x, COURSE_Y, label, cur.x + 12 + measure(label) > Math.min(sagaEnd + 4, l.width - 4));
const wav = (x: number, k: number) => Math.sin(x / 26 + k * 1.7) * 1.6;
shown.forEach((m, k) => {
const y = COURSE_H + k * ROW_H + ROW_H / 2;
const hits = songsWithMotif(m.id).map((s) => ({ s, x: xOf.get(s.id)! }));
if (!hits.length) return;
const x0 = hits[0]!.x, x1 = hits.at(-1)!.x;
let d = "";
for (let x = x0; x < x1; x += 5) d += `${d ? "L" : "M"}${f(x)},${f(y + wav(x, k))}`;
d += `${d ? "L" : "M"}${f(x1)},${f(y + wav(x1, k))}`;
svg += `<g class="tl-thread${pickCls(m.id, pick)}" data-m="${m.id}" style="--c:${inkOnParchment(m.color)};--c-bright:${m.color}">
<path class="tl-thread__line" d="${d}"/>
${hits.map(({ s, x }) => `<circle class="tl-thread__knot${s.id === songId ? " is-here" : ""}" cx="${f(x)}" cy="${f(y + wav(x, k))}" r="${s.id === songId ? 5 : 4}"><title>${esc(m.name)} in ${esc(s.title)}</title></circle>`).join("")}
</g>`;
});
return `<svg class="tl-svg" width="${f(l.width)}" height="${H}" viewBox="0 0 ${f(l.width)} ${H}">${svg}</svg>`;
}
/** The collapsed strip: all 40 stops evenly spaced (a little extra room at saga changes) and the ship. */
function slimSvg(width: number, songId: string): string {
const breaks = songs.filter((s, i) => i > 0 && s.sagaId !== songs[i - 1]!.sagaId).length;
const pad = 12;
const step = (width - pad * 2) / (songs.length - 1 + breaks * 0.6);
let x = pad, svg = "";
const xs: number[] = [];
songs.forEach((s, i) => {
if (i > 0) {
const brk = s.sagaId !== songs[i - 1]!.sagaId;
if (brk) svg += `<path class="tl-divider" d="M${f(x + step * 0.8)},${SLIM_Y - 9}V${SLIM_Y + 9}"/>`;
x += step * (brk ? 1.6 : 1);
}
xs.push(x);
});
svg = `<path class="tl-course" d="M${f(xs[0]! - 6)},${SLIM_Y}H${f(xs.at(-1)! + 6)}"/>` + svg;
songs.forEach((s, i) => {
const cur = s.id === songId;
svg += `<g class="tl-stop tl-stop--slim${cur ? " is-current" : ""}" ${stopAttrs(s.id, cur)}><title>${s.track}. ${esc(s.title)}</title>
<rect class="tl-stop__hit" x="${f(xs[i]! - step / 2)}" y="${SLIM_Y - 10}" width="${f(step)}" height="20"/>
<circle cx="${f(xs[i]!)}" cy="${SLIM_Y}" r="${cur ? 6 : 4}"/></g>`;
});
const ci = songs.findIndex((s) => s.id === songId);
const label = `you are here · ${songById(songId)!.title}`;
svg += ship(xs[ci]!, SLIM_Y - 2, label, xs[ci]! + 12 + measure(label) > width - 4);
return `<svg class="tl-svg" width="${f(width)}" height="${SLIM_H}" viewBox="0 0 ${f(width)} ${SLIM_H}">${svg}</svg>`;
}
/** Fade the strip's edges where more of it is scrolled out of view. */
function updateEdges(scroll: HTMLElement) {
const max = scroll.scrollWidth - scroll.clientWidth;
scroll.classList.toggle("is-more-left", max > 1 && scroll.scrollLeft > 1);
scroll.classList.toggle("is-more-right", max > 1 && scroll.scrollLeft < max - 1);
}
export function mountTimeline(root: HTMLElement, store: Store): { refresh(): void } {
root.classList.add("tl");
root.innerHTML = `
<div class="tl-bar">
<div class="timeline__controls"></div>
<button type="button" class="tl-switch" role="switch" aria-checked="false" data-action="ribbons">
<span class="tl-switch__track" aria-hidden="true"><span class="tl-switch__knob"></span></span>
Threads on the sea
</button>
<button type="button" class="tl-toggle" data-action="toggle-timeline" aria-expanded="true" aria-controls="tl-body">
<span class="tl-toggle__text">Hide timeline</span><span class="tl-toggle__chev" aria-hidden="true"></span>
</button>
</div>
<div class="tl-body" id="tl-body"></div>`;
const body = root.querySelector<HTMLElement>(".tl-body")!;
const sw = root.querySelector<HTMLButtonElement>("[data-action=ribbons]")!;
const toggle = root.querySelector<HTMLButtonElement>("[data-action=toggle-timeline]")!;
let showAll = false;
function render() {
const s = store.get();
const open = s.timeline === "open";
root.dataset.state = open ? "open" : "closed";
sw.setAttribute("aria-checked", String(s.ribbons));
toggle.setAttribute("aria-expanded", String(open));
toggle.querySelector(".tl-toggle__text")!.textContent = open ? "Hide timeline" : "Show timeline";
// keep keyboard focus on the equivalent control across the re-render
const active = document.activeElement;
const had = active && body.contains(active)
? (active.hasAttribute("data-song") ? "[aria-current=step]" : active.getAttribute("data-pick") ? `[data-pick="${active.getAttribute("data-pick")}"]` : active.getAttribute("data-action") === "more" ? "[data-action=more]" : null)
: null;
const oldScroll = body.querySelector<HTMLElement>(".tl-scroll");
const keepLeft = oldScroll?.scrollLeft ?? 0;
const all = open ? threadMotifs(s.songId, s.motif) : [];
const { shown, hidden } = visibleMotifs(all, s.motif, showAll);
const moreRow = hidden > 0 || (showAll && all.length > MAX_ROWS);
const rows = shown.length + (moreRow ? 1 : 0);
const labelW = Math.ceil(Math.max(measure("The voyage") + 10, ...shown.map((m) => measure(m.name) + 26), moreRow ? measure("Show fewer") + 10 : 0));
const labels = shown.map((m) => `<button type="button" class="tl-m${pickCls(m.id, s.motif)}" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${s.motif === m.id}" style="--c:${inkOnParchment(m.color)};--c-bright:${m.color}" title="${esc(m.name)}: ${esc(m.description)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`).join("");
const more = !moreRow ? "" : hidden > 0
? `<button type="button" class="tl-more" data-action="more" aria-expanded="false" title="Also: ${esc(all.filter((m) => !shown.includes(m)).map((m) => m.name).join(", "))}">+${hidden} more</button>`
: `<button type="button" class="tl-more" data-action="more" aria-expanded="true">Show fewer</button>`;
body.style.setProperty("--tl-label-w", `${labelW}px`);
body.innerHTML = `<div class="tl-labels"><span class="tl-labels__course">The voyage</span>${labels}${more}</div><div class="tl-scroll"></div>`;
const scroll = body.querySelector<HTMLElement>(".tl-scroll")!;
const avail = Math.max(0, scroll.clientWidth || (root.clientWidth ? root.clientWidth - labelW - 24 : 980));
if (open) {
const l = layoutTimeline(s.songId, avail, measure);
scroll.innerHTML = openSvg(l, s.songId, shown, s.motif, rows);
if (l.width > avail) {
// bring the expanded saga into view; the current stop must be visible in any case
const g = l.sagas.find((x) => x.expanded)!;
const cur = l.stops.find((x) => x.current)!.x;
let left = (g.x0 + g.x1) / 2 - avail / 2;
if (cur < left + 40 || cur > left + avail - 40) left = cur - avail / 2;
scroll.scrollLeft = Math.max(0, Math.min(scroll.scrollWidth - scroll.clientWidth, s.songId === lastSong ? keepLeft : left));
}
} else {
scroll.innerHTML = slimSvg(Math.max(avail, 320), s.songId);
}
updateEdges(scroll);
scroll.addEventListener("scroll", () => updateEdges(scroll), { passive: true });
lastSong = s.songId;
if (had) body.querySelector<HTMLElement | SVGElement>(had)?.focus();
}
let lastSong = "";
const selectSong = (id: string) => { if (songById(id)) store.set({ songId: id }); };
root.addEventListener("click", (e) => {
const t = e.target as Element;
const stop = t.closest("[data-song]");
if (stop) return selectSong(stop.getAttribute("data-song")!);
const pick = t.closest("[data-pick]");
if (pick) {
const id = pick.getAttribute("data-pick")!;
return store.set({ motif: store.get().motif === id ? null : id });
}
const action = t.closest("[data-action]")?.getAttribute("data-action");
if (action === "ribbons") store.set({ ribbons: !store.get().ribbons });
else if (action === "toggle-timeline") store.set({ timeline: store.get().timeline === "open" ? "closed" : "open" });
else if (action === "more") { showAll = !showAll; render(); }
});
// stops: Enter/Space select; arrow keys move along the course (one tab stop for the whole strip)
root.addEventListener("keydown", (e) => {
const stop = (e.target as Element).closest?.("[data-song]");
if (!stop) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectSong(stop.getAttribute("data-song")!);
} else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) {
e.preventDefault();
const all = [...body.querySelectorAll<SVGElement>("[data-song]")];
const i = all.indexOf(stop as SVGElement);
const j = e.key === "Home" ? 0 : e.key === "End" ? all.length - 1 : Math.max(0, Math.min(all.length - 1, i + (e.key === "ArrowLeft" ? -1 : 1)));
all[i]!.setAttribute("tabindex", "-1");
all[j]!.setAttribute("tabindex", "0");
all[j]!.focus();
}
});
store.subscribe((s, prev) => {
if (s.songId !== prev.songId) showAll = false;
if (s.songId !== prev.songId || s.motif !== prev.motif || s.timeline !== prev.timeline || s.ribbons !== prev.ribbons) render();
});
let lastW = -1;
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(() => {
const w = root.clientWidth;
if (w !== lastW) { lastW = w; render(); }
}).observe(root);
}
// titles are laid out by measured width: measure again once Inter has loaded
document.fonts?.ready.then(() => { widths.clear(); render(); });
render();
return { refresh: render };
}
+18
View File
@@ -0,0 +1,18 @@
import { sagaOf, sagas, songById, type LandingId, type PlaceId } from "../data/album";
export type Transition = { kind: "sail" } | { kind: "curtain"; sagaId: PlaceId } | { kind: "fade" };
export interface Step { transition: Transition; landing: LandingId | null }
/**
* How the stage moves from one song to another (spec 002, goals 4–5): within a saga the ship sails; into
* the next or previous saga that saga's frieze drops like a curtain; across two or more sagas the stage
* fades through the curtain colour. The step ends in the target song's landing, if it has one.
*/
export function transitionFor(fromSongId: string, toSongId: string): Step {
const from = songById(fromSongId)!;
const to = songById(toSongId)!;
const order = (id: PlaceId) => sagas.findIndex((s) => s.id === id);
const gap = Math.abs(order(sagaOf(to).id) - order(sagaOf(from).id));
const transition: Transition = gap === 0 ? { kind: "sail" } : gap === 1 ? { kind: "curtain", sagaId: to.sagaId } : { kind: "fade" };
return { transition, landing: to.landing };
}
+290
View File
@@ -0,0 +1,290 @@
// The nine places of the voyage, each a distinct stylised silhouette in the toon/ink look: Troy's walls,
// the Cyclops' island with its cave mouth, a storm spiral at sea, Aeaea with Circe's palace, the dark gate
// of the Underworld, Scylla's cliffs under thunder, Olympus' peak, Charybdis' whirlpool, and Ithaca with
// Odysseus' palace. Each is built around its own origin with local +z facing the route.
import * as THREE from "three";
import type { PlaceId } from "../../data/album";
import { PAL, edgeLines, hash, inkBack, islandGeometry, outline, toon, flat, type IslandColors, type IslandOpts } from "./materials";
/** Charybdis' water: bands spiralling down into the funnel, drawn in the sea's flat inks. */
function whirlMaterial(time: { value: number }) {
return new THREE.ShaderMaterial({
side: THREE.DoubleSide,
uniforms: { uTime: time, uDeep: { value: new THREE.Color(PAL.deep) }, uMid: { value: new THREE.Color(PAL.mid) }, uLine: { value: new THREE.Color(PAL.contour) }, uDark: { value: new THREE.Color("#123f4e") } },
vertexShader: /* glsl */ `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: /* glsl */ `
uniform float uTime; uniform vec3 uDeep; uniform vec3 uMid; uniform vec3 uLine; uniform vec3 uDark; varying vec3 vP;
void main(){
float r = length(vP.xz);
float a = atan(vP.z, vP.x) / 6.28318;
float band = fract(a * 6.0 + 30.0 / (r + 4.0) - uTime * 0.35);
vec3 c = mix(uMid, uDeep, step(0.5, band));
float fw = fwidth(band) * 1.5;
c = mix(c, uLine, smoothstep(0.0, fw, band) * (1.0 - smoothstep(0.07, 0.07 + fw, band)));
c = mix(c, uDark, smoothstep(-3.0, -7.0, vP.y));
gl_FragColor = vec4(c, 1.0);
#include <colorspace_fragment>
}`,
});
}
/** Materials shared by every place (built once per world, disposed with it). */
export function placeKit(time: { value: number }) {
return {
island: toon("#ffffff", { vertexColors: true }),
ink: inkBack(),
edge: edgeLines(0.4),
mouth: flat(PAL.ink, { side: THREE.DoubleSide }),
stone: toon(PAL.stone),
stoneDark: toon(PAL.stoneDark),
wall: toon(PAL.stone, { side: THREE.DoubleSide }),
clay: toon(PAL.clay),
wood: toon(PAL.wood),
green: toon("#4f6b34"),
cypress: toon("#34502e"),
olive: toon("#7d8a5a"),
basalt: toon(PAL.basalt),
storm: toon(PAL.storm),
stormDark: toon(PAL.stormDark),
cloud: toon("#f4eee2"),
bolt: flat("#fff1b8", { side: THREE.DoubleSide }),
spray: flat(PAL.contour, { side: THREE.DoubleSide, transparent: true, opacity: 0.85 }),
serpent: toon("#3f5a4c"),
gold: toon("#d9a441"),
void: flat("#0c0705", { side: THREE.DoubleSide }),
glow: flat("#7a4a8c", { side: THREE.DoubleSide }),
whirl: whirlMaterial(time),
};
}
export type PlaceKit = ReturnType<typeof placeKit>;
const GREEN: IslandColors = { sand: PAL.sand, rock: PAL.rock, top: PAL.top };
const LUSH: IslandColors = { sand: PAL.sand, rock: "#8f7a4a", top: "#4f6b34" };
const DARK: IslandColors = { sand: "#5d5560", rock: PAL.basalt, top: PAL.basaltTop };
const PEAK: IslandColors = { sand: PAL.sand, rock: "#9a8a72", top: PAL.snow };
const CLIFF: IslandColors = { sand: "#7c6a58", rock: "#8a7058", top: "#5e6b48" };
function island(k: PlaceKit, parent: THREE.Object3D, o: IslandOpts, cols: IslandColors, x: number, z: number, inkT = 0.4) {
const geo = islandGeometry(o, cols);
const m = outline(new THREE.Mesh(geo, k.island), inkT, k.ink);
m.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 32), k.edge));
m.position.set(x, 0, z);
parent.add(m);
return m;
}
function mesh(parent: THREE.Object3D, geo: THREE.BufferGeometry, mat: THREE.Material, x: number, y: number, z: number, ink?: { t: number; mat: THREE.Material }) {
const m = new THREE.Mesh(geo, mat);
m.position.set(x, y, z);
if (ink) outline(m, ink.t, ink.mat);
parent.add(m);
return m;
}
/** A small temple or palace: a stepped base, a row of columns, a roof with a pediment. */
function temple(k: PlaceKit, parent: THREE.Object3D, o: { w: number; d: number; h: number; cols: number; roof: THREE.Material; x: number; y: number; z: number }) {
const g = new THREE.Group();
const ink = { t: 0.18, mat: k.ink };
mesh(g, new THREE.BoxGeometry(o.w + 2, 1.2, o.d + 2), k.stoneDark, 0, 0.6, 0, ink);
mesh(g, new THREE.BoxGeometry(o.w * 0.86, o.h, o.d * 0.7), k.stone, 0, 1.2 + o.h / 2, -o.d * 0.08, ink);
const colGeo = new THREE.CylinderGeometry(0.45, 0.55, o.h, 6);
for (let i = 0; i < o.cols; i++) {
const x = -o.w / 2 + 0.8 + (i * (o.w - 1.6)) / (o.cols - 1);
mesh(g, colGeo, k.stone, x, 1.2 + o.h / 2, o.d / 2 - 0.4, ink);
}
mesh(g, new THREE.BoxGeometry(o.w + 1, 1, o.d + 1), k.stoneDark, 0, 1.7 + o.h, 0, ink);
const roof = new THREE.CylinderGeometry(1, 1, o.d + 1.2, 3);
roof.rotateX(Math.PI / 2);
roof.rotateZ(Math.PI);
roof.scale((o.w + 1.4) / 1.73, 1.6, 1);
mesh(g, roof, o.roof, 0, 2.2 + o.h + 0.8, 0, ink);
g.position.set(o.x, o.y, o.z);
parent.add(g);
return g;
}
function cloudBank(k: PlaceKit, parent: THREE.Object3D, mat: THREE.Material, puffs: [number, number, number, number][]) {
const geo = new THREE.IcosahedronGeometry(1, 1);
for (const [x, y, z, r] of puffs) {
const m = mesh(parent, geo, mat, x, y, z, { t: 0.06, mat: k.ink });
m.scale.set(r * 1.5, r * 0.62, r);
}
}
function boltMesh(k: PlaceKit, parent: THREE.Object3D, x: number, y: number, z: number, s: number) {
const sh = new THREE.Shape();
[[0, 0], [5, 0], [2, -8], [6, -8], [-2, -22], [0, -12], [-4, -12]].forEach(([a, b], i) => (i ? sh.lineTo(a!, b!) : sh.moveTo(a!, b!)));
sh.closePath();
const m = mesh(parent, new THREE.ShapeGeometry(sh), k.bolt, x, y, z);
m.scale.setScalar(s);
const edge = new THREE.LineSegments(new THREE.EdgesGeometry(m.geometry), k.edge);
m.add(edge);
return m;
}
/* ---------- the places ---------- */
function troy(k: PlaceKit, g: THREE.Group) {
// a low plateau with a ring of walls and towers, the wooden horse on the beach
const base = island(k, g, { radius: 30, height: 15, seed: 11, segments: 10 }, GREEN, 0, -4);
base.scale.set(1, 1, 0.9);
const ink = { t: 0.2, mat: k.ink };
const wall = new THREE.CylinderGeometry(14, 14.6, 5, 18, 1, true);
const w = mesh(g, wall, k.wall, 0, 11, -4);
w.add(new THREE.LineSegments(new THREE.EdgesGeometry(wall), k.edge));
const tower = new THREE.CylinderGeometry(2.3, 2.6, 9, 7);
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 + 0.3;
mesh(g, tower, k.stoneDark, Math.sin(a) * 14.3, 12.5, -4 + Math.cos(a) * 14.3, ink);
}
mesh(g, new THREE.BoxGeometry(4, 4.4, 1.2), k.mouth, 0, 10.6, -4 + 14.7);
const roofs = new THREE.BoxGeometry(3.5, 3, 3.5);
for (let i = 0; i < 7; i++) mesh(g, roofs, i % 2 ? k.clay : k.stone, (hash(i, 1, 2) - 0.5) * 18, 13, -4 + (hash(2, i, 1) - 0.5) * 18, ink);
// the wooden horse on the shore
const horse = new THREE.Group();
mesh(horse, new THREE.BoxGeometry(5, 2.4, 2), k.wood, 0, 4, 0, ink);
for (const [lx, lz] of [[-1.8, -0.6], [-1.8, 0.6], [1.8, -0.6], [1.8, 0.6]] as const) mesh(horse, new THREE.BoxGeometry(0.5, 3, 0.5), k.wood, lx, 1.6, lz);
const neck = mesh(horse, new THREE.BoxGeometry(1.2, 4, 1.2), k.wood, 2.4, 6.4, 0, ink);
neck.rotation.z = -0.4;
mesh(horse, new THREE.BoxGeometry(2.4, 1.1, 1.1), k.wood, 3.6, 8.4, 0, ink);
horse.position.set(14, 0.6, 22);
horse.rotation.y = -0.6;
g.add(horse);
}
function cyclops(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 26, height: 34, seed: 23 }, GREEN, 0, 0, 0.45);
island(k, g, { radius: 17, height: 22, seed: 41 }, GREEN, -20, -14, 0.4);
island(k, g, { radius: 11, height: 13, seed: 57 }, GREEN, 21, -6, 0.35);
// the cave mouth: a black arch facing the route
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();
mesh(g, new THREE.ShapeGeometry(arch, 8), k.mouth, 0, 0.6, 22);
}
function ocean(k: PlaceKit, g: THREE.Group) {
// a storm: a dark cloud bank wheeling over a spiral drawn on the sea, a bolt between them
const puffs: [number, number, number, number][] = [];
for (let i = 0; i < 9; i++) {
const a = (i / 9) * Math.PI * 2;
puffs.push([Math.cos(a) * 18, 34 + (i % 3) * 3, Math.sin(a) * 18, 7 + (i % 2) * 2]);
}
puffs.push([0, 40, 0, 10]);
cloudBank(k, g, k.stormDark, puffs);
cloudBank(k, g, k.storm, [[-10, 30, 14, 6], [12, 31, 10, 6], [4, 46, -6, 7]]);
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 120; i++) {
const a = (i / 120) * Math.PI * 5;
const r = 4 + (i / 120) * 26;
pts.push(new THREE.Vector3(Math.cos(a) * r, 1.4, Math.sin(a) * r));
}
const tube = new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts), 160, 0.55, 3, false);
mesh(g, tube, k.spray, 0, 0, 0);
boltMesh(k, g, 4, 30, 6, 1.35);
boltMesh(k, g, -12, 30, -4, 1.1);
island(k, g, { radius: 5, height: 7, seed: 71 }, DARK, -24, -16, 0.3);
}
function circe(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 30, height: 18, seed: 31, top: 11 }, LUSH, 0, -2, 0.45);
island(k, g, { radius: 14, height: 16, seed: 37 }, LUSH, 22, -8, 0.35);
temple(k, g, { w: 13, d: 8, h: 4.5, cols: 6, roof: k.glow, x: 0, y: 14.4, z: 0 });
const cone = new THREE.ConeGeometry(1.3, 7, 5);
for (let i = 0; i < 9; i++) {
const a = 0.5 + i * 0.55;
const r = 12 + hash(i, 4, 4) * 6;
mesh(g, cone, k.cypress, Math.cos(a) * r, 2 + 15 * (30 - r) / 19 + 2.5, Math.sin(a) * r - 2, { t: 0.12, mat: k.ink });
}
}
function underworld(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 28, height: 26, seed: 47, segments: 7 }, DARK, 0, -6, 0.45);
island(k, g, { radius: 12, height: 18, seed: 53, segments: 6 }, DARK, -22, 2, 0.35);
// the gate of the dead: two black pillars, a lintel, a void with a cold glow at its heart
const ink = { t: 0.2, mat: k.ink };
const pillar = new THREE.BoxGeometry(3, 18, 3);
mesh(g, pillar, k.basalt, -6.5, 9, 18, ink);
mesh(g, pillar, k.basalt, 6.5, 9, 18, ink);
mesh(g, new THREE.BoxGeometry(18, 2.6, 3.6), k.basalt, 0, 19, 18, ink);
mesh(g, new THREE.PlaneGeometry(10, 17), k.void, 0, 8.6, 17.6);
const twig = new THREE.CylinderGeometry(0.15, 0.3, 7, 4);
for (let i = 0; i < 5; i++) {
const t = mesh(g, twig, k.mouth, -16 + i * 7, 8 + hash(i, 2, 2) * 6, 4 - hash(i, 5, 1) * 10);
t.rotation.z = (hash(i, 7, 7) - 0.5) * 0.8;
}
}
function thunder(k: PlaceKit, root: THREE.Group) {
const g = new THREE.Group();
g.position.x = 14;
root.add(g);
// Scylla's cliffs: two sheer crags with a strait between, necks reaching out of a cave; thunder above
island(k, g, { radius: 14, height: 36, seed: 61, segments: 6 }, CLIFF, -10, -10, 0.45);
island(k, g, { radius: 11, height: 27, seed: 67, segments: 6 }, CLIFF, 16, -22, 0.4);
mesh(g, new THREE.CircleGeometry(2.6, 10), k.mouth, -8, 12, 1.4);
const ink = { t: 0.14, mat: k.ink };
const necks: [number, number, number][] = [[-4, 21, 10], [3, 16, 13], [5, 8, 15], [-11, 15, 13]];
for (const [x, y, z] of necks) {
const curve = new THREE.CatmullRomCurve3([new THREE.Vector3(-8, 12, 1), new THREE.Vector3((x - 8) / 2, y + 4, (z + 1) / 2), new THREE.Vector3(x, y, z)]);
mesh(g, new THREE.TubeGeometry(curve, 16, 0.8, 5, false), k.serpent, 0, 0, 0, ink);
const head = mesh(g, new THREE.ConeGeometry(1.4, 3.6, 5), k.serpent, x, y, z, ink);
head.lookAt(new THREE.Vector3(x * 2, y - 4, z + 10));
head.rotateX(Math.PI / 2);
}
cloudBank(k, g, k.stormDark, [[18, 42, -40, 8], [30, 45, -34, 6], [6, 44, -44, 7]]);
boltMesh(k, g, 22, 37, -36, 1.2);
}
function wisdom(k: PlaceKit, g: THREE.Group) {
// Olympus: a snow-capped peak with a golden temple at the summit, ringed by cloud
island(k, g, { radius: 30, height: 40, seed: 79, segments: 9, top: 4 }, PEAK, 0, -18, 0.5);
temple(k, g, { w: 7, d: 5, h: 2.6, cols: 4, roof: k.gold, x: 0, y: 36.6, z: -18 });
const puffs: [number, number, number, number][] = [];
for (let i = 0; i < 8; i++) {
const a = (i / 8) * Math.PI * 2;
puffs.push([Math.cos(a) * 19, 17 + (i % 2) * 3, -18 + Math.sin(a) * 19, 4 + (i % 3)]);
}
cloudBank(k, g, k.cloud, puffs);
}
function vengeance(k: PlaceKit, g: THREE.Group) {
// Charybdis: a funnel cut into the sea (the sea has a hole here), spiral-striped, spray at the rim;
// the fig tree on its rock beside it
const funnel = new THREE.CylinderGeometry(26, 3, 7, 48, 8, true);
funnel.translate(0, -3.5, 0);
mesh(g, funnel, k.whirl, 0, 0.2, 0);
const rim = mesh(g, new THREE.TorusGeometry(26.5, 0.7, 4, 48), k.spray, 0, 0.6, 0);
rim.rotation.x = Math.PI / 2;
island(k, g, { radius: 8, height: 12, seed: 83 }, GREEN, -34, -6, 0.3);
mesh(g, new THREE.CylinderGeometry(0.4, 0.6, 6, 5), k.wood, -34, 11, -6);
mesh(g, new THREE.IcosahedronGeometry(3.4, 0), k.green, -34, 15, -6, { t: 0.15, mat: k.ink });
}
function ithaca(k: PlaceKit, g: THREE.Group) {
island(k, g, { radius: 32, height: 16, seed: 89, segments: 10, top: 14 }, GREEN, 0, -6, 0.45);
island(k, g, { radius: 15, height: 15, seed: 97 }, GREEN, -24, -2, 0.35);
temple(k, g, { w: 16, d: 10, h: 4.6, cols: 7, roof: k.clay, x: 1, y: 12.4, z: -2 });
const ink = { t: 0.18, mat: k.ink };
mesh(g, new THREE.BoxGeometry(7, 5, 7), k.stone, -9, 14.5, -10, ink);
mesh(g, new THREE.BoxGeometry(6, 4, 6), k.stone, 11, 14, -12, ink);
const puff = new THREE.IcosahedronGeometry(2.2, 0);
for (let i = 0; i < 8; i++) {
const a = 1.2 + i * 0.5;
mesh(g, puff, k.olive, Math.cos(a) * 20, 1 + 13 * 12 / 18 + 1.5, Math.sin(a) * 20 - 6, ink);
}
}
const BUILDERS: Record<PlaceId, (k: PlaceKit, g: THREE.Group) => void> = {
troy, cyclops, ocean, circe, underworld, thunder, wisdom, vengeance, ithaca,
};
/** A place's silhouette, built around its origin with local +z facing the route. */
export function buildPlace(id: PlaceId, k: PlaceKit): THREE.Group {
const g = new THREE.Group();
g.name = id;
BUILDERS[id](k, g);
return g;
}
/** Where a place cuts a hole in the sea (Charybdis), in local units: radius or 0. */
export const SEA_HOLE: Partial<Record<PlaceId, number>> = { vengeance: 26 };
+108
View File
@@ -0,0 +1,108 @@
// Landing: God Games (Wisdom Saga). Athena and Odysseus as black-figure cut-outs facing off on a sandy
// ring, a colonnade and tiers of stone seats behind, clouds of Olympus below the horizon.
import * as THREE from "three";
import type { Landing } from "../materials";
import { PAL, disposeTree, easeInOut, edgeLines, hash, inkBack, lerp, outline, skyDome, toon } from "../materials";
import { cutout } from "./figures";
export function buildArena(): Landing {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 16 / 9, 0.5, 1400);
const sky = skyDome(["#f3dfb6", "#e9d3b0", "#c9cfc0", "#9cbcc6"], PAL.sun, PAL.sunRing);
sky.uniforms.uSunDir.value.set(-0.5, 0.32, -0.8).normalize();
scene.add(sky.mesh);
scene.fog = new THREE.Fog("#efd9b0", 120, 520);
const sun = new THREE.DirectionalLight("#fff6e6", 2.3);
sun.position.set(-40, 80, 50);
scene.add(sun, new THREE.AmbientLight("#b9b4c2", 1.3));
const ink = inkBack(), edge = edgeLines(0.4);
const sand = toon("#e3c58c"), sandDark = toon("#c9a46a"), stone = toon(PAL.stone), stoneDark = toon(PAL.stoneDark);
const seats = toon("#d8c7a2", { side: THREE.DoubleSide }), cloud = toon("#f6f0e4");
const clay = new THREE.MeshBasicMaterial({ color: PAL.clay, side: THREE.DoubleSide });
const fig = { glaze: new THREE.MeshBasicMaterial({ color: "#16100c" }), clay, clayHull: new THREE.MeshBasicMaterial({ color: PAL.clay, side: THREE.BackSide }), eye: new THREE.MeshBasicMaterial({ color: PAL.cream }) };
// the ring: sand with a raked darker band and an inked boundary
const floor = new THREE.Mesh(new THREE.CircleGeometry(60, 40), sandDark);
floor.rotation.x = -Math.PI / 2;
scene.add(floor);
const ring = new THREE.Mesh(new THREE.CircleGeometry(30, 40), sand);
ring.rotation.x = -Math.PI / 2;
ring.position.y = 0.02;
scene.add(ring);
const line = new THREE.Mesh(new THREE.RingGeometry(29.6, 30.6, 48), new THREE.MeshBasicMaterial({ color: PAL.ink }));
line.rotation.x = -Math.PI / 2;
line.position.y = 0.04;
scene.add(line);
// tiers of seats on the far half, and a colonnade in front of them
for (const [r, h] of [[40, 3], [44, 6], [48, 9], [52, 12]] as const) {
const geo = new THREE.CylinderGeometry(r, r, h, 48, 1, true, Math.PI * 0.55, Math.PI * 0.9);
geo.translate(0, h / 2, 0);
const m = new THREE.Mesh(geo, seats);
m.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 30), edge));
scene.add(m);
}
const colGeo = new THREE.CylinderGeometry(0.9, 1.1, 15, 8), capGeo = new THREE.BoxGeometry(3, 1, 3), baseGeo = new THREE.BoxGeometry(2.8, 0.8, 2.8);
for (let i = 0; i < 11; i++) {
const a = Math.PI * (0.12 + (i / 10) * 0.76);
const x = Math.cos(a) * 35, z = -Math.sin(a) * 35;
const c = outline(new THREE.Mesh(colGeo, stone), 0.14, ink);
c.position.set(x, 8.3, z);
const cap = outline(new THREE.Mesh(capGeo, stoneDark), 0.12, ink);
cap.position.set(x, 16.3, z);
const base = outline(new THREE.Mesh(baseGeo, stoneDark), 0.12, ink);
base.position.set(x, 0.4, z);
scene.add(c, cap, base);
}
// an architrave over the middle columns, with a terracotta frieze band
const beam = outline(new THREE.Mesh(new THREE.BoxGeometry(40, 2, 2.4), stoneDark), 0.14, ink);
beam.position.set(0, 17.8, -35.6);
const band = new THREE.Mesh(new THREE.BoxGeometry(40, 0.8, 0.2), clay);
band.position.set(0, 17.8, -34.3);
scene.add(beam, band);
// clouds of Olympus beyond
const puff = new THREE.IcosahedronGeometry(1, 1);
for (let i = 0; i < 14; i++) {
const a = Math.PI * (0.2 + (i / 13) * 0.6);
const r = 190 + hash(i, 2, 2) * 80;
const m = outline(new THREE.Mesh(puff, cloud), 0.04, ink);
m.position.set(Math.cos(a) * r * (i % 2 ? 1 : -1), -14 + hash(i, 3, 1) * 6, -Math.sin(a) * r);
const s = 10 + hash(1, i, 5) * 10;
m.scale.set(s * 1.6, s * 0.5, s);
scene.add(m);
}
// Athena and Odysseus, facing off across the ring
const athena = cutout({ robe: true, crest: true, hair: true, shield: true, spear: "thrust" }, fig);
athena.position.set(-9, 0, 0);
athena.scale.setScalar(0.5);
athena.rotation.y = 0.12;
const odysseus = cutout({ crest: true, beard: true, shield: true, spear: "raised" }, fig);
odysseus.position.set(9, 0, -1);
odysseus.scale.set(-0.5, 0.5, 0.5);
odysseus.rotation.y = -0.12;
scene.add(athena, odysseus);
const shadow = new THREE.MeshBasicMaterial({ color: "#8a6a3c", transparent: true, opacity: 0.45 });
for (const x of [-9, 9]) {
const s = new THREE.Mesh(new THREE.CircleGeometry(4, 20), shadow);
s.rotation.x = -Math.PI / 2;
s.scale.set(1.4, 0.6, 1);
s.position.set(x, 0.06, -0.5);
scene.add(s);
}
return {
scene, camera,
update(k, t) {
const e = easeInOut(k);
camera.position.set(lerp(0, 3, e), lerp(16, 9, e), lerp(70, 44, e));
camera.lookAt(lerp(0, 1, e), lerp(9, 11, e), 0);
// a slow sway, as if circling each other
athena.position.x = -9 + Math.sin(t * 0.7) * 0.6;
odysseus.position.x = 9 + Math.sin(t * 0.7 + 1.2) * 0.6;
},
dispose() { disposeTree(scene); },
};
}
+138
View File
@@ -0,0 +1,138 @@
// Landing: the Cyclops' cave (ported from mockup 17). A faceted, firelit dome; the fire as layered flat
// flames; Polyphemus as a black-figure cut-out incised in clay, with one great eye; his flock.
import * as THREE from "three";
import type { Landing } from "../materials";
import { disposeTree, easeInOut, fire, flicker, hash, lerp, outline, shape, strokeGeo, toon, v3 } from "../materials";
export function buildCave(): Landing {
const scene = new THREE.Scene();
scene.background = new THREE.Color("#140c08");
const camera = new THREE.PerspectiveCamera(54, 16 / 9, 0.3, 400);
const m = {
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 }),
};
// a faceted dome seen from inside, 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, m.rock);
shell.add(new THREE.LineSegments(new THREE.EdgesGeometry(dome, 1), m.edge));
shell.scale.set(44, 30, 56);
shell.position.set(0, 4, -16);
scene.add(shell);
const floor = new THREE.Mesh(new THREE.CircleGeometry(70, 24), m.floor);
floor.rotation.x = -Math.PI / 2;
floor.position.y = -9;
scene.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), m.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);
scene.add(outline(b, 0.18, m.ink));
}
const f = fire({ fire: m.fire, core: m.core, log: m.face });
f.group.position.set(-12, -9, -30);
scene.add(f.group);
const fireLight = new THREE.PointLight(0xf08a3a, 900, 0, 1.7);
fireLight.position.set(-12, -2, -26);
scene.add(fireLight, new THREE.AmbientLight(0xffffff, 0.55));
// Polyphemus: one profile cut-out, facing his flock and the mouth, striding, club raised behind
const giant = new THREE.Group();
const D = 2.4;
const part = (s: THREE.Shape, segs = 12) => {
const geo = new THREE.ExtrudeGeometry(s, { depth: D, bevelEnabled: false, curveSegments: segs });
geo.translate(0, 0, -D / 2);
giant.add(outline(new THREE.Mesh(geo, m.giant), 0.15, m.giantHull));
};
part(shape([[-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]]));
part(shape([[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]]));
part(shape([[-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]]));
part(shape([[-4.2, 25.8], [-10.4, 19.4], [-11.6, 17.2], [-10.4, 16.4], [-8.4, 18.4], [-2.4, 22.6]]));
part(shape([[1.6, 27.0], [6.0, 31.4], [7.6, 34.6], [6.4, 35.6], [4.4, 33.0], [0.4, 24.4]]));
part(shape([[5.0, 33.6], [6.6, 32.8], [14.6, 47.4], [14.6, 49.8], [12.6, 50.6], [10.6, 48.6]]));
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);
part(head, 8);
const z = D / 2 + 0.03;
const inc = (pts: [number, number][], w = 0.26) => giant.add(new THREE.Mesh(strokeGeo(pts, w, z), m.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);
inc([[-1.0, 33.8], [-2.8, 34.2], [-4.6, 33.2]], 0.3);
const eye = new THREE.Mesh(new THREE.CircleGeometry(1.15, 20), m.eye);
eye.position.set(-2.7, 31.9, z + 0.01);
const pupil = new THREE.Mesh(new THREE.CircleGeometry(0.55, 14), m.face);
pupil.position.set(-0.4, 0, 0.02);
eye.add(pupil);
giant.add(eye);
giant.position.set(7, -9, -34);
giant.scale.setScalar(0.72);
giant.rotation.y = -0.18;
scene.add(giant);
// the flock: wool puffs with black heads and legs
const bodyGeo = new THREE.IcosahedronGeometry(1.35, 1), puffGeo = new THREE.IcosahedronGeometry(0.8, 1);
const headGeo = new THREE.ConeGeometry(0.55, 1.4, 6), legGeo = new THREE.BoxGeometry(0.24, 1.1, 0.24);
for (let i = 0; i < 6; i++) {
const sheep = new THREE.Group();
const body = new THREE.Mesh(bodyGeo, m.wool);
body.scale.set(1.4, 1, 1);
sheep.add(outline(body, 0.06, m.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(puffGeo, m.wool);
puff.position.set(px, py, 0);
sheep.add(outline(puff, 0.05, m.ink));
}
const h = new THREE.Mesh(headGeo, m.face);
h.rotation.z = Math.PI / 2 + 0.4;
h.position.set(2.0, 0.35, 0);
sheep.add(h);
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(legGeo, m.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);
scene.add(sheep);
}
const eyeBase = new THREE.Color("#f6ead0");
return {
scene, camera,
update(k, t) {
const e = easeInOut(k);
camera.position.copy(v3(lerp(0, 1, e), lerp(1, -1, e), lerp(44, 30, e)));
camera.lookAt(lerp(0, -4, e), lerp(0, 5, e), -40);
flicker(f.flames, t, camera);
fireLight.intensity = 820 * (0.9 + 0.1 * Math.sin(t * 11) * Math.sin(t * 4.3));
m.eye.color.copy(eyeBase).multiplyScalar(0.85 + 0.15 * Math.sin(t * 2));
},
dispose() { disposeTree(scene); },
};
}
+93
View File
@@ -0,0 +1,93 @@
// Black-figure cut-outs for the landings: flat glaze silhouettes, extruded a little, with a clay edge and
// clay incisions, as the giant in the cave (13/17). Built facing +x, about 38 units tall; mirror with
// scale.x = -1 to face the other way.
import * as THREE from "three";
import { outline, shape, strokeGeo } from "../materials";
export interface FigureMats { glaze: THREE.Material; clay: THREE.Material; clayHull: THREE.Material; eye: THREE.Material }
export interface FigureOpts { robe?: boolean; crest?: boolean; beard?: boolean; hair?: boolean; shield?: boolean; spear?: "thrust" | "raised"; bow?: boolean }
const D = 1.6;
/** A straight limb or shaft from a to b, w thick, as a closed outline. */
const bar = (ax: number, ay: number, bx: number, by: number, w: number): [number, number][] => {
const l = Math.hypot(bx - ax, by - ay) || 1, nx = (-(by - ay) / l) * w / 2, ny = ((bx - ax) / l) * w / 2;
return [[ax + nx, ay + ny], [bx + nx, by + ny], [bx - nx, by - ny], [ax - nx, ay - ny]];
};
export function cutout(o: FigureOpts, m: FigureMats): THREE.Group {
const g = new THREE.Group();
const part = (s: THREE.Shape, depth = D, zOff = 0) => {
const geo = new THREE.ExtrudeGeometry(s, { depth, bevelEnabled: false, curveSegments: 10 });
geo.translate(0, 0, -depth / 2 + zOff);
g.add(outline(new THREE.Mesh(geo, m.glaze), 0.14, m.clayHull));
};
const z = D / 2 + 0.03;
const inc = (pts: [number, number][], w = 0.22, dz = 0) => g.add(new THREE.Mesh(strokeGeo(pts, w, z + dz), m.clay));
if (o.robe) {
part(shape([[-5.4, 0], [6, 0], [3.8, 13], [3.4, 18], [3.9, 27.4], [-3.4, 27.6], [-2.8, 18], [-3.4, 13]]));
inc([[-4.6, 3], [5.2, 3]]);
inc([[-0.4, 4], [0, 16]], 0.18);
inc([[2.2, 4], [2.4, 16]], 0.18);
} else {
part(shape([[2, 0], [6.4, 0], [6.4, 1.1], [4.2, 1.3], [3.4, 9], [1.4, 15], [-1.2, 14.6], [1.2, 8.6], [2.2, 1.1]]));
part(shape([[-8.2, 0], [-4.4, 0], [-4.6, 1.2], [-2.6, 8], [0.8, 14.6], [-1.6, 15.2], [-5, 8.6], [-7.2, 1.6], [-8.4, 1.1]]));
part(shape([[-3.6, 13], [4, 13], [3, 18], [3.8, 27.4], [-3.4, 27.6], [-2.8, 18]]));
inc([[-3.4, 13.8], [-2.4, 14.8], [-1.2, 13.8], [0, 14.8], [1.2, 13.8], [2.4, 14.8], [3.6, 13.8]], 0.18);
inc([[2.8, 5.2], [4, 5.6]]);
inc([[-5, 5.4], [-3.8, 6]]);
}
inc([[-2.8, 18.4], [3.2, 18.4]]);
inc([[-2.4, 24.6], [0.4, 22.6], [3, 24.4]], 0.18);
// neck and head
part(shape(bar(0.4, 26.8, 0.8, 29.2, 2)));
const head = new THREE.Shape();
head.absarc(0.8, 30.6, 2.5, 0, Math.PI * 2, false);
part(head);
part(shape([[2.9, 31], [4.4, 30.2], [3, 29.6]])); // nose
if (o.beard) part(shape([[0.2, 29.2], [3.4, 29], [1.4, 26.4]]));
if (o.hair) part(shape([[-1.4, 32], [-3.8, 25.4], [-2, 25], [0.4, 29.6]]));
if (o.crest) {
part(shape([[-2, 30.4], [3.4, 30.8], [3.2, 33.6], [-2, 33.6]]));
part(shape([[-4.6, 33], [-2, 38.2], [4.4, 36.6], [1.6, 33.4]]));
inc([[-1.6, 31], [3, 31.2]], 0.18);
}
const eye = new THREE.Mesh(new THREE.CircleGeometry(0.42, 10), m.eye);
eye.position.set(2.2, 31, z + 0.01);
g.add(eye);
// arms and weapons
if (o.bow) {
part(shape(bar(2.6, 25.8, 10.6, 26.2, 1.4)));
part(shape(bar(-1, 25.6, 4.6, 26.4, 1.3)));
const bow = new THREE.Mesh(new THREE.TorusGeometry(6.4, 0.42, 5, 18, Math.PI * 0.82), m.glaze);
bow.rotation.z = -Math.PI * 0.41;
bow.position.set(6.2, 26.2, 0);
g.add(bow);
inc([[11, 31.6], [4.6, 26.4], [11, 20.6]], 0.14, 0.4);
} else if (o.spear === "thrust") {
part(shape(bar(2.6, 25.8, 8.6, 22.6, 1.4)));
part(shape([...bar(-12, 25.6, 22, 21.4, 0.5)]), 0.5, 0.9);
part(shape([[22, 22.4], [26.4, 20.9], [21.8, 20.4]]), 0.5, 0.9);
} else if (o.spear === "raised") {
part(shape(bar(2.6, 25.8, 5.4, 32, 1.4)));
part(shape(bar(-9, 26.6, 18, 36.4, 0.5)), 0.5, 0.9);
part(shape([[18, 36.9], [21.6, 39], [17.6, 35.6]]), 0.5, 0.9);
}
if (o.shield) {
const disc = new THREE.Shape();
disc.absarc(0, 0, 6.4, 0, Math.PI * 2, false);
const sg = new THREE.ExtrudeGeometry(disc, { depth: 0.8, bevelEnabled: false, curveSegments: 20 });
const sh = outline(new THREE.Mesh(sg, m.glaze), 0.14, m.clayHull);
sh.position.set(4.6, 19.6, D / 2 + 0.6);
g.add(sh);
const ring = (r: number, w: number) => {
const mesh = new THREE.Mesh(new THREE.RingGeometry(r - w / 2, r + w / 2, 28), m.clay);
mesh.position.set(4.6, 19.6, D / 2 + 1.45);
g.add(mesh);
};
ring(5.2, 0.24);
ring(1.4, 0.22);
}
return g;
}
+126
View File
@@ -0,0 +1,126 @@
// Landing: the hall of Odysseus' palace on Ithaca (The Challenge onwards). A megaron: plastered walls
// with a terracotta and black frieze band, red tapering columns around a round hearth with its fire, the
// great bow on its stand, and the row of twelve axes the arrow must pass through. Daylight at the door.
import * as THREE from "three";
import type { Landing } from "../materials";
import { PAL, disposeTree, easeInOut, edgeLines, fire, flicker, inkBack, lerp, outline, toon } from "../materials";
export function buildHall(): Landing {
const scene = new THREE.Scene();
scene.background = new THREE.Color("#1d140e");
const camera = new THREE.PerspectiveCamera(54, 16 / 9, 0.3, 400);
const ink = inkBack(), edge = edgeLines(0.5);
const plaster = toon("#e2cfa8", { side: THREE.BackSide }), floorMat = toon("#8a5a36"), tile = toon("#6e4428");
const red = toon("#a8432a"), glaze = toon(PAL.glaze), stone = toon(PAL.stoneDark), wood = toon("#5b3c20"), bronze = toon("#b8863a");
const clay = new THREE.MeshBasicMaterial({ color: PAL.clay }), black = new THREE.MeshBasicMaterial({ color: PAL.glaze });
const day = new THREE.MeshBasicMaterial({ color: "#fbe9c4" });
// the room
const W = 56, H = 26, L = 80;
const room = new THREE.Mesh(new THREE.BoxGeometry(W, H, L), plaster);
room.position.set(0, H / 2, -L / 2 + 30);
room.add(new THREE.LineSegments(new THREE.EdgesGeometry(room.geometry), edge));
scene.add(room);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, L), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(0, 0.01, -L / 2 + 30);
scene.add(floor);
// a painted floor grid, as on Mycenaean floors
const tileGeo = new THREE.PlaneGeometry(7.4, 7.4);
for (let x = -3; x <= 3; x++) for (let z = 0; z < 10; z++) {
if ((x + z) % 2) continue;
const t = new THREE.Mesh(tileGeo, tile);
t.rotation.x = -Math.PI / 2;
t.position.set(x * 8, 0.02, 26 - z * 8);
scene.add(t);
}
// the frieze band round the walls: terracotta between black rules
for (const [w, x, z, ry] of [[L, -W / 2 + 0.1, -10, Math.PI / 2], [L, W / 2 - 0.1, -10, -Math.PI / 2], [W, 0, -L + 30.1, 0]] as const) {
for (const [h, y, mat] of [[2.4, 15, clay], [0.4, 16.4, black], [0.4, 13.6, black]] as const) {
const b = new THREE.Mesh(new THREE.PlaneGeometry(w, h), mat);
b.position.set(x, y, z);
b.rotation.y = ry;
scene.add(b);
}
}
// the door at the far end, with daylight
const door = new THREE.Mesh(new THREE.PlaneGeometry(9, 14), day);
door.position.set(0, 7, -L + 30.2);
scene.add(door);
const lintel = outline(new THREE.Mesh(new THREE.BoxGeometry(13, 1.6, 1.4), stone), 0.14, ink);
lintel.position.set(0, 14.6, -L + 30.6);
scene.add(lintel);
// four red columns, tapering downwards, with black cushion capitals, round the hearth
const colGeo = new THREE.CylinderGeometry(1.3, 0.95, 22, 10), capGeo = new THREE.CylinderGeometry(2, 1.4, 1.6, 10);
for (const [x, z] of [[-11, -2], [11, -2], [-11, -24], [11, -24]] as const) {
const c = outline(new THREE.Mesh(colGeo, red), 0.14, ink);
c.position.set(x, 11, z);
const cap = outline(new THREE.Mesh(capGeo, glaze), 0.1, ink);
cap.position.set(x, 22.6, z);
scene.add(c, cap);
}
// the hearth: a raised stone ring with the fire in it
const hearth = outline(new THREE.Mesh(new THREE.CylinderGeometry(6, 6.4, 1.2, 20), stone), 0.14, ink);
hearth.position.set(0, 0.6, -13);
const hearthRing = new THREE.Mesh(new THREE.TorusGeometry(5.6, 0.3, 4, 24), clay);
hearthRing.rotation.x = Math.PI / 2;
hearthRing.position.set(0, 1.25, -13);
scene.add(hearth, hearthRing);
const flames = fire({ fire: new THREE.MeshBasicMaterial({ color: "#f08a3a", side: THREE.DoubleSide }), core: new THREE.MeshBasicMaterial({ color: "#ffe08a", side: THREE.DoubleSide }), log: black });
flames.group.position.set(0, 1.2, -13);
flames.group.scale.setScalar(0.8);
scene.add(flames.group);
const fireLight = new THREE.PointLight(0xf08a3a, 320, 0, 1.6);
fireLight.position.set(0, 7, -11);
scene.add(fireLight, new THREE.AmbientLight(0xffe2c0, 1.1));
// the great bow on its stand by the left wall
const stand = new THREE.Group();
for (const x of [-4, 4]) {
const post = outline(new THREE.Mesh(new THREE.BoxGeometry(0.7, 9, 0.7), wood), 0.08, ink);
post.position.set(x, 4.5, 0);
stand.add(post);
}
const bar = outline(new THREE.Mesh(new THREE.BoxGeometry(9.6, 0.6, 0.8), wood), 0.08, ink);
bar.position.set(0, 8.4, 0);
stand.add(bar);
const bow = outline(new THREE.Mesh(new THREE.TorusGeometry(6.2, 0.36, 6, 28, Math.PI * 0.86), glaze), 0.1, new THREE.MeshBasicMaterial({ color: PAL.clay, side: THREE.BackSide }));
bow.rotation.z = Math.PI * 0.07;
bow.position.set(0, 4.6, 0.6);
const string = new THREE.Mesh(new THREE.BoxGeometry(12.2, 0.08, 0.08), new THREE.MeshBasicMaterial({ color: "#efe3c8" }));
string.position.set(0, 5.0, 0.6);
stand.add(bow, string);
stand.position.set(-17, 0, -20);
stand.rotation.y = Math.PI / 5;
scene.add(stand);
// the twelve axes in a row, their handles planted in the floor, the heads' rings lined up
const handle = new THREE.BoxGeometry(0.32, 6, 0.32), head = new THREE.TorusGeometry(0.9, 0.28, 5, 14), blade = new THREE.BoxGeometry(2.8, 1.5, 0.22);
for (let i = 0; i < 12; i++) {
const z = 18 - i * 3.6;
const h = new THREE.Mesh(handle, wood);
h.position.set(16, 3, z);
const ringM = new THREE.Mesh(head, bronze);
ringM.position.set(16, 6.6, z);
const b = outline(new THREE.Mesh(blade, bronze), 0.06, ink);
b.position.set(16, 6.6, z);
scene.add(h, ringM, b);
}
// a throne and table by the right wall
const throne = outline(new THREE.Mesh(new THREE.BoxGeometry(4, 8, 3), wood), 0.12, ink);
throne.position.set(22, 4, -30);
scene.add(throne);
return {
scene, camera,
update(k, t) {
const e = easeInOut(k);
camera.position.set(lerp(0, -4, e), lerp(9, 7, e), lerp(40, 27, e));
camera.lookAt(lerp(0, 3, e), lerp(7, 6, e), -30);
flicker(flames.flames, t, camera);
fireLight.intensity = 300 * (0.9 + 0.1 * Math.sin(t * 11) * Math.sin(t * 4.3));
},
dispose() { disposeTree(scene); },
};
}
+222
View File
@@ -0,0 +1,222 @@
// Shared 3D look (design record idea 5; ported from mockup 17's scene.ts): toon shading with three flat
// tones, ink outlines as inverted hulls, a posterised sky with a small flat sun, and a sea of flat colour
// bands with drawn wave strokes. The wave field exists twice, in GLSL and in JS, and they agree.
import * as THREE from "three";
import { mergeVertices } from "three/examples/jsm/utils/BufferGeometryUtils.js";
export 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);
}`;
export 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);
/** Look 2's flat inks: a poster-paint sea, teal and cream, inked in the frieze's black. */
export const PAL = {
sky: ["#f3dfb6", "#dcdcc4", "#b4cdc8", "#88b1c0"] as const, sun: "#fff4dc", sunRing: "#f7e6c0",
deep: "#1d5a6e", mid: "#2b7b88", crest: "#9cd0c4", contour: "#f1efdc", fog: "#f0d7aa", fogNear: 110, fogFar: 420,
ink: "#1d140e", light: "#fff6e6", ambient: "#9fb8c2",
hull: "#2a211b", band: "#c46a33", wood: "#5b3c20", sail: "#f3ead6", sailStripe: "#1d5f86", eye: "#f3ead6",
sand: "#e6cf98", rock: "#b0804e", top: "#6f7d3e", stone: "#e9dcc0", stoneDark: "#c8b48e",
basalt: "#3a3540", basaltTop: "#5a5262", snow: "#f6f1e6", storm: "#4a5866", stormDark: "#2f3a45",
clay: "#c46a33", glaze: "#1d140e", cream: "#f3ead6",
} as const;
export 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);
}
export 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); };
export const easeInOut = (x: number) => { const t = clamp01(x); return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
export const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
export 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;
}
let steps: THREE.DataTexture | null = null;
export const toon = (color: THREE.ColorRepresentation, opts: THREE.MeshToonMaterialParameters = {}) =>
new THREE.MeshToonMaterial({ color, gradientMap: (steps ??= stepMap()), ...opts });
export const flat = (color: THREE.ColorRepresentation, opts: THREE.MeshBasicMaterialParameters = {}) =>
new THREE.MeshBasicMaterial({ color, ...opts });
export const inkBack = (color: THREE.ColorRepresentation = PAL.ink) => new THREE.MeshBasicMaterial({ color, side: THREE.BackSide });
export const edgeLines = (opacity = 0.4, color: THREE.ColorRepresentation = PAL.ink) =>
new THREE.LineBasicMaterial({ color, transparent: true, opacity });
/** Inverted hull: the same shape with smoothed normals, pushed out by t and drawn back faces only. */
export 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;
}
/** Add an ink outline (inverted hull) to a mesh. */
export function outline<T extends THREE.Mesh>(m: T, t: number, mat: THREE.Material): T {
m.add(new THREE.Mesh(hullOf(m.geometry, t), mat));
return m;
}
/** A flat ribbon along a 2D polyline (incised lines on cut-out figures). */
export function strokeGeo(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;
}
export 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;
};
export interface IslandOpts { radius: number; height: number; seed: number; segments?: number; /** a flat top of this radius (a plateau) */ top?: number }
export interface IslandColors { sand: THREE.ColorRepresentation; rock: THREE.ColorRepresentation; top: THREE.ColorRepresentation }
/** A bold graphic island: few facets, three height bands (sand, rock, scrub) as vertex colours. */
export function islandGeometry(o: IslandOpts, cols: IslandColors): THREE.BufferGeometry {
let g: THREE.BufferGeometry = o.top
? new THREE.CylinderGeometry(o.top, o.radius, o.height, o.segments ?? 9, 3, false)
: new THREE.ConeGeometry(o.radius, o.height, o.segments ?? 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();
const sand = new THREE.Color(cols.sand), rock = new THREE.Color(cols.rock), top = new THREE.Color(cols.top);
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 ? sand : y > o.height * 0.45 - 3 ? top : rock;
for (let k = 0; k < 3; k++) col.toArray(colors, (f + k) * 3);
}
g.setAttribute("color", new THREE.BufferAttribute(colors, 3));
g.computeVertexNormals();
return g;
}
/** Posterised sky bands, horizon → zenith, with a flat paper sun and one ring (no glare). */
export function skyDome(bands: readonly [string, string, string, string], sun: string, ring: string, radius = 1000) {
const uniforms = {
uB0: { value: new THREE.Color(bands[0]) }, uB1: { value: new THREE.Color(bands[1]) },
uB2: { value: new THREE.Color(bands[2]) }, uB3: { value: new THREE.Color(bands[3]) },
uSun: { value: new THREE.Color(sun) }, uRing: { value: new THREE.Color(ring) },
uSunDir: { value: new THREE.Vector3(0.3, 0.17, -0.94).normalize() },
};
const mat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false, uniforms,
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));
float s = dot(d, uSunDir);
col = mix(col, uRing, step(0.99955, s) * (1.0 - step(0.99967, s)));
col = mix(col, uSun, step(0.99978, s));
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`,
});
const mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 32, 16), mat);
mesh.renderOrder = -1;
mesh.frustumCulled = false;
return { mesh, uniforms };
}
/** Dispose every geometry and material under an object (landing scenes are thrown away when left). */
export function disposeTree(root: THREE.Object3D) {
const mats = new Set<THREE.Material>();
root.traverse((o) => {
const m = o as THREE.Mesh;
if (m.geometry) m.geometry.dispose();
const mat = m.material as THREE.Material | THREE.Material[] | undefined;
if (Array.isArray(mat)) mat.forEach((x) => mats.add(x));
else if (mat) mats.add(mat);
});
for (const m of mats) {
// the shared toon gradient (`steps`) also shades the sea's islands: never dispose it with a landing
for (const v of Object.values(m)) if (v instanceof THREE.Texture && v !== steps) v.dispose();
m.dispose();
}
}
/** Flat, layered flame shapes, as painted (17's cave fire). */
export function flameGeo(w: number, h: number): THREE.ShapeGeometry {
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);
}
/** A fire of layered flames; returns the group and its flames for flickering. */
export function fire(mats: { fire: THREE.Material; core: THREE.Material; log: THREE.Material }) {
const group = new THREE.Group();
const flames: THREE.Mesh[] = [];
([[-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]] as const).forEach(([x, w, h, i]) => {
const core = i >= 3;
const f = new THREE.Mesh(flameGeo(w, h), core ? mats.core : mats.fire);
f.position.set(x, 0, core ? 0.3 : 0);
f.userData = { phase: i * 1.7 };
group.add(f);
flames.push(f);
});
const logs = new THREE.Mesh(new THREE.BoxGeometry(6, 0.8, 1.2), mats.log);
logs.position.y = 0.2;
group.add(logs);
return { group, flames };
}
/** Flicker flames and turn them to face the camera. */
export function flicker(flames: THREE.Mesh[], t: number, cam: THREE.Camera) {
for (const f of 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);
const wp = f.parent!.getWorldPosition(new THREE.Vector3());
f.lookAt(cam.position.x, wp.y + f.position.y, cam.position.z);
}
}
/** A small landing scene: its own scene and camera, an intro move, per-frame update, and disposal. */
export interface Landing {
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
/** k: 0..1 through the arrival move (1 = settled); t: seconds. */
update(k: number, t: number): void;
dispose(): void;
}
+189
View File
@@ -0,0 +1,189 @@
// "Threads on the sea" (design record idea 8, ported from mockup 17's ribbons.ts): the current song's
// motifs as flat, ink-edged ribbons laid along the voyage's route, one colour per motif. A ribbon runs
// from the first to the last song where its motif plays: solid behind the ship, dashed ahead of it, with
// a knot at each song where the motif plays. The ribbons ride the same wave field as the sea (in the
// vertex shader), so nothing is updated per vertex on the CPU.
import * as THREE from "three";
import { motifs, songs, songsWithMotif } from "../../data/album";
import { PAL, WAVE_GLSL, seaWave } from "./materials";
import { ROUTE_LENGTH, pointAt, sAt, sIndex } from "./route";
const WIDTH = 0.9;
const GAP = 3.2;
const LIFT = 0.6;
const KNOT_R = 1.5;
/** Fade out within this distance of the camera, where a flat ribbon would become a wide road. */
const NEAR = 30;
/** Dash length (world units) ahead of the ship. */
const DASH = 5;
/** Samples per world unit along the route. */
const RES = 0.7;
interface Ribbon {
id: string;
off: number;
mats: THREE.MeshBasicMaterial[];
knots: { g: THREE.Group; i: number; x: number; z: number }[];
}
export const colorOf = (id: string) => motifs.find((m) => m.id === id)?.color ?? PAL.cream;
/** Song indices (0-based, album order) where a motif plays. */
export const hitsOf = (id: string) => songsWithMotif(id).map((s) => s.track - 1);
/** A point on the route at a fractional song index, and its sideways normal. */
function at(idx: number): { x: number; z: number; nx: number; nz: number } {
const p = pointAt(sAt(idx));
return { x: p.x, z: p.z, nx: -Math.cos(p.heading), nz: Math.sin(p.heading) };
}
export class RibbonSet {
readonly group = new THREE.Group();
private ribbons: Ribbon[] = [];
private split = { value: 0 };
private near = { value: NEAR };
private highlight: string | null = null;
private time = 0;
constructor(private waveU: { uTime: { value: number } }) {
this.group.renderOrder = 2;
}
get ids(): string[] { return this.ribbons.map((r) => r.id); }
private material(color: string, layer: number): THREE.MeshBasicMaterial {
const m = new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 });
m.onBeforeCompile = (sh) => {
Object.assign(sh.uniforms, this.waveU);
sh.uniforms.uSplit = this.split;
sh.uniforms.uNear = this.near;
sh.vertexShader = sh.vertexShader
.replace("#include <common>", `#include <common>\n${WAVE_GLSL}\nattribute float aU;\nattribute float aD;\nvarying float vU;\nvarying float vD;\nvarying float vDepth;`)
.replace("#include <begin_vertex>", `#include <begin_vertex>\nvU = aU; vD = aD;\ntransformed.y += seaWave((modelMatrix * vec4(transformed, 1.0)).xz) + ${LIFT.toFixed(2)};`)
.replace("#include <project_vertex>", "#include <project_vertex>\nvDepth = -mvPosition.z;");
sh.fragmentShader = sh.fragmentShader
.replace("#include <common>", "#include <common>\nuniform float uSplit;\nuniform float uNear;\nvarying float vU;\nvarying float vD;\nvarying float vDepth;")
.replace("#include <color_fragment>", "#include <color_fragment>\ndiffuseColor.a *= smoothstep(uNear * 0.45, uNear, vDepth);")
.replace("#include <clipping_planes_fragment>", `#include <clipping_planes_fragment>
// ahead of the ship the ribbon is dashed: the motif is still to come
if (vU > uSplit + 0.12 && fract(vD / ${DASH.toFixed(1)}) > 0.58) discard;`);
};
return m;
}
/** A strip from song index i0 to i1, offset sideways by off, w wide; sampled by distance along the route. */
private strip(i0: number, i1: number, off: number, w: number): THREE.BufferGeometry {
const s0 = sAt(i0), s1 = sAt(i1);
const n = Math.max(2, Math.ceil((s1 - s0) * ROUTE_LENGTH * RES) + 1);
const pos = new Float32Array(n * 6), u = new Float32Array(n * 2), d = new Float32Array(n * 2);
const idx: number[] = [];
for (let k = 0; k < n; k++) {
const s = s0 + ((s1 - s0) * k) / (n - 1);
// the fractional song index here, for the solid/dashed split
const fi = sIndex(s);
const p = pointAt(s);
const nx = -Math.cos(p.heading), nz = Math.sin(p.heading);
for (const side of [0, 1]) {
const o = off + (side ? 1 : -1) * w / 2;
const j = k * 2 + side;
pos.set([p.x + nx * o, 0, p.z + nz * o], j * 3);
u[j] = fi;
d[j] = s * ROUTE_LENGTH;
}
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.setAttribute("aD", new THREE.BufferAttribute(d, 1));
geo.setIndex(idx);
return geo;
}
private clear() {
this.group.traverse((o) => {
const m = o as THREE.Mesh;
if (m.geometry) m.geometry.dispose();
if (m.material) (m.material as THREE.Material).dispose();
});
this.group.clear();
this.ribbons = [];
}
/** Lay out ribbons for these motifs (null or [] removes them all). */
set(motifIds: string[] | null) {
const ids = (motifIds ?? []).filter((id) => hitsOf(id).length > 0);
if (ids.join() === this.ids.join()) return;
this.clear();
ids.forEach((id, k) => {
const hits = hitsOf(id);
const off = (k - (ids.length - 1) / 2) * GAP;
const i0 = hits[0]!, i1 = Math.max(hits.at(-1)!, i0 + 0.05);
const fillMat = this.material(colorOf(id), 2);
const edgeMat = this.material(PAL.ink, 1);
const fill = new THREE.Mesh(this.strip(i0, i1, off, WIDTH), fillMat);
const edge = new THREE.Mesh(this.strip(i0, i1, off, WIDTH * 1.8), edgeMat);
fill.renderOrder = 3; edge.renderOrder = 2;
fill.frustumCulled = edge.frustumCulled = false;
this.group.add(edge, fill);
const mats = [fillMat, edgeMat];
const knots = hits.map((i) => {
const p = at(i);
const x = p.x + p.nx * off, z = p.z + p.nz * off;
const g = new THREE.Group();
const disc = (r: number, color: string, layer: number) => {
const mat = new THREE.MeshBasicMaterial({ color, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -layer, polygonOffsetUnits: -layer * 2 });
mats.push(mat);
const mesh = new THREE.Mesh(new THREE.CircleGeometry(r, 20), mat);
mesh.rotation.x = -Math.PI / 2;
mesh.renderOrder = 3 + layer;
g.add(mesh);
};
disc(KNOT_R * 1.32, PAL.ink, 3);
disc(KNOT_R, colorOf(id), 4);
disc(KNOT_R * 0.46, PAL.cream, 5);
g.position.set(x, 0, z);
this.group.add(g);
return { g, i, x, z };
});
this.ribbons.push({ id, off, mats, knots });
});
this.setHighlight(this.highlight);
}
setHighlight(id: string | null) {
this.highlight = id && this.ribbons.some((r) => r.id === id) ? id : null;
for (const r of this.ribbons) {
const op = this.highlight && r.id !== this.highlight ? 0.3 : 1;
r.mats.forEach((m) => (m.opacity = op));
r.knots.forEach((k) => k.g.scale.setScalar(this.highlight === r.id ? 1.35 : 1));
}
}
/** Keep the knots on the waves and move the solid/dashed split to the ship's place (fractional song index). */
update(t: number, split: number) {
this.time = t;
this.split.value = split;
for (const r of this.ribbons) for (const k of r.knots) k.g.position.y = seaWave(k.x, k.z, t) + LIFT + 0.05;
}
/**
* Where each ribbon's label points: its next knot ahead of the ship, or, if the motif is not heard again,
* its last knot. World space, a little above the knot. `near` is a point on the same ribbon just ahead of
* (or behind) the ship, for when the knot itself is out of view.
*/
labels(split: number): { id: string; pos: THREE.Vector3; near: 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.at(-1)!;
const lift = (x: number, z: number) => seaWave(x, z, this.time) + LIFT + 1.2;
const ni = Math.min(Math.max(ahead ? split + 0.5 : split - 0.5, r.knots[0]!.i), r.knots.at(-1)!.i);
const p = at(ni);
const nx = p.x + p.nx * r.off, nz = p.z + p.nz * r.off;
return { id: r.id, pos: new THREE.Vector3(k.x, lift(k.x, k.z), k.z), near: new THREE.Vector3(nx, lift(nx, nz), nz), song: k.i, ahead: !!ahead };
});
}
dispose() { this.clear(); }
}
export const songAt = (i: number) => songs[i]!;
+139
View File
@@ -0,0 +1,139 @@
// The voyage's geometry, pure (no three.js): nine places along a winding route and one stop per song.
// The route runs roughly towards -z. Each saga has a waypoint the route passes; its place (island, cliffs,
// whirlpool …) lies to one side of it, and that saga's songs are stops spread along the route past it.
// Within a saga the stops are a few ship-lengths apart; between sagas the route crosses open sea.
import { songs } from "../../data/album";
import type { PlaceId } from "../../data/album";
export interface Place { id: PlaceId; x: number; z: number; radius: number }
export interface Stop { songId: string; placeId: PlaceId; x: number; z: number; heading: number; s: number /* 0..1 along route */ }
export interface RoutePoint { x: number; z: number; heading: number }
/** The route's waypoint per saga, which side of it the place lies on (+1 starboard, -1 port), and its radius. */
const WAYPOINTS: { id: PlaceId; x: number; z: number; side: 1 | -1; radius: number }[] = [
{ id: "troy", x: 0, z: 0, side: -1, radius: 64 },
{ id: "cyclops", x: -150, z: -290, side: -1, radius: 58 },
{ id: "ocean", x: 110, z: -560, side: 1, radius: 60 },
{ id: "circe", x: -90, z: -850, side: -1, radius: 60 },
{ id: "underworld", x: 160, z: -1130, side: 1, radius: 56 },
{ id: "thunder", x: -70, z: -1420, side: -1, radius: 62 },
{ id: "wisdom", x: 170, z: -1700, side: 1, radius: 66 },
{ id: "vengeance", x: -50, z: -1990, side: -1, radius: 60 },
{ id: "ithaca", x: 120, z: -2280, side: 1, radius: 64 },
];
/** How far off the route the place's centre lies, and how much of the route its stops spread over (× radius). */
const OFFSET = 0.9;
const SPAN = 1.1;
function unit(x: number, z: number): [number, number] {
const l = Math.hypot(x, z) || 1;
return [x / l, z / l];
}
/** The place's centre and the route direction at its waypoint. */
function layout(i: number) {
const w = WAYPOINTS[i]!;
const a = WAYPOINTS[Math.max(0, i - 1)]!, b = WAYPOINTS[Math.min(WAYPOINTS.length - 1, i + 1)]!;
const [dx, dz] = unit(b.x - a.x, b.z - a.z);
const [nx, nz] = [-dz, dx]; // starboard when sailing along (dx, dz)
return { w, dx, dz, cx: w.x + w.side * nx * OFFSET * w.radius, cz: w.z + w.side * nz * OFFSET * w.radius };
}
export const PLACES: Place[] = WAYPOINTS.map((w, i) => {
const l = layout(i);
return { id: w.id, x: l.cx, z: l.cz, radius: w.radius };
});
/** Which side of the route each place lies on (+1 starboard, -1 port): the camera keeps to the other side. */
export const PLACE_SIDE: Record<PlaceId, 1 | -1> = Object.fromEntries(WAYPOINTS.map((w) => [w.id, w.side])) as Record<PlaceId, 1 | -1>;
/* ---------- stop positions and the spline through them ---------- */
const POS: { x: number; z: number; songId: string; placeId: PlaceId }[] = [];
WAYPOINTS.forEach((w, i) => {
const l = layout(i);
const own = songs.filter((s) => s.sagaId === w.id);
own.forEach((song, k) => {
const t = own.length > 1 ? k / (own.length - 1) : 0.5;
const along = (t - 0.5) * SPAN * w.radius;
POS.push({ x: w.x + l.dx * along, z: w.z + l.dz * along, songId: song.id, placeId: w.id });
});
});
const CUM: number[] = [0];
for (let i = 1; i < POS.length; i++) CUM.push(CUM[i - 1]! + Math.hypot(POS[i]!.x - POS[i - 1]!.x, POS[i]!.z - POS[i - 1]!.z));
/** The route's length in world units (straight lines between stops); s × ROUTE_LENGTH is a distance. */
export const ROUTE_LENGTH = CUM.at(-1)!;
/** Route position s of each stop: cumulative straight-line distance, normalised to 0..1. */
const S: number[] = CUM.map((v) => v / ROUTE_LENGTH);
/** Control points for the spline, with the ends extended so the first and last segments have neighbours. */
const P: [number, number][] = (() => {
const pts = POS.map((p) => [p.x, p.z] as [number, number]);
const n = pts.length;
return [
[2 * pts[0]![0] - pts[1]![0], 2 * pts[0]![1] - pts[1]![1]],
...pts,
[2 * pts[n - 1]![0] - pts[n - 2]![0], 2 * pts[n - 1]![1] - pts[n - 2]![1]],
];
})();
/** Centripetal Catmull–Rom (Barry–Goldman) on segment i (between stops i and i+1), u in 0..1. No loops or cusps
* where short in-saga hops meet long crossings. */
function spline(i: number, u: number): [number, number] {
const p0 = P[i]!, p1 = P[i + 1]!, p2 = P[i + 2]!, p3 = P[i + 3]!;
const knot = (a: [number, number], b: [number, number]) => Math.max(1e-6, Math.sqrt(Math.hypot(b[0] - a[0], b[1] - a[1])));
const t0 = 0, t1 = t0 + knot(p0, p1), t2 = t1 + knot(p1, p2), t3 = t2 + knot(p2, p3);
const t = t1 + (t2 - t1) * u;
const mix = (a: [number, number], b: [number, number], ta: number, tb: number): [number, number] => {
const k = (t - ta) / (tb - ta);
return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
};
const a1 = mix(p0, p1, t0, t1), a2 = mix(p1, p2, t1, t2), a3 = mix(p2, p3, t2, t3);
const b1 = mix(a1, a2, t0, t2), b2 = mix(a2, a3, t1, t3);
return mix(b1, b2, t1, t2);
}
const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/** The segment index and local parameter for a route position. */
function locate(s: number): { i: number; u: number } {
const v = clamp01(s);
let i = 0;
while (i < S.length - 2 && v >= S[i + 1]!) i++;
return { i, u: clamp01((v - S[i]!) / (S[i + 1]! - S[i]!)) };
}
/** A point on the route and the heading there (radians; atan2(dx, dz), as three.js' rotation.y for a ship facing +z). */
export function pointAt(s: number): RoutePoint {
const { i, u } = locate(s);
const [x, z] = spline(i, u);
const e = 1e-3;
const [ax, az] = spline(i, Math.max(0, u - e));
const [bx, bz] = spline(i, Math.min(1, u + e));
return { x, z, heading: Math.atan2(bx - ax, bz - az) };
}
/** Route position → fractional song index (0..39), linear between stops. */
export function sIndex(s: number): number {
const { i, u } = locate(s);
return i + u;
}
/** Fractional song index → route position. */
export function sAt(index: number): number {
const i = Math.min(S.length - 2, Math.max(0, Math.floor(index)));
const u = clamp01(index - i);
return S[i]! + (S[i + 1]! - S[i]!) * u;
}
const STOPS: Stop[] = POS.map((p, i) => ({ ...p, s: S[i]!, heading: pointAt(S[i]!).heading }));
/** One stop per song, in album order. */
export function stops(): Stop[] {
return STOPS.map((s) => ({ ...s }));
}
export function stopOf(songId: string): Stop | undefined {
return STOPS.find((s) => s.songId === songId);
}
+85
View File
@@ -0,0 +1,85 @@
// Odysseus' ship (ported from mockup 17): a black hull with a terracotta band, a curled stern post, a ram,
// painted eyes, oars and a striped sail — silhouette first. Faces +z; the world turns it with rotation.y.
import * as THREE from "three";
import { PAL, flat, inkBack, outline, toon } from "./materials";
export function buildShip(): THREE.Group {
const s = new THREE.Group();
const ink = inkBack();
const hullMat = toon(PAL.hull), bandMat = toon(PAL.band), wood = flat(PAL.wood);
const eyeMat = flat(PAL.eye), pupilMat = flat(PAL.ink);
const hullShape = new THREE.Shape();
hullShape.moveTo(0, 4.8);
hullShape.quadraticCurveTo(1.8, 1.6, 1.6, -1.6);
hullShape.quadraticCurveTo(1.35, -3.4, 0, -3.9);
hullShape.quadraticCurveTo(-1.35, -3.4, -1.6, -1.6);
hullShape.quadraticCurveTo(-1.8, 1.6, 0, 4.8);
const hullGeo = new THREE.ExtrudeGeometry(hullShape, { 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(outline(new THREE.Mesh(hullGeo, hullMat), 0.12, ink));
const band = new THREE.Mesh(new THREE.BoxGeometry(3.45, 0.32, 6.4), bandMat);
band.position.set(0, 1.0, 0.35);
s.add(outline(band, 0.07, ink));
const stern = new THREE.Mesh(new THREE.TorusGeometry(0.75, 0.17, 5, 10, Math.PI * 1.1), hullMat);
stern.rotation.y = Math.PI / 2;
stern.position.set(0, 2.15, -3.9);
s.add(outline(stern, 0.08, ink));
const ram = new THREE.Mesh(new THREE.ConeGeometry(0.35, 1.8, 4), hullMat);
ram.rotation.x = Math.PI / 2;
ram.position.set(0, 0.35, 5.2);
s.add(outline(ram, 0.06, ink));
const eyeGeo = new THREE.CircleGeometry(0.38, 12), pupilGeo = new THREE.CircleGeometry(0.17, 10), oarGeo = new THREE.BoxGeometry(3.6, 0.1, 0.2);
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(eyeGeo, eyeMat);
eye.position.set(side * 1.25, 1.45, 3.1);
eye.rotation.y = side * (Math.PI / 2 - 0.5);
const pupil = new THREE.Mesh(pupilGeo, pupilMat);
pupil.position.z = 0.02;
eye.add(pupil);
s.add(eye);
for (let i = 0; i < 5; i++) {
const oar = new THREE.Mesh(oarGeo, 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), wood);
mast.position.y = 5;
const yard = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 6.8, 5), wood);
yard.rotation.z = Math.PI / 2;
yard.position.y = 8.2;
s.add(mast, yard);
// the striped sail: a small canvas texture, nearest-filtered so the stripes stay crisp
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 64;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.fillStyle = PAL.sail; ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = PAL.sailStripe;
for (const x of [8, 27, 46]) ctx.fillRect(x, 0, 10, 64);
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.magFilter = THREE.NearestFilter;
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));
}
sailGeo.computeVertexNormals();
const sail = new THREE.Mesh(sailGeo, toon("#ffffff", { side: THREE.DoubleSide, map: tex }));
sail.position.set(0, 5.8, 0.2);
sail.add(new THREE.LineSegments(new THREE.EdgesGeometry(sailGeo, 30), new THREE.LineBasicMaterial({ color: PAL.ink })));
s.add(sail);
return s;
}
+70
View File
@@ -0,0 +1,70 @@
/* The 3D world's overlays and the frieze curtain (plan task 6): the curtain fitted between the caption
and the timeline, the black-figure painting classes (scoped to .frieze), and the motif labels that
float over the ribbons on the sea. */
/* ---------- the curtain: fitted between the caption card and the timeline ---------- */
.frieze-layer {
--fz-top: 116px;
--fz-bot: calc(var(--timeline-h) + 30px);
}
.fz-box, .fz-host { position: absolute; left: 0; right: 0; top: var(--fz-top); bottom: var(--fz-bot); }
.fz-box { visibility: hidden; pointer-events: none; }
.fz-host { will-change: transform, opacity; }
/* fade(): the stage covered in the curtain's terracotta for long jumps */
.fz-cover { position: absolute; inset: 0; background: var(--clay-deep); will-change: opacity; }
/* ---------- black-figure on terracotta ---------- */
.frieze { display: block; width: 100%; height: 100%; overflow: visible; }
.frieze .f-surround { fill: var(--glaze); }
.frieze .f-band { fill: var(--clay); }
.frieze .ink { fill: var(--glaze); }
.frieze .clay { fill: var(--clay); }
.frieze .inc { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.frieze .limb { fill: none; stroke: var(--glaze); stroke-linecap: round; stroke-linejoin: round; }
.frieze .limb-edge { fill: none; stroke: var(--clay); stroke-linecap: round; stroke-linejoin: round; }
.frieze .oar { stroke: var(--glaze); stroke-width: 3.2; stroke-linecap: round; }
.frieze .rig { fill: none; stroke: var(--glaze); stroke-width: 1.4; }
.frieze .wave { fill: none; stroke: var(--glaze); stroke-linecap: round; }
.frieze .ground { stroke: var(--glaze); stroke-width: 3.5; }
.frieze .rule { fill: none; stroke: var(--glaze); stroke-width: 2.6; }
.frieze .vol { fill: none; stroke: var(--glaze); stroke-width: 3; stroke-linecap: round; }
.frieze .mean { fill: none; stroke: var(--glaze); stroke-width: 3; }
.frieze .scroll { fill: none; stroke: var(--glaze); stroke-width: 3.4; stroke-linecap: round; }
/* At 1440×900 the frieze is drawn at ~0.7 of its units: 28 units ≈ 19.7px on screen. Plain capitals with
little tracking (15's wide-tracked small capitals were "very hard to read"). */
.frieze .inscr { font: 700 28px/1 var(--serif); letter-spacing: 0.02em; fill: var(--glaze); }
.frieze .saga-name { font: 600 20px var(--sans); letter-spacing: 0.05em; fill: var(--glaze); }
/* the light through each doorway: the cave's firelight, the arena's sunlit sand, the hall's hearth */
.frieze--cyclops .glow-a { stop-color: #e2803a; }
.frieze--cyclops .glow-b { stop-color: #3a170a; }
.frieze--cyclops .glow-c { stop-color: #0c0705; }
.frieze--wisdom .glow-a { stop-color: #f6e2b8; }
.frieze--wisdom .glow-b { stop-color: #d6ad6e; }
.frieze--wisdom .glow-c { stop-color: #5a3d1e; }
.frieze--ithaca .glow-a { stop-color: #f2a95a; }
.frieze--ithaca .glow-b { stop-color: #6a2c14; }
.frieze--ithaca .glow-c { stop-color: #1d0c06; }
/* ---------- threads on the sea: labels over the ribbons, lifted on leader lines ---------- */
.wlabels { position: absolute; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; }
.wl-leaders { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.wl-leaders path { stroke: var(--glaze); stroke-width: 1.6; fill: none; }
.wl-leaders circle { fill: var(--added-white); stroke: var(--glaze); stroke-width: 1.6; }
.wl {
position: absolute;
left: 0; top: 0;
padding: 7px 11px 8px;
border: 1.5px solid var(--glaze);
border-radius: 2px;
background: var(--vellum-hi);
color: var(--iron);
white-space: nowrap;
box-shadow: 0 6px 14px -8px rgb(0 0 0 / 0.6);
}
.wl::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 4px; background: var(--clay); }
.wl__head { margin: 2px 0 4px; font-size: var(--fs-label); font-weight: 600; color: var(--iron-soft); }
.wl__ms { display: flex; flex-direction: column; gap: 2px; margin: 0; }
.wl__m { display: flex; align-items: center; gap: 7px; font-size: var(--fs-body); font-weight: 600; }
.wl__m i { width: 11px; height: 11px; border-radius: 50%; background: var(--c); box-shadow: 0 0 0 1.5px var(--glaze); }
.wl__m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
.wl__m.is-dim { opacity: 0.45; }
+443
View File
@@ -0,0 +1,443 @@
// The 3D world (spec 002 goals 3 and 5): one sea with all nine places along a winding route, the ship
// sailing from stop to stop with the camera following, three landing scenes the world switches to, and
// the motif ribbons on the sea with their labels. Rendered on the app's canvas; null without WebGL 2.
import * as THREE from "three";
import { motifs, songById, songs } from "../../data/album";
import type { LandingId, PlaceId } from "../../data/album";
import { SEA_HOLE, buildPlace, placeKit } from "./islands";
import { buildArena } from "./landings/arena";
import { buildCave } from "./landings/cave";
import { buildHall } from "./landings/hall";
import type { Landing } from "./materials";
import { PAL, WAVE_GLSL, clamp01, disposeTree, easeInOut, lerp, seaWave, skyDome, v3 } from "./materials";
import { RibbonSet, colorOf } from "./ribbons";
import { PLACES, PLACE_SIDE, ROUTE_LENGTH, pointAt, sIndex, stopOf } from "./route";
import { buildShip } from "./ship";
export interface World {
sailTo(songId: string, opts?: { instant?: boolean }): Promise<void>;
setLanding(id: LandingId | null): Promise<void>;
setRibbons(motifIds: string[] | null, highlight: string | null): void;
/** Camera pitch so the horizon sits on the frieze's ground line (px from the canvas top) as the curtain lifts. */
horizonFromFrieze(groundYpx: number): void;
resize(): void;
dispose(): void;
}
const FOV = 46;
const LANDINGS: Record<LandingId, () => Landing> = { cave: buildCave, arena: buildArena, hall: buildHall };
const INTRO_MS = 1800;
/** How long the horizon stays locked to the frieze's ground line, then how long it takes to let go. */
const LOCK_HOLD = 800, LOCK_RELEASE = 1200;
/** Places further than this from the camera are not drawn. */
const DRAW_DIST = 640;
const MOTIF_NAMES = new Map(motifs.map((m) => [m.id, m.name] as const));
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
const smooth = (a: number, b: number, x: number) => clamp01((x - a) / (b - a));
const angleLerp = (a: number, b: number, k: number) => a + Math.atan2(Math.sin(b - a), Math.cos(b - a)) * k;
export function createWorld(canvas: HTMLCanvasElement): World | null {
let renderer: THREE.WebGLRenderer;
try {
const gl = canvas.getContext("webgl2", { antialias: true, preserveDrawingBuffer: true, powerPreference: "high-performance" });
if (!gl) return null;
renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true, preserveDrawingBuffer: true });
} catch {
return null;
}
const win = canvas.ownerDocument.defaultView ?? window;
const reduced = !!win.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
renderer.setPixelRatio(Math.min(win.devicePixelRatio || 1, 2));
const host = canvas.parentElement;
/* ---------- the sea scene ---------- */
const sea = new THREE.Scene();
sea.fog = new THREE.Fog(PAL.fog, PAL.fogNear, PAL.fogFar);
const ambient = new THREE.AmbientLight(PAL.ambient, 1.2);
const sunLight = new THREE.DirectionalLight(PAL.light, 2.4);
sea.add(ambient, sunLight, sunLight.target);
const sky = skyDome(PAL.sky, PAL.sun, PAL.sunRing);
sea.add(sky.mesh);
const SUN_DIR = new THREE.Vector3(0.3, 0.17, -0.94).normalize();
const waveU = { uTime: { value: 0 } };
const holeU = { uHole: { value: new THREE.Vector3(0, 0, 0) } };
const seaU = { uDeep: { value: new THREE.Color(PAL.deep) }, uMid: { value: new THREE.Color(PAL.mid) }, uCrest: { value: new THREE.Color(PAL.crest) }, uContour: { value: new THREE.Color(PAL.contour) } };
const seaMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
seaMat.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, waveU, seaU, holeU);
shader.vertexShader = shader.vertexShader
.replace("#include <common>", `#include <common>\n${WAVE_GLSL}\nvarying float vH;\nvarying vec2 vWp;`)
.replace("#include <begin_vertex>", `#include <begin_vertex>
vec4 seaWp = modelMatrix * vec4(transformed, 1.0);
float seaH = seaWave(seaWp.xz);
transformed.z += seaH;
vH = seaH;
vWp = seaWp.xz;`);
shader.fragmentShader = shader.fragmentShader
.replace("#include <common>", `#include <common>\nuniform float uTime;\nuniform vec3 uDeep;\nuniform vec3 uMid;\nuniform vec3 uCrest;\nuniform vec3 uContour;\nuniform vec3 uHole;\nvarying float vH;\nvarying vec2 vWp;`)
.replace("#include <clipping_planes_fragment>", "#include <clipping_planes_fragment>\nif (uHole.z > 0.0 && distance(vWp, uHole.xy) < uHole.z) discard;")
.replace("#include <color_fragment>", `#include <color_fragment>
// two flat bands by swell, then thin drawn wave strokes on the upper swells, thinning with distance
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_SIZE = 1400, SEA_SEG = 175, CELL = SEA_SIZE / SEA_SEG;
const seaMesh = new THREE.Mesh(new THREE.PlaneGeometry(SEA_SIZE, SEA_SIZE, SEA_SEG, SEA_SEG), seaMat);
seaMesh.rotation.x = -Math.PI / 2;
seaMesh.frustumCulled = false;
sea.add(seaMesh);
const kit = placeKit(waveU.uTime);
const places = PLACES.map((p) => {
const g = buildPlace(p.id, kit);
// local +z faces the route: towards the middle of the place's stops
const own = songs.filter((s) => s.sagaId === p.id);
const st = stopOf(own[Math.floor(own.length / 2)]!.id)!;
const toRoute = Math.atan2(st.x - p.x, st.z - p.z);
g.position.set(p.x, 0, p.z);
g.rotation.y = toRoute;
sea.add(g);
return { ...p, g };
});
const placeById = new Map(places.map((p) => [p.id, p] as const));
const hole = (() => {
const id = (Object.keys(SEA_HOLE) as PlaceId[])[0];
const p = id ? placeById.get(id) : undefined;
return p ? new THREE.Vector3(p.x, p.z, SEA_HOLE[p.id]! - 0.5) : new THREE.Vector3();
})();
holeU.uHole.value.copy(hole);
const ship = buildShip();
sea.add(ship);
const camera = new THREE.PerspectiveCamera(FOV, 16 / 9, 0.5, 1600);
camera.rotation.order = "YXZ";
const ribbons = new RibbonSet(waveU);
sea.add(ribbons.group);
/* ---------- the ribbons' labels: DOM over the canvas ---------- */
const labels = canvas.ownerDocument.createElement("div");
labels.className = "wlabels";
labels.hidden = true;
labels.setAttribute("aria-hidden", "true");
host?.append(labels);
let labelKey = "";
/* ---------- state ---------- */
let s = 0;
let songIdx = 0;
let sail: { from: number; to: number; t0: number; dur: number; done: () => void } | null = null;
let landing: { id: LandingId; obj: Landing; t0: number; done: (() => void) | null } | null = null;
let camHeading = pointAt(0).heading;
let shipYaw = camHeading;
let lock: { at: number; t0: number } | null = null;
let ribbonIds: string[] | null = null;
let lastT = performance.now();
let raf = 0;
let disposed = false;
/** Lift the picture so the ship clears the bottom timeline: the timeline's height, from the app's tokens. */
function frameShift(): number {
const el = host ?? canvas;
const v = parseFloat(win.getComputedStyle?.(el).getPropertyValue("--timeline-h") || "") || 190;
return Math.max(0, (v - 92) * 0.55);
}
let shift = 0;
function applyView(cam: THREE.PerspectiveCamera, fov: number) {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
const fullH = h + 2 * shift;
cam.fov = THREE.MathUtils.radToDeg(2 * Math.atan(Math.tan(THREE.MathUtils.degToRad(fov / 2)) * (fullH / h)));
cam.aspect = w / fullH;
if (shift > 0) cam.setViewOffset(w, fullH, 0, 2 * shift, w, h);
else cam.clearViewOffset();
cam.updateProjectionMatrix();
}
function resize() {
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
shift = frameShift();
applyView(camera, FOV);
if (landing) applyView(landing.obj.camera, landing.obj.camera.userData.fov ?? landing.obj.camera.fov);
render(performance.now());
}
/* ---------- frame ---------- */
const tmp = new THREE.Vector3();
/** 1 while the horizon is held on the frieze's ground line, easing to 0 as it lets go. */
function lockFactor(now: number): number {
if (!lock) return 0;
const e = now - lock.t0;
const L = e < LOCK_HOLD ? 1 : 1 - easeInOut((e - LOCK_HOLD) / LOCK_RELEASE);
return Math.max(0, L);
}
function aim(pos: THREE.Vector3, target: THREE.Vector3, now: number) {
const d = 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 L = lockFactor(now);
let pitchLock = 0;
if (lock) {
const h = canvas.clientHeight || 1;
const tanHalf = Math.tan(THREE.MathUtils.degToRad(FOV / 2));
// in the un-lifted frame: the horizon's offset from the picture's centre, which sits `shift` px above the canvas'
pitchLock = Math.atan((lock ? lock.at : 0.5) * 2 * tanHalf - tanHalf + (2 * shift / h) * tanHalf);
}
camera.position.copy(pos);
camera.rotation.set(lerp(pitchAim, pitchLock, L), yaw, 0);
if (lock && L <= 0) lock = null;
return yaw;
}
function renderSea(now: number, t: number, dt: number) {
if (sail) {
const k = clamp01((now - sail.t0) / sail.dur);
s = lerp(sail.from, sail.to, easeInOut(k));
if (k >= 1) { const done = sail.done; sail = null; done(); }
}
const p = pointAt(s);
// the ship faces where it is going, and turns back to the route's heading at rest
const moving = sail ? Math.sign(sail.to - sail.from) : 0;
const want = moving < 0 ? p.heading + Math.PI : p.heading;
shipYaw = angleLerp(shipYaw, want, 1 - Math.exp(-dt * 3));
camHeading = angleLerp(camHeading, p.heading, 1 - Math.exp(-dt * 1.6));
ship.position.set(p.x, seaWave(p.x, p.z, t) * 0.6 - 0.15, p.z);
ship.rotation.set(Math.sin(t * 0.9) * 0.03, shipYaw, Math.sin(t * 1.1) * 0.05);
// framing: out at sea, low behind the ship; near a place, pulled back and up with the place in view
let wSum = 0, side = 0;
const placeT = v3(0, 0, 0);
for (const pl of places) {
const d = Math.hypot(pl.x - p.x, pl.z - p.z);
const w = 1 - smooth(1.4 * pl.radius, 3.4 * pl.radius, d);
if (w <= 0) continue;
wSum += w;
side += w * -PLACE_SIDE[pl.id];
placeT.add(v3((p.x + pl.x) / 2, 7, (p.z + pl.z) / 2).multiplyScalar(w));
}
const W = Math.min(1, wSum);
if (wSum > 0) placeT.multiplyScalar(1 / wSum);
side = Math.max(-1, Math.min(1, side));
const fx = Math.sin(camHeading), fz = Math.cos(camHeading);
const sx = -fz, sz = fx; // starboard
// while the horizon is held on the frieze's line the camera sits low (as in 13/17), so the fogged sea
// meets the sky on the line instead of a band below it; it rises as the lock lets go
const L = lockFactor(now);
const back = lerp(46, 74, W), up = lerp(lerp(8, 19, W), 3.2, L), lat = 15 * side * W;
const pos = v3(p.x - fx * back + sx * lat, up, p.z - fz * back + sz * lat);
const open = v3(p.x + fx * 34, 3, p.z + fz * 34);
const target = wSum > 0 ? open.lerp(placeT, W) : open;
const yaw = aim(pos, target, now);
// the sky, the sea and the light travel with the camera; the sun keeps its place in the picture
sky.mesh.position.set(pos.x, 0, pos.z);
seaMesh.position.set(Math.round(pos.x / CELL) * CELL, 0, Math.round(pos.z / CELL) * CELL - 400);
const sun = SUN_DIR.clone().applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw);
sky.uniforms.uSunDir.value.copy(sun);
sunLight.position.copy(pos).add(v3(-60, 90, 40).applyAxisAngle(new THREE.Vector3(0, 1, 0), yaw));
sunLight.target.position.copy(pos);
for (const pl of places) pl.g.visible = Math.hypot(pl.x - pos.x, pl.z - pos.z) < DRAW_DIST;
const split = sIndex(s);
if (ribbonIds?.length) {
ribbons.update(t, split);
placeLabels(split);
}
renderer.render(sea, camera);
}
function render(now: number) {
const dt = Math.min(0.1, (now - lastT) / 1000);
lastT = now;
const t = (reduced ? 0.25 : 1) * now / 1000;
waveU.uTime.value = t;
if (landing) {
const k = reduced ? 1 : clamp01((now - landing.t0) / INTRO_MS);
landing.obj.update(k, t);
if (k >= 1 && landing.done) { const d = landing.done; landing.done = null; d(); }
labels.hidden = true;
renderer.render(landing.obj.scene, landing.obj.camera);
if (sail) renderSeaState(now);
} else {
labels.hidden = !ribbonIds?.length;
renderSea(now, t, dt);
}
}
/** While ashore, a running sail still finishes (the ship arrives off-screen). */
function renderSeaState(now: number) {
if (!sail) return;
const k = clamp01((now - sail.t0) / sail.dur);
s = lerp(sail.from, sail.to, easeInOut(k));
if (k >= 1) { const done = sail.done; sail = null; done(); }
}
const loop = (now: number) => {
if (disposed) return;
render(now);
raf = win.requestAnimationFrame(loop);
};
/* ---------- labels: merged per knot, lifted on leader lines, stacked so they never overlap ---------- */
const LIFT = 70;
function placeLabels(split: number) {
const W = canvas.clientWidth, H = canvas.clientHeight;
// each label points at its knot, or, when the knot is out of view, at its ribbon just ahead of the ship
const onScreen = (p: THREE.Vector3) => {
const v = p.clone().project(camera);
return { x: (v.x * 0.5 + 0.5) * W, y: (-v.y * 0.5 + 0.5) * H, ok: v.z < 1 && Math.abs(v.x) < 0.96 && Math.abs(v.y) < 0.96 };
};
const pts = ribbons.labels(split).flatMap((l) => {
const a = onScreen(l.pos);
const b = a.ok ? a : onScreen(l.near);
return b.ok ? [{ ...l, x: b.x, y: b.y }] : [];
});
const groups = new Map<string, { song: number; ahead: boolean; ids: string[]; x: number; y: number }>();
for (const pt of pts) {
const k = `${pt.song}:${pt.ahead}`;
const g = groups.get(k);
if (g) { g.ids.push(pt.id); g.x = (g.x + pt.x) / 2; g.y = Math.max(g.y, pt.y); }
else groups.set(k, { song: pt.song, ahead: pt.ahead, ids: [pt.id], x: pt.x, y: pt.y });
}
const list = [...groups.values()].sort((a, b) => b.y - a.y);
const key = list.map((g) => `${g.song}${g.ahead}${g.ids.join()}`).join("|") + `#${songIdx}#${highlightId}`;
if (key !== labelKey) {
labelKey = key;
labels.innerHTML = `<svg class="wl-leaders" aria-hidden="true"></svg>` + list.map((g) => {
const song = songs[g.song]!;
const head = g.ahead ? `Next: ${song.title}` : g.song === songIdx ? "Not heard again after this" : `Last heard: ${song.title}`;
return `<div class="wl"><p class="wl__head">${esc(head)}</p><p class="wl__ms">${g.ids.map((id) => {
const dim = highlightId && highlightId !== id ? " is-dim" : highlightId === id ? " is-on" : "";
return `<span class="wl__m${dim}" style="--c:${colorOf(id)}"><i></i>${esc(motifName(id))}</span>`;
}).join("")}</p></div>`;
}).join("");
}
const els = [...labels.querySelectorAll<HTMLElement>(".wl")];
const top = 132;
const placed: { l: number; r: number; t: number; b: number }[] = [];
let lines = "";
list.forEach((g, i) => {
const el = els[i];
if (!el) return;
const w = el.offsetWidth, h = el.offsetHeight;
// first free slot: straight above the anchor, then stacked higher, then beside, never over another label
const clampL = (x: number) => Math.min(W - w - 12, Math.max(12, x));
const free = (l: number, b: number) => !placed.some((q) => l < q.r + 8 && l + w > q.l - 8 && b > q.t - 8 && b - h < q.b + 8);
const l0 = clampL(g.x - w / 2), b0 = Math.max(top + h, g.y - LIFT);
let l = l0, b = b0;
search: for (const dx of [0, 1, -1, 2, -2, 3, -3]) {
for (let k = 0; k < 6; k++) {
const cl = clampL(l0 + dx * (w + 12)), cb = b0 - k * (h + 10);
if (cb < top + h) break;
if (free(cl, cb)) { l = cl; b = cb; break search; }
}
}
placed.push({ l, r: l + w, t: b - h, b });
el.style.transform = `translate(${Math.round(l)}px, ${Math.round(b - h)}px)`;
const ax = Math.min(l + w - 14, Math.max(l + 14, g.x));
lines += `<path d="M${ax.toFixed(1)},${b.toFixed(1)} L${g.x.toFixed(1)},${(g.y - 6).toFixed(1)}"/><circle cx="${g.x.toFixed(1)}" cy="${g.y.toFixed(1)}" r="4"/>`;
});
const svg = labels.querySelector<SVGSVGElement>(".wl-leaders");
if (svg && svg.innerHTML !== lines) svg.innerHTML = lines;
}
let highlightId: string | null = null;
const motifName = (id: string) => MOTIF_NAMES.get(id) ?? id;
/* ---------- the interface ---------- */
const world: World = {
sailTo(songId, opts = {}) {
const stop = stopOf(songId);
if (!stop) return Promise.resolve();
songIdx = songById(songId)!.track - 1;
sail?.done();
sail = null;
const distance = Math.abs(stop.s - s) * ROUTE_LENGTH;
if (opts.instant || reduced || distance < 0.01) {
s = stop.s;
camHeading = shipYaw = stop.heading;
render(performance.now());
return Promise.resolve();
}
return new Promise<void>((resolve) => {
sail = { from: s, to: stop.s, t0: performance.now(), dur: Math.min(3600, Math.max(1300, 900 + distance * 7)), done: resolve };
});
},
setLanding(id) {
if ((landing?.id ?? null) === id) return Promise.resolve();
if (landing) {
landing.done?.();
landing.obj.dispose();
landing = null;
}
if (!id) { render(performance.now()); return Promise.resolve(); }
const obj = LANDINGS[id]();
obj.camera.userData.fov = obj.camera.fov;
return new Promise<void>((resolve) => {
landing = { id, obj, t0: performance.now(), done: resolve };
applyView(obj.camera, obj.camera.userData.fov);
render(performance.now());
});
},
setRibbons(motifIds, highlight) {
ribbonIds = motifIds && motifIds.length ? [...motifIds] : null;
ribbons.set(ribbonIds);
ribbons.group.visible = !!ribbonIds;
highlightId = highlight;
ribbons.setHighlight(highlight);
labelKey = "";
labels.hidden = !ribbonIds || !!landing;
if (!ribbonIds) labels.innerHTML = "";
},
horizonFromFrieze(groundYpx) {
const h = canvas.clientHeight;
if (!h) return;
lock = { at: groundYpx / h, t0: performance.now() };
render(performance.now());
},
resize,
dispose() {
disposed = true;
win.cancelAnimationFrame(raf);
ro?.disconnect();
sail?.done();
landing?.done?.();
landing?.obj.dispose();
landing = null;
ribbons.dispose();
disposeTree(sea);
renderer.dispose();
labels.remove();
},
};
const ro = typeof ResizeObserver === "function" ? new ResizeObserver(() => resize()) : null;
ro?.observe(canvas);
resize();
render(performance.now());
raf = win.requestAnimationFrame(loop);
return world;
}
+62
View File
@@ -0,0 +1,62 @@
export interface HomerBook {
book: number;
heading: string;
summary: string;
paragraphs: string[];
}
const HEADING = /^BOOK ([IVXL]+)\.?$/;
function romanToNumber(roman: string): number {
const v: Record<string, number> = { I: 1, V: 5, X: 10, L: 50 };
let total = 0;
for (let i = 0; i < roman.length; i++) {
const cur = v[roman[i]!]!;
const next = v[roman[i + 1]!] ?? 0;
total += cur < next ? -cur : cur;
}
return total;
}
/**
* Splits a Project Gutenberg Homer text (Butler's prose translation) into books. Only the text between
* the START/END markers counts. The first paragraph after a heading is the book's summary line. A contents
* list repeats the headings, so for each book number the last occurrence wins.
*/
export function splitBooks(text: string): HomerBook[] {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((l) => l.startsWith("*** START OF"));
const end = lines.findIndex((l) => l.startsWith("*** END OF"));
const body = lines.slice(start + 1, end === -1 ? undefined : end);
const books: HomerBook[] = [];
let current: { heading: string; book: number; paras: string[]; buf: string[] } | null = null;
const flush = () => {
if (current && current.buf.length) current.paras.push(current.buf.join(" "));
if (current) current.buf = [];
};
for (const raw of body) {
const line = raw.trim();
const h = HEADING.exec(line);
if (h) {
flush();
if (current) books.push(toBook(current));
current = { heading: line.replace(/\.$/, ""), book: romanToNumber(h[1]!), paras: [], buf: [] };
} else if (!current) {
continue;
} else if (line === "") {
flush();
} else {
current.buf.push(line);
}
}
flush();
if (current) books.push(toBook(current));
const byNumber = new Map(books.map((b) => [b.book, b]));
return [...byNumber.values()].sort((a, b) => a.book - b.book);
}
function toBook(c: { heading: string; book: number; paras: string[] }): HomerBook {
const [summary = "", ...paragraphs] = c.paras;
return { book: c.book, heading: c.heading, summary, paragraphs };
}
+71
View File
@@ -0,0 +1,71 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
export interface LibrarySong {
track: number;
title: string;
audio: string;
lyrics: { url: string; timed: boolean } | null;
}
export interface LibrarySaga {
name: string;
year: number;
cover: string | null;
songs: LibrarySong[];
}
export interface Library {
sagas: LibrarySaga[];
}
const SONGS_DIR = "Songs and Lyrics";
const SAGA_DIR = /^EPIC - (.+ Saga) \((\d{4})\)$/;
const SONG_FILE = / - (\d{2}) - (.+)\.flac$/;
const STORY_ORDER = ["Troy", "Cyclops", "Ocean", "Circe", "Underworld", "Thunder", "Wisdom", "Vengeance", "Ithaca"];
/** URL under /content/files/ for a path relative to the content folder, each segment encoded. */
export function fileUrl(...segments: string[]): string {
return "/content/files/" + segments.map(encodeURIComponent).join("/");
}
function storyRank(name: string): number {
const i = STORY_ORDER.findIndex((word) => name === `The ${word} Saga`);
return i === -1 ? STORY_ORDER.length : i;
}
/** Scans `<dir>/Songs and Lyrics/EPIC - <Saga> (<year>)/` folders. A missing folder gives no sagas. */
export function scanLibrary(dir: string): Library {
const root = join(dir, SONGS_DIR);
if (!existsSync(root)) return { sagas: [] };
const sagas: LibrarySaga[] = [];
for (const folder of readdirSync(root)) {
const m = SAGA_DIR.exec(folder);
if (!m) continue;
const files = new Set(readdirSync(join(root, folder)));
const songs: LibrarySong[] = [];
for (const file of files) {
const s = SONG_FILE.exec(file);
if (!s) continue;
const base = file.slice(0, -".flac".length);
const lrc = files.has(base + ".lrc") ? base + ".lrc" : null;
const txt = files.has(base + ".txt") ? base + ".txt" : null;
songs.push({
track: Number(s[1]),
title: s[2]!,
audio: fileUrl(SONGS_DIR, folder, file),
lyrics: lrc ? { url: fileUrl(SONGS_DIR, folder, lrc), timed: true }
: txt ? { url: fileUrl(SONGS_DIR, folder, txt), timed: false } : null,
});
}
songs.sort((a, b) => a.track - b.track);
sagas.push({
name: m[1]!,
year: Number(m[2]),
cover: files.has("folder.jpg") ? fileUrl(SONGS_DIR, folder, "folder.jpg") : null,
songs,
});
}
sagas.sort((a, b) => storyRank(a.name) - storyRank(b.name) || a.year - b.year || a.name.localeCompare(b.name));
return { sagas };
}
+88
View File
@@ -0,0 +1,88 @@
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { scanLibrary } from "./library";
import { splitBooks, type HomerBook } from "./homer";
// Preferred first: translations that keep the Greek names (Butcher & Lang, Gutenberg #1728; Lang, Leaf
// & Myers, #3059). Butler's (#1727, #2199) use Roman names and are the fallback.
const HOMER_FILES = {
odyssey: ["The Odyssey (Butcher and Lang).txt", "The Odyssey.txt"],
iliad: ["The Iliad (Lang, Leaf and Myers).txt", "The Illiad.txt"],
} as const;
const HOMER_DIR = "Homers the Odyssey Book";
const notFound = () => new Response("Not found", { status: 404 });
// Bun reports FLAC as audio/x-flac; audio/flac is the registered type.
const contentType = (file: Blob) => (file.type.startsWith("audio/x-flac") ? "audio/flac" : file.type);
const bookCache = new Map<string, HomerBook[]>();
function homerBooks(dir: string, work: keyof typeof HOMER_FILES): HomerBook[] | null {
const path = HOMER_FILES[work].map((f) => join(dir, HOMER_DIR, f)).find((p) => existsSync(p));
if (!path) return null;
let books = bookCache.get(path);
if (!books) {
books = splitBooks(readFileSync(path, "utf8"));
bookCache.set(path, books);
}
return books;
}
/** Resolves a /content/files/ path inside `dir`; null if it escapes the folder or is not a file. */
function safeFile(dir: string, encoded: string): string | null {
let rel: string;
try {
rel = decodeURIComponent(encoded);
} catch {
return null;
}
const root = realpathSync(dir);
const target = resolve(root, rel);
if (!target.startsWith(root + sep) || !existsSync(target)) return null;
const real = realpathSync(target);
if (!real.startsWith(root + sep) || !statSync(real).isFile()) return null;
return real;
}
function serveFile(path: string, req: Request): Response {
const file = Bun.file(path);
const size = file.size;
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? "");
if (!range) return new Response(file, { headers: { "Accept-Ranges": "bytes", "Content-Type": contentType(file) } });
let start = range[1] ? Number(range[1]) : size - Number(range[2]);
let end = range[1] && range[2] ? Number(range[2]) : size - 1;
start = Math.max(0, start);
end = Math.min(end, size - 1);
if (start > end) return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
"Accept-Ranges": "bytes",
"Content-Range": `bytes ${start}-${end}/${size}`,
"Content-Type": contentType(file),
},
});
}
/**
* Handles /content/* from the mounted content folder (see docs/specs/003-content-mount.md).
* Returns null for paths outside /content/; 404 for every /content/ path when no folder is mounted.
*/
export function contentResponse(req: Request, dir: string | undefined): Response | null {
const { pathname } = new URL(req.url);
if (!pathname.startsWith("/content/")) return null;
if (!dir || !existsSync(dir)) return notFound();
if (pathname === "/content/library.json") return Response.json(scanLibrary(dir));
if (pathname.startsWith("/content/files/")) {
const path = safeFile(dir, pathname.slice("/content/files/".length));
return path ? serveFile(path, req) : notFound();
}
const homer = /^\/content\/homer\/(odyssey|iliad)\/(\d+)\.json$/.exec(pathname);
if (homer) {
const book = homerBooks(dir, homer[1] as keyof typeof HOMER_FILES)?.find((b) => b.book === Number(homer[2]));
return book ? Response.json(book) : notFound();
}
return notFound();
}
+10
View File
@@ -0,0 +1,10 @@
import type { FanWork } from "./types";
export type * from "./types";
export { sagas } from "./sagas";
export { songs } from "./songs";
export { motifs } from "./motifs";
export { songById, songsInSaga, sagaOf, songsWithMotif, prevNextWithMotif } from "./query";
/** Credited fan works per song. Empty in v1: no invented works. */
export const fanWorks: FanWork[] = [];
+43
View File
@@ -0,0 +1,43 @@
import type { Motif } from "./types";
// Curated motif set. Colours for warrior, open-arms, just-a-man, odysseus, athena and polyphemus are
// reused from src/data/sample.ts. Musical motifs list the songs where the melody or its signature phrase
// returns; character themes follow the songs where the character sings (see songs.ts).
export const motifs: Motif[] = [
// Musical motifs
{ id: "warrior", name: "Warrior of the Mind", kind: "motif", color: "#d9a441", description: "Athena's lesson in strategy over strength; it returns whenever wit is put to the test." }, // TODO(verify) recurrences
{ id: "open-arms", name: "Open Arms", kind: "motif", color: "#e8836b", description: "Polites' melody of kindness and trust, remembered long after he is gone." }, // TODO(verify) recurrences
{ id: "just-a-man", name: "Just a Man", kind: "motif", color: "#7aa6c9", description: "Odysseus's plea that he is only mortal, set against what the gods demand of him." }, // TODO(verify) recurrences
{ id: "full-speed", name: "Full Speed Ahead", kind: "motif", color: "#4f9fa6", description: "The crew's rallying cry for the voyage home, which sours as the losses mount." }, // TODO(verify) recurrences
{ id: "six-hundred", name: "Six Hundred Men", kind: "motif", color: "#9c7a54", description: "The count of the men Odysseus vowed to bring home, a number that keeps falling." }, // TODO(verify) recurrences
{ id: "ruthlessness", name: "Ruthlessness", kind: "motif", color: "#2f5d8a", description: "Poseidon's creed of mercy as weakness, which Odysseus slowly takes on as his own." }, // TODO(verify) recurrences
{ id: "monster", name: "Monster", kind: "motif", color: "#7d2e3a", description: "The question of whether surviving has made Odysseus into the thing he fears." }, // TODO(verify) recurrences
{ id: "friends-close", name: "Keep Your Friends Close", kind: "motif", color: "#b5a33a", description: "Aeolus's warning about trust that echoes on Calypso's island." }, // TODO(verify) recurrences
{ id: "in-the-water", name: "In the Water", kind: "motif", color: "#3d8fc2", description: "The sea as a threat and a battlefield, from the Sirens to the duel with Poseidon." }, // TODO(verify) recurrences
{ id: "thunder-bringer", name: "Thunder Bringer", kind: "motif", color: "#e2c84a", description: "Zeus's theme of judgement from the sky." }, // TODO(verify) recurrences
{ id: "little-wolf", name: "Little Wolf", kind: "motif", color: "#8a8f99", description: "Athena's name and melody for Telemachus as he learns to fight." }, // TODO(verify) recurrences
{ id: "fall-in-love", name: "Fall in Love Again", kind: "motif", color: "#d46f9a", description: "The question of love tested by time and temptation, answered at the very end." }, // TODO(verify) recurrences
// Character themes
{ id: "athena", name: "Athena", kind: "character", color: "#b69bd6", description: "Theme of the goddess of wisdom." },
{ id: "polyphemus", name: "Polyphemus", kind: "character", color: "#c0504d", description: "Theme of the Cyclops, Poseidon's son." },
{ id: "poseidon", name: "Poseidon", kind: "character", color: "#1f7a7a", description: "Theme of the god of the sea." },
{ id: "zeus", name: "Zeus", kind: "character", color: "#f0b429", description: "Theme of the king of the gods." },
{ id: "penelope", name: "Penelope", kind: "character", color: "#c98bb9", description: "Theme of the queen of Ithaca, waiting at home." },
{ id: "telemachus", name: "Telemachus", kind: "character", color: "#6c9a5a", description: "Theme of Odysseus's son, the infant left behind who grows up." },
{ id: "circe", name: "Circe", kind: "character", color: "#9b5fc0", description: "Theme of the sorceress of Aeaea." },
{ id: "hermes", name: "Hermes", kind: "character", color: "#5fb3a1", description: "Theme of the messenger god." },
{ id: "calypso", name: "Calypso", kind: "character", color: "#e39b6a", description: "Theme of the nymph of Ogygia." },
{ id: "eurylochus", name: "Eurylochus", kind: "character", color: "#a35f3b", description: "Theme of Odysseus's second-in-command." },
{ id: "polites", name: "Polites", kind: "character", color: "#f0a679", description: "Theme of Odysseus's optimistic friend." },
{ id: "aeolus", name: "Aeolus", kind: "character", color: "#a8c7d8", description: "Theme of the keeper of the winds." },
{ id: "antinous", name: "Antinous", kind: "character", color: "#5a4a6b", description: "Theme of the leader of the suitors." },
];
/** Singer name → character motif id, used to derive character themes from a song's singers. */
export const characterMotifBySinger: Record<string, string> = {
// Odysseus sings in almost every song, so a thread for him would mark nearly all 40 stops and say
// nothing; his story is carried by the musical motifs instead (Warrior of the Mind, Just a Man…).
Athena: "athena", Polyphemus: "polyphemus", Poseidon: "poseidon", Zeus: "zeus",
Penelope: "penelope", Telemachus: "telemachus", Circe: "circe", Hermes: "hermes", Calypso: "calypso",
Eurylochus: "eurylochus", Polites: "polites", Aeolus: "aeolus", Antinous: "antinous",
};
+33
View File
@@ -0,0 +1,33 @@
import { sagas } from "./sagas";
import { songs } from "./songs";
import type { AlbumSaga, AlbumSong, PlaceId } from "./types";
const byId = new Map(songs.map((s) => [s.id, s]));
const sagaById = new Map(sagas.map((s) => [s.id, s]));
export function songById(id: string): AlbumSong | undefined {
return byId.get(id);
}
/** Songs of one saga in album order. */
export function songsInSaga(sagaId: PlaceId): AlbumSong[] {
return songs.filter((s) => s.sagaId === sagaId);
}
export function sagaOf(song: AlbumSong): AlbumSaga {
return sagaById.get(song.sagaId)!;
}
/** Songs that use a motif (song-level or in a part), in album order. */
export function songsWithMotif(motifId: string): AlbumSong[] {
return songs.filter((s) => s.motifIds.includes(motifId) || s.parts.some((p) => p.motifIds.includes(motifId)));
}
/** The previous and next songs in album order that share a motif with `song`. */
export function prevNextWithMotif(song: AlbumSong, motifId: string): { prev: AlbumSong | null; next: AlbumSong | null } {
const withIt = songsWithMotif(motifId);
return {
prev: withIt.filter((s) => s.track < song.track).at(-1) ?? null,
next: withIt.find((s) => s.track > song.track) ?? null,
};
}
+14
View File
@@ -0,0 +1,14 @@
import type { AlbumSaga } from "./types";
// Release years follow the content folder names ("EPIC - The Troy Saga (2022)").
export const sagas: AlbumSaga[] = [
{ id: "troy", name: "The Troy Saga", order: 1, year: 2022, place: "Troy", blurb: "The war ends, and Odysseus must decide what kind of leader he will be on the way home." },
{ id: "cyclops", name: "The Cyclops Saga", order: 2, year: 2023, place: "The island of the Cyclopes", blurb: "A cave full of food turns into a trap, and Odysseus makes an enemy of a god." },
{ id: "ocean", name: "The Ocean Saga", order: 3, year: 2023, place: "The open sea", blurb: "Storms, the wind-god Aeolus and the wrath of Poseidon cost Odysseus most of his fleet." },
{ id: "circe", name: "The Circe Saga", order: 4, year: 2024, place: "Aeaea, Circe's island", blurb: "A sorceress turns the crew into pigs, and Odysseus finds that there are other ways home." },
{ id: "underworld", name: "The Underworld Saga", order: 5, year: 2024, place: "The Underworld", blurb: "Among the dead, Odysseus hears a prophecy and resolves to become someone harder." },
{ id: "thunder", name: "The Thunder Saga", order: 6, year: 2024, place: "Scylla's strait and the thunder sea", blurb: "Sirens, Scylla and a mutiny lead to Zeus's judgement on the last ship." },
{ id: "wisdom", name: "The Wisdom Saga", order: 7, year: 2024, place: "Ithaca, Ogygia and Olympus", blurb: "Telemachus grows up, Calypso holds Odysseus, and Athena plays the gods' games for his freedom." }, // TODO(verify) place grouping
{ id: "vengeance", name: "The Vengeance Saga", order: 8, year: 2024, place: "Charybdis and Poseidon's sea", blurb: "Freed at last, Odysseus turns the gods' own weapons against Poseidon." }, // TODO(verify) place
{ id: "ithaca", name: "The Ithaca Saga", order: 9, year: 2024, place: "Ithaca, the palace hall", blurb: "Home at last, Odysseus faces the suitors and the question of whether Penelope still knows him." },
];
+322
View File
@@ -0,0 +1,322 @@
import { characterMotifBySinger } from "./motifs";
import type { AlbumPart, AlbumSong, LandingId, OdysseyRef, PlaceId } from "./types";
// Album data for all 40 songs. No lyric text here, ever. Odyssey keywords occur in that book of the
// Butcher & Lang translation (checked by tests/album.test.ts when the content folder is mounted).
// Character themes are derived from `singers`; `motifs` lists the musical motifs only.
const od = (book: number, ...keywords: string[]): OdysseyRef => ({
book, keywords, url: `https://www.gutenberg.org/cache/epub/1728/pg1728-images.html#chap${String(book).padStart(2, "0")}`,
});
const yt = (q: string) => `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`;
const at = (yearsAfterTroy: number, label: string) => ({ yearsAfterTroy, label });
interface Draft {
title: string; saga: PlaceId; time: AlbumSong["storyTime"]; odyssey: OdysseyRef | null;
singers: string[]; motifs: string[]; parts?: AlbumPart[]; facts: string[]; landing?: LandingId;
}
const drafts: Draft[] = [
// ── The Troy Saga
{
title: "The Horse and the Infant", saga: "troy", time: at(0, "Year 0 · the fall of Troy"),
odyssey: od(8, "horse", "Epeius", "Ilios"),
singers: ["Odysseus", "Zeus", "Soldiers"], motifs: [],
facts: ["Opens the musical at the end of the Trojan War, where Odysseus's wooden horse has won the city.", "Zeus warns Odysseus about the infant son of Hector, setting up the choice of the next song."],
},
{
title: "Just a Man", saga: "troy", time: at(0, "Year 0 · the walls of Troy"),
odyssey: null, // the fate of Hector's son is not told in the Odyssey
singers: ["Odysseus", "Soldiers"], motifs: ["just-a-man", "monster"], // TODO(verify) monster phrase as motif
facts: ["Odysseus wrestles with the choice Zeus put to him and thinks of his own wife and son."],
},
{
title: "Full Speed Ahead", saga: "troy", time: at(0, "Year 0 · setting sail from Troy"),
odyssey: od(9, "Cicones", "Ismarus"), // TODO(verify) closest Homer episode
singers: ["Odysseus", "Eurylochus", "Polites", "Soldiers"], motifs: ["full-speed", "six-hundred"],
facts: ["Introduces Eurylochus and Polites and the crew's eagerness to get home.", "The fleet runs short of food and lands on an unknown island."], // TODO(verify) second fact
},
{
title: "Open Arms", saga: "troy", time: at(0, "Year 0 · the island of the lotus-eaters"), // TODO(verify) label
odyssey: od(9, "lotus", "lotus-eaters"), // TODO(verify)
singers: ["Polites", "Odysseus"], motifs: ["open-arms", "six-hundred"],
parts: [
{ name: "Polites", singers: ["Polites"], motifIds: ["open-arms"] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
facts: ["Polites urges Odysseus to meet the world with kindness.", "Its melody returns later as a memory of Polites."], // TODO(verify) second fact
},
{
title: "Warrior of the Mind", saga: "troy", time: at(0, "Year 0 · Athena's counsel"),
odyssey: od(13, "Athene", "crafty", "guile"), // TODO(verify) Homer's Athene praises his craft on Ithaca
singers: ["Athena", "Odysseus"], motifs: ["warrior"],
parts: [
{ name: "Athena", singers: ["Athena"], motifIds: ["warrior"] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
facts: ["Athena, Odysseus's patron goddess, explains why she chose him.", "Gives the musical one of its central themes: strategy over strength."],
},
// ── The Cyclops Saga
{
title: "Polyphemus", saga: "cyclops", time: at(1, "Year 1 · the island of the Cyclopes"), // TODO(verify) year
odyssey: od(9, "Polyphemus", "Cyclops", "cave"),
singers: ["Polyphemus", "Odysseus"], motifs: [], landing: "cave",
facts: ["The crew feasts in a cave stocked with food and meets its owner, a Cyclops.", "As in Homer, Odysseus hides his real name from the Cyclops."], // TODO(verify) second fact placement
},
{
title: "Survive", saga: "cyclops", time: at(1, "Year 1 · inside the Cyclops' cave"), // TODO(verify) year
odyssey: od(9, "Noman", "stake", "wine"),
singers: ["Odysseus", "Polyphemus", "Soldiers"], motifs: ["six-hundred"], landing: "cave",
facts: ["The crew fights its way out of the cave.", "Polites dies in this song."], // TODO(verify) Polites' death here
},
{
title: "Remember Them", saga: "cyclops", time: at(1, "Year 1 · escaping the island"), // TODO(verify) year
odyssey: od(9, "ram", "Cyclops"),
singers: ["Odysseus", "Soldiers"], motifs: ["warrior"], // TODO(verify) singers
facts: ["Odysseus must decide what to do with the blinded Cyclops.", "He reveals his true name, as Homer's Odysseus does."], // TODO(verify)
},
{
title: "My Goodbye", saga: "cyclops", time: at(1, "Year 1 · leaving the island"), // TODO(verify) year
odyssey: od(13, "Athene", "Poseidon"), // TODO(verify) Homer's Athene explains why she kept away
singers: ["Athena", "Odysseus"], motifs: ["just-a-man", "warrior"], // TODO(verify) warrior motif
facts: ["Athena and Odysseus part ways over his choices on the island.", "Ends the Cyclops Saga."],
},
// ── The Ocean Saga
{
title: "Storm", saga: "ocean", time: at(1, "Year 1 · a storm at sea"), // TODO(verify) year
odyssey: null, // TODO(verify) no single Homer storm matches
singers: ["Odysseus", "Eurylochus", "Soldiers"], motifs: ["full-speed"],
facts: ["Opens the Ocean Saga with the fleet caught in a storm."],
},
{
title: "Luck Runs Out", saga: "ocean", time: at(1, "Year 1 · adrift after the storm"), // TODO(verify) year and label
odyssey: od(10, "Aeolus"), // TODO(verify)
singers: ["Odysseus", "Eurylochus", "Soldiers"], motifs: [], // TODO(verify) singers
facts: ["The crew's luck turns as Poseidon's anger follows them."], // TODO(verify)
},
{
title: "Keep Your Friends Close", saga: "ocean", time: at(1, "Year 1 · the island of Aeolus"), // TODO(verify) year
odyssey: od(10, "Aeolus", "winds", "wallet"),
singers: ["Aeolus", "Odysseus", "Eurylochus", "Soldiers"], motifs: ["friends-close", "full-speed"],
facts: ["Aeolus, keeper of the winds, gives Odysseus a bag that holds the winds.", "The bag is opened within sight of home, as in Homer."], // TODO(verify) second fact
},
{
title: "Ruthlessness", saga: "ocean", time: at(1, "Year 1 · Poseidon's reckoning"), // TODO(verify) year
odyssey: od(10, "Laestrygons", "Antiphates"), // TODO(verify) Homer loses the ships to the Laestrygons instead
singers: ["Poseidon", "Odysseus"], motifs: ["ruthlessness"],
facts: ["Poseidon takes revenge for his son Polyphemus.", "Most of the fleet is lost; in Homer, the Laestrygons destroy the ships."], // TODO(verify)
},
// ── The Circe Saga
{
title: "Puppeteer", saga: "circe", time: at(1, "Year 1 · Aeaea, Circe's island"), // TODO(verify) year
odyssey: od(10, "Circe", "swine", "Eurylochus"),
singers: ["Circe", "Eurylochus", "Odysseus"], motifs: [], // TODO(verify) singers
facts: ["Introduces the sorceress Circe.", "Eurylochus escapes to warn Odysseus, as in Homer."], // TODO(verify) second fact
},
{
title: "Wouldn’t You Like", saga: "circe", time: at(1, "Year 1 · Aeaea, Hermes appears"), // TODO(verify) year
odyssey: od(10, "Hermes", "Moly", "herb"),
singers: ["Hermes", "Odysseus"], motifs: ["fall-in-love"], // TODO(verify) motif
parts: [
{ name: "Hermes", singers: ["Hermes"], motifIds: [] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
facts: ["Hermes gives Odysseus a magic herb that protects him from Circe's spells (Homer's moly)."],
},
{
title: "Done For", saga: "circe", time: at(1, "Year 1 · Circe's hall"), // TODO(verify) year
odyssey: od(10, "Circe", "sword"),
singers: ["Circe", "Odysseus"], motifs: [],
facts: ["Odysseus confronts Circe to free his crew."],
},
{
title: "There Are Other Ways", saga: "circe", time: at(2, "Year 2 · leaving Aeaea"), // TODO(verify) year
odyssey: od(10, "Teiresias", "Hades", "Persephone"),
singers: ["Circe", "Odysseus"], motifs: ["just-a-man"],
facts: ["Circe points Odysseus towards the Underworld and the prophet Tiresias.", "In Homer, Odysseus stays a year on Aeaea before leaving."],
},
// ── The Underworld Saga
{
title: "The Underworld", saga: "underworld", time: at(2, "Year 2 · the land of the dead"), // TODO(verify) year
odyssey: od(11, "Elpenor", "Anticleia", "ghosts"),
singers: ["Odysseus", "Polites", "Anticlea", "Soldiers"], motifs: ["open-arms", "full-speed", "ruthlessness"], // TODO(verify) singers
facts: ["Odysseus meets the ghosts of those he has lost.", "His mother Anticlea is among them, as in Homer's Book 11."], // TODO(verify) Anticlea sings here
},
{
title: "No Longer You", saga: "underworld", time: at(2, "Year 2 · the prophet Tiresias"), // TODO(verify) year
odyssey: od(11, "Teiresias", "Thebes"),
singers: ["Tiresias"], motifs: [], // TODO(verify) singers
facts: ["The blind prophet Tiresias foretells what the way home will cost."],
},
{
title: "Monster", saga: "underworld", time: at(2, "Year 2 · leaving the Underworld"), // TODO(verify) year
odyssey: null, // EPIC's own turning point
singers: ["Odysseus", "Soldiers"], motifs: ["monster", "ruthlessness"], // TODO(verify) singers
facts: ["Odysseus decides to become ruthless to get home.", "Ends the Underworld Saga."],
},
// ── The Thunder Saga
{
title: "Suffering", saga: "thunder", time: at(2, "Year 2 · the Sirens' sea"), // TODO(verify) year
odyssey: od(12, "Sirens"),
singers: ["Sirens", "Odysseus", "Eurylochus"], motifs: ["in-the-water"], // TODO(verify) singers
facts: ["The Sirens try to lure the crew into the water."], // TODO(verify)
},
{
title: "Different Beast", saga: "thunder", time: at(2, "Year 2 · the Sirens' sea"), // TODO(verify) year
odyssey: od(12, "Sirens", "wax"),
singers: ["Odysseus", "Sirens"], motifs: ["in-the-water", "monster"], // TODO(verify) singers
facts: ["Odysseus shows how far he has changed since the Troy Saga."],
},
{
title: "Scylla", saga: "thunder", time: at(2, "Year 2 · Scylla's strait"), // TODO(verify) year
odyssey: od(12, "Scylla", "six"),
singers: ["Scylla", "Odysseus"], motifs: [], // TODO(verify) singers
facts: ["The six-headed monster Scylla guards the strait.", "As in Homer, Odysseus chooses to sail past Scylla and lose some men."], // TODO(verify) second fact
},
{
title: "Mutiny", saga: "thunder", time: at(2, "Year 2 · the island of the Sun god's cattle"), // TODO(verify) year
odyssey: od(12, "Eurylochus", "kine", "Thrinacia"),
singers: ["Eurylochus", "Odysseus", "Soldiers"], motifs: ["just-a-man", "full-speed"],
facts: ["Eurylochus turns the crew against Odysseus.", "In Homer, the starving crew eat the cattle of Helios on Thrinacia."],
},
{
title: "Thunder Bringer", saga: "thunder", time: at(3, "Year 3 · Zeus's judgement"), // TODO(verify) year
odyssey: od(12, "Zeus", "Helios", "bolt"),
singers: ["Zeus", "Odysseus", "Eurylochus"], motifs: ["thunder-bringer", "monster"],
facts: ["Zeus punishes the crew for the slain cattle.", "Odysseus is left the only survivor, as in Homer."],
},
// ── The Wisdom Saga
{
title: "Legendary", saga: "wisdom", time: at(10, "Year 10 · Ithaca, Telemachus at twenty"), // TODO(verify) year
odyssey: od(1, "Telemachus", "wooers"),
singers: ["Telemachus"], motifs: [],
facts: ["The first song sung by Telemachus, now grown up.", "Ithaca is overrun by suitors (Homer's wooers) courting Penelope."],
},
{
title: "Little Wolf", saga: "wisdom", time: at(10, "Year 10 · Ithaca, Athena trains Telemachus"), // TODO(verify) year
odyssey: od(1, "Athene", "Mentes", "Telemachus"),
singers: ["Athena", "Telemachus"], motifs: ["little-wolf", "warrior"], // TODO(verify) warrior motif
facts: ["Athena comes to Telemachus, as she does in disguise in Homer's Book 1."],
},
{
title: "We'll Be Fine", saga: "wisdom", time: at(10, "Year 10 · Ithaca, Athena and Telemachus"), // TODO(verify) year
odyssey: od(2, "Telemachus", "Mentor", "Athene"), // TODO(verify)
singers: ["Telemachus", "Athena"], motifs: ["little-wolf"], // TODO(verify) motif
facts: ["Telemachus helps Athena find her way back to Odysseus."], // TODO(verify)
},
{
title: "Love in Paradise", saga: "wisdom", time: at(10, "Year 10 · Ogygia, seven years with Calypso"), // TODO(verify) year
odyssey: od(5, "Calypso", "Hermes"),
singers: ["Calypso", "Odysseus"], motifs: ["open-arms", "friends-close", "ruthlessness"], // TODO(verify) motifs
facts: ["Calypso has kept Odysseus on her island for seven years, as in Homer."],
},
{
title: "God Games", saga: "wisdom", time: at(10, "Year 10 · Olympus"), // TODO(verify) year
odyssey: od(5, "Zeus", "Hermes", "Athene"),
singers: ["Athena", "Zeus", "Hermes", "Hera", "Aphrodite", "Ares", "Odysseus"], motifs: ["thunder-bringer", "warrior"], // TODO(verify) singers and motifs
landing: "arena",
facts: ["Athena challenges the Olympians to win Odysseus's release.", "In Homer, the gods in council send Hermes to free Odysseus."],
},
// ── The Vengeance Saga
{
title: "Not Sorry for Loving You", saga: "vengeance", time: at(10, "Year 10 · Ogygia"), // TODO(verify) year
odyssey: od(5, "Calypso", "raft"),
singers: ["Calypso"], motifs: ["fall-in-love"],
facts: ["Calypso's farewell as Odysseus is set free."],
},
{
title: "Dangerous", saga: "vengeance", time: at(10, "Year 10 · leaving Ogygia"), // TODO(verify) year
odyssey: null, // TODO(verify) EPIC's own plan
singers: ["Hermes", "Odysseus"], motifs: ["ruthlessness", "six-hundred"], // TODO(verify) singers
facts: ["Odysseus and Hermes plan how to escape Poseidon."], // TODO(verify)
},
{
title: "Charybdis", saga: "vengeance", time: at(10, "Year 10 · Charybdis"), // TODO(verify) year
odyssey: od(12, "Charybdis", "fig-tree"),
singers: ["Odysseus", "Hermes"], motifs: ["monster"], // TODO(verify) singers
facts: ["The whirlpool Charybdis becomes part of Odysseus's plan.", "In Homer, Odysseus survives Charybdis by clinging to a fig-tree."],
},
{
title: "Get in the Water", saga: "vengeance", time: at(10, "Year 10 · Poseidon's sea"), // TODO(verify) year
odyssey: od(5, "Poseidon", "raft"),
singers: ["Odysseus", "Poseidon", "Aeolus"], motifs: ["in-the-water", "ruthlessness"], // TODO(verify) singers
facts: ["Odysseus lures Poseidon into a trap."], // TODO(verify)
},
{
title: "Six Hundred Strike", saga: "vengeance", time: at(10, "Year 10 · the duel with Poseidon"), // TODO(verify) year
odyssey: null, // EPIC's own climax
singers: ["Odysseus", "Poseidon"], motifs: ["six-hundred", "ruthlessness", "monster"],
facts: ["Odysseus faces Poseidon in a final duel at sea.", "Ends the Vengeance Saga."], // TODO(verify) first fact
},
// ── The Ithaca Saga
{
title: "The Challenge", saga: "ithaca", time: at(10, "Year 10 · Ithaca, the palace hall"),
odyssey: od(21, "bow", "axes", "Penelope"),
singers: ["Antinous", "Penelope", "Odysseus", "Suitors"], motifs: [], // TODO(verify) singers
landing: "hall",
facts: ["Penelope sets the suitors a challenge with Odysseus's bow, as in Homer's Book 21."], // TODO(verify)
},
{
title: "Hold Them Down", saga: "ithaca", time: at(10, "Year 10 · Ithaca, the palace hall"),
odyssey: od(22, "wooers", "Antinous", "Eurymachus"), // TODO(verify)
singers: ["Antinous", "Suitors", "Odysseus"], motifs: [], // TODO(verify) singers
landing: "hall",
facts: ["The suitors close in on Odysseus."], // TODO(verify)
},
{
title: "Odysseus", saga: "ithaca", time: at(10, "Year 10 · Ithaca, the palace hall"),
odyssey: od(22, "wooers", "arrow", "Athene"),
singers: ["Odysseus", "Athena"], motifs: ["monster", "open-arms"], // TODO(verify) singers and motifs
landing: "hall",
facts: ["The battle against the suitors, told in Homer's Book 22.", "A callback song: themes from across the musical return."], // TODO(verify) second fact
},
{
title: "I Can't Help but Wonder", saga: "ithaca", time: at(10, "Year 10 · Ithaca, father and son"),
odyssey: od(16, "Telemachus", "father", "Eumaeus"),
singers: ["Odysseus", "Telemachus"], motifs: ["little-wolf"], // TODO(verify) motif
landing: "hall",
facts: ["Odysseus meets the grown son he left as an infant.", "In Homer, father and son reunite in Eumaeus's hut."],
},
{
title: "Would You Fall in Love with Me Again", saga: "ithaca", time: at(10, "Year 10 · Ithaca, Penelope"),
odyssey: od(23, "Penelope", "bed", "olive"),
singers: ["Penelope", "Odysseus"], motifs: ["fall-in-love"],
parts: [
{ name: "Penelope", singers: ["Penelope"], motifIds: [] },
{ name: "Odysseus", singers: ["Odysseus"], motifIds: [] },
],
landing: "hall",
facts: ["The final song of the musical.", "Penelope tests Odysseus, as she tests him with their bed in Homer's Book 23."],
},
];
const kebab = (title: string) =>
title.toLowerCase().replace(/[’']/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const sagaCounts = new Map<PlaceId, number>();
export const songs: AlbumSong[] = drafts.map((d, i) => {
const sagaTrack = (sagaCounts.get(d.saga) ?? 0) + 1;
sagaCounts.set(d.saga, sagaTrack);
const parts = (d.parts ?? []).map((p) => ({
...p,
motifIds: [...new Set([...p.singers.flatMap((s) => characterMotifBySinger[s] ?? []), ...p.motifIds])],
}));
const characters = d.singers.flatMap((s) => characterMotifBySinger[s] ?? []);
return {
id: kebab(d.title), title: d.title, sagaId: d.saga, track: i + 1, sagaTrack,
storyTime: d.time, odyssey: d.odyssey, singers: d.singers,
motifIds: [...new Set([...characters, ...d.motifs, ...parts.flatMap((p) => p.motifIds)])],
parts, facts: d.facts,
videos: [{ title: `${d.title} — search`, url: yt(`EPIC The Musical ${d.title}`) }],
landing: d.landing ?? null,
};
});
+27
View File
@@ -0,0 +1,27 @@
// Album data shapes for the voyage app. Independent of the mockups' src/data/types.ts.
export type LandingId = "cave" | "arena" | "hall";
export type PlaceId =
| "troy" | "cyclops" | "ocean" | "circe" | "underworld" | "thunder" | "wisdom" | "vengeance" | "ithaca";
export interface Motif { id: string; name: string; kind: "motif" | "character"; color: string; description: string }
/** `year` is the saga's release year as in the content folder name. */
export interface AlbumSaga { id: PlaceId; name: string; order: number; year: number; place: string; blurb: string }
/** A book of the Odyssey plus keywords that occur in that book (Butcher & Lang); url: Gutenberg #1728 (Butcher & Lang) book anchor. */
export interface OdysseyRef { book: number; keywords: string[]; url: string }
export interface AlbumPart { name: string; singers: string[]; motifIds: string[] }
export interface AlbumSong {
id: string; title: string; sagaId: PlaceId; track: number /* 1..40 album order */; sagaTrack: number;
storyTime: { yearsAfterTroy: number; label: string }; odyssey: OdysseyRef | null;
singers: string[]; motifIds: string[]; parts: AlbumPart[]; facts: string[];
videos: { title: string; url: string }[]; landing: LandingId | null;
}
export interface FanWork {
songId: string; type: "art" | "animatic" | "cover" | "cosplay" | "other";
title: string; creator: string; url: string; permission: string;
}
-27
View File
@@ -1,27 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EPIC: The Musical Timeline — design mockups</title>
<link rel="stylesheet" href="./index.css" />
<link rel="stylesheet" href="./shared/credits.css" />
</head>
<body>
<main>
<h1>EPIC: The Musical Timeline</h1>
<p class="lede">Seven design directions for an interactive timeline. Pick one, or mix several. Sample content: placeholder lyrics and unverified details, for comparing designs only.</p>
<ul class="cards">
<li class="card"><a href="/mockups/01-editorial/"><span class="card__num">01</span><h2>Editorial scroll</h2><p>A clean horizontal line in a parchment-and-serif editorial style, with motifs as thin coloured threads underneath.</p><p class="card__try">Try this: Pan sideways, notice gap length tracking in-story time, and open a song.</p></a></li>
<li class="card"><a href="/mockups/02-vertical-story/"><span class="card__num">02</span><h2>Vertical story</h2><p>The voyage scrolls downward; songs reveal one at a time beside a sticky detail panel.</p><p class="card__try">Try this: Scroll and watch the sticky detail panel update.</p></a></li>
<li class="card"><a href="/mockups/03-transit-map/"><span class="card__num">03</span><h2>Transit map</h2><p>Each motif is a coloured metro line and each song a station; shared stations are interchanges.</p><p class="card__try">Try this: Follow a line between stations, find an interchange, and check the legend.</p></a></li>
<li class="card"><a href="/mockups/04-greek-frieze/"><span class="card__num">04</span><h2>Greek frieze</h2><p>The timeline as an unrolled black-figure vase frieze, panned sideways.</p><p class="card__try">Try this: Pan the frieze and open a song panel.</p></a></li>
<li class="card"><a href="/mockups/05-score/"><span class="card__num">05</span><h2>Score / waveform</h2><p>The line is a music staff or waveform; song parts are segments and motifs are recurring marks.</p><p class="card__try">Try this: Look at song parts as segments and follow motif marks as they recur.</p></a></li>
<li class="card"><a href="/mockups/06-constellation/"><span class="card__num">06</span><h2>Constellation (3D)</h2><p>Songs are stars along a path through space, joined by motif constellation lines (three.js).</p><p class="card__try">Try this: Fly the camera through, hover a star, and open a song.</p></a></li>
<li class="card"><a href="/mockups/07-sea-voyage/"><span class="card__num">07</span><h2>Sea voyage (3D)</h2><p>A ship crosses a 3D sea past saga islands while the camera follows the route (three.js).</p><p class="card__try">Try this: Watch the ship sail the route, spot the saga islands, and open a song.</p></a></li>
</ul>
</main>
<footer data-fan-credits></footer>
<script type="module" src="./index.ts"></script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
import { songs } from "../../data/sample";
import { gapYears } from "../../data/query";
/** Chart world in SVG units. Geography is stylised; nothing is to scale. */
export const W = 1600;
export const H = 600;
export type Pt = readonly [number, number];
/** Closed Catmull-Rom outline through the points, for coastlines. */
export function closedSmooth(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]![0]},${pts[0]![1]}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n]!, p1 = pts[i]!, p2 = pts[(i + 1) % n]!, p3 = pts[(i + 2) % n]!;
d += cubic(p0, p1, p2, p3);
}
return d + "Z";
}
function cubic(p0: Pt, p1: Pt, p2: Pt, p3: Pt): string {
const t = 1 / 6;
const c1: Pt = [p1[0] + (p2[0] - p0[0]) * t, p1[1] + (p2[1] - p0[1]) * t];
const c2: Pt = [p2[0] - (p3[0] - p1[0]) * t, p2[1] - (p3[1] - p1[1]) * t];
return ` C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0]},${p2[1]}`;
}
export const land = {
troy: closedSmooth([[-40, -40], [170, -30], [186, 60], [160, 130], [205, 192], [232, 258], [214, 322], [246, 384], [222, 452], [178, 520], [196, 600], [120, 660], [-40, 660]]),
cicones: closedSmooth([[300, -40], [660, -40], [622, 38], [562, 78], [484, 92], [420, 70], [362, 84], [312, 40]]),
cyclops: closedSmooth([[800, 300], [842, 256], [905, 246], [972, 254], [1020, 298], [1030, 360], [990, 414], [920, 430], [852, 416], [806, 372]]),
goat: closedSmooth([[1078, 168], [1104, 160], [1122, 176], [1110, 196], [1084, 194]]),
rock: closedSmooth([[1508, 470], [1532, 462], [1546, 480], [1526, 494]]),
};
export interface PortGeo { at: Pt; label: [number, number, "start" | "middle" | "end"]; via?: Pt[] }
/** Port positions per song id, label offset, and waypoints sailed *before* arriving. */
export const ports: Record<string, PortGeo> = {
"the-horse-and-the-infant": { at: [258, 296], label: [18, 5, "start"] },
"just-a-man": { at: [300, 412], label: [18, 5, "start"] },
"full-speed-ahead": { at: [420, 134], label: [0, 34, "middle"], via: [[340, 270]] },
"open-arms": { at: [548, 142], label: [18, 5, "start"] },
"warrior-of-the-mind": { at: [652, 302], label: [0, 34, "middle"], via: [[612, 214]] },
polyphemus: { at: [768, 262], label: [-16, -14, "end"] },
survive: { at: [905, 220], label: [0, -20, "middle"], via: [[830, 222]] },
"remember-them": { at: [1052, 290], label: [18, 5, "start"], via: [[985, 226]] },
"my-goodbye": { at: [930, 458], label: [0, 34, "middle"], via: [[1068, 384], [1012, 452]] },
storm: { at: [1400, 320], label: [0, 44, "middle"], via: [[1100, 476], [1262, 408]] },
};
export interface RouteSeg { from: number; to: number; d: string; years: number }
/** One smooth course through every port and waypoint, split into port-to-port legs. */
export function buildRoute(): RouteSeg[] {
const pts: { p: Pt; port: number }[] = [];
songs.forEach((s, i) => {
const g = ports[s.id]!;
for (const v of g.via ?? []) pts.push({ p: v, port: -1 });
pts.push({ p: g.at, port: i });
});
const segs: RouteSeg[] = [];
let cur = "";
let from = 0;
for (let k = 0; k < pts.length - 1; k++) {
const p0 = pts[Math.max(0, k - 1)]!.p, p1 = pts[k]!.p, p2 = pts[k + 1]!.p, p3 = pts[Math.min(pts.length - 1, k + 2)]!.p;
if (!cur) cur = `M${p1[0]},${p1[1]}`;
cur += cubic(p0, p1, p2, p3);
const next = pts[k + 1]!;
if (next.port >= 0) {
segs.push({ from, to: next.port, d: cur, years: gapYears(songs[from]!, songs[next.port]!) });
from = next.port;
cur = "";
}
}
return segs;
}
/** Log line x positions: a short hop between songs, plus a fixed width per in-story year. */
export const LOG = { start: 70, hop: 156, year: 300, end: 260 };
export const logX: number[] = [];
songs.forEach((s, i) => {
if (i === 0) logX.push(LOG.start);
else logX.push(logX[i - 1]! + LOG.hop + Math.max(0, gapYears(songs[i - 1]!, s)) * LOG.year);
});
export const LOG_W = logX[logX.length - 1]! + LOG.end;
/** Log x → fractional song index (piecewise linear between song columns). */
export function xToIndex(x: number): number {
if (x <= logX[0]!) return 0;
for (let i = 0; i < logX.length - 1; i++) {
const a = logX[i]!, b = logX[i + 1]!;
if (x <= b) return i + (x - a) / (b - a);
}
return logX.length - 1;
}
export const yearsLabel = (y: number) => `${y} ${y === 1 ? "year" : "years"} at sea`;
/** Small deterministic RNG for the depth soundings. */
export function rng(seed: number) {
return () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sea chart + log line — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+527
View File
@@ -0,0 +1,527 @@
import { motifs, sagas, songs } from "../../data/sample";
import { sagaOf } from "../../data/query";
import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import { H, LOG, LOG_W, W, buildRoute, land, logX, ports, rng, xToIndex, yearsLabel } from "./geo";
mountCredits();
/* ------------------------------------------------------------------ state */
const params = new URLSearchParams(location.search);
const overlay = params.get("overlay") === "1";
const HERE = songs.findIndex((s) => s.id === "survive");
let selected: number = songs.findIndex((s) => s.id === params.get("song"));
let motifId: string | null = motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null;
let shipAt = overlay ? HERE : songs.length - 1; // fractional song index
let probeAt: number | null = null;
if (overlay) document.body.classList.add("is-overlay");
/* ------------------------------------------------------------------ helpers */
const SVG = "http://www.w3.org/2000/svg";
type Attrs = Record<string, string | number | undefined>;
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] {
const el = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
parent?.append(el);
return el;
}
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, ...kids: (Node | string)[]): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
el.append(...kids);
return el;
}
function text(parent: Element, x: number, y: number, str: string, cls: string, anchor = "middle") {
const t = s("text", { x, y, class: cls, "text-anchor": anchor }, parent);
t.textContent = str;
return t;
}
/** Shared motif colours are tuned for dark pages; on the chart they are mixed toward the navy ink. */
function inkOf(m: Motif): string {
const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
const a = hex(m.color), b = hex("#1e2c3b");
const k = m.id === "odysseus" ? 0.55 : 0.28;
return `rgb(${a.map((v, i) => Math.round(v * (1 - k) + b[i]! * k)).join(",")})`;
}
const motifById = new Map(motifs.map((m) => [m.id, m]));
const has = (song: Song, id: string) => song.parts.some((p) => p.motifIds.includes(id));
/* ------------------------------------------------------------------ shell */
const app = document.getElementById("app")!;
const titleBar = h("header", { class: "bar" });
if (overlay) {
titleBar.append(
h("p", { class: "bar__here" }, h("span", { class: "bar__pin", "aria-hidden": "true" }), "You are here: ", h("strong", {}, songs[HERE]!.title)),
h("p", { class: "bar__hint" }, "Pick a port to set a course. Scrub the log to look ahead without sailing."),
);
} else {
titleBar.append(
h("h1", { class: "bar__title" }, "EPIC, charted"),
h("p", { class: "bar__hint" }, "The chart shows where. The log below shows when. Run a finger along the log and the ship follows."),
);
}
const chartWrap = h("section", { class: "chart", "aria-label": "Sea chart of the voyage" });
const logWrap = h("section", { class: "log", "aria-label": "Log line in story time" });
const panel = h("aside", { class: "panel", "aria-live": "polite", hidden: "" });
const pop = h("div", { class: "pop", hidden: "" });
chartWrap.append(panel, pop);
app.append(titleBar, chartWrap, logWrap);
/* ================================================================== CHART */
const svg = s("svg", { class: "chart__svg", role: "img", "aria-label": "Stylised chart: Troy, the land of the Cicones, the island of the Cyclopes and the open sea" }, chartWrap);
const defs = s("defs", {}, svg);
const hatch = s("pattern", { id: "hatch", width: 7, height: 7, patternUnits: "userSpaceOnUse", patternTransform: "rotate(35)" }, defs);
s("line", { x1: 0, y1: 0, x2: 0, y2: 7, class: "hatch" }, hatch);
// Sea, graticule, soundings
s("rect", { x: -400, y: -300, width: W + 800, height: H + 600, class: "sea" }, svg);
const grat = s("g", { class: "grat" }, svg);
for (let x = 0; x <= W; x += 200) s("line", { x1: x, y1: -300, x2: x, y2: H + 300 }, grat);
for (let y = 0; y <= H; y += 200) s("line", { x1: -400, y1: y, x2: W + 400, y2: y }, grat);
const sounds = s("g", { class: "sounds" }, svg);
// Land: shallow-water bands, then the land fill and hachure
const landG = s("g", {}, svg);
const landPaths: SVGPathElement[] = [];
for (const [w, cls] of [[30, "shoal-1"], [16, "shoal-2"], [6, "shoal-3"]] as const) {
for (const d of Object.values(land)) s("path", { d, class: cls, "stroke-width": w }, landG);
}
for (const d of Object.values(land)) {
landPaths.push(s("path", { d, class: "land" }, landG));
s("path", { d, class: "land-hatch" }, landG);
}
// Names: land upright, sea areas in italic (chart convention)
const names = s("g", { class: "names" }, svg);
text(names, 70, 250, "Troy", "place place--big");
text(names, 70, 274, "the burning city", "place-sub");
text(names, 486, 34, "Land of the Cicones", "place");
text(names, 918, 336, "Island of the", "place");
text(names, 918, 358, "Cyclopes", "place");
text(names, 1100, 150, "Goat Island", "place place--small");
text(names, 420, 548, "The Troy Saga", "sea-area");
text(names, 690, 470, "The Cyclops Saga", "sea-area");
text(names, 1290, 200, "The Ocean Saga", "sea-area");
text(names, 1290, 226, "open water, no landfall", "place-sub");
// Compass rose
const rose = s("g", { class: "rose", transform: "translate(1500 92)" }, svg);
s("circle", { r: 46, class: "rose__ring" }, rose);
s("circle", { r: 38, class: "rose__ring rose__ring--in" }, rose);
for (let a = 0; a < 360; a += 10) {
const r1 = a % 90 === 0 ? 30 : 38, rad = (a * Math.PI) / 180;
s("line", { x1: Math.sin(rad) * r1, y1: -Math.cos(rad) * r1, x2: Math.sin(rad) * 46, y2: -Math.cos(rad) * 46, class: "rose__tick" }, rose);
}
s("path", { d: "M0,-44 L7,0 L0,44 L-7,0Z M-44,0 L0,7 L44,0 L0,-7Z", class: "rose__star" }, rose);
s("path", { d: "M0,-44 L7,0 L-7,0Z", class: "rose__north" }, rose);
text(rose, 0, -54, "N", "rose__n");
// Route: one leg per pair of consecutive songs
const route = buildRoute();
const routeG = s("g", { class: "route" }, svg);
const motifG = s("g", { class: "motif-route" }, svg);
const legs = route.map((seg) => {
const base = s("path", { d: seg.d, class: "leg leg--plan" }, routeG);
const sailed = s("path", { d: seg.d, class: "leg leg--sailed" }, routeG);
return { seg, base, sailed, len: 0 };
});
for (const l of legs) {
l.len = l.base.getTotalLength();
if (l.seg.years > 0) {
const p = l.base.getPointAtLength(l.len * 0.5), q = l.base.getPointAtLength(l.len * 0.5 + 4);
let a = Math.atan2(q.y - p.y, q.x - p.x);
if (Math.cos(a) < 0) a += Math.PI; // keep text upright
const nx = Math.sin(a) * 16, ny = -Math.cos(a) * 16; // normal, on the upper side of the text
const lab = text(routeG, p.x + nx, p.y + ny, yearsLabel(l.seg.years), "crossing");
lab.setAttribute("transform", `rotate(${((a * 180) / Math.PI).toFixed(1)} ${p.x + nx} ${p.y + ny})`);
}
}
// Soundings: deterministic, kept off land and off the course
{
const r = rng(8);
let placed = 0;
for (let k = 0; k < 600 && placed < 70; k++) {
const x = r() * W, y = r() * H;
const pt = new DOMPoint(x, y);
if (landPaths.some((p) => p.isPointInFill(pt) || p.isPointInStroke(pt))) continue;
const near = legs.some((l) => { for (let t = 0; t <= 1; t += 0.05) { const q = l.base.getPointAtLength(l.len * t); if (Math.hypot(q.x - x, q.y - y) < 34) return true; } return false; });
if (near) continue;
const ns = Object.values(ports).some((g) => Math.hypot(g.at[0] - x, g.at[1] - y) < 90);
if (ns) continue;
text(sounds, x, y, String(Math.round(8 + r() * 60 * (x / W + 0.3))), "sound");
placed++;
}
}
// Ports
const portG = s("g", { class: "ports" }, svg);
const portEls = songs.map((song, i) => {
const g = ports[song.id]!;
const el = s("g", { class: "port", transform: `translate(${g.at[0]} ${g.at[1]})`, tabindex: 0, role: "button", "aria-label": `${song.track}. ${song.title}` }, portG);
s("circle", { r: 22, class: "port__ring" }, el);
s("circle", { r: 12, class: "port__dot" }, el);
text(el, 0, 4, String(song.track), "port__num");
const [lx, ly, anchor] = g.label;
text(el, lx, ly, song.title, "port__label", anchor);
el.addEventListener("click", (e) => { e.stopPropagation(); onPort(i); });
el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPort(i); } });
return el;
});
// Ship and probe
const probe = s("circle", { r: 26, class: "probe", visibility: "hidden" }, svg);
const ship = s("g", { class: "ship" }, svg);
s("path", { d: "M-16,0 L-10,-7 L12,-7 L18,0 L12,7 L-10,7Z", class: "ship__hull" }, ship);
s("path", { d: "M-6,-7 L2,-24 L6,-7Z", class: "ship__sail" }, ship);
function pointAt(f: number): { x: number; y: number; a: number } {
const i = Math.min(Math.floor(f), legs.length - 1);
if (f >= songs.length - 1) { const g = ports[songs[songs.length - 1]!.id]!; const l = legs[legs.length - 1]!; const q = l.base.getPointAtLength(l.len - 1); return { x: g.at[0], y: g.at[1], a: Math.atan2(g.at[1] - q.y, g.at[0] - q.x) }; }
const l = legs[i]!, t = f - i;
const p = l.base.getPointAtLength(l.len * t), q = l.base.getPointAtLength(Math.min(l.len, l.len * t + 2));
return { x: p.x, y: p.y, a: Math.atan2(q.y - p.y, q.x - p.x) };
}
/** The ship rides beside the course, on the side away from each port's label. */
const shipOff = songs.map((x) => {
const [lx, ly] = ports[x.id]!.label;
const n = Math.hypot(lx, ly) || 1;
return [(-lx / n) * 30, (-ly / n) * 30 - 6] as const;
});
function drawShip() {
const p = pointAt(shipAt);
const i = Math.min(Math.floor(shipAt), songs.length - 1), t = shipAt - i;
const a = shipOff[i]!, b = shipOff[Math.min(i + 1, songs.length - 1)]!;
const ox = a[0] + (b[0] - a[0]) * t, oy = a[1] + (b[1] - a[1]) * t;
ship.setAttribute("transform", `translate(${(p.x + ox).toFixed(1)} ${(p.y + oy).toFixed(1)})${Math.cos(p.a) < 0 ? " scale(-1 1)" : ""}`);
legs.forEach((l, i) => {
const done = shipAt >= i + 1 ? 1 : shipAt <= i ? 0 : shipAt - i;
l.sailed.setAttribute("stroke-dasharray", `${l.len * done} ${l.len + 10}`);
});
if (probeAt === null) probe.setAttribute("visibility", "hidden");
else { const q = pointAt(probeAt); probe.setAttribute("cx", String(q.x)); probe.setAttribute("cy", String(q.y)); probe.setAttribute("visibility", "visible"); }
}
/* ------------------------------------------------------------------ view (animated viewBox) */
const view = { cx: W / 2, cy: H / 2, z: 1 };
let target = { ...view };
let raf = 0;
function applyView() {
const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1;
const scale = Math.min(cw / W, ch / H) * view.z;
const vw = cw / scale, vh = ch / scale;
svg.setAttribute("viewBox", `${(view.cx - vw / 2).toFixed(1)} ${(view.cy - vh / 2).toFixed(1)} ${vw.toFixed(1)} ${vh.toFixed(1)}`);
svg.style.setProperty("--z", String(view.z));
}
function tick() {
const k = 0.16;
view.cx += (target.cx - view.cx) * k;
view.cy += (target.cy - view.cy) * k;
view.z += (target.z - view.z) * k;
applyView();
if (Math.abs(target.cx - view.cx) + Math.abs(target.cy - view.cy) + Math.abs(target.z - view.z) * 100 > 0.3) raf = requestAnimationFrame(tick);
else { Object.assign(view, target); applyView(); raf = 0; }
}
function goTo(t: { cx: number; cy: number; z: number }, instant = false) {
target = t;
if (instant || matchMedia("(prefers-reduced-motion: reduce)").matches) { Object.assign(view, t); applyView(); return; }
if (!raf) raf = requestAnimationFrame(tick);
}
const fullView = () => ({ cx: W / 2, cy: H / 2, z: 1 });
/** Centre on a world point, shifted so it lands at `frac` of the chart width. */
function focusOn(x: number, y: number, z: number, frac = 0.5) {
const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1;
const vw = cw / (Math.min(cw / W, ch / H) * z);
return { cx: x + (0.5 - frac) * vw, cy: y, z };
}
function restView() {
if (selected >= 0 && !overlay) { const g = ports[songs[selected]!.id]!; return focusOn(g.at[0], g.at[1], 1.55, 0.36); }
return fullView();
}
new ResizeObserver(() => applyView()).observe(svg);
/* ================================================================== LOG LINE */
const AXIS_Y = 92, LANE_Y = 140, LANE = 16;
const logTrack = h("div", { class: "log__track" });
const legend = h("div", { class: "log__legend" });
const logBody = h("div", { class: "log__body", style: `width:${LOG_W}px` });
logTrack.append(legend, logBody);
logWrap.append(logTrack);
const logSvg = s("svg", { class: "log__svg", width: LOG_W, height: LANE_Y + motifs.length * LANE }, logBody);
// Saga brackets
for (const saga of sagas) {
const idx = songs.map((x, i) => (x.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (!idx.length) continue;
const x0 = logX[idx[0]!]! - 62, x1 = logX[idx[idx.length - 1]!]! + 62;
s("path", { d: `M${x0},24 V18 H${x1} V24`, class: "bracket" }, logSvg);
const lab = text(logSvg, x0 + 4, 12, saga.name, "bracket__name", "start");
lab.setAttribute("dy", "0");
}
// Axis: straight within a year, a long swell across the years at sea
for (let i = 0; i < songs.length - 1; i++) {
const a = logX[i]!, b = logX[i + 1]!, gap = route[i]!.years;
if (gap <= 0) { s("line", { x1: a, y1: AXIS_Y, x2: b, y2: AXIS_Y, class: "axis" }, logSvg); continue; }
const g0 = a + LOG.hop / 2, g1 = b - LOG.hop / 2;
let d = `M${a},${AXIS_Y} H${g0}`;
const waves = Math.round((g1 - g0) / 30);
for (let k = 0; k < waves; k++) { const x = g0 + ((g1 - g0) * (k + 0.5)) / waves, x2 = g0 + ((g1 - g0) * (k + 1)) / waves; d += ` Q${x},${AXIS_Y + (k % 2 ? 5 : -5)} ${x2},${AXIS_Y}`; }
d += ` H${b}`;
s("path", { d, class: "axis axis--sea" }, logSvg);
text(logSvg, (g0 + g1) / 2, AXIS_Y - 14, yearsLabel(gap), "gap-label");
for (let k = 1; k <= gap; k++) {
const x = g0 + ((g1 - g0) * k) / gap;
s("line", { x1: x, y1: 30, x2: x, y2: AXIS_Y + 6, class: "year-tick" }, logSvg);
text(logSvg, x - 4, 38, `year ${songs[i]!.storyTime.yearsAfterTroy + k}`, "year-label", "end");
}
}
s("line", { x1: logX[0]! - 50, y1: AXIS_Y, x2: logX[0]!, y2: AXIS_Y, class: "axis" }, logSvg);
s("line", { x1: logX[logX.length - 1]!, y1: AXIS_Y, x2: LOG_W - 20, y2: AXIS_Y, class: "axis axis--fade" }, logSvg);
text(logSvg, logX[0]! - 56, 38, "year 0", "year-label", "start");
// Motif lanes with part boxes
const laneEls = new Map<string, SVGGElement>();
motifs.forEach((m, mi) => {
const y = LANE_Y + mi * LANE;
const g = s("g", { class: "lane", "data-motif": m.id, style: `--c:${inkOf(m)}` }, logSvg);
laneEls.set(m.id, g);
const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0);
if (occ.length) {
s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__case" }, g);
s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__line" }, g);
}
for (const i of occ) {
const parts = songs[i]!.parts, bw = 8, gap = 2, tot = parts.length * bw + (parts.length - 1) * gap;
parts.forEach((p, k) => {
s("rect", { x: logX[i]! - tot / 2 + k * (bw + gap), y: y - 4, width: bw, height: 8, class: p.motifIds.includes(m.id) ? "box box--on" : "box" }, g);
});
}
});
// Song entries (HTML, so titles can wrap)
const entryEls = songs.map((song, i) => {
const b = h("button", { type: "button", class: "entry", style: `left:${logX[i]}px` },
h("span", { class: "entry__num" }, String(song.track)),
h("span", { class: "entry__title" }, song.title),
);
b.addEventListener("click", () => select(i, "log"));
logBody.append(b, h("span", { class: "entry__when", style: `left:${logX[i]}px` }, song.storyTime.label));
s("circle", { cx: logX[i]!, cy: AXIS_Y, r: 4.5, class: "stop", "data-i": i }, logSvg);
return b;
});
// Scrub cursor
const cursor = h("div", { class: "cursor", hidden: "" }, h("span", { class: "cursor__read" }));
logBody.append(cursor);
// Legend
legend.append(h("p", { class: "legend__head" }, "Motifs, sung where filled"));
const legendBtns = new Map<string, HTMLButtonElement>();
for (const m of motifs) {
const b = h("button", { type: "button", class: "legend__btn", style: `--c:${inkOf(m)}`, "aria-pressed": "false" },
h("span", { class: "legend__sw", "aria-hidden": "true" }), m.name);
b.addEventListener("click", () => setMotif(motifId === m.id ? null : m.id));
legend.append(b);
legendBtns.set(m.id, b);
}
/* ------------------------------------------------------------------ scrubbing */
let leaveTimer = 0;
let lastClientX: number | null = null;
function scrubAt(clientX: number) {
const r = logBody.getBoundingClientRect();
const x = Math.max(logX[0]!, Math.min(logX[logX.length - 1]!, clientX - r.left));
const f = xToIndex(x);
cursor.hidden = false;
cursor.style.left = `${x}px`;
const i = Math.round(f);
const atPort = Math.abs(f - i) < 0.12;
const yrs = atPort ? songs[i]!.storyTime.yearsAfterTroy : Math.floor(interpYears(f));
(cursor.firstChild as HTMLElement).textContent = atPort ? `year ${yrs}, ${songs[i]!.title}` : `year ${yrs}, at sea`;
if (overlay) probeAt = f; else shipAt = f;
drawShip();
const p = pointAt(f);
goTo(focusOn(p.x, p.y, 1.5, selected >= 0 && !overlay ? 0.36 : 0.5));
}
function interpYears(f: number) {
const i = Math.floor(f), a = songs[i]!, b = songs[Math.min(i + 1, songs.length - 1)]!;
return a.storyTime.yearsAfterTroy + (b.storyTime.yearsAfterTroy - a.storyTime.yearsAfterTroy) * (f - i);
}
logBody.addEventListener("pointermove", (e) => { clearTimeout(leaveTimer); lastClientX = e.clientX; scrubAt(e.clientX); });
logWrap.addEventListener("scroll", () => { if (lastClientX !== null) scrubAt(lastClientX); }, { passive: true });
logBody.addEventListener("pointerleave", () => {
lastClientX = null;
leaveTimer = window.setTimeout(() => {
cursor.hidden = true;
probeAt = null;
drawShip();
goTo(restView());
}, 600);
});
/* ================================================================== SELECTION */
function scrollLogTo(i: number, instant = false) {
const visible = logWrap.clientWidth - legend.offsetWidth;
const still = instant || matchMedia("(prefers-reduced-motion: reduce)").matches;
logWrap.scrollTo({ left: Math.max(0, logX[i]! - visible / 2), behavior: still ? "auto" : "smooth" });
}
function onPort(i: number) {
if (overlay) { showPop(i); scrollLogTo(i); return; }
select(i, "chart");
}
function select(i: number, from: "chart" | "log" | "init") {
selected = i;
if (overlay) { showPop(i); syncUrl(); return; }
shipAt = i;
drawShip();
renderPanel();
if (from !== "log") scrollLogTo(i, from === "init");
goTo(restView(), from === "init");
syncUrl();
paint();
}
function close() {
selected = -1;
panel.hidden = true;
pop.hidden = true;
goTo(restView());
syncUrl();
paint();
}
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
chartWrap.addEventListener("click", (e) => { if (e.target === svg || (e.target as Element).classList?.contains("sea")) { if (overlay) { pop.hidden = true; selected = -1; syncUrl(); paint(); } } });
function renderPanel() {
const song = songs[selected];
if (!song) { panel.hidden = true; return; }
const closeBtn = h("button", { type: "button", class: "panel__close", "aria-label": "Close notes" }, "Close");
closeBtn.addEventListener("click", close);
const odyssey = song.odyssey
? h("a", { href: song.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`)
: h("span", { class: "panel__none" }, "No counterpart in Homer");
const parts = h("ol", { class: "parts" });
for (const p of song.parts) {
const chips = h("span", { class: "chips" });
for (const id of p.motifIds) {
const m = motifById.get(id)!;
const c = h("button", { type: "button", class: `chip${id === motifId ? " is-on" : ""}`, style: `--c:${inkOf(m)}` }, m.name);
c.addEventListener("click", () => setMotif(motifId === id ? null : id));
chips.append(c);
}
parts.append(h("li", {}, h("span", { class: "parts__name" }, p.name), h("span", { class: "parts__who" }, p.singers.join(" and ")), chips));
}
panel.replaceChildren(
closeBtn,
h("p", { class: "panel__kicker" }, `${sagaOf(song).name}, track ${song.track}`),
h("h2", { class: "panel__title" }, song.title),
h("p", { class: "panel__when" }, `${song.storyTime.label}, year ${song.storyTime.yearsAfterTroy} after Troy`),
h("p", { class: "panel__ody" }, odyssey),
parts,
createAudioSlot(song),
createVideoCards(song),
);
panel.hidden = false;
}
/* ------------------------------------------------------------------ overlay popover */
function showPop(i: number) {
selected = i;
const song = songs[i]!;
const g = ports[song.id]!;
const isHere = Math.round(shipAt) === i && Math.abs(shipAt - i) < 0.01;
const sail = h("button", { type: "button", class: "pop__sail" }, isHere ? "You are moored here" : "Sail here");
if (isHere) sail.setAttribute("disabled", "");
sail.addEventListener("click", () => sailTo(i));
const yrs = song.storyTime.yearsAfterTroy - songs[Math.round(shipAt)]!.storyTime.yearsAfterTroy;
pop.replaceChildren(
h("p", { class: "pop__num" }, `Port ${song.track}`),
h("p", { class: "pop__title" }, song.title),
h("p", { class: "pop__when" }, song.storyTime.label + (yrs ? `, ${Math.abs(yrs)} ${Math.abs(yrs) === 1 ? "year" : "years"} ${yrs > 0 ? "ahead" : "back"}` : "")),
sail,
);
pop.hidden = false;
placePop(g.at[0], g.at[1]);
syncUrl();
paint();
}
function placePop(x: number, y: number) {
const m = svg.getScreenCTM();
if (!m) return;
const p = new DOMPoint(x, y).matrixTransform(m);
const r = chartWrap.getBoundingClientRect();
pop.style.left = `${p.x - r.left}px`;
pop.style.top = `${p.y - r.top}px`;
}
function sailTo(i: number) {
const from = shipAt, t0 = performance.now(), dur = 900 + Math.abs(i - from) * 260;
pop.hidden = true;
const step = (now: number) => {
const t = Math.min(1, (now - t0) / dur), e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
shipAt = from + (i - from) * e;
drawShip();
if (t < 1) requestAnimationFrame(step);
else { const here = titleBar.querySelector("strong"); if (here) here.textContent = songs[i]!.title; selected = -1; paint(); syncUrl(); }
};
requestAnimationFrame(step);
}
/* ================================================================== MOTIF */
function setMotif(id: string | null) {
motifId = id;
syncUrl();
paint();
if (selected >= 0 && !overlay) renderPanel();
}
function paint() {
document.body.classList.toggle("has-motif", !!motifId);
// Chart: course coloured where the motif carries on, dotted where it skips a port
motifG.replaceChildren();
const m = motifId ? motifById.get(motifId) : undefined;
if (m) {
const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0);
const c = inkOf(m);
for (let i = occ[0]!; i < occ[occ.length - 1]!; i++) {
const both = has(songs[i]!, m.id) && has(songs[i + 1]!, m.id);
s("path", { d: legs[i]!.seg.d, class: both ? "mleg" : "mleg mleg--skip", style: `--c:${c}` }, motifG);
}
}
portEls.forEach((el, i) => {
el.classList.toggle("is-motif", !!m && has(songs[i]!, m.id));
el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id));
el.classList.toggle("is-selected", i === selected);
if (m) el.style.setProperty("--c", inkOf(m)); else el.style.removeProperty("--c");
});
entryEls.forEach((el, i) => {
el.classList.toggle("is-selected", i === selected);
el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id));
el.setAttribute("aria-pressed", String(i === selected));
});
for (const [id, g] of laneEls) g.classList.toggle("is-faded", !!m && id !== m.id);
for (const [id, b] of legendBtns) { b.setAttribute("aria-pressed", String(id === motifId)); b.classList.toggle("is-faded", !!m && id !== m.id); }
}
function syncUrl() {
const p = new URLSearchParams();
if (overlay) p.set("overlay", "1");
if (selected >= 0) p.set("song", songs[selected]!.id);
if (motifId) p.set("motif", motifId);
const q = p.toString();
history.replaceState(null, "", q ? `?${q}` : location.pathname);
}
/* ------------------------------------------------------------------ boot */
applyView();
drawShip();
paint();
if (selected >= 0) requestAnimationFrame(() => select(selected, "init"));
if (overlay) requestAnimationFrame(() => scrollLogTo(selected >= 0 ? selected : HERE, true));
+356
View File
@@ -0,0 +1,356 @@
/* 08 · Sea chart + log line: an Admiralty chart above, a ship's log in story time below. */
@import "@fontsource/cormorant-garamond/latin-400.css";
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-400-italic.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
:root {
color-scheme: light;
--water: #dfe9e6;
--shoal: #c9dcd8;
--shoal-deep: #b5cfcc;
--land: #e8d8a8;
--ink: #1e2c3b;
--ink-soft: #4d5b69;
--ink-faint: #8794a0;
--course: #a3195b;
--paper: #fbfbf7;
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
--log-h: 268px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
display: flex;
flex-direction: column;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
overflow: hidden;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--course); outline-offset: 2px; }
#app {
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: 56px minmax(0, 1fr) var(--log-h);
}
/* ------------------------------------------------------------------ title bar */
.bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 2rem;
padding: 0 28px;
border-bottom: 1.5px solid var(--ink);
background: var(--paper);
}
.bar__title { margin: 0; align-self: center; font-weight: 500; font-size: 28px; letter-spacing: 0.01em; }
.bar__hint { margin: 0; align-self: center; font-style: italic; font-size: 17px; color: var(--ink-soft); }
/* ------------------------------------------------------------------ chart */
.chart { position: relative; overflow: hidden; background: var(--water); }
.chart__svg { display: block; width: 100%; height: 100%; }
.sea { fill: var(--water); }
.grat line { stroke: var(--ink); stroke-opacity: 0.08; stroke-width: 1; vector-effect: non-scaling-stroke; }
.sound { font: italic 400 11px var(--serif); fill: var(--ink-faint); text-anchor: middle; }
.hatch { stroke: #b49a5c; stroke-width: 1.2; }
.shoal-1, .shoal-2, .shoal-3 { fill: none; stroke-linejoin: round; }
.shoal-1 { stroke: var(--shoal); }
.shoal-2 { stroke: var(--shoal-deep); }
.shoal-3 { stroke: #9fbfbd; }
.land { fill: var(--land); stroke: var(--ink); stroke-width: 1.3; }
.land-hatch { fill: url(#hatch); opacity: 0.35; pointer-events: none; }
.names text { pointer-events: none; }
.place { font: 500 17px var(--serif); fill: var(--ink); letter-spacing: 0.06em; }
.place--big { font-size: 30px; letter-spacing: 0.14em; }
.place--small { font-size: 13px; }
.place-sub { font: italic 400 13px var(--serif); fill: var(--ink-soft); }
.sea-area { font: italic 400 30px var(--serif); fill: #3e6378; letter-spacing: 0.12em; }
.rose__ring { fill: none; stroke: var(--course); stroke-width: 1; }
.rose__ring--in { stroke-opacity: 0.5; }
.rose__tick { stroke: var(--course); stroke-width: 0.8; }
.rose__star { fill: var(--paper); stroke: var(--course); stroke-width: 1; }
.rose__north { fill: var(--course); }
.rose__n { font: 600 16px var(--serif); fill: var(--course); }
.leg { fill: none; stroke-linecap: round; }
.leg--plan { stroke: var(--course); stroke-width: 2; stroke-dasharray: 9 7; opacity: 0.55; }
.leg--sailed { stroke: var(--course); stroke-width: 3; }
.crossing { font: italic 500 17px var(--serif); fill: var(--course); }
.mleg { fill: none; stroke: var(--c); stroke-width: 9; stroke-linecap: round; opacity: 0.85; }
.mleg--skip { stroke-width: 4; stroke-dasharray: 1 9; opacity: 1; }
.has-motif .leg--sailed, .has-motif .leg--plan { opacity: 0.18; }
.has-motif .crossing { opacity: 0.45; }
.port { cursor: pointer; outline: none; }
.port__ring { fill: none; stroke: transparent; stroke-width: 3; }
.port__dot { fill: var(--paper); stroke: var(--ink); stroke-width: 2; }
.port__num { font: 500 12px var(--sans); fill: var(--ink); text-anchor: middle; pointer-events: none; }
.port__label {
font: 600 19px var(--serif);
fill: var(--ink);
paint-order: stroke;
stroke: var(--water);
stroke-width: 5px;
stroke-linejoin: round;
}
.port:hover .port__dot, .port:focus-visible .port__dot { fill: #fff3d6; }
.port:focus-visible .port__ring { stroke: var(--course); stroke-dasharray: 3 3; }
.port.is-selected .port__dot { fill: var(--course); stroke: var(--course); }
.port.is-selected .port__num { fill: var(--paper); }
.port.is-motif .port__ring { stroke: var(--c); }
.port.is-dim { opacity: 0.38; }
.ship__hull { fill: var(--ink); }
.ship__sail { fill: var(--paper); stroke: var(--ink); stroke-width: 1.4; }
.probe { fill: none; stroke: var(--course); stroke-width: 2.5; stroke-dasharray: 4 4; }
/* ------------------------------------------------------------------ detail panel */
.panel {
position: absolute;
top: 16px;
right: 16px;
bottom: 16px;
width: 380px;
overflow: auto;
padding: 22px 24px 24px;
background: var(--paper);
border: 1.5px solid var(--ink);
box-shadow: 6px 6px 0 rgb(30 44 59 / 0.12);
font-family: var(--serif);
}
.panel[hidden] { display: none; }
.panel__close {
float: right;
padding: 4px 10px;
border: 1px solid var(--ink-faint);
background: none;
font: 400 12px var(--sans);
cursor: pointer;
}
.panel__kicker { margin: 0; font-style: italic; font-size: 16px; color: var(--ink-soft); }
.panel__title { margin: 4px 0 6px; font-weight: 500; font-size: 36px; line-height: 1.05; }
.panel__when { margin: 0 0 4px; font-size: 18px; }
.panel__ody { margin: 0 0 16px; font-size: 16px; }
.panel__ody a { color: var(--course); }
.panel__none { color: var(--ink-faint); font-style: italic; }
.parts { list-style: none; margin: 0 0 16px; padding: 0; border-top: 1px solid rgb(30 44 59 / 0.2); }
.parts li { display: grid; grid-template-columns: 70px 1fr; gap: 2px 10px; padding: 8px 0; border-bottom: 1px solid rgb(30 44 59 / 0.2); }
.parts__name { font: 500 12px var(--sans); color: var(--ink-soft); padding-top: 3px; }
.parts__who { font-size: 18px; }
.chips { grid-column: 2; display: flex; flex-wrap: wrap; gap: 4px; }
.chip {
padding: 1px 8px 2px;
border: 1px solid var(--c);
border-radius: 10px;
background: none;
color: var(--c);
font: 400 12px var(--sans);
cursor: pointer;
}
.chip.is-on { background: var(--c); color: var(--paper); }
.panel .audio-slot { margin-bottom: 12px; font: 400 13px var(--sans); color: var(--ink-soft); border-radius: 0; }
.panel .video-card { border-radius: 0; font: 400 13px var(--sans); color: var(--ink); }
/* ------------------------------------------------------------------ log line */
.log {
overflow-x: auto;
overflow-y: hidden;
background: var(--paper);
border-top: 1.5px solid var(--ink);
scrollbar-width: thin;
scrollbar-color: var(--ink-faint) transparent;
}
.log__track { display: flex; width: max-content; height: 100%; }
.log__legend {
position: sticky;
left: 0;
z-index: 3;
flex: 0 0 200px;
padding: 0 14px 0 24px;
padding-top: 109px;
background: linear-gradient(90deg, var(--paper) 88%, rgb(251 251 247 / 0));
}
.legend__head { margin: 0 0 7px; font: italic 400 14px var(--serif); color: var(--ink-soft); }
.legend__btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 16px;
padding: 0;
border: 0;
background: none;
font: 400 11.5px var(--sans);
color: var(--ink);
text-align: left;
cursor: pointer;
white-space: nowrap;
}
.legend__sw { width: 22px; height: 3px; background: var(--c); box-shadow: 0 0 0 1px var(--ink); flex: none; }
.legend__btn[aria-pressed="true"] { font-weight: 500; color: var(--c); }
.legend__btn[aria-pressed="true"] .legend__sw { height: 6px; }
.legend__btn.is-faded { opacity: 0.4; }
.legend__btn:hover { color: var(--c); }
.log__body { position: relative; flex: none; height: 100%; cursor: ew-resize; }
.log__svg { position: absolute; top: 0; left: 0; overflow: visible; }
.bracket { fill: none; stroke: var(--ink); stroke-width: 1; }
.bracket__name { font: italic 500 15px var(--serif); fill: var(--ink); }
.axis { fill: none; stroke: var(--ink); stroke-width: 1.5; }
.axis--sea { stroke: var(--course); stroke-width: 1.5; }
.axis--fade { stroke-dasharray: 2 5; }
.gap-label { font: italic 500 17px var(--serif); fill: var(--course); text-anchor: middle; }
.year-tick { stroke: var(--course); stroke-width: 1; stroke-dasharray: 2 3; stroke-opacity: 0.6; }
.year-label { font: 500 11px var(--sans); fill: var(--course); }
.stop { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
.lane__case { stroke: var(--ink); stroke-width: 4; stroke-opacity: 0.7; }
.lane__line { stroke: var(--c); stroke-width: 2.4; }
.box { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
.box--on { fill: var(--c); }
.lane { transition: opacity 0.2s; }
.lane.is-faded { opacity: 0.14; }
.entry {
position: absolute;
bottom: calc(100% - 84px);
transform: translateX(-50%);
width: 140px;
padding: 0;
border: 0;
background: none;
text-align: center;
cursor: pointer;
line-height: 1;
}
.entry__num { display: block; font: 400 10.5px var(--sans); color: var(--ink-faint); margin-bottom: 3px; }
.entry__title { font-weight: 600; font-size: 18px; line-height: 1.02; }
.entry:hover .entry__title { color: var(--course); }
.entry.is-selected .entry__title { color: var(--course); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.entry.is-dim { opacity: 0.35; }
.entry__when {
position: absolute;
top: 101px;
transform: translateX(-50%);
width: 140px;
font-style: italic;
font-size: 13px;
line-height: 1.05;
color: var(--ink-soft);
text-align: center;
pointer-events: none;
}
.cursor { position: absolute; top: 26px; bottom: 6px; width: 0; border-left: 1.5px solid var(--course); pointer-events: none; z-index: 2; }
.cursor[hidden] { display: none; }
.cursor__read {
position: absolute;
top: -2px;
left: 6px;
padding: 1px 6px;
background: var(--course);
color: var(--paper);
font: 500 11px var(--sans);
white-space: nowrap;
}
/* ------------------------------------------------------------------ credits */
.fan-credits { flex: none; padding: 10px 28px; border-top: 1px solid rgb(30 44 59 / 0.2); background: var(--paper); font-style: italic; font-size: 13.5px; opacity: 1; color: var(--ink-soft); text-align: left; }
/* ================================================================== in-3D overlay state */
body.is-overlay {
background:
radial-gradient(ellipse 70% 50% at 50% 115%, rgb(120 160 170 / 0.35), transparent 70%),
linear-gradient(180deg, #0f2231 0%, #183748 55%, #0d1c28 100%);
align-items: center;
justify-content: center;
}
body.is-overlay::before {
content: "";
position: fixed;
inset: 0;
background: rgb(5 12 20 / 0.45);
pointer-events: none;
}
.is-overlay #app {
position: relative;
flex: none;
width: 1180px;
height: 740px;
margin-bottom: 40px;
grid-template-rows: 46px minmax(0, 1fr) var(--log-h);
border: 1.5px solid #e8d8a8;
box-shadow: 0 30px 80px rgb(0 0 0 / 0.55);
}
.is-overlay .bar { background: var(--ink); color: var(--paper); border-bottom: 0; }
.is-overlay .bar__here { margin: 0; align-self: center; font-size: 19px; display: flex; align-items: center; gap: 6px; }
.is-overlay .bar__here strong { font-weight: 600; color: #f3c9dd; }
.bar__pin { width: 9px; height: 9px; border-radius: 50%; background: #e5579a; box-shadow: 0 0 0 4px rgb(229 87 154 / 0.3); margin-right: 4px; }
.is-overlay .bar__hint { color: #b9c4cc; font-size: 15px; }
.is-overlay .fan-credits {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: rgb(5 12 20 / 0.6);
border-top: 1px solid rgb(232 216 168 / 0.25);
color: #e6e1d2;
text-align: center;
z-index: 10;
}
.pop {
position: absolute;
z-index: 4;
transform: translate(30px, -50%);
min-width: 200px;
padding: 10px 14px 12px;
background: var(--paper);
border: 1.5px solid var(--ink);
box-shadow: 4px 4px 0 rgb(30 44 59 / 0.18);
text-align: center;
}
.pop[hidden] { display: none; }
.pop::after {
content: "";
position: absolute;
left: -8px;
top: 50%;
width: 12px;
height: 12px;
background: var(--paper);
border-left: 1.5px solid var(--ink);
border-bottom: 1.5px solid var(--ink);
transform: translateY(-50%) rotate(45deg);
}
.pop p { margin: 0; }
.pop__num { font: 400 11px var(--sans); color: var(--ink-faint); }
.pop__title { font-weight: 600; font-size: 22px; }
.pop__when { font-style: italic; font-size: 14px; color: var(--ink-soft); margin-bottom: 8px !important; }
.pop__sail {
padding: 5px 16px;
border: 0;
background: var(--course);
color: var(--paper);
font: 500 13px var(--sans);
cursor: pointer;
}
.pop__sail:disabled { background: var(--ink-faint); cursor: default; }
@media (prefers-reduced-motion: reduce) {
.lane { transition: none; }
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Schematic sea chart — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+260
View File
@@ -0,0 +1,260 @@
import { motifs, songs } from "../../data/sample";
import { gapYears } from "../../data/query";
import type { Motif, Song } from "../../data/types";
/**
* Chart geometry for the schematic sea chart.
*
* The ship's course exists twice: as an octilinear schematic (x = story time, one row per saga) and
* as a rough true-geography track. Both are parameterised by `u` (song index + fraction of the leg),
* so any morph value t in 0..1 is a straight lerp between the two, sample by sample.
*/
export const VW = 1440;
export const VH = 850;
export type Pt = [number, number];
// ---------- schematic ----------
export const S = { x0: 110, hop: 92, year: 100, saga: 70 };
const ROW: Record<string, number> = { troy: 290, cyclops: 390, ocean: 590 };
export const schemPorts: Pt[] = [];
songs.forEach((s, i) => {
const prev = songs[i - 1];
const y = ROW[s.sagaId] ?? 500;
if (!prev) return void schemPorts.push([S.x0, y]);
const x = schemPorts[i - 1]![0] + S.hop + Math.max(0, gapYears(prev, s)) * S.year + (prev.sagaId !== s.sagaId ? S.saga : 0);
schemPorts.push([x, y]);
});
/** Leg i: port i → port i+1, with a single 45° diagonal centred in the gap when the row changes. */
const schemLegs: Pt[][] = songs.slice(1).map((_, i) => {
const a = schemPorts[i]!, b = schemPorts[i + 1]!;
if (a[1] === b[1]) return [a, b];
const dy = Math.abs(b[1] - a[1]);
const mid = (a[0] + b[0]) / 2;
return [a, [mid - dy / 2, a[1]], [mid + dy / 2, b[1]], b];
});
// ---------- true geography (equirectangular, x scaled by cos 38.5°) ----------
const K = 92;
const COS = Math.cos((38.5 * Math.PI) / 180);
export const geo = (lon: number, lat: number): Pt => [90 + (lon - 11) * COS * K, 120 + (42 - lat) * K];
// Positions are approximate and illustrative, not a claim about where the musical places each song.
const GEO_PORT: Record<string, [number, number]> = {
"the-horse-and-the-infant": [26.2, 39.95],
"just-a-man": [25.7, 40.25],
"full-speed-ahead": [25.1, 40.55],
"open-arms": [24.6, 40.3],
"warrior-of-the-mind": [22.6, 36.0],
polyphemus: [15.6, 37.4],
survive: [15.25, 37.65],
"remember-them": [15.0, 37.95],
"my-goodbye": [14.7, 38.25],
storm: [12.6, 39.4],
};
/** Waypoints sailed before arriving at a port, so the track stays at sea. */
const GEO_VIA: Record<string, [number, number][]> = {
"warrior-of-the-mind": [[24.7, 39.4], [24.5, 38.3], [24.3, 37.2], [23.5, 36.1]],
polyphemus: [[21.0, 36.3], [18.5, 36.6], [16.2, 37.0]],
};
export const geoPorts: Pt[] = songs.map((s) => geo(...(GEO_PORT[s.id] ?? [20, 38])));
const geoLegs: Pt[][] = songs.slice(1).map((s, i) => [geoPorts[i]!, ...(GEO_VIA[s.id] ?? []).map((p) => geo(...p)), geoPorts[i + 1]!]);
/** Rough coastlines (lon, lat). Shapes are hand-drafted for a stylised chart, not survey-accurate. */
const LAND_LL: Record<string, [number, number][]> = {
italy: [[10.6, 42.8], [11.0, 42.4], [11.8, 42.0], [12.6, 41.4], [13.6, 41.2], [14.2, 40.8], [14.9, 40.4], [15.6, 40.0], [15.8, 39.6], [16.1, 38.9], [15.7, 38.2], [16.0, 37.95], [16.6, 38.4], [17.1, 39.0], [17.2, 39.4], [16.6, 39.9], [16.9, 40.4], [17.9, 40.3], [18.5, 40.1], [18.4, 40.4], [17.5, 40.8], [16.4, 41.3], [15.9, 41.6], [16.2, 41.9], [15.2, 41.95], [14.5, 42.2], [14.0, 42.8]],
sicily: [[12.4, 38.1], [13.3, 38.2], [14.0, 38.0], [15.0, 38.1], [15.65, 38.25], [15.1, 37.4], [15.3, 36.7], [15.0, 36.65], [14.4, 36.8], [12.9, 37.55], [12.4, 37.8]],
greece: [[19.4, 42.8], [19.5, 41.0], [19.4, 40.5], [20.0, 39.7], [20.7, 39.0], [21.1, 38.4], [21.8, 38.35], [22.9, 38.2], [23.0, 37.95], [22.3, 38.1], [21.6, 38.2], [21.3, 37.9], [21.6, 37.4], [21.7, 36.9], [22.0, 36.8], [22.4, 36.45], [22.5, 36.9], [22.8, 36.6], [23.2, 36.45], [23.0, 37.0], [22.8, 37.5], [23.2, 37.6], [23.5, 37.45], [23.2, 37.9], [23.6, 37.95], [24.0, 37.65], [24.0, 38.2], [23.6, 38.5], [22.9, 38.9], [22.9, 39.5], [22.6, 40.2], [22.9, 40.6], [23.6, 40.2], [24.0, 40.7], [25.0, 40.95], [26.0, 40.8], [26.6, 41.0], [27.6, 41.3], [28.4, 42.0], [28.6, 42.8]],
asia: [[26.2, 40.05], [26.5, 40.4], [27.5, 40.35], [29.4, 40.4], [29.4, 36.2], [28.2, 36.7], [27.4, 36.95], [27.3, 37.4], [27.0, 37.9], [26.3, 38.3], [26.9, 38.6], [26.8, 39.3], [26.1, 39.5]],
crete: [[23.5, 35.3], [24.2, 35.55], [25.0, 35.4], [26.3, 35.3], [26.1, 35.0], [24.7, 34.95], [23.6, 35.2]],
ithaca: [[20.6, 38.5], [20.75, 38.45], [20.72, 38.33], [20.62, 38.36]],
};
export const geoLand: Record<string, Pt[]> = Object.fromEntries(Object.entries(LAND_LL).map(([k, v]) => [k, v.map((p) => geo(...p))]));
export const geoNames: { text: string; at: Pt; sea?: boolean }[] = [
{ text: "Italia", at: geo(15.2, 41.0) },
{ text: "Sicilia", at: geo(13.9, 37.45) },
{ text: "Hellas", at: geo(21.6, 40.4) },
{ text: "Asia Minor", at: geo(28.0, 38.9) },
{ text: "Crete", at: geo(24.8, 35.2) },
{ text: "Ithaca", at: geo(20.15, 38.75) },
{ text: "Mare Ionium", at: geo(18.2, 37.6), sea: true },
{ text: "Aegean", at: geo(25.2, 37.6), sea: true },
];
// ---------- morphable course ----------
export interface Sample { u: number; p: Pt }
function lengths(pl: Pt[]): number[] {
const out = [0];
for (let i = 1; i < pl.length; i++) out.push(out[i - 1]! + Math.hypot(pl[i]![0] - pl[i - 1]![0], pl[i]![1] - pl[i - 1]![1]));
return out;
}
function atFrac(pl: Pt[], f: number): Pt {
const L = lengths(pl);
const target = f * L[L.length - 1]!;
for (let i = 1; i < pl.length; i++) {
if (target <= L[i]! + 1e-9) {
const seg = L[i]! - L[i - 1]! || 1;
const k = (target - L[i - 1]!) / seg;
return [pl[i - 1]![0] + (pl[i]![0] - pl[i - 1]![0]) * k, pl[i - 1]![1] + (pl[i]![1] - pl[i - 1]![1]) * k];
}
}
return pl[pl.length - 1]!;
}
const vertexFracs = (pl: Pt[]) => { const L = lengths(pl); const T = L[L.length - 1]! || 1; return L.map((l) => l / T); };
const legFracs: number[][] = schemLegs.map((leg, i) => {
const set = new Set<number>();
for (let k = 0; k <= 16; k++) set.add(k / 16);
for (const f of [...vertexFracs(leg), ...vertexFracs(geoLegs[i]!)]) set.add(Math.round(f * 1e6) / 1e6);
return [...set].sort((a, b) => a - b);
});
export function course(t: number): Sample[] {
const out: Sample[] = [];
schemLegs.forEach((leg, i) => {
for (const f of legFracs[i]!) {
if (i > 0 && f === 0) continue;
const a = atFrac(leg, f), b = atFrac(geoLegs[i]!, f);
out.push({ u: i + f, p: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] });
}
});
return out;
}
/** Offset a polyline sideways; positive = left of travel (north on the schematic). Miter joins. */
export function offset(pts: Pt[], d: number): Pt[] {
if (d === 0) return pts.map((p) => [p[0], p[1]]);
const n = pts.length;
const normal = (a: Pt, b: Pt): Pt => { const dx = b[0] - a[0], dy = b[1] - a[1]; const l = Math.hypot(dx, dy) || 1; return [dy / l, -dx / l]; };
return pts.map((p, i) => {
const n1 = i > 0 ? normal(pts[i - 1]!, p) : null;
const n2 = i < n - 1 ? normal(p, pts[i + 1]!) : null;
const a = n1 ?? n2!, b = n2 ?? n1!;
let mx = a[0] + b[0], my = a[1] + b[1];
const ml = Math.hypot(mx, my);
if (ml < 1e-6) return [p[0] + a[0] * d, p[1] + a[1] * d];
mx /= ml; my /= ml;
const k = Math.min(3, 1 / Math.max(0.2, mx * a[0] + my * a[1]));
return [p[0] + mx * d * k, p[1] + my * d * k];
});
}
/** Points of a (possibly offset) course between u0 and u1, interpolating the ends. */
export function slice(samples: Sample[], pts: Pt[], u0: number, u1: number): Pt[] {
const at = (u: number): Pt => {
for (let i = 1; i < samples.length; i++) {
if (u <= samples[i]!.u + 1e-9) {
const a = samples[i - 1]!, b = samples[i]!;
const k = (u - a.u) / (b.u - a.u || 1);
const pa = pts[i - 1]!, pb = pts[i]!;
return [pa[0] + (pb[0] - pa[0]) * k, pa[1] + (pb[1] - pa[1]) * k];
}
}
return pts[pts.length - 1]!;
};
const out: Pt[] = [at(u0)];
samples.forEach((s, i) => { if (s.u > u0 + 1e-6 && s.u < u1 - 1e-6) out.push(pts[i]!); });
out.push(at(u1));
return out;
}
/** Drop near-collinear points so corners keep long arms to round off. */
function simplify(pts: Pt[]): Pt[] {
if (pts.length < 3) return pts;
const out: Pt[] = [pts[0]!];
for (let i = 1; i < pts.length - 1; i++) {
const a = out[out.length - 1]!, b = pts[i]!, c = pts[i + 1]!;
const a1 = Math.atan2(b[1] - a[1], b[0] - a[0]), a2 = Math.atan2(c[1] - b[1], c[0] - b[0]);
let diff = Math.abs(a1 - a2);
if (diff > Math.PI) diff = 2 * Math.PI - diff;
if (diff > 0.01 && Math.hypot(b[0] - a[0], b[1] - a[1]) > 0.5) out.push(b);
}
out.push(pts[pts.length - 1]!);
return out;
}
/** Polyline → SVG path with corners rounded by quadratic curves of radius r. */
export function roundedPath(raw: Pt[], r = 14): string {
const pts = simplify(raw);
const f = (p: Pt) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`;
if (pts.length < 3) return `M${pts.map(f).join("L")}`;
let d = `M${f(pts[0]!)}`;
for (let i = 1; i < pts.length - 1; i++) {
const a = pts[i - 1]!, b = pts[i]!, c = pts[i + 1]!;
const l1 = Math.hypot(b[0] - a[0], b[1] - a[1]), l2 = Math.hypot(c[0] - b[0], c[1] - b[1]);
const rr = Math.min(r, l1 / 2, l2 / 2);
const p1: Pt = [b[0] + ((a[0] - b[0]) / l1) * rr, b[1] + ((a[1] - b[1]) / l1) * rr];
const p2: Pt = [b[0] + ((c[0] - b[0]) / l2) * rr, b[1] + ((c[1] - b[1]) / l2) * rr];
d += `L${f(p1)}Q${f(b)} ${f(p2)}`;
}
return d + `L${f(pts[pts.length - 1]!)}`;
}
/** Position and heading (radians) of the course at u. */
export function poseAt(samples: Sample[], u: number): { p: Pt; angle: number } {
const pts = samples.map((s) => s.p);
const end = songs.length - 1;
const p = slice(samples, pts, Math.max(0, u - 0.02), u).pop()!;
const a = slice(samples, pts, Math.max(0, u - 0.012), Math.max(0, u - 0.011))[0]!;
const b = slice(samples, pts, Math.min(end, u + 0.011), Math.min(end, u + 0.012))[0]!;
return { p, angle: Math.atan2(b[1] - a[1], b[0] - a[0]) };
}
// ---------- motif lanes ----------
export const LANE_GAP = 10;
/** Fixed lane per motif: character themes to the left (north) of the course, motifs to the right. */
export const laneOffset = new Map<string, number>();
motifs.filter((m) => m.kind === "character").forEach((m, k) => laneOffset.set(m.id, 12 + LANE_GAP * k));
motifs.filter((m) => m.kind !== "character").forEach((m, k) => laneOffset.set(m.id, -12 - LANE_GAP * k));
export const NORTH_EDGE = Math.max(...laneOffset.values());
export const SOUTH_EDGE = Math.min(...laneOffset.values());
export const motifIdsOf = (s: Song) => new Set(s.parts.flatMap((p) => p.motifIds));
export interface Run { u0: number; u1: number }
/** Consecutive-song runs where the motif plays; a single song gets a short stub either side. */
export function runsOf(m: Motif): Run[] {
const idx = songs.flatMap((s, i) => (motifIdsOf(s).has(m.id) ? [i] : []));
const runs: Run[] = [];
for (const i of idx) {
const last = runs[runs.length - 1];
if (last && Math.round(last.u1) === i - 1) last.u1 = i;
else runs.push({ u0: i, u1: i });
}
const end = songs.length - 1;
// Stub of ~30 schematic px either side, whatever the leg length.
const legLen = (i: number) => { const L = lengths(schemLegs[i] ?? [[0, 0], [92, 0]]); return L[L.length - 1]!; };
return runs.map((r) => (r.u0 === r.u1 ? { u0: Math.max(0, r.u0 - 30 / legLen(r.u0 - 1)), u1: Math.min(end, r.u1 + 30 / legLen(r.u1)) } : r));
}
// ---------- chart decoration ----------
/** Closed Catmull-Rom outline through the points. */
export function closedSmooth(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n]!, p1 = pts[i]!, p2 = pts[(i + 1) % n]!, p3 = pts[(i + 2) % n]!;
const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += ` C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
}
return d + "Z";
}
export const scaleAbout = (pts: Pt[], k: number): Pt[] => {
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length, cy = pts.reduce((s, p) => s + p[1], 0) / pts.length;
return pts.map((p) => [cx + (p[0] - cx) * k, cy + (p[1] - cy) * k]);
};
export const polyPath = (pts: Pt[]) => `M${pts.map((p) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join("L")}Z`;
/** Schematic islands: one per landed saga, sitting under its row. */
export const blobs: { id: string; name: string; pts: Pt[]; label: Pt }[] = [
{ id: "troy", name: "The Troy Saga", label: [300, 505], pts: [[70, 450], [200, 432], [340, 444], [470, 430], [548, 462], [536, 528], [450, 566], [300, 576], [150, 566], [64, 520]] },
{ id: "cyclops", name: "The Cyclops Saga", label: [876, 590], pts: [[710, 536], [800, 518], [910, 524], [1020, 512], [1066, 556], [1046, 626], [956, 664], [830, 668], [730, 640], [700, 588]] },
];
export const storm = { at: [1378, 590] as Pt, name: "The Ocean Saga" };
export const yearsPhrase = (y: number) => (y === 1 ? "a year passes" : `${["", "one", "two", "three", "four", "five"][y] ?? y} years pass`);
+513
View File
@@ -0,0 +1,513 @@
import { motifs, songs } from "../../data/sample";
import { gapYears, motifsInSong, sagaOf } from "../../data/query";
import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import {
NORTH_EDGE, SOUTH_EDGE, VH, VW, blobs, closedSmooth, course, geo, geoLand, geoNames, laneOffset, motifIdsOf,
offset, polyPath, poseAt, roundedPath, runsOf, scaleAbout, slice, storm, yearsPhrase, type Pt, type Sample,
} from "./layout";
mountCredits();
// ---------- state ----------
const params = new URLSearchParams(location.search);
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
const state = {
t: clamp01(Number(params.get("morph") ?? 0) || 0),
motif: motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null,
song: songs.some((s) => s.id === params.get("song")) ? params.get("song") : null,
overlay: params.get("overlay") === "1",
shipU: Math.max(0, songs.findIndex((s) => s.id === "survive")),
sailing: null as string | null,
pop: null as string | null,
};
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---------- colour: pale data colours darkened until they read on the chart paper ----------
const PAPER = "#f4f7f5";
const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const lum = (c: number[]) => {
const [r, g, b] = c.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
};
const contrast = (a: number[], b: number[]) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x! + 0.05) / (y! + 0.05); };
function chartInk(hex: string, target = 2.6): string {
const base = rgb(hex), dark = [58, 38, 22];
let c = base;
for (let k = 0; k <= 1 && contrast(c, rgb(PAPER)) < target; k += 0.03) c = base.map((v, i) => Math.round(v + (dark[i]! - v) * k));
return `#${c.map((v) => v.toString(16).padStart(2, "0")).join("")}`;
}
// Odysseus's cream would land next to Warrior gold, so it is pushed further toward brown.
const ink = new Map(motifs.map((m) => [m.id, chartInk(m.color, m.id === "odysseus" ? 4.6 : 2.6)]));
// ---------- DOM helpers ----------
const NS = "http://www.w3.org/2000/svg";
type Attrs = Record<string, string | number | undefined>;
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, kids: (Node | string)[] = []): SVGElementTagNameMap[K] {
const e = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) e.setAttribute(k, String(v));
for (const c of kids) e.append(c);
return e;
}
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, kids: (Node | string)[] = []): HTMLElementTagNameMap[K] {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) e.setAttribute(k, String(v));
for (const c of kids) e.append(c);
return e;
}
/** Split into the fewest lines of about `max` characters, keeping the lines as even as possible. */
function wrap(text: string, max: number): string[] {
const words = text.split(" ");
const n = Math.ceil(text.length / max);
if (n <= 1 || words.length === 1) return [text];
let best: string[] = [text], bestW = Infinity;
const go = (from: number, left: number, acc: string[]) => {
if (left === 1) {
const lines = [...acc, words.slice(from).join(" ")];
const w = Math.max(...lines.map((l) => l.length));
if (w < bestW) { bestW = w; best = lines; }
return;
}
for (let k = from + 1; k <= words.length - left + 1; k++) go(k, left - 1, [...acc, words.slice(from, k).join(" ")]);
};
go(0, Math.min(n, words.length), []);
return best;
}
const smooth = (a: number, b: number, x: number) => { const k = clamp01((x - a) / (b - a)); return k * k * (3 - 2 * k); };
const pad2 = (n: number) => String(n).padStart(2, "0");
// ---------- page skeleton ----------
const app = document.getElementById("app")!;
document.body.classList.toggle("is-overlay", state.overlay);
// A deep link lands in its final state; transitions only answer later actions.
document.body.classList.add("is-booting");
const svg = s("svg", { viewBox: state.overlay ? "40 180 1400 560" : `0 0 ${VW} ${VH}`, class: "chart", role: "img", "aria-label": "Schematic sea chart of the songs of EPIC: The Musical" });
const pan = h("div", { class: "pan" }, [svg]);
const cartouche = h("header", { class: "cartouche" }, [
h("h1", {}, ["EPIC, charted"]),
h("p", {}, ["Odysseus's course, straightened so every port name sits level. Each port is a song; each coloured line is a theme sailing beside him."]),
h("p", { class: "cartouche__how" }, ["Pick a port to open its song. Pick a theme to trace it."]),
]);
const slider = h("input", { type: "range", min: 0, max: 1, step: 0.01, id: "morph", "aria-label": "Schematic to true geography" }) as HTMLInputElement;
slider.value = String(state.t);
const btnSchem = h("button", { type: "button", class: "morph__end" }, ["Schematic"]);
const btnGeo = h("button", { type: "button", class: "morph__end" }, ["True geography"]);
const morphNote = h("p", { class: "morph__note" });
const morph = h("div", { class: "morph" }, [h("div", { class: "morph__row" }, [btnSchem, slider, btnGeo]), morphNote]);
const key = h("nav", { class: "key", "aria-label": "Themes" });
const pop = h("div", { class: "pop", hidden: "" });
const card = h("div", { class: "card" }, [pan, cartouche, morph, key, pop]);
const drawer = h("aside", { class: "drawer", "aria-label": "Song details" });
const backdropNote = h("p", { class: "backdrop-note" }, ["Chart overlay as it would open over the 3D voyage. The sea behind is a stand-in."]);
app.append(card, drawer, backdropNote);
// ---------- static chart layer ----------
const defs = s("defs", {}, [
s("pattern", { id: "hatch", width: 7, height: 7, patternUnits: "userSpaceOnUse", patternTransform: "rotate(35)" }, [s("line", { x1: 0, y1: 0, x2: 0, y2: 7, class: "hatch" })]),
]);
const gSea = s("g", { class: "sea" });
const gSchemLand = s("g", { class: "land land--schem" });
const gGeoLand = s("g", { class: "land land--geo" });
const gGrat = s("g", { class: "graticule" });
const gDyn = s("g", { class: "dyn" });
const roseG = s("g", { class: "rose" });
svg.append(defs, gSea, gSchemLand, gGeoLand, gGrat, gDyn);
// Compass rose with faint rhumb lines across the whole sheet.
{
const [cx, cy] = [1210, 190];
for (let k = 0; k < 32; k++) {
const a = (k / 32) * Math.PI * 2;
gSea.append(s("line", { x1: cx, y1: cy, x2: cx + Math.cos(a) * 2000, y2: cy + Math.sin(a) * 2000, class: k % 4 === 0 ? "rhumb rhumb--main" : "rhumb" }));
}
const rose = roseG;
rose.setAttribute("transform", `translate(${cx} ${cy})`);
rose.append(s("circle", { r: 74, class: "rose__ring" }), s("circle", { r: 66, class: "rose__ring rose__ring--thin" }));
for (let d = 0; d < 360; d += 5) {
const a = ((d - 90) * Math.PI) / 180, r1 = d % 45 === 0 ? 58 : d % 15 === 0 ? 61 : 63;
rose.append(s("line", { x1: Math.cos(a) * r1, y1: Math.sin(a) * r1, x2: Math.cos(a) * 66, y2: Math.sin(a) * 66, class: "rose__tick" }));
}
const star = (r: number, w: number, rot: number, cls: string) => {
for (let q = 0; q < 4; q++) {
const a = ((q * 90 + rot - 90) * Math.PI) / 180, n = a + Math.PI / 2;
const tip: Pt = [Math.cos(a) * r, Math.sin(a) * r];
const l: Pt = [Math.cos(n) * w, Math.sin(n) * w], rr: Pt = [-l[0], -l[1]];
rose.append(s("path", { d: `M0,0L${l[0]},${l[1]}L${tip[0]},${tip[1]}Z`, class: `${cls} ${cls}--a` }), s("path", { d: `M0,0L${rr[0]},${rr[1]}L${tip[0]},${tip[1]}Z`, class: `${cls} ${cls}--b` }));
}
};
star(44, 7, 45, "rose__pt rose__pt--minor");
star(56, 9, 0, "rose__pt");
rose.append(s("text", { y: -84, class: "rose__n" }, ["N"]));
gSea.append(rose);
}
// Schematic islands with shoal band and dashed depth contours.
for (const b of blobs) {
const g = s("g", { class: `blob blob--${b.id}` });
g.append(
s("path", { d: closedSmooth(scaleAbout(b.pts, 1.28)), class: "contour contour--far" }),
s("path", { d: closedSmooth(scaleAbout(b.pts, 1.13)), class: "contour" }),
s("path", { d: closedSmooth(b.pts), class: "shoal" }),
s("path", { d: closedSmooth(b.pts), class: "coast" }),
s("path", { d: closedSmooth(b.pts), class: "coast-hatch", fill: "url(#hatch)" }),
s("text", { x: b.label[0], y: b.label[1], class: "land-name" }, [b.name]),
);
gSchemLand.append(g);
}
{
const g = s("g", { class: "blob blob--storm" });
for (const r of [96, 72, 50]) g.append(s("circle", { cx: storm.at[0], cy: storm.at[1], r, class: "storm-ring" }));
g.append(s("text", { x: storm.at[0] - 20, y: storm.at[1] + 128, class: "sea-name" }, [storm.name]));
gSchemLand.append(g);
}
// True-geography coastlines.
for (const [id, pts] of Object.entries(geoLand)) {
const d = polyPath(pts);
gGeoLand.append(s("g", { class: `geo geo--${id}` }, [s("path", { d, class: "shoal" }), s("path", { d, class: "coast" }), s("path", { d, class: "coast-hatch", fill: "url(#hatch)" })]));
}
for (const n of geoNames) gGeoLand.append(s("text", { x: n.at[0], y: n.at[1], class: n.sea ? "sea-name" : "land-name land-name--geo" }, [n.text]));
for (let lon = 12; lon <= 28; lon += 2) {
const [x] = geo(lon, 0);
gGrat.append(s("line", { x1: x, x2: x, y1: 0, y2: VH, class: "grat" }), s("text", { x: x + 4, y: VH - 70, class: "grat-label" }, [`${lon}°E`]));
}
for (let lat = 36; lat <= 42; lat++) {
const [, y] = geo(0, lat);
gGrat.append(s("line", { x1: 0, x2: VW, y1: y, y2: y, class: "grat" }), s("text", { x: VW - 8, y: y - 4, class: "grat-label grat-label--lat" }, [`${lat}°N`]));
}
// ---------- dynamic layer: course, lanes, ports ----------
const portAt = new Map<string, Pt>();
function render() {
const t = state.t;
const geoK = smooth(0.12, 0.88, t);
gSchemLand.style.opacity = String(1 - geoK);
gGeoLand.style.opacity = String(geoK);
gGrat.style.opacity = String(geoK);
const labelK = 1 - smooth(0.18, 0.5, t);
const numK = smooth(0.42, 0.72, t);
const timeK = state.overlay ? 0 : 1 - smooth(0, 0.3, t);
const samples = course(t);
const spread = 1 - 0.4 * geoK; // lanes pull in toward the course on the real map
roseG.style.opacity = String(1 - geoK);
const pts = samples.map((x) => x.p);
gDyn.replaceChildren();
const gLanes = s("g", { class: "lanes" });
const gCourse = s("g", { class: "course" });
const gPorts = s("g", { class: "ports" });
const gLabels = s("g", { class: "labels" });
gDyn.append(gCourse, gLanes, gPorts, gLabels);
svg.classList.toggle("has-motif", !!state.motif);
// Ship's course, the one bold element.
const coursePath = roundedPath(pts, 16);
gCourse.append(s("path", { d: coursePath, class: "course__casing" }), s("path", { d: coursePath, class: "course__line" }));
// Motif lanes parallel to the course.
for (const m of motifs) {
const off = laneOffset.get(m.id)!;
const op = offset(pts, off * spread);
const runs = runsOf(m);
const g = s("g", { class: `lane${state.motif === m.id ? " is-on" : ""}`, "data-motif": m.id, style: `--c:${ink.get(m.id)}` });
for (let k = 0; k < runs.length - 1; k++) g.append(s("path", { d: roundedPath(slice(samples, op, runs[k]!.u1, runs[k + 1]!.u0), 12), class: "lane__return" }));
for (const r of runs) {
const d = roundedPath(slice(samples, op, r.u0, r.u1), 12);
g.append(s("path", { d, class: "lane__casing" }), s("path", { d, class: "lane__line" }));
}
gLanes.append(g);
}
// Years at sea, noted beside the course.
songs.forEach((song, i) => {
const prev = songs[i - 1];
if (!prev) return;
const years = gapYears(prev, song);
if (years <= 0) return;
const mid = poseAt(samples, i - 0.5);
const n: Pt = [Math.sin(mid.angle), -Math.cos(mid.angle)];
const dist = NORTH_EDGE + 18;
const anchor = Math.abs(n[0]) < 0.3 ? "middle" : n[0] > 0 ? "start" : "end";
gLabels.append(s("text", { x: mid.p[0] + n[0] * dist, y: mid.p[1] + n[1] * dist + 4, "text-anchor": anchor, class: "gap-note", opacity: timeK }, [yearsPhrase(years)]));
});
// Ports: a capsule across the course and every lane that calls there.
songs.forEach((song, i) => {
const pose = poseAt(samples, i);
const [x, y] = pose.p;
portAt.set(song.id, pose.p);
const ids = motifIdsOf(song);
const offs = [0, ...[...ids].map((id) => (laneOffset.get(id) ?? 0) * spread)];
const hi = Math.max(...offs) + 7, lo = Math.min(...offs) - 7;
const inter = ids.size >= 3;
const lit = state.motif ? ids.has(state.motif) : true;
const cls = ["port", inter ? "port--inter" : "", state.song === song.id ? "is-open" : "", lit ? "" : "is-dim", state.motif && lit ? "is-lit" : ""].filter(Boolean).join(" ");
const g = s("g", { class: cls, "data-song": song.id, tabindex: 0, role: "button", "aria-label": `${pad2(song.track)} ${song.title}` });
const rot = s("g", { transform: `translate(${x.toFixed(1)} ${y.toFixed(1)}) rotate(${((pose.angle * 180) / Math.PI).toFixed(1)})` });
if (state.motif && lit) rot.append(s("rect", { x: -11, y: -hi - 5, width: 22, height: hi - lo + 10, rx: 11, class: "port__ring", style: `--c:${ink.get(state.motif)}` }));
rot.append(s("rect", { x: -16, y: -hi - 10, width: 32, height: hi - lo + 20, class: "port__hit" }));
rot.append(s("rect", { x: -6, y: -hi, width: 12, height: hi - lo, rx: 6, class: "port__cap" }));
g.append(rot, s("title", {}, [`${song.title}, track ${song.track}`]));
// Name above the lanes (fades to a track number as geography takes over).
const lines = wrap(song.title, 12);
const top = y - NORTH_EDGE - 14 - (lines.length - 1) * 14;
const label = s("text", { x, y: top, class: "port__name", opacity: labelK, "text-anchor": "middle" });
lines.forEach((ln, k) => label.append(s("tspan", { x, dy: k ? 14 : 0 }, [ln])));
g.append(label);
g.append(s("text", { x: x + 9, y: y - 9, class: "port__num", opacity: numK }, [pad2(song.track)]));
gPorts.append(g);
// In-story time below the lanes, with a year tag where the year changes.
if (timeK > 0.01) {
const base = y - SOUTH_EDGE + 22;
const prev = songs[i - 1];
if (!prev || prev.storyTime.yearsAfterTroy !== song.storyTime.yearsAfterTroy) {
const yr = song.storyTime.yearsAfterTroy;
gLabels.append(s("text", { x, y: base, class: "year-tag", "text-anchor": "middle", opacity: timeK }, [yr === 0 ? "Year 0" : `Year ${yr}`]));
}
const tl = s("text", { x, y: base + 18, class: "time-label", "text-anchor": "middle", opacity: timeK });
wrap(song.storyTime.label, 16).forEach((ln, k) => tl.append(s("tspan", { x, dy: k ? 15 : 0 }, [ln])));
gLabels.append(tl);
}
});
if (state.overlay) drawShip(samples);
}
function drawShip(samples: Sample[]) {
const { p, angle } = poseAt(samples, state.shipU);
const deg = (angle * 180) / Math.PI;
const flip = Math.cos(angle) < 0 ? -1 : 1;
const g = s("g", { class: "ship" });
g.append(s("g", { transform: `translate(${p[0].toFixed(1)} ${p[1].toFixed(1)}) rotate(${deg.toFixed(1)}) scale(1 ${flip})` }, [
s("circle", { r: 15, class: "ship__halo" }),
s("path", { d: "M-11,-5 L7,-5 L13,0 L7,5 L-11,5 Z", class: "ship__hull" }),
s("path", { d: "M-6,-5 L-6,-14 L4,-5 Z", class: "ship__sail" }),
]));
const here = songs[Math.round(state.shipU)]!;
const text = state.sailing ? `Sailing to ${songs.find((x) => x.id === state.sailing)!.title}` : `You are here: ${here.title}`;
const ly = p[1] - SOUTH_EDGE + 30;
const w = text.length * 8.1 + 28;
g.append(
s("line", { x1: p[0], y1: p[1] + 16, x2: p[0], y2: ly - 12, class: "ship__leader" }),
s("rect", { x: p[0] - w / 2, y: ly - 12, width: w, height: 26, rx: 13, class: "ship__pill" }),
s("text", { x: p[0], y: ly + 5, "text-anchor": "middle", class: "ship__text" }, [text]),
);
gDyn.append(g);
}
// ---------- key ----------
function renderKey() {
key.replaceChildren();
const group = (title: string, side: string, list: Motif[]) => {
const ul = h("ul");
for (const m of list) {
const n = songs.filter((x) => motifIdsOf(x).has(m.id)).length;
const b = h("button", { type: "button", class: "key__item", "aria-pressed": String(state.motif === m.id), style: `--c:${ink.get(m.id)}` }, [
h("span", { class: "key__swatch", "aria-hidden": "true" }),
h("span", { class: "key__name" }, [m.name]),
h("span", { class: "key__count", title: `Plays in ${n} songs` }, [String(n)]),
]);
b.addEventListener("click", () => selectMotif(state.motif === m.id ? null : m.id));
ul.append(h("li", {}, [b]));
}
return h("section", { class: "key__group" }, [h("h2", {}, [title, h("span", {}, [side])]), ul]);
};
key.append(
group("Character themes", "left of the course", motifs.filter((m) => m.kind === "character")),
group("Musical motifs", "right of the course", motifs.filter((m) => m.kind !== "character")),
);
if (!state.overlay) {
key.append(h("dl", { class: "key__legend" }, [
h("dt", { class: "lg lg--course", "aria-hidden": "true" }), h("dd", {}, ["Ship's course"]),
h("dt", { class: "lg lg--port", "aria-hidden": "true" }), h("dd", {}, ["Port: one song"]),
h("dt", { class: "lg lg--inter", "aria-hidden": "true" }), h("dd", {}, ["Interchange: three or more themes meet"]),
h("dt", { class: "lg lg--return", "aria-hidden": "true" }), h("dd", {}, ["Theme is silent here, returns later"]),
]));
}
if (state.motif) {
const m = motifs.find((x) => x.id === state.motif)!;
const hits = songs.filter((x) => motifIdsOf(x).has(m.id));
key.append(h("p", { class: "key__trace", style: `--c:${ink.get(m.id)}` }, [
h("strong", {}, [m.name]), ` plays in ${hits.length} songs: ${hits.map((x) => x.title).join(", ")}.`,
]));
}
}
// ---------- detail drawer ----------
function renderDrawer() {
const song = songs.find((x) => x.id === state.song);
document.body.classList.toggle("drawer-open", !!song);
drawer.replaceChildren();
if (!song) return;
const saga = sagaOf(song);
const close = h("button", { type: "button", class: "drawer__close" }, ["Close"]);
close.addEventListener("click", () => openSong(null));
const odyssey = song.odyssey
? h("a", { href: song.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, [`Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`])
: h("span", {}, ["No counterpart in Homer's Odyssey"]);
const chips = h("div", { class: "chips" });
for (const m of motifsInSong(song)) {
const c = h("button", { type: "button", class: "chip", style: `--c:${ink.get(m.id)}`, "aria-pressed": String(state.motif === m.id) }, [m.name]);
c.addEventListener("click", () => selectMotif(state.motif === m.id ? null : m.id));
chips.append(c);
}
const parts = h("ol", { class: "parts" });
for (const p of song.parts) {
parts.append(h("li", {}, [
h("span", { class: "parts__name" }, [p.name]),
h("span", { class: "parts__who" }, [p.singers.join(", ")]),
h("span", { class: "parts__dots" }, p.motifIds.map((id) => h("i", { style: `--c:${ink.get(id)}`, title: motifs.find((m) => m.id === id)?.name ?? id }))),
]));
}
drawer.append(
close,
h("p", { class: "drawer__track" }, [`Port ${pad2(song.track)} of ${songs.length}, ${saga.name}`]),
h("h2", {}, [song.title]),
h("p", { class: "drawer__time" }, [song.storyTime.label, h("span", {}, [song.storyTime.yearsAfterTroy === 0 ? ", the year Troy fell" : `, ${song.storyTime.yearsAfterTroy} ${song.storyTime.yearsAfterTroy === 1 ? "year" : "years"} after Troy`])]),
h("p", { class: "drawer__odyssey" }, [odyssey]),
h("h3", {}, ["Themes"]), chips,
h("h3", {}, ["Parts and singers"]), parts,
h("h3", {}, ["Listen"]), createAudioSlot(song), createVideoCards(song),
);
}
/** Slide the chart left when the open port would sit under the drawer. */
function panToSong() {
const at = state.song ? portAt.get(state.song) : undefined;
if (!at || state.overlay) return void (pan.style.transform = "");
// The SVG letterboxes its viewBox ("meet"); map the port from user units to the untransformed card.
const w = card.clientWidth, hgt = card.clientHeight;
const k = Math.min(w / VW, hgt / VH);
const cx = (w - VW * k) / 2 + at[0] * k;
const limit = w - drawer.offsetWidth - 150;
pan.style.transform = cx > limit ? `translateX(${-(cx - limit)}px)` : "";
}
// ---------- overlay popover + sailing ----------
function showPop(id: string | null) {
state.pop = id;
if (!id) { pop.hidden = true; return; }
const song = songs.find((x) => x.id === id)!;
const port = svg.querySelector<SVGGElement>(`.port[data-song="${id}"] .port__cap`);
if (!port) return;
const r = port.getBoundingClientRect(), c = card.getBoundingClientRect();
const here = Math.round(state.shipU) === songs.indexOf(song) && !state.sailing;
const sail = h("button", { type: "button", class: "pop__sail" }, [here ? "You are here" : "Sail here"]);
if (here) sail.setAttribute("disabled", "");
sail.addEventListener("click", () => sailTo(song.id));
const open = h("button", { type: "button", class: "pop__open" }, ["Song details"]);
open.addEventListener("click", () => { showPop(null); openSong(song.id); });
pop.replaceChildren(
h("p", { class: "pop__track" }, [`Port ${pad2(song.track)}, ${sagaOf(song).name}`]),
h("p", { class: "pop__title" }, [song.title]),
h("p", { class: "pop__time" }, [song.storyTime.label]),
h("div", { class: "pop__actions" }, [sail, open]),
);
pop.hidden = false;
const left = Math.min(c.width - 250, Math.max(10, r.left - c.left + r.width / 2 - 115));
pop.style.left = `${left}px`;
pop.style.top = `${r.bottom - c.top + 12}px`;
}
function sailTo(id: string) {
const to = songs.findIndex((x) => x.id === id);
const from = state.shipU;
state.sailing = id;
showPop(null);
const dur = reduceMotion ? 0 : Math.min(3200, 450 * Math.abs(to - from) + 400);
const t0 = performance.now();
const step = (now: number) => {
const k = dur ? clamp01((now - t0) / dur) : 1;
const e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2;
state.shipU = from + (to - from) * e;
if (k >= 1) { state.shipU = to; state.sailing = null; }
render();
if (k < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
// ---------- actions ----------
function syncUrl() {
const q = new URLSearchParams();
if (state.overlay) q.set("overlay", "1");
if (state.song) q.set("song", state.song);
if (state.motif) q.set("motif", state.motif);
if (state.t > 0) q.set("morph", state.t.toFixed(2));
history.replaceState(null, "", q.size ? `?${q}` : location.pathname);
}
function selectMotif(id: string | null) {
state.motif = id;
render(); renderKey(); renderDrawer(); syncUrl();
}
function openSong(id: string | null) {
state.song = id;
render(); renderDrawer(); syncUrl();
requestAnimationFrame(panToSong);
if (id) drawer.querySelector<HTMLElement>(".drawer__close")?.focus({ preventScroll: true });
}
function setT(t: number) {
state.t = clamp01(t);
slider.value = String(state.t);
morphNote.textContent = state.t < 0.05
? "Straightened: time runs left to right, one row per saga."
: state.t > 0.95
? "Where the voyage went on a real map. Names give way to track numbers."
: "Between the two: watch each port travel to its place.";
btnSchem.setAttribute("aria-pressed", String(state.t < 0.05));
btnGeo.setAttribute("aria-pressed", String(state.t > 0.95));
render(); syncUrl();
if (state.song) panToSong();
}
let tween = 0;
function animateT(target: number) {
cancelAnimationFrame(tween);
const from = state.t, t0 = performance.now(), dur = reduceMotion ? 0 : 1100;
const step = (now: number) => {
const k = dur ? clamp01((now - t0) / dur) : 1;
setT(from + (target - from) * (k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2));
if (k < 1) tween = requestAnimationFrame(step);
};
tween = requestAnimationFrame(step);
}
slider.addEventListener("input", () => { cancelAnimationFrame(tween); setT(Number(slider.value)); });
btnSchem.addEventListener("click", () => animateT(0));
btnGeo.addEventListener("click", () => animateT(1));
const portFrom = (e: Event) => (e.target as Element).closest<SVGGElement>(".port")?.dataset.song ?? null;
svg.addEventListener("click", (e) => {
const id = portFrom(e);
if (!id) return void (state.overlay ? showPop(null) : null);
if (state.overlay) showPop(state.pop === id ? null : id);
else openSong(state.song === id ? null : id);
});
svg.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
const id = portFrom(e);
if (!id) return;
e.preventDefault();
if (state.overlay) showPop(id); else openSong(id);
});
addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (state.pop) showPop(null);
else if (state.song) openSong(null);
else if (state.motif) selectMotif(null);
});
addEventListener("resize", () => { panToSong(); if (state.pop) showPop(state.pop); });
// ---------- boot ----------
setT(state.t);
renderKey();
renderDrawer();
panToSong();
requestAnimationFrame(() => requestAnimationFrame(() => document.body.classList.remove("is-booting")));
if (state.overlay && params.get("pop")) requestAnimationFrame(() => showPop(params.get("pop")));
+252
View File
@@ -0,0 +1,252 @@
/* 09 — Schematic sea chart: Admiralty-chart palette, one magenta course. */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
:root {
--water: #f4f7f5;
--shoal: #d3e5ea;
--land: #ecdca4;
--land-edge: #b49a52;
--ink: #1f3346;
--ink-2: #4c5e6e;
--ink-3: #8a99a5;
--magenta: #a8326e;
--foot: 42px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--water);
color: var(--ink);
font: 14px/1.45 var(--sans);
overflow: hidden;
}
button { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--magenta); outline-offset: 2px; }
#app { position: fixed; inset: 0 0 var(--foot) 0; }
/* Fan credit: a chart's imprint line, always in view. */
.fan-credits {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
height: var(--foot); display: flex; align-items: center; justify-content: center;
padding: 0 1.5rem; opacity: 1;
background: var(--water); border-top: 1px solid rgba(31, 51, 70, 0.25);
color: var(--ink-2); font-size: 12.5px;
}
/* ---------- card + chart ---------- */
.card { position: absolute; inset: 0; overflow: hidden; }
.pan { position: absolute; inset: 0; transition: transform 0.45s cubic-bezier(0.2, 0.7, 0.2, 1); }
.chart { display: block; width: 100%; height: 100%; }
.rhumb { stroke: var(--ink); stroke-width: 0.5; opacity: 0.07; }
.rhumb--main { opacity: 0.12; }
.rose__ring { fill: none; stroke: var(--ink); stroke-width: 1; opacity: 0.55; }
.rose__ring--thin { stroke-width: 0.6; }
.rose__tick { stroke: var(--ink); stroke-width: 0.7; opacity: 0.6; }
.rose__pt--a { fill: var(--ink); opacity: 0.7; }
.rose__pt--b { fill: var(--water); stroke: var(--ink); stroke-width: 0.6; opacity: 0.8; }
.rose__pt--minor.rose__pt--a { opacity: 0.35; }
.rose__n { font: 600 18px var(--serif); fill: var(--ink); text-anchor: middle; opacity: 0.75; }
.land { transition: opacity 0.1s linear; }
.shoal { fill: none; stroke: var(--shoal); stroke-width: 18; stroke-linejoin: round; }
.coast { fill: var(--land); stroke: var(--land-edge); stroke-width: 1.1; stroke-linejoin: round; }
.hatch { stroke: var(--land-edge); stroke-width: 0.6; opacity: 0.35; }
.coast-hatch { stroke: none; opacity: 0.6; }
.contour { fill: none; stroke: #7fa7b5; stroke-width: 0.8; stroke-dasharray: 5 4; opacity: 0.55; }
.contour--far { opacity: 0.3; stroke-dasharray: 2 5; }
.storm-ring { fill: none; stroke: #6c8696; stroke-width: 1; stroke-dasharray: 3 5; opacity: 0.6; }
.land-name { font: 500 24px var(--serif); fill: #7a6630; text-anchor: middle; letter-spacing: 0.04em; }
.land-name--geo { font-size: 19px; opacity: 0.9; }
.sea-name { font: italic 500 21px var(--serif); fill: #5f7b8b; text-anchor: middle; letter-spacing: 0.06em; }
.grat { stroke: var(--ink); stroke-width: 0.5; opacity: 0.12; }
.grat-label { font: 10.5px var(--sans); fill: var(--ink-3); }
.grat-label--lat { text-anchor: end; }
.course__casing { fill: none; stroke: var(--water); stroke-width: 9; stroke-linejoin: round; stroke-linecap: round; }
.course__line { fill: none; stroke: var(--magenta); stroke-width: 4.5; stroke-linejoin: round; stroke-linecap: round; }
.lane { transition: opacity 0.25s; }
.lane__casing { fill: none; stroke: var(--water); stroke-width: 8.5; stroke-linecap: round; stroke-linejoin: round; }
.lane__line { fill: none; stroke: var(--c); stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
.lane__return { fill: none; stroke: var(--c); stroke-width: 2; stroke-dasharray: 0.5 6; stroke-linecap: round; opacity: 0.6; }
.has-motif .lane { opacity: 0.13; }
.has-motif .lane.is-on { opacity: 1; }
.has-motif .lane.is-on .lane__return { opacity: 1; stroke-width: 2.6; }
.port { cursor: pointer; }
.port:focus { outline: none; }
.port__hit { fill: transparent; }
.port__cap { fill: #fff; stroke: var(--ink); stroke-width: 1.6; }
.port--inter .port__cap { stroke-width: 3; }
.port__ring { fill: none; stroke: var(--c); stroke-width: 2.5; }
.port.is-open .port__cap { fill: var(--magenta); stroke: var(--ink); }
.port__name { font: 500 12.5px var(--sans); fill: var(--ink); letter-spacing: -0.005em; }
.port__num { font: 600 11px var(--sans); fill: var(--ink); paint-order: stroke; stroke: var(--water); stroke-width: 3px; }
.port:hover .port__name, .port:focus-visible .port__name { fill: var(--magenta); text-decoration: underline; }
.port:focus-visible .port__cap { stroke: var(--magenta); stroke-width: 3; }
.port.is-open .port__name { font-weight: 600; fill: var(--magenta); }
.port.is-dim .port__name, .port.is-dim .port__num { fill: #b3bec6; }
.port.is-dim .port__cap { stroke: var(--ink-3); }
.port.is-lit .port__name { font-weight: 600; }
.time-label { font: italic 500 15px var(--serif); fill: var(--ink-2); }
.year-tag { font: 600 10.5px var(--sans); fill: var(--magenta); letter-spacing: 0.02em; }
.gap-note { font: italic 500 15px var(--serif); fill: var(--magenta); }
.ship__halo { fill: var(--water); stroke: var(--magenta); stroke-width: 1.5; }
.ship__hull { fill: var(--magenta); }
.ship__sail { fill: var(--ink); }
.ship__leader { stroke: var(--magenta); stroke-width: 1.2; stroke-dasharray: 2 3; }
.ship__pill { fill: var(--magenta); }
.ship__text { font: 600 12.5px var(--sans); fill: #fff; }
/* ---------- title cartouche ---------- */
.cartouche {
position: absolute; top: 20px; left: 24px; width: 372px; padding: 14px 18px 14px;
background: var(--water);
border: 1px solid var(--ink); outline: 1px solid var(--ink); outline-offset: 3px;
}
.cartouche h1 { margin: 0 0 4px; font: 600 34px/1 var(--serif); letter-spacing: 0.01em; }
.cartouche p { margin: 0; font-size: 13px; color: var(--ink-2); max-width: 34em; }
.cartouche .cartouche__how { margin-top: 6px; color: var(--ink); font-weight: 500; }
/* ---------- morph control ---------- */
.morph {
position: absolute; top: 22px; left: 50%; transform: translateX(-50%);
display: grid; justify-items: center; gap: 4px;
padding: 8px 14px 8px; background: rgba(244, 247, 245, 0.92); border: 1px solid rgba(31, 51, 70, 0.35); border-radius: 4px;
}
.morph__row { display: flex; align-items: center; gap: 10px; }
.morph__end { border: 0; background: none; padding: 4px 6px; font: italic 500 17px var(--serif); color: var(--ink-2); cursor: pointer; border-bottom: 2px solid transparent; }
.morph__end[aria-pressed="true"] { color: var(--ink); border-bottom-color: var(--magenta); }
.morph input { width: 220px; accent-color: var(--magenta); }
.morph__note { margin: 0; font-size: 11.5px; color: var(--ink-2); }
/* ---------- key ---------- */
.key {
position: absolute; left: 24px; bottom: 16px; width: 420px;
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px;
padding: 12px 14px; background: rgba(244, 247, 245, 0.94); border: 1px solid rgba(31, 51, 70, 0.3);
}
.key__group h2 { margin: 0 0 4px; font: 600 12px var(--sans); }
.key__group h2 span { display: block; font: italic 500 13px var(--serif); color: var(--ink-2); }
.key ul { list-style: none; margin: 0; padding: 0; }
.key__item {
display: grid; grid-template-columns: 22px 1fr auto; align-items: center; gap: 8px; width: 100%;
padding: 3px 4px; border: 0; border-radius: 3px; background: none; text-align: left; font-size: 12.5px; cursor: pointer;
}
.key__item:hover { background: rgba(31, 51, 70, 0.06); }
.key__item[aria-pressed="true"] { background: var(--ink); color: var(--water); }
.key__swatch { height: 5px; border-radius: 3px; background: var(--c); box-shadow: 0 0 0 1.5px var(--water); }
.key__count { font-size: 11px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
.key__item[aria-pressed="true"] .key__count { color: var(--shoal); }
.key__legend {
grid-column: 1 / -1; display: grid; grid-template-columns: 26px 1fr; align-items: center; gap: 3px 8px;
margin: 6px 0 0; padding-top: 8px; border-top: 1px solid rgba(31, 51, 70, 0.18); font-size: 11.5px; color: var(--ink-2);
}
.key__legend dd { margin: 0; }
.lg { height: 14px; position: relative; }
.lg--course::after { content: ""; position: absolute; left: 0; right: 0; top: 5px; height: 4px; border-radius: 2px; background: var(--magenta); }
.lg--port::after, .lg--inter::after { content: ""; position: absolute; left: 8px; top: 0; width: 9px; height: 14px; border-radius: 5px; background: #fff; border: 1.5px solid var(--ink); }
.lg--inter::after { border-width: 3px; }
.lg--return::after { content: ""; position: absolute; left: 0; right: 0; top: 6px; border-top: 2px dotted var(--ink-2); }
.key__trace { grid-column: 1 / -1; margin: 6px 0 0; padding: 6px 8px; border-left: 4px solid var(--c); background: rgba(31, 51, 70, 0.04); font-size: 12px; }
/* ---------- drawer ---------- */
.drawer {
position: absolute; top: 0; right: 0; bottom: 0; width: 400px; z-index: 5;
padding: 22px 26px 28px; overflow-y: auto;
background: #fbfcfa; border-left: 1px solid rgba(31, 51, 70, 0.3); box-shadow: -10px 0 30px rgba(31, 51, 70, 0.08);
transform: translateX(105%); transition: transform 0.35s cubic-bezier(0.2, 0.7, 0.2, 1);
}
.drawer-open .drawer { transform: none; }
.drawer__close { float: right; border: 1px solid var(--ink-3); background: none; border-radius: 3px; padding: 3px 10px; font-size: 12px; cursor: pointer; }
.drawer__track { margin: 4px 0 2px; font-size: 12px; color: var(--magenta); font-weight: 600; }
.drawer h2 { margin: 0 0 6px; font: 600 38px/1.02 var(--serif); }
.drawer__time { margin: 0; font: italic 500 18px/1.3 var(--serif); color: var(--ink-2); }
.drawer__odyssey { margin: 6px 0 0; font-size: 13px; }
.drawer__odyssey a { color: var(--ink); text-underline-offset: 3px; }
.drawer h3 { margin: 18px 0 6px; font: 600 12px var(--sans); color: var(--ink-2); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px 3px 8px; border: 1px solid rgba(31, 51, 70, 0.3); border-radius: 999px; background: none; font-size: 12px; cursor: pointer; }
.chip::before { content: ""; width: 14px; height: 4px; border-radius: 2px; background: var(--c); }
.chip[aria-pressed="true"] { background: var(--ink); color: var(--water); border-color: var(--ink); }
.parts { list-style: none; margin: 0; padding: 0; }
.parts li { display: grid; grid-template-columns: 74px 1fr auto; gap: 10px; align-items: baseline; padding: 5px 0; border-bottom: 1px solid rgba(31, 51, 70, 0.1); font-size: 13px; }
.parts__name { font-weight: 600; }
.parts__who { color: var(--ink-2); }
.parts__dots { display: flex; gap: 3px; }
.parts__dots i { width: 12px; height: 4px; border-radius: 2px; background: var(--c); }
.drawer .audio-slot { border-color: rgba(31, 51, 70, 0.35); margin-bottom: 10px; font-size: 13px; }
.drawer .video-card { border-color: rgba(31, 51, 70, 0.35); font-size: 13px; }
.drawer .video-card:hover { border-color: var(--magenta); color: var(--magenta); opacity: 1; }
/* ---------- overlay popover ---------- */
.pop {
position: absolute; z-index: 6; width: 230px; padding: 10px 12px 12px;
background: #fff; border: 1px solid var(--ink); border-radius: 4px; box-shadow: 0 8px 24px rgba(8, 24, 40, 0.25);
}
.pop[hidden] { display: none; }
.pop p { margin: 0; }
.pop__track { font-size: 11px; color: var(--ink-2); }
.pop__title { font: 600 22px/1.1 var(--serif); }
.pop__time { font: italic 500 14px var(--serif); color: var(--ink-2); }
.pop__actions { display: flex; gap: 6px; margin-top: 8px; }
.pop__sail { flex: 1; border: 0; border-radius: 3px; padding: 6px 10px; background: var(--magenta); color: #fff; font-weight: 600; font-size: 13px; cursor: pointer; }
.pop__sail:disabled { background: var(--ink-3); cursor: default; }
.pop__open { border: 1px solid var(--ink-3); border-radius: 3px; padding: 6px 10px; background: none; font-size: 12.5px; cursor: pointer; }
.backdrop-note { display: none; }
/* ---------- in-3D overlay state ---------- */
.is-overlay { background: #0d2232; }
.is-overlay #app {
background:
repeating-linear-gradient(172deg, rgba(255, 255, 255, 0.025) 0 2px, transparent 2px 22px),
radial-gradient(ellipse at 50% 120%, #2f6176 0%, #163a4f 45%, #0b1d2b 100%);
}
.is-overlay #app::before { content: ""; position: absolute; inset: 0; background: rgba(5, 14, 22, 0.45); }
.is-overlay .card {
inset: auto; top: 50%; left: 50%; width: 1180px; height: 650px; transform: translate(-50%, -52%);
background: var(--water); border-radius: 6px; box-shadow: 0 30px 80px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.2);
}
.is-overlay .pan { inset: 52px 0 0 0; }
.is-overlay .cartouche, .is-overlay .morph { display: none; }
.is-overlay .key {
left: 0; right: 0; top: 0; bottom: auto; width: auto; height: 52px;
display: flex; align-items: center; gap: 14px; padding: 0 16px;
background: var(--water); border: 0; border-bottom: 1px solid rgba(31, 51, 70, 0.2);
}
.is-overlay .key__group { display: flex; align-items: center; gap: 6px; }
.is-overlay .key__group h2 { margin: 0; font-size: 0; }
.is-overlay .key__group h2 span { display: none; }
.is-overlay .key ul { display: flex; gap: 4px; }
.is-overlay .key__item { grid-template-columns: 14px auto; gap: 6px; padding: 4px 8px; border: 1px solid rgba(31, 51, 70, 0.22); border-radius: 999px; font-size: 12px; }
.is-overlay .key__count { display: none; }
.is-overlay .key__group + .key__group { padding-left: 14px; border-left: 1px solid rgba(31, 51, 70, 0.2); }
.is-overlay .key__trace { display: none; }
.is-overlay .backdrop-note {
display: block; position: absolute; left: 0; right: 0; bottom: 14px; margin: 0; text-align: center;
font: italic 500 16px var(--serif); color: rgba(226, 238, 243, 0.75);
}
.is-overlay .fan-credits { background: #0b1d2b; color: rgba(226, 238, 243, 0.9); border-top-color: rgba(255, 255, 255, 0.15); }
.is-overlay .drawer { z-index: 8; }
.is-overlay .port__name { font-size: 14px; }
.is-overlay .ship__text { font-size: 14px; }
.is-booting .pan, .is-booting .drawer { transition: none; }
@media (prefers-reduced-motion: reduce) {
.pan, .drawer, .lane { transition: none; }
}
+119
View File
@@ -0,0 +1,119 @@
import { songs } from "../../data/sample";
import { gapYears } from "../../data/query";
/* Chart geometry in chart units (1 unit = 1px at scale 1). Mockup 01's reading
model: every song is one STEP after the last, and every in-story year adds
YEAR units, so elapsed time reads as distance sailed. */
export const STEP = 220;
export const YEAR = 600;
export const START = 300;
export const W = 4600;
export const H = 626;
export const SEA_TOP = 44;
export const SEA_BOTTOM = 412;
export const SCALE_Y = 396;
export type Pt = readonly [number, number];
export const xs: number[] = [];
songs.forEach((s, i) => {
xs.push(i === 0 ? START : xs[i - 1]! + STEP + Math.max(0, gapYears(songs[i - 1]!, s)) * YEAR);
});
/* Port heights: gentle, deterministic undulation; the Cyclops ports sit a
little lower so the island has room above them. */
const PORT_Y = [262, 282, 266, 288, 280, 296, 304, 292, 300, 276];
export const ports: Pt[] = xs.map((x, i) => [x, PORT_Y[i] ?? 280] as const);
/* Extra control points keep the long open-water gaps from being straight. */
const EXTRA: Pt[] = [
[130, 246],
[1370, 322],
[3040, 250],
[3460, 318],
[3800, 258],
[4470, 262],
];
const CONTROL: Pt[] = [...EXTRA, ...ports].sort((a, b) => a[0] - b[0]);
/* The route is y = f(x): a cubic Hermite spline over x, so it can never loop
back on itself and stays left-to-right. */
const tangents = CONTROL.map((p, i) => {
const a = CONTROL[Math.max(0, i - 1)]!;
const b = CONTROL[Math.min(CONTROL.length - 1, i + 1)]!;
return (b[1] - a[1]) / (b[0] - a[0]);
});
export function routeY(x: number): number {
const c = CONTROL;
if (x <= c[0]![0]) return c[0]![1];
if (x >= c[c.length - 1]![0]) return c[c.length - 1]![1];
let i = 0;
while (c[i + 1]![0] < x) i++;
const [x0, y0] = c[i]!;
const [x1, y1] = c[i + 1]!;
const h = x1 - x0;
const t = (x - x0) / h;
const t2 = t * t, t3 = t2 * t;
return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * tangents[i]! + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * tangents[i + 1]!;
}
export const routeAngle = (x: number) => (Math.atan2(routeY(x + 4) - routeY(x - 4), 8) * 180) / Math.PI;
export const ROUTE_START = CONTROL[0]![0];
export const ROUTE_END = CONTROL[CONTROL.length - 1]![0];
/** Path along the route between two x positions, optionally offset vertically. */
export function routePath(a: number, b: number, dy = 0): string {
let d = "";
for (let x = a; x < b; x += 6) d += `${d ? "L" : "M"}${x.toFixed(1)},${(routeY(x) + dy).toFixed(1)}`;
return d + `L${b.toFixed(1)},${(routeY(b) + dy).toFixed(1)}`;
}
/* ---------------------------------------------------------------- coastlines */
function rand(seed: number) {
let s = seed >>> 0;
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
}
function seg(p0: Pt, p1: Pt, p2: Pt, p3: Pt): string {
const t = 1 / 6;
return ` C${(p1[0] + (p2[0] - p0[0]) * t).toFixed(1)},${(p1[1] + (p2[1] - p0[1]) * t).toFixed(1)} ${(p2[0] - (p3[0] - p1[0]) * t).toFixed(1)},${(p2[1] - (p3[1] - p1[1]) * t).toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
}
export function closedSmooth(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`;
for (let i = 0; i < n; i++) d += seg(pts[(i - 1 + n) % n]!, pts[i]!, pts[(i + 1) % n]!, pts[(i + 2) % n]!);
return d + "Z";
}
export function openSmooth(pts: Pt[]): string {
let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) d += seg(pts[Math.max(0, i - 1)]!, pts[i]!, pts[i + 1]!, pts[Math.min(pts.length - 1, i + 2)]!);
return d;
}
/** A seeded, irregular island outline; `grow` inflates it for offset water lines. */
export function island(cx: number, cy: number, rx: number, ry: number, seed: number, grow = 0, n = 30): string {
const r = rand(seed);
const jitter = Array.from({ length: n }, () => r());
const pts: Pt[] = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const k = 1 + 0.13 * Math.sin(3 * a + seed) + 0.08 * Math.sin(7 * a + seed * 2) + (jitter[i]! - 0.5) * 0.12;
pts.push([cx + Math.cos(a) * (rx * k + grow), cy + Math.sin(a) * (ry * k + grow)]);
}
return closedSmooth(pts);
}
/** Troy's mainland: a coast hanging from the top edge of the sea. */
export function troyCoast(dy = 0): Pt[] {
const r = rand(7);
const pts: Pt[] = [];
for (let x = -20; x <= 1180; x += 40) {
const fall = x > 900 ? ((x - 900) / 280) ** 2 * 120 : 0;
pts.push([x, 112 + 24 * Math.sin(x / 150) + 15 * Math.sin(x / 61 + 1) + 8 * Math.sin(x / 23 + 2) + (r() - 0.5) * 18 - fall + dy]);
}
pts.push([1240, SEA_TOP - 6 + dy]);
return pts;
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Unrolling chart — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+608
View File
@@ -0,0 +1,608 @@
import { motifs, sagas, songs } from "../../data/sample";
import { gapYears, motifsInSong, sagaOf, songsWithMotif } from "../../data/query";
import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import {
H, ROUTE_END, ROUTE_START, SCALE_Y, SEA_BOTTOM, SEA_TOP, STEP, W,
island, openSmooth, ports, routeAngle, routePath, routeY, troyCoast, xs,
} from "./chart";
mountCredits();
const params = new URLSearchParams(location.search);
const OVERLAY = params.get("overlay") === "1";
const S = OVERLAY ? 0.62 : 1.1;
const SVG_NS = "http://www.w3.org/2000/svg";
/* Shared motif colours are tuned for dark pages; on vellum they are re-inked
to darker iron-gall versions of the same hue. */
const INK: Record<string, string> = {
warrior: "#93650b",
"open-arms": "#b2472c",
"just-a-man": "#2e5d8b",
remember: "#3b7347",
odysseus: "#5b3c20",
athena: "#6a4a9d",
polyphemus: "#8e1e24",
};
const ink = (m: Motif) => INK[m.id] ?? m.color;
/* Thread rows beneath the sea: motifs, a small gap, then characters. */
const threadY = new Map<string, number>();
motifs.filter((m) => m.kind === "motif").forEach((m, i) => threadY.set(m.id, 448 + i * 24));
motifs.filter((m) => m.kind === "character").forEach((m, i) => threadY.set(m.id, 554 + i * 24));
/* ------------------------------------------------------------------ helpers */
type Attrs = Record<string, string | number | undefined>;
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, ...kids: (Node | string)[]) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
el.append(...kids);
return el;
}
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, text?: string) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
if (text !== undefined) el.textContent = text;
return el;
}
/** Break a label into at most two lines near the middle. */
function twoLines(text: string, max: number): string[] {
if (text.length <= max) return [text];
const words = text.split(" ");
let best = 1, bestDiff = Infinity;
for (let i = 1; i < words.length; i++) {
const d = Math.abs(words.slice(0, i).join(" ").length - words.slice(i).join(" ").length);
if (d < bestDiff) { bestDiff = d; best = i; }
}
return [words.slice(0, best).join(" "), words.slice(best).join(" ")];
}
const WORDS = ["no time", "a year", "two years", "three years", "four years"];
const yearsPass = (n: number) => (n === 1 ? "a year passes" : `${WORDS[n] ?? `${n} years`} pass at sea`);
const yearName = (y: number) => (y === 0 ? "The year Troy falls" : `Year ${y} after Troy`);
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ------------------------------------------------------------------ skeleton */
const app = document.getElementById("app")!;
document.body.classList.toggle("is-overlay", OVERLAY);
const minimap = h("div", { class: "minimap", role: "slider", tabindex: 0, "aria-label": "Whole chart. Click to jump.", "aria-valuemin": 0, "aria-valuemax": 100 });
const masthead = h(
"header",
{ class: "masthead" },
h(
"div",
{ class: "masthead__words" },
h("h1", { class: "masthead__title" }, OVERLAY ? "Sea chart" : "The voyage, unrolled"),
h("p", { class: "masthead__dek" }, OVERLAY ? "Pick a port to set sail." : "A chart of EPIC: The Musical. Pan east to sail; the longer the water, the more years pass."),
),
minimap,
);
const scroller = h("section", { class: "roll", tabindex: 0, "aria-label": "Sea chart of the songs. Drag, scroll or use the arrow keys to sail." });
const track = h("div", { class: "roll__track", style: `width:${W * S}px;height:${H * S}px` });
const svg = s("svg", { class: "chart", width: W * S, height: H * S, viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Sea chart" });
track.append(svg);
scroller.append(track);
const rollers = h("div", { class: "rollers", "aria-hidden": "true" }, h("span", { class: "roller roller--west" }), h("span", { class: "roller roller--east" }));
const sheet = h("div", { class: "sheet" }, scroller, rollers);
const legend = h("nav", { class: "legend", "aria-label": "Motifs", style: `height:${H * S}px` });
const stage = h("div", { class: "stage" }, legend, sheet);
const caption = h("p", { class: "caption", "aria-live": "polite" });
const panel = h("aside", { class: "drawer", "aria-label": "Song", "aria-hidden": "true" });
if (OVERLAY) {
const frame = h("div", { class: "frame" }, masthead, stage, caption);
app.append(h("div", { class: "backdrop", "aria-hidden": "true" }, h("span", { class: "backdrop__note" }, "3D sea voyage, paused")), frame);
} else {
app.append(masthead, stage, caption, panel);
}
/* ------------------------------------------------------------------ svg layers */
const defs = s("defs");
defs.append(s("clipPath", { id: "sea-clip" }));
defs.lastElementChild!.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: SEA_BOTTOM - SEA_TOP }));
svg.append(defs);
// Vellum ground with a faint fold every saga.
svg.append(s("rect", { x: 0, y: 0, width: W, height: H, class: "vellum" }));
svg.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: SEA_BOTTOM - SEA_TOP, class: "sea" }));
// Rhumb lines radiating from two compass roses, beneath everything.
const gRhumb = s("g", { class: "rhumbs", "clip-path": "url(#sea-clip)" });
const roses: [number, number][] = [[1370, 168], [3060, 150]];
for (const [cx, cy] of roses) {
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const cls = i % 4 === 0 ? "rhumb rhumb--ink" : i % 2 === 0 ? "rhumb rhumb--green" : "rhumb rhumb--red";
gRhumb.append(s("line", { x1: cx, y1: cy, x2: cx + Math.cos(a) * 1400, y2: cy + Math.sin(a) * 1400, class: cls }));
}
}
svg.append(gRhumb);
// Land.
const gLand = s("g", { class: "land", "clip-path": "url(#sea-clip)" });
const coast = troyCoast();
const coastPath = `${openSmooth(coast)} L1260,0 L-40,0 Z`;
for (const g of [26, 14]) gLand.append(s("path", { d: openSmooth(troyCoast(g)), class: "waterline" }));
gLand.append(s("path", { d: coastPath, class: "landmass" }));
gLand.append(s("path", { d: openSmooth(coast), class: "coastwash" }));
const cyclops = { cx: 2330, cy: 132, rx: 380, ry: 64, seed: 11 };
for (const g of [24, 12]) gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed, g), class: "waterline" }));
gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "landmass" }));
gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "coastwash" }));
// A few rocks in the open water.
for (const [x, y, r, seed] of [[1660, 360, 16, 3], [2860, 356, 12, 5], [3600, 140, 22, 9], [3720, 176, 10, 13]] as const) {
gLand.append(s("path", { d: island(x, y, r, r * 0.7, seed, 7, 14), class: "waterline" }));
gLand.append(s("path", { d: island(x, y, r, r * 0.7, seed, 0, 14), class: "landmass" }));
}
svg.append(gLand);
// Place names, written the way a portolan writes them: italic, along the coast.
const gNames = s("g", { class: "places" });
gNames.append(s("text", { x: 300, y: 82, class: "place place--big" }, "Troy"));
// Towers of Troy.
const towers = s("g", { class: "towers", transform: "translate(420 64)" });
for (const [dx, hgt] of [[0, 18], [12, 26], [24, 20], [36, 14]] as const) towers.append(s("rect", { x: dx, y: 26 - hgt, width: 9, height: hgt }));
gNames.append(towers);
gNames.append(s("text", { x: cyclops.cx, y: cyclops.cy + 8, class: "place place--big", "text-anchor": "middle" }, "Island of the Cyclopes"));
gNames.append(s("text", { x: 3300, y: 214, class: "place place--sea", "text-anchor": "middle" }, "The open sea"));
svg.append(gNames);
// Compass roses.
for (const [cx, cy] of roses) {
const g = s("g", { class: "rose", transform: `translate(${cx} ${cy})` });
g.append(s("circle", { r: 44, class: "rose__ring" }));
g.append(s("circle", { r: 36, class: "rose__ring rose__ring--thin" }));
for (let i = 0; i < 8; i++) {
const len = i % 2 === 0 ? 52 : 30;
const cls = i % 2 === 0 ? "rose__point" : "rose__point rose__point--minor";
g.append(s("path", { d: `M0,${-len} L6,0 L0,0 Z`, class: cls, transform: `rotate(${i * 45})` }));
g.append(s("path", { d: `M0,${-len} L-6,0 L0,0 Z`, class: `${cls} rose__point--shade`, transform: `rotate(${i * 45})` }));
}
g.append(s("text", { y: -58, class: "rose__n", "text-anchor": "middle" }, "N"));
svg.append(g);
}
// The storm: a swirl of ink above Storm's port.
const storm = s("g", { class: "storm", transform: `translate(${xs[9]! + 150} 150)` });
for (let k = 0; k < 3; k++) {
let d = "";
for (let t = 0; t < 4.2 * Math.PI; t += 0.2) {
const r = 6 + t * 5.2;
const x = Math.cos(t + k * 2.1) * r * 1.5;
const y = Math.sin(t + k * 2.1) * r * 0.75;
d += `${d ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`;
}
storm.append(s("path", { d, class: "storm__arm" }));
}
for (let i = 0; i < 9; i++) storm.append(s("line", { x1: -110 + i * 26, y1: 70, x2: -126 + i * 26, y2: 108, class: "storm__rain" }));
svg.append(storm);
// Saga band.
const gSaga = s("g", { class: "sagas" });
for (const saga of sagas) {
const idx = songs.map((so, i) => (so.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (!idx.length) continue;
const a = xs[idx[0]!]! - 90;
const b = xs[idx[idx.length - 1]!]! + 90;
gSaga.append(s("path", { d: `M${a} 38 v-7 H${b} v7`, class: "saga__bracket" }));
gSaga.append(s("text", { x: a + 8, y: 24, class: "saga__name" }, saga.name));
}
svg.append(gSaga);
// Nautical scale bar doubling as the time axis.
const gScale = s("g", { class: "scale" });
for (let x = ROUTE_START, k = 0; x < ROUTE_END; x += 110, k++) {
gScale.append(s("rect", { x, y: SCALE_Y, width: Math.min(110, ROUTE_END - x), height: 6, class: k % 2 ? "scale__cell" : "scale__cell scale__cell--ink" }));
}
songs.forEach((so, i) => {
const gy = i === 0 ? 1 : gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
const x = i === 0 ? ROUTE_START : xs[i]! - STEP / 2;
gScale.append(s("line", { x1: x, x2: x, y1: SCALE_Y - 8, y2: SCALE_Y + 14, class: "scale__tick" }));
gScale.append(s("text", { x: x + 6, y: SCALE_Y - 6, class: "scale__label" }, yearName(so.storyTime.yearsAfterTroy)));
});
svg.append(gScale);
// Meridians from each port down through the motif threads.
const gMer = s("g", { class: "meridians" });
xs.forEach((x) => gMer.append(s("line", { x1: x, x2: x, y1: SCALE_Y + 16, y2: H - 8 })));
svg.append(gMer);
// Motif threads.
const gThreads = s("g", { class: "threads" });
const threadEls = new Map<string, SVGGElement>();
for (const m of motifs) {
const y = threadY.get(m.id)!;
const g = s("g", { class: "thread", "data-motif": m.id, style: `--ink:${ink(m)}` });
g.append(s("line", { x1: ROUTE_START, x2: ROUTE_END, y1: y, y2: y, class: "thread__ghost" }));
const hits = songs.map((so, i) => [so, i] as const).filter(([so]) => so.parts.some((p) => p.motifIds.includes(m.id)));
if (hits.length) {
g.append(s("line", { x1: xs[hits[0]![1]]!, x2: xs[hits[hits.length - 1]![1]]!, y1: y, y2: y, class: "thread__line" }));
for (const [so, i] of hits) {
const parts = so.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name);
g.append(s("circle", { cx: xs[i]!, cy: y, r: 5, class: "thread__knot" }));
g.append(s("text", { x: xs[i]! + 9, y: y - 7, class: "thread__part" }, parts.join(", ")));
}
}
threadEls.set(m.id, g);
gThreads.append(g);
}
svg.append(gThreads);
// The route itself.
const gRoute = s("g", { class: "route" });
gRoute.append(s("path", { d: routePath(ROUTE_START, ROUTE_END), class: "route__line" }));
const gMotifRoute = s("g", { class: "route__motif" });
gRoute.append(gMotifRoute);
// Elapsed time written along the water.
songs.forEach((so, i) => {
if (i === 0) return;
const gy = gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
const id = `gap-${i}`;
defs.append(s("path", { id, d: routePath(xs[i - 1]! + 120, xs[i]! - 120, -12) }));
const t = s("text", { class: "route__elapsed" });
const tp = s("textPath", { href: `#${id}`, startOffset: "50%", "text-anchor": "middle" }, yearsPass(gy));
t.append(tp);
gRoute.append(t);
});
svg.append(gRoute);
// Ports.
const gPorts = s("g", { class: "ports" });
const portEls: SVGGElement[] = [];
songs.forEach((so, i) => {
const [x, y] = ports[i]!;
const g = s("g", { class: "port", transform: `translate(${x} ${y})`, tabindex: -1, role: "button", "aria-label": `${so.title}, ${so.storyTime.label}`, "data-song": so.id });
g.append(s("circle", { r: 18, class: "port__hit" }));
g.append(s("circle", { r: 13, class: "port__ring" }));
g.append(s("circle", { r: 7, class: "port__dot" }));
g.append(s("circle", { r: 2.2, class: "port__pin" }));
const title = twoLines(so.title, 15);
title.forEach((line, k) => g.append(s("text", { y: -38 - (title.length - 1 - k) * 20, class: "port__title", "text-anchor": "middle" }, line)));
const when = twoLines(so.storyTime.label, 22);
when.forEach((line, k) => g.append(s("text", { y: 32 + k * 16, class: "port__when", "text-anchor": "middle" }, line)));
portEls.push(g);
gPorts.append(g);
});
svg.append(gPorts);
// The ship.
const ship = s("g", { class: "ship" });
const shipBody = s("g", { class: "ship__body", transform: "scale(0.72)" });
shipBody.append(s("path", { d: "M-20,-2 L20,-2 L13,8 L-14,8 Z", class: "ship__hull" }));
shipBody.append(s("path", { d: "M0,-2 L0,-34", class: "ship__mast" }));
shipBody.append(s("path", { d: "M-13,-31 Q0,-24 13,-31 L12,-9 Q0,-4 -12,-9 Z", class: "ship__sail" }));
shipBody.append(s("path", { d: "M0,-34 L10,-38 L0,-41 Z", class: "ship__pennant" }));
ship.append(s("ellipse", { cx: 0, cy: 9, rx: 26, ry: 4, class: "ship__wake" }), shipBody);
svg.append(ship);
const youAreHere = h("div", { class: "here", "aria-live": "polite" });
if (OVERLAY) track.append(youAreHere);
/* ------------------------------------------------------------------ legend */
const legendKey = h(
"div",
{ class: "legend__key", style: `height:${(SEA_BOTTOM - 4) * S}px` },
h("p", {}, "Each port is a song, in story order."),
h("p", {}, "Open water between ports is time passing: the wider the gap, the more years lost."),
h("p", {}, "Threads below show where each motif plays. Pick one to follow it."),
);
legend.append(legendKey);
const legendBtns = new Map<string, HTMLButtonElement>();
for (const m of motifs) {
const b = h("button", { type: "button", class: `legend__item legend__item--${m.kind}`, style: `top:${(threadY.get(m.id)! - 11) * S}px;--ink:${ink(m)}`, "aria-pressed": "false", title: m.description }, h("span", { class: "legend__swatch" }), m.name);
b.addEventListener("click", () => setMotif(state.motif === m.id ? null : m.id));
legendBtns.set(m.id, b);
legend.append(b);
}
/* ------------------------------------------------------------------ minimap */
const MINI_H = SEA_BOTTOM;
const mini = s("svg", { class: "minimap__svg", viewBox: `0 ${SEA_TOP} ${W} ${MINI_H - SEA_TOP}`, preserveAspectRatio: "none", "aria-hidden": "true" });
mini.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: MINI_H - SEA_TOP, class: "mini__sea" }));
mini.append(s("path", { d: coastPath, class: "mini__land" }));
mini.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "mini__land" }));
for (const saga of sagas.slice(1)) {
const i = songs.findIndex((so) => so.sagaId === saga.id);
if (i > 0) mini.append(s("line", { x1: xs[i]! - 110, x2: xs[i]! - 110, y1: SEA_TOP, y2: MINI_H, class: "mini__fold" }));
}
mini.append(s("path", { d: routePath(ROUTE_START, ROUTE_END), class: "mini__route" }));
const miniDots = ports.map(([x, y]) => {
const r = s("rect", { x: x - 14, y: y - 30, width: 28, height: 60, class: "mini__port" });
mini.append(r);
return r;
});
const miniShip = h("span", { class: "minimap__ship" });
const miniView = h("span", { class: "minimap__view" });
minimap.append(mini, miniView, miniShip);
/* ------------------------------------------------------------------ state */
const state = { motif: null as string | null, song: null as string | null, shipX: xs[0]!, here: 6 };
const FIRST = xs[0]!, LAST = xs[xs.length - 1]!;
const maxScroll = () => Math.max(1, scroller.scrollWidth - scroller.clientWidth);
function placeShip(x: number) {
state.shipX = x;
ship.setAttribute("transform", `translate(${x.toFixed(1)} ${routeY(x).toFixed(1)}) rotate(${routeAngle(x).toFixed(1)})`);
miniShip.style.left = `${(x / W) * 100}%`;
if (OVERLAY) {
youAreHere.style.left = `${x * S}px`;
youAreHere.style.top = `${(routeY(x) - 64) * S}px`;
}
}
function nearestPort(x: number) {
let best = 0;
xs.forEach((px, i) => { if (Math.abs(px - x) < Math.abs(xs[best]! - x)) best = i; });
return best;
}
/** Scroll position at which the ship sits exactly on port i. */
const scrollForPort = (i: number) => ((xs[i]! - FIRST) / (LAST - FIRST)) * maxScroll();
function onScroll() {
const f = scroller.scrollLeft / maxScroll();
const vw = scroller.clientWidth / S;
miniView.style.left = `${((scroller.scrollLeft / S) / W) * 100}%`;
miniView.style.width = `${(vw / W) * 100}%`;
minimap.setAttribute("aria-valuenow", String(Math.round(f * 100)));
if (OVERLAY) return;
placeShip(FIRST + (LAST - FIRST) * f);
const i = nearestPort(state.shipX);
const so = songs[i]!;
const atPort = Math.abs(xs[i]! - state.shipX) < 40;
caption.replaceChildren(
h("span", { class: "caption__lead" }, atPort ? "Moored at " : "Sailing past "),
h("strong", {}, so.title),
`. ${so.storyTime.label}, in ${sagaOf(so).name}.`,
);
portEls.forEach((p, k) => p.classList.toggle("is-near", k === i));
}
scroller.addEventListener("scroll", onScroll, { passive: true });
/* ------------------------------------------------------------------ motif */
function setMotif(id: string | null) {
state.motif = id;
svg.classList.toggle("has-motif", !!id);
for (const [mid, g] of threadEls) g.classList.toggle("is-on", mid === id);
for (const [mid, b] of legendBtns) b.setAttribute("aria-pressed", String(mid === id));
legend.classList.toggle("has-motif", !!id);
gMotifRoute.replaceChildren();
const hitIds = new Set(id ? songsWithMotif(id).map((so) => so.id) : []);
portEls.forEach((p, i) => {
p.classList.toggle("is-motif", hitIds.has(songs[i]!.id));
p.style.setProperty("--motif", id ? ink(motifs.find((m) => m.id === id)!) : "");
});
miniDots.forEach((r, i) => r.classList.toggle("is-on", hitIds.has(songs[i]!.id)));
if (!id) return;
const m = motifs.find((mm) => mm.id === id)!;
const idx = songs.map((so, i) => (hitIds.has(so.id) ? i : -1)).filter((i) => i >= 0);
gMotifRoute.style.setProperty("--ink", ink(m));
if (idx.length > 1) gMotifRoute.append(s("path", { d: routePath(xs[idx[0]!]!, xs[idx[idx.length - 1]!]!), class: "route__motif-span" }));
for (const i of idx) gMotifRoute.append(s("path", { d: routePath(xs[i]! - 80, xs[i]! + 80), class: "route__motif-run" }));
mini.style.setProperty("--ink", ink(m));
}
/* ------------------------------------------------------------------ song drawer */
function openSong(id: string | null) {
state.song = id;
portEls.forEach((p) => p.classList.toggle("is-open", p.dataset.song === id));
const so = songs.find((x) => x.id === id);
if (!so) {
panel.setAttribute("aria-hidden", "true");
panel.classList.remove("is-open");
return;
}
panel.replaceChildren(...songDetail(so));
panel.setAttribute("aria-hidden", "false");
panel.classList.add("is-open");
}
function songDetail(so: Song): Node[] {
const close = h("button", { type: "button", class: "drawer__close", "aria-label": "Close song" }, "Close");
close.addEventListener("click", () => openSong(null));
const odyssey = so.odyssey
? h("a", { href: so.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, `Odyssey, Book ${so.odyssey.book}, lines ${so.odyssey.lines}`)
: h("span", {}, "No direct counterpart in the Odyssey");
const parts = h(
"ol",
{ class: "drawer__parts" },
...so.parts.map((p) =>
h(
"li",
{},
h("span", { class: "drawer__part" }, p.name),
h("span", { class: "drawer__singers" }, p.singers.join(", ")),
h(
"span",
{ class: "drawer__chips" },
...p.motifIds.map((mid) => {
const m = motifs.find((x) => x.id === mid)!;
const chip = h("button", { type: "button", class: "chip", style: `--ink:${ink(m)}` }, m.name);
chip.addEventListener("click", () => setMotif(mid));
return chip;
}),
),
),
),
);
return [
close,
h("p", { class: "drawer__meta" }, `Track ${so.track}, ${sagaOf(so).name}`),
h("h2", { class: "drawer__title" }, so.title),
h("p", { class: "drawer__when" }, `${so.storyTime.label}. ${yearName(so.storyTime.yearsAfterTroy)}.`),
h("p", { class: "drawer__odyssey" }, odyssey),
parts,
h("p", { class: "drawer__motifs" }, `Motifs here: ${motifsInSong(so).map((m) => m.name).join(", ")}.`),
createAudioSlot(so),
createVideoCards(so),
];
}
/* ------------------------------------------------------------------ overlay: sail here */
const pop = h("div", { class: "pop", hidden: "" });
if (OVERLAY) track.append(pop);
function setHere(i: number) {
state.here = i;
youAreHere.textContent = `You are here: ${songs[i]!.title}`;
portEls.forEach((p, k) => p.classList.toggle("is-here", k === i));
caption.textContent = `The ship is at ${songs[i]!.title}. ${songs[i]!.storyTime.label}. Pick another port to sail there.`;
}
function sailTo(i: number) {
const from = state.shipX, to = xs[i]!;
pop.hidden = true;
youAreHere.textContent = `Sailing to ${songs[i]!.title}`;
const dur = reduceMotion ? 0 : Math.min(2600, 500 + Math.abs(to - from) * 0.9);
const t0 = performance.now();
const step = (now: number) => {
const t = dur ? Math.min(1, (now - t0) / dur) : 1;
const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
placeShip(from + (to - from) * e);
if (t < 1) requestAnimationFrame(step);
else setHere(i);
};
requestAnimationFrame(step);
}
function showPop(i: number) {
const so = songs[i]!;
const [x, y] = ports[i]!;
const sail = h("button", { type: "button", class: "pop__sail" }, "Sail here");
sail.addEventListener("click", () => sailTo(i));
const here = i === state.here;
pop.replaceChildren(
h("strong", { class: "pop__title" }, so.title),
h("span", { class: "pop__when" }, so.storyTime.label),
here ? h("span", { class: "pop__now" }, "The ship is here") : sail,
);
pop.style.left = `${x * S}px`;
pop.style.top = `${(y + 22) * S}px`;
pop.hidden = false;
if (!here) sail.focus({ preventScroll: true });
}
/* ------------------------------------------------------------------ input */
function activatePort(i: number) {
if (OVERLAY) showPop(i);
else {
openSong(songs[i]!.id);
goToPort(i);
}
}
function goToPort(i: number, smooth = true) {
if (OVERLAY) {
scroller.scrollTo({ left: xs[i]! * S - scroller.clientWidth / 2, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
} else {
scroller.scrollTo({ left: scrollForPort(i), behavior: smooth && !reduceMotion ? "smooth" : "auto" });
}
}
// Drag to pan; a press that barely moves is a click on whatever is under it.
let drag: { x: number; left: number; moved: boolean } | null = null;
scroller.addEventListener("pointerdown", (e) => {
if ((e.target as Element).closest(".pop")) return;
drag = { x: e.clientX, left: scroller.scrollLeft, moved: false };
});
window.addEventListener("pointermove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
if (Math.abs(dx) > 4) {
drag.moved = true;
scroller.classList.add("is-dragging");
}
if (drag.moved) scroller.scrollLeft = drag.left - dx;
});
window.addEventListener("pointerup", (e) => {
const d = drag;
drag = null;
scroller.classList.remove("is-dragging");
if (!d || d.moved) return;
const port = (e.target as Element).closest?.(".port") as SVGGElement | null;
if (port) activatePort(portEls.indexOf(port));
else if (OVERLAY && !(e.target as Element).closest?.(".pop")) pop.hidden = true;
});
// A vertical wheel sails sideways.
scroller.addEventListener(
"wheel",
(e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
e.preventDefault();
scroller.scrollLeft += e.deltaY;
},
{ passive: false },
);
// Arrow keys step from port to port.
scroller.addEventListener("keydown", (e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const cur = OVERLAY ? nearestPort((scroller.scrollLeft + scroller.clientWidth / 2) / S) : nearestPort(state.shipX);
const next = Math.max(0, Math.min(songs.length - 1, cur + (e.key === "ArrowRight" ? 1 : -1)));
goToPort(next);
});
scroller.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
activatePort(OVERLAY ? nearestPort((scroller.scrollLeft + scroller.clientWidth / 2) / S) : nearestPort(state.shipX));
});
// Minimap: click or drag to jump; the clicked spot becomes the middle of the view.
function miniJump(e: PointerEvent) {
const r = minimap.getBoundingClientRect();
const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
scroller.scrollLeft = f * W * S - scroller.clientWidth / 2;
}
let miniDrag = false;
minimap.addEventListener("pointerdown", (e) => {
miniDrag = true;
minimap.setPointerCapture(e.pointerId);
miniJump(e);
});
minimap.addEventListener("pointermove", (e) => { if (miniDrag) miniJump(e); });
minimap.addEventListener("pointerup", () => { miniDrag = false; });
minimap.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") scroller.scrollLeft += scroller.clientWidth * 0.8;
if (e.key === "ArrowLeft") scroller.scrollLeft -= scroller.clientWidth * 0.8;
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (OVERLAY) pop.hidden = true;
else openSong(null);
});
/* ------------------------------------------------------------------ boot */
const motifParam = params.get("motif");
if (motifParam && threadEls.has(motifParam)) setMotif(motifParam);
const songParam = songs.findIndex((so) => so.id === params.get("song"));
if (OVERLAY) {
const here = songs.findIndex((so) => so.id === "survive");
placeShip(xs[here]!);
setHere(here);
goToPort(here, false);
if (songParam >= 0) showPop(songParam);
} else if (songParam >= 0) {
openSong(songs[songParam]!.id);
goToPort(songParam, false);
} else if (state.motif) {
const first = songs.findIndex((so) => so.parts.some((p) => p.motifIds.includes(state.motif!)));
if (first > 0) goToPort(first, false);
}
onScroll();
// One orchestrated moment: the chart unrolls from the west on load.
if (!reduceMotion && !OVERLAY) {
sheet.classList.add("is-unrolling");
sheet.addEventListener("animationend", () => sheet.classList.remove("is-unrolling"), { once: true });
}
+381
View File
@@ -0,0 +1,381 @@
/* 10-chart-scroll: a portolan chart unrolled on vellum */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
:root {
--vellum: #e3d6b4;
--vellum-hi: #ece2c6;
--land: #c9b07a;
--iron: #2b2118;
--iron-soft: #5e4d3a;
--rhumb-red: #a23a2a;
--rhumb-green: #3f6b5c;
--desk: #3a2f24;
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--desk) radial-gradient(ellipse at 50% 40%, #4a3c2e 0%, var(--desk) 70%);
color: var(--iron);
font-family: var(--sans);
}
#app { flex: 1; display: flex; flex-direction: column; padding: 18px 28px 0; gap: 12px; }
/* ---------------------------------------------------------------- masthead */
.masthead { display: flex; align-items: flex-end; gap: 36px; color: var(--vellum-hi); }
.masthead__words { flex: 0 0 360px; }
.masthead__title { margin: 0; font: 600 32px/1 var(--serif); letter-spacing: 0.01em; }
.masthead__dek { margin: 6px 0 0; font: 400 12.5px/1.45 var(--sans); color: #cbbd9b; max-width: 46ch; }
.minimap {
position: relative;
flex: 1;
height: 56px;
border: 1px solid #8c7752;
background: var(--vellum);
cursor: pointer;
touch-action: none;
}
.minimap:focus-visible { outline: 2px solid var(--vellum-hi); outline-offset: 3px; }
.minimap__svg { display: block; width: 100%; height: 100%; }
.mini__sea { fill: var(--vellum); }
.mini__land { fill: var(--land); stroke: var(--iron-soft); stroke-width: 3; vector-effect: non-scaling-stroke; }
.mini__route { fill: none; stroke: var(--rhumb-red); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.mini__fold { stroke: var(--iron-soft); stroke-dasharray: 2 3; stroke-width: 1; vector-effect: non-scaling-stroke; opacity: 0.6; }
.mini__port { fill: var(--iron); transform-box: fill-box; transform-origin: center; transform: scale(0.35, 0.12); }
.mini__port.is-on { fill: var(--ink, var(--rhumb-red)); transform: scale(0.7, 0.25); }
.minimap__view {
position: absolute;
top: -4px;
bottom: -4px;
border: 2px solid var(--iron);
background: rgb(43 33 24 / 0.08);
pointer-events: none;
}
.minimap__ship {
position: absolute;
top: -9px;
width: 0;
height: 0;
margin-left: -6px;
border: 6px solid transparent;
border-top: 8px solid var(--rhumb-red);
pointer-events: none;
}
/* ---------------------------------------------------------------- stage */
.stage { display: flex; align-items: flex-start; gap: 0; }
.legend { position: relative; flex: 0 0 210px; background: var(--vellum-hi); border-right: 1px solid #b9a47a; }
.legend__key { padding: 58px 18px 0 20px; font: 400 12px/1.5 var(--sans); color: var(--iron-soft); }
.legend__key p { margin: 0 0 10px; }
.legend__item {
position: absolute;
left: 10px;
right: 8px;
display: flex;
align-items: center;
gap: 9px;
height: 22px;
padding: 0 8px;
border: 0;
border-radius: 3px;
background: none;
color: var(--iron);
font: 500 13px/1 var(--sans);
text-align: left;
cursor: pointer;
}
.legend__item--character { font: 600 15px/1 var(--serif); }
.legend__swatch { flex: 0 0 22px; height: 3px; background: var(--ink); border-radius: 2px; }
.legend__item:hover { background: rgb(43 33 24 / 0.07); }
.legend__item:focus-visible { outline: 2px solid var(--iron); }
.legend__item[aria-pressed="true"] { background: var(--ink); color: var(--vellum-hi); }
.legend__item[aria-pressed="true"] .legend__swatch { background: var(--vellum-hi); }
.legend.has-motif .legend__item[aria-pressed="false"] { opacity: 0.5; }
.sheet { position: relative; flex: 1; min-width: 0; }
.roll {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
cursor: grab;
outline: none;
background: var(--vellum);
}
.roll::-webkit-scrollbar { display: none; }
.roll.is-dragging { cursor: grabbing; user-select: none; }
.roll:focus-visible { box-shadow: inset 0 0 0 2px var(--rhumb-red); }
.roll__track { position: relative; }
.chart { display: block; }
/* Paper rollers: the chart is wound on to a rod at each edge. */
.rollers { position: absolute; inset: 0; pointer-events: none; }
.roller { position: absolute; top: -8px; bottom: -8px; width: 22px; border-radius: 11px; }
.roller--west {
left: -11px;
background: linear-gradient(90deg, #5d4a30, #b49d71 35%, #efe4c8 55%, #a68e62 80%, #5d4a30);
box-shadow: 10px 0 18px -6px rgb(43 33 24 / 0.45);
}
.roller--east {
right: -11px;
background: linear-gradient(90deg, #5d4a30, #a68e62 20%, #efe4c8 45%, #b49d71 65%, #5d4a30);
box-shadow: -10px 0 18px -6px rgb(43 33 24 / 0.45);
}
.sheet::before,
.sheet::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 44px;
z-index: 1;
pointer-events: none;
}
.sheet::before { left: 0; background: linear-gradient(90deg, rgb(93 74 48 / 0.35), transparent); }
.sheet::after { right: 0; background: linear-gradient(270deg, rgb(93 74 48 / 0.35), transparent); }
.sheet.is-unrolling .roll { animation: unroll 1000ms cubic-bezier(0.3, 0.7, 0.2, 1) both; }
.sheet.is-unrolling .roller--east { animation: roll-east 1000ms cubic-bezier(0.3, 0.7, 0.2, 1) both; }
@keyframes unroll { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes roll-east { from { right: calc(100% - 11px); } to { right: -11px; } }
/* ---------------------------------------------------------------- chart ink */
.vellum { fill: var(--vellum); }
.sea { fill: var(--vellum); }
.rhumb { stroke-width: 0.8; opacity: 0.35; }
.rhumb--ink { stroke: var(--iron); opacity: 0.22; }
.rhumb--green { stroke: var(--rhumb-green); }
.rhumb--red { stroke: var(--rhumb-red); }
.landmass { fill: var(--land); stroke: var(--iron); stroke-width: 1.6; }
.coastwash { fill: none; stroke: var(--rhumb-green); stroke-width: 9; opacity: 0.28; }
.waterline { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 3 5; opacity: 0.7; }
.place { font-family: var(--serif); font-style: italic; fill: var(--iron); }
.place--big { font-size: 26px; font-weight: 500; letter-spacing: 0.04em; }
.place--sea { font-size: 30px; letter-spacing: 0.32em; fill: var(--rhumb-green); opacity: 0.8; }
.towers rect { fill: var(--iron); }
.rose__ring { fill: none; stroke: var(--iron); stroke-width: 1; }
.rose__ring--thin { stroke-width: 0.6; stroke-dasharray: 2 2; }
.rose__point { fill: var(--rhumb-red); stroke: var(--iron); stroke-width: 0.6; }
.rose__point--minor { fill: var(--rhumb-green); }
.rose__point--shade { fill: var(--vellum-hi); }
.rose__n { font: 600 15px var(--serif); fill: var(--iron); }
.storm__arm { fill: none; stroke: var(--iron); stroke-width: 1.4; opacity: 0.75; }
.storm__rain { stroke: var(--iron-soft); stroke-width: 1; opacity: 0.6; }
.saga__bracket { fill: none; stroke: var(--iron); stroke-width: 1; }
.saga__name { font: italic 500 21px var(--serif); fill: var(--iron); letter-spacing: 0.02em; }
.scale__cell { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 0.8; }
.scale__cell--ink { fill: var(--iron); }
.scale__tick { stroke: var(--iron); stroke-width: 1.4; }
.scale__label { font: italic 500 16px var(--serif); fill: var(--iron); }
.meridians line { stroke: var(--iron-soft); stroke-width: 0.8; stroke-dasharray: 1 4; opacity: 0.6; }
.thread__ghost { stroke: var(--iron-soft); stroke-width: 0.6; stroke-dasharray: 1 6; opacity: 0.5; }
.thread__line { stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; }
.thread__knot { fill: var(--vellum); stroke: var(--ink); stroke-width: 2.4; }
.thread__part { font: 500 10.5px var(--sans); fill: var(--ink); opacity: 0; paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.chart.has-motif .thread { opacity: 0.18; }
.chart.has-motif .thread.is-on { opacity: 1; }
.thread.is-on .thread__line { stroke-width: 4; }
.thread.is-on .thread__knot { fill: var(--ink); r: 6.5; }
.thread.is-on .thread__part { opacity: 1; }
.route__line { fill: none; stroke: var(--rhumb-red); stroke-width: 2.2; stroke-dasharray: 9 5; }
.route__motif-span { fill: none; stroke: var(--ink); stroke-width: 2; opacity: 0.55; }
.route__motif-run { fill: none; stroke: var(--ink); stroke-width: 7; stroke-linecap: round; opacity: 0.85; }
.route__elapsed { font: italic 500 19px var(--serif); fill: var(--rhumb-red); letter-spacing: 0.08em; }
.port { cursor: pointer; outline: none; }
.port__hit { fill: transparent; }
.port__ring { fill: none; stroke: transparent; stroke-width: 3; }
.port__dot { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 2; transition: r 160ms; }
.port__pin { fill: var(--iron); }
.port__title, .port__when { paint-order: stroke; stroke: var(--vellum); stroke-width: 5px; stroke-linejoin: round; }
.port__title { font: 600 19px var(--serif); fill: var(--iron); }
.port__when { font: italic 500 14px var(--serif); fill: var(--iron-soft); }
.port:hover .port__dot, .port:focus-visible .port__dot, .port.is-near .port__dot { r: 9; }
.port.is-open .port__dot { fill: var(--rhumb-red); }
.chart.has-motif .port:not(.is-motif) .port__title { fill: #8c7b62; }
.port.is-motif .port__ring { stroke: var(--motif); }
.port.is-motif .port__dot { stroke: var(--motif); }
.ship { pointer-events: none; transition: none; }
.ship__hull { fill: var(--iron); }
.ship__mast { stroke: var(--iron); stroke-width: 1.6; }
.ship__sail { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1.3; }
.ship__pennant { fill: var(--rhumb-red); }
.ship__wake { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 4 3; }
/* ---------------------------------------------------------------- caption */
.caption { margin: 0; padding-left: 210px; font: 400 20px/1.3 var(--serif); color: var(--vellum-hi); min-height: 26px; }
.caption strong { font-weight: 600; }
.caption__lead { font-style: italic; color: #cbbd9b; }
/* ---------------------------------------------------------------- drawer */
.drawer {
position: fixed;
top: 100px;
right: 0;
bottom: 56px;
width: 400px;
z-index: 5;
padding: 22px 26px 24px;
overflow-y: auto;
background: var(--vellum-hi);
border-left: 3px double var(--iron);
box-shadow: -18px 0 30px -12px rgb(20 14 8 / 0.55);
transform: translateX(105%);
transition: transform 260ms ease-out;
}
.drawer.is-open { transform: none; }
.drawer__close {
float: right;
border: 1px solid var(--iron);
background: none;
font: 500 12px var(--sans);
color: var(--iron);
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
}
.drawer__meta { margin: 0; font: 400 12px var(--sans); color: var(--iron-soft); }
.drawer__title { margin: 6px 0 4px; font: 600 34px/1.05 var(--serif); }
.drawer__when { margin: 0 0 10px; font: italic 500 17px/1.35 var(--serif); color: var(--iron-soft); }
.drawer__odyssey { margin: 0 0 14px; font: 400 13px var(--sans); }
.drawer__odyssey a { color: var(--rhumb-red); }
.drawer__parts { margin: 0 0 12px; padding: 0; list-style: none; border-top: 1px solid #b9a47a; }
.drawer__parts li { display: grid; grid-template-columns: 70px 1fr; gap: 2px 10px; padding: 9px 0; border-bottom: 1px solid #b9a47a; }
.drawer__part { font: 600 16px var(--serif); }
.drawer__singers { font: 400 13px/1.4 var(--sans); }
.drawer__chips { grid-column: 2; display: flex; flex-wrap: wrap; gap: 5px; }
.chip {
border: 1.5px solid var(--ink);
border-radius: 999px;
background: none;
color: var(--ink);
font: 500 11px var(--sans);
padding: 2px 8px;
cursor: pointer;
}
.chip:hover { background: var(--ink); color: var(--vellum-hi); }
.drawer__motifs { margin: 0 0 14px; font: italic 500 15px var(--serif); color: var(--iron-soft); }
.drawer .audio-slot { margin-bottom: 12px; font-size: 13px; }
.drawer .video-card { font-size: 13px; }
/* ---------------------------------------------------------------- credits */
.fan-credits { color: #d8ccae; opacity: 0.9; position: relative; z-index: 6; }
/* ---------------------------------------------------------------- overlay (in-3D map) */
body.is-overlay { background: #0d2230; }
.is-overlay #app { align-items: center; justify-content: center; padding: 24px; }
.backdrop {
position: fixed;
inset: 0;
background:
linear-gradient(rgb(6 16 24 / 0.55), rgb(6 16 24 / 0.55)),
repeating-linear-gradient(172deg, transparent 0 22px, rgb(160 205 220 / 0.07) 22px 24px),
radial-gradient(ellipse at 50% 120%, #2c6b7a 0%, #133447 45%, #071521 100%);
}
.backdrop__note { position: absolute; left: 28px; top: 22px; font: 400 12px var(--sans); color: #8fb3c0; }
.frame {
position: relative;
width: 1200px;
padding: 16px 22px 14px;
display: flex;
flex-direction: column;
gap: 10px;
background: #4a3c2e;
border: 1px solid #8c7752;
border-radius: 6px;
box-shadow: 0 30px 80px rgb(0 0 0 / 0.6);
}
.is-overlay .masthead { align-items: center; }
.is-overlay .masthead__words { flex-basis: 220px; }
.is-overlay .masthead__title { font-size: 26px; }
.is-overlay .masthead__dek { font-size: 12px; }
.is-overlay .minimap { height: 40px; }
.is-overlay .legend { flex-basis: 150px; }
.is-overlay .legend__key { padding: 30px 10px 0 12px; font-size: 10.5px; line-height: 1.4; }
.is-overlay .legend__key p:last-child { display: none; }
.is-overlay .legend__item { height: 15px; font-size: 10.5px; left: 4px; padding: 0 5px; gap: 6px; }
.is-overlay .legend__item--character { font-size: 12.5px; }
.is-overlay .legend__swatch { flex-basis: 14px; }
.is-overlay .caption { padding-left: 150px; font-size: 17px; }
.is-overlay .fan-credits { color: #b9cfd8; }
.here {
position: absolute;
transform: translate(-50%, -100%);
padding: 4px 9px;
background: var(--iron);
color: var(--vellum-hi);
font: 500 12px var(--sans);
white-space: nowrap;
border-radius: 3px;
pointer-events: none;
}
.here::after {
content: "";
position: absolute;
left: 50%;
bottom: -5px;
margin-left: -5px;
border: 5px solid transparent;
border-bottom: 0;
border-top-color: var(--iron);
}
.port.is-here .port__dot { fill: var(--iron); }
.pop {
position: absolute;
transform: translate(-50%, 8px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
min-width: 170px;
padding: 10px 12px;
background: var(--vellum-hi);
border: 1.5px solid var(--iron);
border-radius: 4px;
box-shadow: 0 8px 18px rgb(20 14 8 / 0.35);
z-index: 3;
}
.pop[hidden] { display: none; }
.pop__title { font: 600 19px/1.1 var(--serif); }
.pop__when { font: italic 500 14px var(--serif); color: var(--iron-soft); }
.pop__sail {
margin-top: 6px;
border: 0;
border-radius: 3px;
padding: 6px 12px;
background: var(--rhumb-red);
color: var(--vellum-hi);
font: 500 13px var(--sans);
cursor: pointer;
}
.pop__sail:focus-visible { outline: 2px solid var(--iron); outline-offset: 2px; }
.pop__now { margin-top: 4px; font: 500 12px var(--sans); color: var(--rhumb-green); }
@media (prefers-reduced-motion: reduce) {
.drawer { transition: none; }
.port__dot { transition: none; }
}
+291
View File
@@ -0,0 +1,291 @@
// 11 · Atlas with insets: geometry for the general chart (overview) and each saga's inset plate.
import { motifs, sagas, songs } from "../../data/sample";
import type { Motif, Saga, Song } from "../../data/types";
export const W = 1100;
export const H = 700;
export type Pt = { x: number; y: number };
/* ------------------------------------------------------------- utilities */
export function rng(seed: number) {
let t = seed >>> 0;
return () => {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
/** Closed smooth path through points (Catmull-Rom → cubic Bézier). */
export function smoothClosed(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n]!, p1 = pts[i]!, p2 = pts[(i + 1) % n]!, p3 = pts[(i + 2) % n]!;
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d + "Z";
}
/** Open smooth path through points. */
export function smoothOpen(pts: Pt[]): string {
if (pts.length < 2) return "";
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)]!, p1 = pts[i]!, p2 = pts[i + 1]!, p3 = pts[Math.min(pts.length - 1, i + 2)]!;
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d;
}
/** Irregular island outline around an ellipse. */
export function blob(cx: number, cy: number, rx: number, ry: number, seed: number, wobble = 0.16, n = 22): Pt[] {
const r = rng(seed);
const pts: Pt[] = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const k = 1 + (r() - 0.5) * wobble * 2;
pts.push({ x: cx + Math.cos(a) * rx * k, y: cy + Math.sin(a) * ry * k });
}
return pts;
}
/** Hand-tinted colours on paper: the data colours are made for dark grounds, so deepen them. */
export function inkTone(hex: string, t = 0.32): string {
const ink = [0x24, 0x2b, 0x3a];
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
// Odysseus' pale gold needs extra weight to read on paper.
const lum = (0.299 * c[0]! + 0.587 * c[1]! + 0.114 * c[2]!) / 255;
const k = lum > 0.8 ? 0.55 : t;
return "#" + c.map((v, i) => Math.round(v * (1 - k) + ink[i]! * k).toString(16).padStart(2, "0")).join("");
}
/* ------------------------------------------------------------- places */
export interface Place {
sagaId: string;
place: string; // place name on the chart
roman: string; // plate number
tint: string; // watercolour wash
label: Pt; // top-left of label block on the general chart
focus: { x: number; y: number; w: number; h: number }; // zoom box on the general chart
}
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
const PLACE_INFO: Record<string, Omit<Place, "sagaId" | "roman">> = {
troy: { place: "Troy", tint: "#e7b9a9", label: { x: 30, y: 70 }, focus: { x: 0, y: 150, w: 470, h: 430 } },
cyclops: { place: "Cyclops' Island", tint: "#bfd3a0", label: { x: 455, y: 92 }, focus: { x: 380, y: 200, w: 420, h: 380 } },
ocean: { place: "Open sea and storm", tint: "#a9c0dc", label: { x: 818, y: 500 }, focus: { x: 790, y: 190, w: 310, h: 300 } },
};
export const places: Place[] = [...sagas]
.sort((a, b) => a.order - b.order)
.map((s, i) => ({
sagaId: s.id,
roman: ROMAN[i] ?? String(i + 1),
...(PLACE_INFO[s.id] ?? {
place: s.name,
tint: "#d8d0c0",
label: { x: 40 + i * 120, y: 560 },
focus: { x: 0, y: 0, w: W, h: H },
}),
}));
export const placeOf = (sagaId: string) => places.find((p) => p.sagaId === sagaId)!;
export const sagaById = (id: string): Saga => sagas.find((s) => s.id === id)!;
export const songsIn = (sagaId: string): Song[] => songs.filter((s) => s.sagaId === sagaId);
export function yearSpan(list: Song[]): string {
const ys = list.map((s) => s.storyTime.yearsAfterTroy);
const a = Math.min(...ys), b = Math.max(...ys);
return a === b ? `Year ${a}` : `Years ${a} to ${b}`;
}
export function motifsPlaying(song: Song): Set<string> {
return new Set(song.parts.flatMap((p) => p.motifIds));
}
/* ------------------------------------------------------------- general chart */
// Troy: mainland on the west edge with a headland. Cyclops' island mid-chart. Storm out east.
export const TROY_CENTER = { x: 230, y: 360 };
export const CYCLOPS = { cx: 585, cy: 405, rx: 135, ry: 92 };
export const STORM = { cx: 945, cy: 335, r: 118 };
export function troyMainland(width = W, height = H, scale = 1): string {
const r = rng(7);
const pts: Pt[] = [];
for (let y = -10; y <= height + 10; y += 26) {
const yy = y / scale;
let x = 248 + (r() - 0.5) * 18;
const dy = yy - TROY_CENTER.y;
if (Math.abs(dy) < 125) x = Math.max(x, TROY_CENTER.x + Math.sqrt(125 * 125 - dy * dy) * 0.9 + (r() - 0.5) * 12);
pts.push({ x: x * scale, y });
}
void width;
const path = smoothOpen(pts);
return `${path} L-20,${height + 20} L-20,-20 Z`;
}
/** Port positions on the general chart, one per song in story order. */
export function overviewPorts(): Pt[] {
const out: Pt[] = [];
for (const place of places) {
const list = songsIn(place.sagaId);
const n = list.length;
list.forEach((_, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
if (place.sagaId === "troy") {
const a = (-62 + t * 112) * (Math.PI / 180);
out.push({ x: TROY_CENTER.x + Math.cos(a) * 172, y: TROY_CENTER.y + Math.sin(a) * 172 });
} else if (place.sagaId === "cyclops") {
const a = (165 - t * 150) * (Math.PI / 180);
out.push({ x: CYCLOPS.cx + Math.cos(a) * (CYCLOPS.rx + 30), y: CYCLOPS.cy + Math.sin(a) * (CYCLOPS.ry + 30) });
} else if (place.sagaId === "ocean") {
const a = (200 + t * 140) * (Math.PI / 180);
const rr = n === 1 ? 0 : 50;
out.push({ x: STORM.cx + Math.cos(a) * rr, y: STORM.cy + Math.sin(a) * rr });
} else {
out.push({ x: place.label.x + 40 + i * 30, y: place.label.y - 30 });
}
});
}
return out;
}
/** Route through the overview ports with a few sea waypoints between sagas. */
export function overviewRoute(ports: Pt[]): Pt[] {
const pts: Pt[] = [];
songs.forEach((s, i) => {
const prev = songs[i - 1];
if (prev && prev.sagaId !== s.sagaId) {
const a = ports[i - 1]!, b = ports[i]!;
if (prev.sagaId === "troy") pts.push({ x: (a.x + b.x) / 2, y: Math.max(a.y, b.y) + 40 });
else if (s.sagaId === "ocean") pts.push({ x: (a.x + b.x) / 2 + 20, y: (a.y + b.y) / 2 + 60 });
}
pts.push(ports[i]!);
});
return pts;
}
/** Storm: a spiral of squall lines. */
export function stormSpiral(cx: number, cy: number, r: number, turns = 2.6): string {
const pts: Pt[] = [];
const steps = 90;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const a = t * turns * Math.PI * 2;
const rr = 14 + t * (r - 14);
pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr * 0.78 });
}
return smoothOpen(pts);
}
/* ------------------------------------------------------------- inset plates */
export interface InsetPort { song: Song; x: number; y: number }
export interface InsetLayout {
ports: InsetPort[];
/** Route polyline per segment i → i+1, starting and ending horizontally. */
segments: Pt[][];
/** Year gaps between consecutive ports (for "a year passes" notes). */
gaps: number[];
motifs: Motif[]; // motifs that play in this saga, in legend order
lead: Pt[]; // route entering from the west edge (empty if first plate)
tail: Pt[]; // route leaving to the east edge (empty if last plate)
}
export const ROUTE_Y = 268;
export const BAND_TOP = 18;
export const BAND_STEP = 9;
export function insetLayout(sagaId: string): InsetLayout {
const list = songsIn(sagaId);
const n = list.length;
const y0 = list[0]?.storyTime.yearsAfterTroy ?? 0;
const gaps = list.slice(1).map((s, i) => s.storyTime.yearsAfterTroy - list[i]!.storyTime.yearsAfterTroy);
const units = Math.max(1, n - 1 + gaps.reduce((a, g) => a + Math.min(g, 2) * 0.9, 0));
const sagaIdx = places.findIndex((p) => p.sagaId === sagaId);
const hasLead = sagaIdx > 0, hasTail = sagaIdx < places.length - 1;
const left = hasLead ? 250 : 130, right = hasTail ? 840 : 960;
const step = Math.min(230, (right - left) / units);
const used = step * units;
let x = n === 1 ? (left + right) / 2 : left + (right - left - used) / 2;
const ports: InsetPort[] = list.map((song, i) => {
if (i > 0) x += step * (1 + Math.min(gaps[i - 1]!, 2) * 0.9);
const dyYears = Math.min(song.storyTime.yearsAfterTroy - y0, 2);
return { song, x, y: ROUTE_Y + dyYears * 46 };
});
const segments: Pt[][] = [];
for (let i = 0; i < ports.length - 1; i++) {
const a = ports[i]!, b = ports[i + 1]!;
const dy = b.y - a.y;
if (Math.abs(dy) < 1) segments.push([{ x: a.x, y: a.y }, { x: b.x, y: b.y }]);
else {
const mid = (a.x + b.x) / 2;
segments.push([
{ x: a.x, y: a.y },
{ x: mid - Math.abs(dy) / 2, y: a.y },
{ x: mid + Math.abs(dy) / 2, y: b.y },
{ x: b.x, y: b.y },
]);
}
}
const first = ports[0], last = ports[ports.length - 1];
const lead = hasLead && first ? [{ x: 34, y: first.y }, { x: first.x, y: first.y }] : [];
const tail = hasTail && last ? [{ x: last.x, y: last.y }, { x: W - 34, y: last.y }] : [];
const ids = new Set(list.flatMap((s) => [...motifsPlaying(s)]));
return { ports, segments, gaps, motifs: motifs.filter((m) => ids.has(m.id)), lead, tail };
}
/** Offset an open polyline sideways by d (positive = to the right of travel, i.e. south when heading east). */
export function offsetPolyline(pts: Pt[], d: number): Pt[] {
const normals = pts.slice(1).map((p, i) => {
const q = pts[i]!;
const dx = p.x - q.x, dy = p.y - q.y;
const len = Math.hypot(dx, dy) || 1;
return { x: -dy / len, y: dx / len };
});
return pts.map((p, i) => {
const n1 = normals[Math.max(0, i - 1)]!, n2 = normals[Math.min(normals.length - 1, i)]!;
const dot = n1.x * n2.x + n1.y * n2.y;
const k = d / (1 + dot);
return { x: p.x + (n1.x + n2.x) * k, y: p.y + (n1.y + n2.y) * k };
});
}
export const polyD = (pts: Pt[]) => pts.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
/** Wrap a title into at most two lines of ~maxChars. */
export function wrap(text: string, maxChars: number): string[] {
if (text.length <= maxChars) return [text];
const words = text.split(" ");
let best = [text];
let bestScore = Infinity;
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
const score = Math.max(a.length, b.length);
if (score < bestScore) { bestScore = score; best = [a, b]; }
}
return best;
}
/** The part of a polyline between fractions t0..t1 of its length. */
export function subPolyline(pts: Pt[], t0: number, t1: number): Pt[] {
const lens = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i]!.x, p.y - pts[i]!.y));
const total = lens.reduce((a, b) => a + b, 0);
const at = (d: number): { p: Pt; i: number } => {
let acc = 0;
for (let i = 0; i < lens.length; i++) {
if (acc + lens[i]! >= d || i === lens.length - 1) {
const k = lens[i]! ? Math.min(1, (d - acc) / lens[i]!) : 0;
const a = pts[i]!, b = pts[i + 1]!;
return { p: { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k }, i };
}
acc += lens[i]!;
}
return { p: pts[pts.length - 1]!, i: lens.length - 1 };
};
const A = at(t0 * total), B = at(t1 * total);
return [A.p, ...pts.slice(A.i + 1, B.i + 1), B.p];
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Atlas with insets — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+679
View File
@@ -0,0 +1,679 @@
// 11 · Atlas with insets: a general chart of the whole voyage, and one inset plate per saga.
import { motifs, songs } from "../../data/sample";
import { motifsInSong, songsWithMotif } from "../../data/query";
import type { Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import {
BAND_STEP, BAND_TOP, CYCLOPS, H, STORM, TROY_CENTER, W, blob, inkTone, insetLayout, motifsPlaying,
offsetPolyline, overviewPorts, subPolyline, overviewRoute, placeOf, places, polyD, sagaById, smoothClosed, smoothOpen,
songsIn, stormSpiral, troyMainland, wrap, yearSpan, type Pt,
} from "./geo";
mountCredits();
/* ------------------------------------------------------------------ state */
const params = new URLSearchParams(location.search);
const overlay = params.get("overlay") === "1";
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const initialSong = songs.find((s) => s.id === params.get("song")) ?? null;
const state = {
sagaId: (initialSong?.sagaId ?? places.find((p) => p.sagaId === params.get("saga"))?.sagaId ?? null) as string | null,
songId: (overlay ? null : initialSong?.id ?? null) as string | null,
motifId: motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null,
shipAt: "survive",
sailPick: (overlay ? initialSong?.id ?? null : null) as string | null,
note: "" as string,
};
if (overlay) document.body.classList.add("is-overlay");
const tone = (id: string) => inkTone(motifs.find((m) => m.id === id)!.color);
/* ------------------------------------------------------------------ dom helpers */
const SVG = "http://www.w3.org/2000/svg";
type Attrs = Record<string, string | number | undefined>;
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] {
const el = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
parent?.append(el);
return el;
}
function h<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text?: string, parent?: Element): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
if (cls) el.className = cls;
if (text !== undefined) el.textContent = text;
parent?.append(el);
return el;
}
function txt(parent: Element, x: number, y: number, text: string, cls: string, attrs: Attrs = {}) {
const t = s("text", { x, y, class: cls, ...attrs }, parent);
t.textContent = text;
return t;
}
function activate(el: Element, label: string, fn: () => void) {
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", label);
el.addEventListener("click", (e) => { e.stopPropagation(); fn(); });
el.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") { e.preventDefault(); e.stopPropagation(); fn(); }
});
}
/* ------------------------------------------------------------------ skeleton */
const app = document.getElementById("app")!;
app.className = "atlas";
const head = h("header", "atlas-head", undefined, app);
const body = h("div", "atlas-body", undefined, app);
const plateWrap = h("section", "plate-wrap", undefined, body);
const plate = h("div", "plate", undefined, plateWrap);
const index = h("aside", "themes", undefined, body);
index.setAttribute("aria-label", "Themes");
let svg: SVGSVGElement = s("svg", {});
/* ------------------------------------------------------------------ shared chart pieces */
function neatline(root: SVGElement) {
const g = s("g", { class: "neatline", "pointer-events": "none" }, root);
s("rect", { x: 3, y: 3, width: W - 6, height: H - 6, class: "nl-outer" }, g);
s("rect", { x: 9, y: 9, width: W - 18, height: H - 18, class: "nl-band" }, g);
s("rect", { x: 13, y: 13, width: W - 26, height: H - 26, class: "nl-inner" }, g);
}
/** Water-lining: concentric strokes outside a coast, the old engraver's way of showing shallows. */
function waterLines(root: SVGElement, d: string) {
const g = s("g", { class: "waterlines", "pointer-events": "none" }, root);
for (const w of [34, 24, 15, 8]) {
s("path", { d, class: "wl-ink", "stroke-width": w }, g);
s("path", { d, class: "wl-paper", "stroke-width": w - 1.6 }, g);
}
}
let clipN = 0;
function land(root: SVGElement, d: string, tint: string, extraCls = "") {
const id = `clip${++clipN}`;
const cp = s("clipPath", { id }, root);
s("path", { d }, cp);
s("path", { d, fill: tint, class: `land ${extraCls}` }, root);
// Watercolour edge: a darker wash pooled just inside the coast.
s("path", { d, stroke: tint, class: "land-edge", "clip-path": `url(#${id})` }, root);
s("path", { d, class: "coast" }, root);
}
function troyGlyph(root: SVGElement, x: number, y: number, k = 1) {
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})` }, root);
s("path", { d: "M-26,0 L-26,-14 L-20,-14 L-20,-22 L-14,-22 L-14,-14 L-4,-14 L-4,-30 L4,-30 L4,-14 L14,-14 L14,-22 L20,-22 L20,-14 L26,-14 L26,0 Z" }, g);
s("path", { d: "M-2,-30 C-6,-40 4,-44 0,-54 C-4,-62 6,-66 2,-74", class: "smoke" }, g);
}
function caveGlyph(root: SVGElement, x: number, y: number, k = 1) {
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})` }, root);
s("path", { d: "M-30,4 C-24,-22 -8,-30 0,-30 C10,-30 24,-20 30,4", class: "hill" }, g);
s("path", { d: "M-9,4 C-9,-10 9,-10 9,4 Z" }, g);
}
function hachures(root: SVGElement, cx: number, cy: number, n: number, spread: number, seed: number) {
let r = seed;
const g = s("g", { class: "hachure", "pointer-events": "none" }, root);
for (let i = 0; i < n; i++) {
r = (r * 9301 + 49297) % 233280;
const x = cx + ((r / 233280) - 0.5) * spread;
r = (r * 9301 + 49297) % 233280;
const y = cy + ((r / 233280) - 0.5) * spread * 0.35;
s("path", { d: `M${x - 7},${y + 3} Q${x},${y - 6} ${x + 7},${y + 3}` }, g);
}
}
function ship(root: SVGElement, x: number, y: number, label: string) {
const g = s("g", { class: "ship", transform: `translate(${x} ${y})`, "pointer-events": "none" }, root);
s("circle", { r: 15, class: "ship-ring" }, g);
s("path", { d: "M-9,4 L9,4 L6,9 L-6,9 Z M0,4 L0,-11 L8,1 Z", class: "ship-hull" }, g);
const t = txt(g, 0, -24, label, "ship-label", { "text-anchor": "middle" });
const bb = { w: label.length * 6.6 + 16 };
const bg = s("rect", { x: -bb.w / 2, y: -38, width: bb.w, height: 20, rx: 2, class: "ship-tag" });
g.insertBefore(bg, t);
}
/* ------------------------------------------------------------------ general chart */
const OV_PORTS = overviewPorts();
const OV_ROUTE = overviewRoute(OV_PORTS);
const CYC_SHAPE = smoothClosed(blob(CYCLOPS.cx, CYCLOPS.cy, CYCLOPS.rx, CYCLOPS.ry, 11, 0.13));
const TROY_SHAPE = troyMainland();
function drawGeneral(root: SVGElement, mini = false) {
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
const motifSongs = state.motifId ? new Set(songsWithMotif(state.motifId).map((x) => x.id)) : null;
// Halo under islands that carry the selected theme.
const lit = (sagaId: string) => !!motifSongs && songsIn(sagaId).some((x) => motifSongs.has(x.id));
if (state.motifId && !mini) {
const c = tone(state.motifId);
if (lit("troy")) s("path", { d: TROY_SHAPE, class: "halo", stroke: c }, root);
if (lit("cyclops")) s("path", { d: CYC_SHAPE, class: "halo", stroke: c }, root);
if (lit("ocean")) s("circle", { cx: STORM.cx, cy: STORM.cy, r: STORM.r - 10, class: "halo", stroke: c }, root);
}
if (!mini) { waterLines(root, TROY_SHAPE); waterLines(root, CYC_SHAPE); }
// decorative islets
for (const [x, y, rx, seed] of [[420, 120, 26, 3], [745, 625, 30, 5], [700, 150, 14, 13]] as const) {
const d = smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14));
if (!mini) waterLines(root, d);
s("path", { d, class: "islet" }, root);
}
const groups: Record<string, SVGGElement> = {};
for (const p of places) {
const g = s("g", { class: "isle", "data-saga": p.sagaId }, root);
if (state.motifId && !lit(p.sagaId) && !mini) g.classList.add("is-dim");
groups[p.sagaId] = g;
}
land(groups.troy!, TROY_SHAPE, placeOf("troy").tint);
troyGlyph(groups.troy!, TROY_CENTER.x - 50, TROY_CENTER.y + 16, mini ? 1.4 : 1);
land(groups.cyclops!, CYC_SHAPE, placeOf("cyclops").tint);
if (!mini) { hachures(groups.cyclops!, CYCLOPS.cx - 10, CYCLOPS.cy - 10, 9, 150, 4); caveGlyph(groups.cyclops!, CYCLOPS.cx + 40, CYCLOPS.cy + 34); }
s("circle", { cx: STORM.cx, cy: STORM.cy, r: STORM.r, class: "storm-wash" }, groups.ocean!);
s("path", { d: stormSpiral(STORM.cx, STORM.cy, STORM.r - 6), class: "storm-spiral" }, groups.ocean!);
// Route
s("path", { d: smoothOpen(OV_ROUTE), class: "course" }, root);
if (mini) return;
// Year notes on long crossings
songs.forEach((song, i) => {
const prev = songs[i - 1];
if (!prev) return;
const g = song.storyTime.yearsAfterTroy - prev.storyTime.yearsAfterTroy;
if (g <= 0) return;
const a = OV_PORTS[i - 1]!, b = OV_PORTS[i]!;
const note = g === 1 ? "a year passes" : `${g === 2 ? "two" : g} years at sea`;
const east = song.sagaId !== "ocean";
txt(root, (a.x + b.x) / 2 + (east ? 18 : -16), (a.y + b.y) / 2 + (east ? 10 : -4), note, "time-note", { "text-anchor": east ? "start" : "end" });
});
// Ports
songs.forEach((song, i) => {
const p = OV_PORTS[i]!;
const on = motifSongs?.has(song.id);
const c = s("circle", { cx: p.x, cy: p.y, r: on ? 7 : 5, class: "ov-port" + (motifSongs && !on ? " is-dim" : "") }, root);
if (on) c.setAttribute("fill", tone(state.motifId!));
txt(root, p.x + (song.sagaId === "troy" ? 12 : 0), p.y + (song.sagaId === "troy" ? 4 : song.sagaId === "ocean" ? 22 : -11), String(song.track), "ov-num", { "text-anchor": song.sagaId === "troy" ? "start" : "middle" });
});
// Label blocks, with the track list so song titles are legible from the general chart
for (const p of places) {
const g = groups[p.sagaId]!;
const list = songsIn(p.sagaId);
const saga = sagaById(p.sagaId);
const lb = s("g", { class: "isle-label", transform: `translate(${p.label.x} ${p.label.y})` }, g);
txt(lb, 0, 0, p.place, "place-name");
txt(lb, 0, 22, `${saga.name}, plate ${p.roman}`, "place-sub");
txt(lb, 0, 40, `${list.length} ${list.length === 1 ? "song" : "songs"}, ${yearSpan(list).toLowerCase()}`, "place-meta");
list.forEach((song, j) => {
const on = motifSongs?.has(song.id);
const row = txt(lb, 0, 64 + j * 19, "", "track-row" + (motifSongs && !on ? " is-dim" : ""));
const n = s("tspan", { class: "track-n" }, row); n.textContent = String(song.track).padStart(2, " ") + " ";
const t = s("tspan", {}, row); t.textContent = song.title;
if (on) row.setAttribute("fill", tone(state.motifId!));
});
if (state.motifId && motifSongs) {
const count = list.filter((x) => motifSongs.has(x.id)).length;
const m = motifs.find((x) => x.id === state.motifId)!;
const y = 64 + list.length * 19 + 8;
const label = count ? `${m.name} plays in ${count} of ${list.length}` : `${m.name} is silent here`;
const badge = s("g", { class: "motif-badge" + (count ? "" : " is-silent"), transform: `translate(0 ${y})` }, lb);
s("rect", { x: 0, y: 0, width: label.length * 6.4 + 20, height: 22, rx: 11, fill: count ? tone(m.id) : "none" }, badge);
txt(badge, 10, 15, label, "badge-text");
}
activate(g, `Open plate ${p.roman}: ${p.place}, ${list.length} songs`, () => openInset(p.sagaId));
}
// Overlay: where the ship is now
if (overlay) {
const i = songs.findIndex((x) => x.id === state.shipAt);
const p = OV_PORTS[i]!;
ship(root, p.x, p.y, `You are here: ${songs[i]!.title}`);
}
// North arrow
const n = s("g", { class: "north", transform: "translate(330 652)", "pointer-events": "none" }, root);
s("path", { d: "M0,-30 L8,8 L0,2 L-8,8 Z" }, n);
txt(n, 0, -36, "N", "north-n", { "text-anchor": "middle" });
}
/* ------------------------------------------------------------------ inset plate */
function insetLand(root: SVGElement, sagaId: string, tint: string) {
if (sagaId === "troy") {
const pts: Pt[] = [];
const rnd = (() => { let t = 21; return () => ((t = (t * 9301 + 49297) % 233280) / 233280); })();
for (let x = -20; x <= W + 20; x += 55) pts.push({ x, y: 478 + Math.sin(x / 140) * 22 + (rnd() - 0.5) * 16 - (x < 260 ? (260 - x) * 0.25 : 0) });
const d = `${smoothOpen(pts)} L${W + 20},${H + 20} L-20,${H + 20} Z`;
waterLines(root, d);
land(root, d, tint);
troyGlyph(root, 210, 588, 1.6);
txt(root, 330, 590, "Troy", "inset-land-name");
hachures(root, 720, 600, 10, 300, 8);
} else if (sagaId === "cyclops") {
const d = smoothClosed(blob(560, 600, 430, 120, 17, 0.08, 26));
waterLines(root, d);
land(root, d, tint);
hachures(root, 720, 590, 12, 260, 12);
caveGlyph(root, 380, 600, 1.6);
txt(root, 470, 616, "Cyclops' Island", "inset-land-name");
} else {
s("circle", { cx: 545, cy: 330, r: 290, class: "storm-wash" }, root);
s("path", { d: stormSpiral(545, 330, 280, 3.2), class: "storm-spiral" }, root);
for (const [x, y, rx, seed] of [[180, 560, 30, 4], [880, 600, 22, 6]] as const) {
const d = smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14));
waterLines(root, d);
s("path", { d, class: "islet" }, root);
}
txt(root, 545, 610, "Open sea", "inset-land-name", { "text-anchor": "middle" });
}
}
function drawInset(root: SVGElement, sagaId: string) {
const place = placeOf(sagaId);
const L = insetLayout(sagaId);
const pIdx = places.indexOf(place);
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
insetLand(root, sagaId, place.tint);
const sel = state.motifId;
const sagaSongs = L.ports.map((p) => p.song);
const offsetOf = new Map(L.motifs.map((m, k) => [m.id, BAND_TOP + k * BAND_STEP]));
const earlier = new Set(songs.filter((x) => placeOf(x.sagaId) && places.indexOf(placeOf(x.sagaId)) < pIdx).flatMap((x) => [...motifsPlaying(x)]));
const later = new Set(songs.filter((x) => places.indexOf(placeOf(x.sagaId)) > pIdx).flatMap((x) => [...motifsPlaying(x)]));
// Route (ship's course): dashed track
const courseG = s("g", { class: "inset-course" }, root);
if (L.lead.length) s("path", { d: polyD(L.lead), class: "course course--edge" }, courseG);
if (L.tail.length) s("path", { d: polyD(L.tail), class: "course course--edge" }, courseG);
for (const seg of L.segments) s("path", { d: polyD(seg), class: "course" }, courseG);
// Motif lines, transit-style, running alongside the course on its seaward side
const lines = s("g", { class: "motif-lines" }, root);
for (const m of L.motifs) {
const d = offsetOf.get(m.id)!;
const c = tone(m.id);
const g = s("g", { class: "mline" + (sel && sel !== m.id ? " is-dim" : "") + (sel === m.id ? " is-on" : ""), stroke: c }, lines);
const idx = sagaSongs.map((x, i) => (motifsPlaying(x).has(m.id) ? i : -1)).filter((i) => i >= 0);
const first = idx[0]!, last = idx[idx.length - 1]!;
for (let i = first; i < last; i++) {
// A theme line is solid for the half-segment next to each port where it plays, quiet in between.
const off = offsetPolyline(L.segments[i]!, d);
const a = idx.includes(i), b = idx.includes(i + 1);
if (a && b) { s("path", { d: polyD(off), class: "ml-solid" }, g); continue; }
s("path", { d: polyD(subPolyline(off, a ? 0.42 : 0, b ? 0.58 : 1)), class: "ml-quiet" }, g);
if (a) s("path", { d: polyD(subPolyline(off, 0, 0.42)), class: "ml-solid" }, g);
if (b) s("path", { d: polyD(subPolyline(off, 0.58, 1)), class: "ml-solid" }, g);
}
// Quiet stubs follow the course out to the plate edge when the theme plays on another plate.
const along = (segs: Pt[][]) => segs.flatMap((sg, k) => (k ? sg.slice(1) : sg));
if (earlier.has(m.id) && L.lead.length) s("path", { d: polyD(offsetPolyline(along([L.lead, ...L.segments.slice(0, first)]), d)), class: "ml-quiet" }, g);
if (later.has(m.id) && L.tail.length) s("path", { d: polyD(offsetPolyline(along([...L.segments.slice(last), L.tail]), d)), class: "ml-quiet" }, g);
}
// Ports (stations)
for (const p of L.ports) {
const playing = motifsPlaying(p.song);
const offs = [...playing].map((id) => offsetOf.get(id)!).filter((v) => v !== undefined);
const deepest = Math.max(0, ...offs);
const g = s("g", { class: "port" + (sel && !playing.has(sel) ? " is-dim" : "") + (state.songId === p.song.id || state.sailPick === p.song.id ? " is-open" : ""), "data-song": p.song.id }, root);
s("rect", { x: p.x - 70, y: p.y - 92, width: 140, height: 190 + deepest, class: "port-hit" }, g);
if (deepest) s("line", { x1: p.x, y1: p.y, x2: p.x, y2: p.y + deepest, class: "port-stem" }, g);
for (const id of playing) {
const off = offsetOf.get(id);
if (off === undefined) continue;
s("circle", { cx: p.x, cy: p.y + off, r: sel === id ? 5 : 3.6, fill: tone(id), class: "port-dot" + (sel && sel !== id ? " is-dim" : "") }, g);
}
s("circle", { cx: p.x, cy: p.y, r: 9, class: "port-ring" }, g);
s("circle", { cx: p.x, cy: p.y, r: 3.2, class: "port-core" }, g);
const lines2 = wrap(p.song.title, 15);
txt(g, p.x, p.y - 22 - (lines2.length - 1) * 23 - 24, `Track ${p.song.track}`, "port-track", { "text-anchor": "middle" });
lines2.forEach((ln, k) => txt(g, p.x, p.y - 22 - (lines2.length - 1 - k) * 23, ln, "port-title", { "text-anchor": "middle" }));
wrap(p.song.storyTime.label, 20).forEach((ln, k) => txt(g, p.x, p.y + BAND_TOP + Math.max(0, L.motifs.length - 1) * BAND_STEP + 30 + k * 18, ln, "port-time", { "text-anchor": "middle" }));
activate(g, `${p.song.title}, track ${p.song.track}. ${p.song.storyTime.label}`, () => pickSong(p.song.id));
}
// Edge notes: "continued on plate…"
if (L.lead.length) {
const prev = places[pIdx - 1]!;
const g = s("g", { class: "plate-link" }, root);
const y = L.lead[0]!.y + BAND_TOP + L.motifs.length * BAND_STEP + 22;
txt(g, 36, y, `From plate ${prev.roman}`, "edge-note");
txt(g, 36, y + 19, prev.place, "edge-note edge-place");
s("path", { d: `M28,${L.lead[0]!.y} l10,-6 v12 z`, class: "edge-arrow" }, g);
activate(g, `Back to plate ${prev.roman}: ${prev.place}`, () => stepInset(-1));
}
if (L.tail.length) {
const next = places[pIdx + 1]!;
const g = s("g", { class: "plate-link" }, root);
const y = L.tail[1]!.y + BAND_TOP + L.motifs.length * BAND_STEP + 22;
txt(g, W - 36, y, `Continued on plate ${next.roman}`, "edge-note", { "text-anchor": "end" });
txt(g, W - 36, y + 19, next.place, "edge-note edge-place", { "text-anchor": "end" });
s("path", { d: `M${W - 28},${L.tail[1]!.y} l-10,-6 v12 z`, class: "edge-arrow" }, g);
activate(g, `Continue to plate ${next.roman}: ${next.place}`, () => stepInset(1));
}
// Scale of story time, aligned under the ports
const sc = s("g", { class: "scale", transform: "translate(0 664)" }, root);
const xs = L.ports.map((p) => p.x);
const x0 = xs.length > 1 ? xs[0]! : xs[0]! - 60, x1 = xs.length > 1 ? xs[xs.length - 1]! : xs[0]! + 60;
txt(sc, x0 - 14, 4, "Scale of story time", "scale-cap", { "text-anchor": "end" });
const stops = xs.length > 1 ? xs : [x0, x1];
for (let i = 0; i < stops.length - 1; i++) s("rect", { x: stops[i]!, y: -4, width: stops[i + 1]! - stops[i]!, height: 6, class: i % 2 ? "scale-w" : "scale-b" }, sc);
let lastYear = -1;
L.ports.forEach((p) => {
const y = p.song.storyTime.yearsAfterTroy;
s("line", { x1: p.x, x2: p.x, y1: -8, y2: 4, class: "scale-tick" }, sc);
if (y !== lastYear) txt(sc, p.x, -14, `Year ${y}`, "scale-year", { "text-anchor": "middle" });
lastYear = y;
});
L.gaps.forEach((gap, i) => {
if (gap <= 0) return;
const a = L.ports[i]!, b = L.ports[i + 1]!;
txt(sc, (a.x + b.x) / 2, -12, gap === 1 ? "a year passes" : `${gap} years pass`, "time-note", { "text-anchor": "middle" });
});
// Locator: where this plate sits on the general chart
const loc = s("g", { class: "locator", transform: "translate(866 34)" }, root);
s("rect", { x: -4, y: -4, width: 218, height: 147, class: "loc-frame" }, loc);
const inner = s("svg", { x: 0, y: 0, width: 210, height: 134, viewBox: `0 0 ${W} ${H}` }, loc);
drawGeneral(inner, true);
const f = place.focus;
s("rect", { x: f.x, y: f.y, width: f.w, height: f.h, class: "loc-focus" }, inner);
txt(loc, 0, 156, "Key to the general chart", "loc-cap");
activate(loc, "Back to the general chart of the Aegean", () => closeInset());
if (overlay) {
const at = L.ports.find((p) => p.song.id === state.shipAt);
if (at) ship(root, at.x, at.y, `You are here: ${at.song.title}`);
}
}
/* ------------------------------------------------------------------ html chrome */
let sheet: HTMLElement | null = null;
let pop: HTMLElement | null = null;
function cartouche() {
const c = h("div", "cartouche", undefined, plate);
if (!state.sagaId) {
h("p", "c-kicker", "General chart", c);
h("h2", "c-title", "The voyage from Troy", c);
h("p", "c-line", `${songs.length} songs across ${places.length} sagas, Year 0 to Year ${Math.max(...songs.map((x) => x.storyTime.yearsAfterTroy))}.`, c);
h("p", "c-line c-hint", "Choose an island to open its plate.", c);
} else {
const p = placeOf(state.sagaId);
const list = songsIn(state.sagaId);
h("p", "c-kicker", `Plate ${p.roman}`, c);
h("h2", "c-title", p.place, c);
h("p", "c-line", `${sagaById(p.sagaId).name}, tracks ${list[0]!.track} to ${list[list.length - 1]!.track}`.replace(/tracks (\d+) to \1$/, "track $1"), c);
h("p", "c-line", yearSpan(list), c);
}
}
function songSheet(song: Song) {
sheet = h("section", "slip", undefined, plate);
sheet.setAttribute("aria-label", `Song: ${song.title}`);
const close = h("button", "slip-close", "Close song", sheet);
close.type = "button";
close.addEventListener("click", () => { state.songId = null; render(); });
const a = h("div", "slip-col slip-id", undefined, sheet);
h("p", "slip-track", `Track ${song.track}, ${sagaById(song.sagaId).name}`, a);
h("h3", "slip-title", song.title, a);
h("p", "slip-time", `${song.storyTime.label}. Year ${song.storyTime.yearsAfterTroy} after Troy.`, a);
if (song.odyssey) {
const l = h("a", "slip-odyssey", `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`, a) as HTMLAnchorElement;
l.href = song.odyssey.url; l.target = "_blank"; l.rel = "noopener noreferrer";
} else h("p", "slip-odyssey is-none", "No counterpart in Homer's Odyssey", a);
const b = h("div", "slip-col slip-parts", undefined, sheet);
h("h4", "slip-h", "Parts", b);
const ol = h("ol", "parts", undefined, b);
for (const part of song.parts) {
const li = h("li", "", undefined, ol);
h("span", "part-name", part.name, li);
h("span", "part-singers", part.singers.join(", "), li);
const ticks = h("span", "part-motifs", undefined, li);
for (const id of part.motifIds) {
const t = h("i", "tick", undefined, ticks);
t.style.background = tone(id);
t.title = motifs.find((m) => m.id === id)!.name;
}
}
const c = h("div", "slip-col slip-media", undefined, sheet);
h("h4", "slip-h", "Themes in this song", c);
const chips = h("div", "chips", undefined, c);
for (const m of motifsInSong(song)) {
const chip = h("button", "chip" + (state.motifId === m.id ? " is-on" : ""), m.name, chips);
chip.type = "button";
chip.style.setProperty("--c", tone(m.id));
chip.addEventListener("click", () => toggleMotif(m.id));
}
c.append(createAudioSlot(song), createVideoCards(song));
}
function sailPopover(song: Song) {
const g = svg.querySelector(`.port[data-song="${song.id}"] .port-ring`) as SVGGraphicsElement | null;
if (!g) return;
const box = g.getBoundingClientRect();
const host = plate.getBoundingClientRect();
pop = h("div", "sail-pop", undefined, plate);
pop.style.left = `${box.left + box.width / 2 - host.left}px`;
pop.style.top = `${box.top - host.top + 40}px`;
h("p", "sp-title", song.title, pop);
if (song.id === state.shipAt) {
h("p", "sp-line", "The ship is anchored here.", pop);
} else {
const btn = h("button", "sp-sail", `Sail here`, pop);
btn.type = "button";
btn.addEventListener("click", () => {
state.shipAt = song.id;
state.sailPick = null;
state.note = `Course set for ${song.title}. In the 3D voyage the ship sails there now.`;
render();
});
}
const open = h("button", "sp-open", "Open song", pop);
open.type = "button";
open.addEventListener("click", () => { state.sailPick = null; state.songId = song.id; render(); });
}
function header() {
head.replaceChildren();
const brand = h("div", "brand", undefined, head);
h("h1", "brand-title", overlay ? "Captain's table" : "EPIC, an atlas", brand);
h("p", "brand-sub", overlay ? "The chart, laid out over the 3D voyage." : "A fan's chart of Odysseus's voyage in EPIC: The Musical.", brand);
const crumbs = h("nav", "crumbs", undefined, head);
crumbs.setAttribute("aria-label", "Breadcrumb");
const root = h(state.sagaId ? "button" : "span", "crumb", "Aegean", crumbs);
if (state.sagaId) { (root as HTMLButtonElement).type = "button"; root.addEventListener("click", () => closeInset()); }
else root.setAttribute("aria-current", "page");
if (state.sagaId) {
h("span", "crumb-sep", "›", crumbs).setAttribute("aria-hidden", "true");
const here = h("span", "crumb", placeOf(state.sagaId).place, crumbs);
here.setAttribute("aria-current", "page");
}
const nav = h("div", "plate-nav", undefined, head);
if (state.sagaId) {
const i = places.findIndex((p) => p.sagaId === state.sagaId);
const prev = places[i - 1], next = places[i + 1];
const pb = h("button", "pn", prev ? `‹ ${prev.roman}. ${prev.place}` : "‹", nav);
pb.type = "button"; pb.disabled = !prev; pb.addEventListener("click", () => stepInset(-1));
pb.setAttribute("aria-label", prev ? `Previous plate: ${prev.place}` : "No previous plate");
const nb = h("button", "pn", next ? `${next.roman}. ${next.place} ›` : "›", nav);
nb.type = "button"; nb.disabled = !next; nb.addEventListener("click", () => stepInset(1));
nb.setAttribute("aria-label", next ? `Next plate: ${next.place}` : "No next plate");
h("p", "keys", "Arrow keys turn plates. Esc returns to the Aegean.", nav);
} else {
h("p", "keys", "Choose an island, or press → to open plate I.", nav);
}
if (overlay) {
const here = songs.find((x) => x.id === state.shipAt)!;
const tag = h("p", "here-tag", undefined, head);
h("span", "here-k", "You are here", tag);
h("span", "here-v", here.title, tag);
}
}
function themes() {
index.replaceChildren();
const inSaga = state.sagaId ? songsIn(state.sagaId) : null;
h("h2", "th-title", "Themes", index);
h("p", "th-sub", state.sagaId ? "Songs on this plate, and in the whole voyage." : "Pick one to light the islands where it plays.", index);
for (const kind of ["motif", "character"] as const) {
h("h3", "th-group", kind === "motif" ? "Musical motifs" : "Character themes", index);
const ul = h("ul", "th-list", undefined, index);
for (const m of motifs.filter((x) => x.kind === kind)) {
const all = songsWithMotif(m.id);
const here = inSaga ? all.filter((x) => x.sagaId === state.sagaId).length : null;
const li = h("li", "", undefined, ul);
const b = h("button", "th-row" + (state.motifId === m.id ? " is-on" : "") + (here === 0 ? " is-absent" : ""), undefined, li);
b.type = "button";
b.setAttribute("aria-pressed", String(state.motifId === m.id));
b.style.setProperty("--c", tone(m.id));
h("span", "th-swatch", undefined, b);
h("span", "th-name", m.name, b);
h("span", "th-count", here === null ? String(all.length) : `${here} / ${all.length}`, b);
b.addEventListener("click", () => toggleMotif(m.id));
}
}
const key = h("div", "th-key", undefined, index);
h("h3", "th-group", "Key", key);
const rows: [string, string][] = [
["k-course", "The ship's course"],
["k-port", "Port: one song"],
["k-line", "Theme plays at both ports"],
["k-quiet", "Theme is silent, returns later"],
];
for (const [cls, label] of rows) {
const r = h("p", "key-row", undefined, key);
h("i", `key-sym ${cls}`, undefined, r);
h("span", "", label, r);
}
}
/* ------------------------------------------------------------------ render */
function render(anim?: "in" | "out" | "next" | "prev") {
header();
themes();
plate.replaceChildren();
sheet = null; pop = null;
svg = s("svg", { viewBox: `0 0 ${W} ${H}`, class: "chart", role: "img" }, plate);
svg.setAttribute("aria-label", state.sagaId ? `Plate ${placeOf(state.sagaId).roman}: ${placeOf(state.sagaId).place}` : "General chart of the voyage");
if (state.sagaId) drawInset(svg, state.sagaId);
else drawGeneral(svg);
neatline(svg);
cartouche();
plate.classList.toggle("is-inset", !!state.sagaId);
plate.classList.toggle("has-slip", !!state.songId);
const song = songs.find((x) => x.id === state.songId);
if (song && state.sagaId) songSheet(song);
const pick = songs.find((x) => x.id === state.sailPick);
if (overlay && pick && state.sagaId === pick.sagaId) requestAnimationFrame(() => sailPopover(pick));
if (state.note) {
const n = h("p", "sail-note", state.note, plate);
n.setAttribute("role", "status");
}
syncUrl();
if (anim && !reduceMotion) {
plate.dataset.anim = anim;
plate.addEventListener("animationend", () => delete plate.dataset.anim, { once: true });
}
}
function syncUrl() {
const q = new URLSearchParams();
if (overlay) q.set("overlay", "1");
if (state.songId) q.set("song", state.songId);
else if (state.sagaId) q.set("saga", state.sagaId);
if (state.motifId) q.set("motif", state.motifId);
const str = q.toString();
history.replaceState(null, "", str ? `?${str}` : location.pathname);
}
/* ------------------------------------------------------------------ navigation */
let busy = false;
function zoomViewBox(to: { x: number; y: number; w: number; h: number }, from: { x: number; y: number; w: number; h: number }, ms: number, done: () => void) {
if (reduceMotion) return done();
busy = true;
const t0 = performance.now();
const step = (t: number) => {
const k = Math.min(1, (t - t0) / ms);
const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
const lerp = (a: number, b: number) => a + (b - a) * e;
svg.setAttribute("viewBox", `${lerp(from.x, to.x)} ${lerp(from.y, to.y)} ${lerp(from.w, to.w)} ${lerp(from.h, to.h)}`);
if (k < 1) requestAnimationFrame(step);
else { busy = false; done(); }
};
requestAnimationFrame(step);
}
const FULL = { x: 0, y: 0, w: W, h: H };
function fitFocus(f: { x: number; y: number; w: number; h: number }) {
// keep the plate's aspect so the zoom does not distort
const a = W / H;
let w = f.w, hh = f.h;
if (w / hh > a) hh = w / a; else w = hh * a;
return { x: f.x + f.w / 2 - w / 2, y: f.y + f.h / 2 - hh / 2, w, h: hh };
}
function openInset(sagaId: string) {
if (busy) return;
state.note = "";
if (state.sagaId) { state.sagaId = sagaId; render("next"); return; }
plate.classList.add("is-zooming");
zoomViewBox(fitFocus(placeOf(sagaId).focus), FULL, 520, () => {
plate.classList.remove("is-zooming");
state.sagaId = sagaId;
render("in");
});
}
function closeInset() {
if (busy || !state.sagaId) return;
const from = fitFocus(placeOf(state.sagaId).focus);
state.sagaId = null; state.songId = null; state.sailPick = null; state.note = "";
render("out");
svg.setAttribute("viewBox", `${from.x} ${from.y} ${from.w} ${from.h}`);
zoomViewBox(FULL, from, 480, () => svg.setAttribute("viewBox", `0 0 ${W} ${H}`));
}
function stepInset(dir: 1 | -1) {
if (busy) return;
if (!state.sagaId) { if (dir === 1) openInset(places[0]!.sagaId); return; }
const i = places.findIndex((p) => p.sagaId === state.sagaId);
const next = places[i + dir];
if (!next) return;
state.sagaId = next.sagaId; state.songId = null; state.sailPick = null; state.note = "";
render(dir === 1 ? "next" : "prev");
}
function pickSong(id: string) {
if (overlay) { state.sailPick = state.sailPick === id ? null : id; state.songId = null; state.note = ""; render(); return; }
state.songId = state.songId === id ? null : id;
render();
}
function toggleMotif(id: string) {
state.motifId = state.motifId === id ? null : id;
render();
}
document.addEventListener("keydown", (e) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "AUDIO") return;
if (e.key === "ArrowRight") { e.preventDefault(); stepInset(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); stepInset(-1); }
else if (e.key === "Escape") {
if (state.songId || state.sailPick) { state.songId = null; state.sailPick = null; render(); }
else closeInset();
}
});
plate.addEventListener("click", (e) => {
if (!state.sailPick) return;
if ((e.target as Element).closest(".sail-pop")) return;
state.sailPick = null; render();
});
render();
+346
View File
@@ -0,0 +1,346 @@
/* 11 · Atlas with insets: a bound atlas. A general chart of the voyage, then one inset plate per saga. */
@import "@fontsource/cormorant-garamond/latin-400.css";
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-400-italic.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/cormorant-garamond/latin-600-italic.css";
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
:root {
color-scheme: light;
--cloth: #2f3d4b;
--cloth-deep: #26323e;
--paper: #eff1eb;
--paper-warm: #f7f7f1;
--sea: #e3eae8;
--ink: #242b3a;
--ink-soft: #5a6372;
--carmine: #a3303d;
--wood: #5d3f2a;
--atlas-serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--atlas-sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--paper);
font-family: var(--atlas-sans);
background:
repeating-linear-gradient(45deg, rgb(255 255 255 / 0.018) 0 2px, transparent 2px 5px),
repeating-linear-gradient(-45deg, rgb(0 0 0 / 0.05) 0 2px, transparent 2px 5px),
var(--cloth);
}
button { font: inherit; color: inherit; cursor: pointer; }
:focus-visible { outline: 2px solid #f0c36b; outline-offset: 2px; }
/* ------------------------------------------------------------- frame */
.atlas {
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
padding: 0 28px;
}
.atlas-head {
display: grid;
grid-template-columns: 330px 1fr auto;
align-items: center;
gap: 24px;
padding: 12px 0 10px;
}
.brand-title { margin: 0; font: 600 30px/1 var(--atlas-serif); letter-spacing: 0.01em; }
.brand-sub { margin: 5px 0 0; font-size: 12.5px; opacity: 0.72; }
.crumbs { white-space: nowrap; display: flex; align-items: baseline; gap: 10px; font: italic 500 24px/1.1 var(--atlas-serif); }
.crumb { background: none; border: 0; padding: 0; }
button.crumb { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 5px; opacity: 0.8; }
button.crumb:hover { opacity: 1; color: #f0c36b; }
.crumb-sep { opacity: 0.55; font-style: normal; }
.plate-nav { display: flex; align-items: center; gap: 10px; justify-content: flex-end; flex-wrap: wrap; max-width: 560px; }
.pn {
background: rgb(239 241 235 / 0.06);
border: 1px solid rgb(239 241 235 / 0.38);
border-radius: 2px;
padding: 5px 12px 6px;
font: 500 17px/1.1 var(--atlas-serif);
}
.pn:hover:not(:disabled) { background: rgb(239 241 235 / 0.16); }
.pn:disabled { opacity: 0.3; cursor: default; }
.keys { margin: 0; flex-basis: 100%; text-align: right; font-size: 11.5px; opacity: 0.62; }
.atlas-body {
min-height: 0;
display: grid;
grid-template-columns: 1fr 252px;
gap: 20px;
padding-bottom: 6px;
}
.plate-wrap { min-height: 0; min-width: 0; display: flex; align-items: flex-start; justify-content: center; }
.plate {
position: relative;
width: min(100%, calc((100vh - 136px) * 1100 / 700));
aspect-ratio: 1100 / 700;
background: var(--paper);
color: var(--ink);
box-shadow: 0 1px 0 rgb(255 255 255 / 0.25) inset, 0 18px 40px rgb(10 16 24 / 0.45), 0 2px 4px rgb(10 16 24 / 0.4);
overflow: hidden;
}
.chart { display: block; width: 100%; height: 100%; }
/* ------------------------------------------------------------- chart ink */
.sea { fill: var(--sea); }
.wl-ink { fill: none; stroke: rgb(36 43 58 / 0.24); stroke-linejoin: round; }
.wl-paper { fill: none; stroke: var(--sea); stroke-linejoin: round; }
.land-edge { fill: none; stroke-width: 16; opacity: 0.75; mix-blend-mode: multiply; }
.coast { fill: none; stroke: var(--ink); stroke-width: 1.3; stroke-linejoin: round; }
.islet { fill: #dcd6c4; stroke: var(--ink); stroke-width: 1; }
.halo { fill: none; stroke-width: 18; opacity: 0.6; stroke-linejoin: round; }
.storm-wash { fill: rgb(96 128 176 / 0.16); }
.storm-spiral { fill: none; stroke: #4f6587; stroke-width: 1.3; stroke-dasharray: 6 4; }
.course { fill: none; stroke: var(--ink); stroke-width: 2; stroke-dasharray: 8 5; stroke-linecap: round; }
.course--edge { opacity: 0.55; }
.glyph path { fill: var(--ink); }
.glyph .smoke { fill: none; stroke: var(--ink-soft); stroke-width: 1.4; }
.glyph .hill { fill: none; stroke: var(--ink); stroke-width: 1.6; }
.hachure path { fill: none; stroke: rgb(36 43 58 / 0.55); stroke-width: 1.1; }
.north path { fill: var(--ink); }
.north-n { font: 600 16px var(--atlas-serif); fill: var(--ink); }
.nl-outer { fill: none; stroke: var(--ink); stroke-width: 1.5; }
.nl-band { fill: none; stroke: var(--ink); stroke-width: 6; stroke-dasharray: 44 44; }
.nl-inner { fill: none; stroke: var(--ink); stroke-width: 0.8; }
.time-note { font: italic 500 18px var(--atlas-serif); fill: var(--carmine); }
.is-dim { opacity: 0.3; }
/* general chart */
.isle { cursor: pointer; outline: none; }
.isle:hover .coast, .isle:focus-visible .coast { stroke: var(--carmine); stroke-width: 2.6; }
.isle:hover .place-name, .isle:focus-visible .place-name { fill: var(--carmine); }
.isle:focus-visible .storm-spiral { stroke: var(--carmine); }
.ov-port { fill: var(--paper); stroke: var(--ink); stroke-width: 1.6; }
.ov-num { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); }
.place-name { font: italic 600 34px var(--atlas-serif); fill: var(--ink); }
.place-sub { font: 500 12.5px var(--atlas-sans); fill: var(--ink); }
.place-meta { font: 400 12.5px var(--atlas-sans); fill: var(--ink-soft); }
.track-row { font: 500 17px var(--atlas-serif); fill: var(--ink); }
.track-n { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); }
.motif-badge rect { stroke: none; }
.motif-badge.is-silent rect { stroke: var(--ink-soft); stroke-dasharray: 3 3; }
.badge-text { font: 600 12px var(--atlas-sans); fill: #fff; }
.motif-badge.is-silent .badge-text { fill: var(--ink-soft); font-weight: 500; }
.ship-ring { fill: var(--paper); stroke: var(--carmine); stroke-width: 2.2; }
.ship-hull { fill: var(--carmine); }
.ship-tag { fill: var(--carmine); }
.ship-label { font: 600 12px var(--atlas-sans); fill: #fff; }
/* inset plates */
.inset-land-name { font: italic 500 46px var(--atlas-serif); letter-spacing: 0.14em; fill: rgb(36 43 58 / 0.6); }
.mline .ml-solid { fill: none; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
.mline .ml-quiet { fill: none; stroke-width: 2.2; stroke-dasharray: 0.5 6; stroke-linecap: round; }
.mline.is-on .ml-solid { stroke-width: 7.5; }
.mline.is-on .ml-quiet { stroke-width: 3.4; }
.mline.is-dim { opacity: 0.16; }
.port { cursor: pointer; outline: none; }
.port-hit { fill: transparent; }
.port-stem { stroke: var(--ink); stroke-width: 1.4; }
.port-dot { stroke: var(--paper); stroke-width: 1.5; }
.port-dot.is-dim { opacity: 0.25; }
.port-ring { fill: var(--paper); stroke: var(--ink); stroke-width: 2.4; }
.port-core { fill: var(--ink); }
.port-title { font: 600 23px var(--atlas-serif); fill: var(--ink); }
.port-track { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); letter-spacing: 0.02em; }
.port-time { font: italic 500 16.5px var(--atlas-serif); fill: var(--ink-soft); }
.port:hover .port-title, .port:focus-visible .port-title { fill: var(--carmine); }
.port:focus-visible .port-ring, .port.is-open .port-ring { stroke: var(--carmine); stroke-width: 3.6; }
.port.is-open .port-core { fill: var(--carmine); }
.port.is-dim { opacity: 0.32; }
.plate-link { cursor: pointer; outline: none; }
.edge-note { font: italic 500 16px var(--atlas-serif); fill: var(--ink); }
.edge-arrow { fill: var(--ink); }
.edge-place { font-weight: 600; }
.plate-link:hover .edge-note, .plate-link:focus-visible .edge-note { fill: var(--carmine); text-decoration: underline; }
.scale-b { fill: var(--ink); }
.scale-w { fill: var(--paper); stroke: var(--ink); stroke-width: 0.8; }
.scale-tick { stroke: var(--ink); stroke-width: 1; }
.scale-year { font: 600 11.5px var(--atlas-sans); fill: var(--ink); }
.scale-cap { font: italic 500 16px var(--atlas-serif); fill: var(--ink); }
.locator { cursor: pointer; outline: none; }
.loc-frame { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
.locator:hover .loc-frame, .locator:focus-visible .loc-frame { stroke: var(--carmine); stroke-width: 2; }
.loc-focus { fill: rgb(163 48 61 / 0.08); stroke: var(--carmine); stroke-width: 12; }
.loc-cap { font: 500 10.5px var(--atlas-sans); fill: var(--ink-soft); }
/* ------------------------------------------------------------- cartouche */
.cartouche {
position: absolute;
left: 3.1%;
top: 4.4%;
max-width: 290px;
padding: 10px 16px 11px;
background: var(--paper-warm);
border: 4px double var(--ink);
pointer-events: none;
}
.plate:not(.is-inset) .cartouche { left: auto; right: 3.1%; max-width: 330px; }
.c-kicker { margin: 0; font: italic 500 15px var(--atlas-serif); color: var(--ink-soft); }
.c-title { margin: 0 0 4px; font: 600 28px/1.05 var(--atlas-serif); }
.c-line { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--ink); }
.c-hint { margin-top: 4px; color: var(--carmine); font-weight: 500; }
/* ------------------------------------------------------------- song slip */
.slip {
position: absolute;
left: 14px; right: 14px; bottom: 14px;
height: 36%;
display: grid;
grid-template-columns: 1fr 1fr 1.15fr;
gap: 28px;
padding: 18px 26px 16px;
background: var(--paper-warm);
border-top: 5px double var(--ink);
box-shadow: 0 -14px 26px rgb(36 43 58 / 0.14);
animation: slip-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
overflow: hidden;
}
.slip-close {
position: absolute; right: 18px; top: 12px;
background: none; border: 1px solid var(--ink-soft); border-radius: 2px;
padding: 3px 10px; font-size: 12px; color: var(--ink);
}
.slip-close:hover { border-color: var(--carmine); color: var(--carmine); }
.slip-col { min-width: 0; overflow: auto; }
.slip-track { margin: 0; font-size: 12px; color: var(--ink-soft); }
.slip-title { margin: 2px 0 6px; font: 600 36px/1.02 var(--atlas-serif); }
.slip-time { margin: 0 0 10px; font: italic 500 18px/1.3 var(--atlas-serif); }
.slip-odyssey { font-size: 13px; color: var(--carmine); }
.slip-odyssey.is-none { margin: 0; color: var(--ink-soft); }
.slip-h { margin: 4px 0 8px; font: italic 600 18px var(--atlas-serif); }
.parts { list-style: none; margin: 0; padding: 0; }
.parts li {
display: grid; grid-template-columns: 78px 1fr auto; gap: 10px; align-items: baseline;
padding: 6px 0; border-bottom: 1px solid rgb(36 43 58 / 0.14); font-size: 13px;
}
.part-name { font: 600 16px var(--atlas-serif); }
.part-motifs { display: flex; gap: 3px; }
.tick { display: inline-block; width: 14px; height: 5px; border-radius: 1px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.chip {
border: 1.5px solid var(--c); background: transparent; border-radius: 999px;
padding: 3px 10px; font-size: 12px; color: var(--ink);
}
.chip.is-on { background: var(--c); color: #fff; }
.slip .audio-slot { font-size: 12.5px; margin-bottom: 8px; padding: 8px 10px; border-radius: 2px; }
.slip .video-card { font-size: 12.5px; border-radius: 2px; padding: 5px 10px; color: var(--ink); }
/* ------------------------------------------------------------- themes slip */
.themes {
align-self: start;
background: var(--paper);
color: var(--ink);
padding: 18px 16px 16px;
box-shadow: 0 12px 30px rgb(10 16 24 / 0.38);
border-top: 4px double var(--ink);
}
.th-title { margin: 0; font: 600 26px var(--atlas-serif); }
.th-sub { margin: 2px 0 10px; font-size: 12px; line-height: 1.4; color: var(--ink-soft); }
.th-group { margin: 12px 0 4px; font: italic 600 16px var(--atlas-serif); }
.th-list { list-style: none; margin: 0; padding: 0; }
.th-row {
width: 100%; display: grid; grid-template-columns: 24px 1fr auto; gap: 9px; align-items: center;
background: none; border: 0; border-left: 3px solid transparent; padding: 5px 6px 5px 5px; text-align: left; font-size: 13.5px;
}
.th-row:hover { background: rgb(36 43 58 / 0.06); }
.th-row.is-on { background: color-mix(in srgb, var(--c) 16%, transparent); border-left-color: var(--c); font-weight: 600; }
.th-row.is-absent { opacity: 0.42; }
.th-swatch { height: 5px; border-radius: 1px; background: var(--c); }
.th-count { font-size: 12px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.th-key { margin-top: 10px; border-top: 1px solid rgb(36 43 58 / 0.18); padding-top: 2px; }
.key-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; font-size: 12px; color: var(--ink-soft); }
.key-sym { width: 24px; flex: none; display: inline-block; }
.k-course { border-top: 2px dashed var(--ink); }
.k-port { width: 14px; height: 14px; margin: 0 5px; border: 2.4px solid var(--ink); border-radius: 50%; background: var(--paper); }
.k-line { height: 5px; background: #8d6b2e; border-radius: 3px; }
.k-quiet { border-top: 3px dotted #8d6b2e; }
/* ------------------------------------------------------------- transitions */
.plate.is-zooming .cartouche { opacity: 0; transition: opacity 0.2s; }
.plate[data-anim="in"] .chart { animation: plate-in 0.36s ease-out; }
.plate[data-anim="next"] .chart { animation: turn-next 0.34s ease-out; }
.plate[data-anim="prev"] .chart { animation: turn-prev 0.34s ease-out; }
@keyframes plate-in { from { opacity: 0.2; transform: scale(1.04); } to { opacity: 1; transform: none; } }
@keyframes turn-next { from { opacity: 0; transform: translateX(36px); } to { opacity: 1; transform: none; } }
@keyframes turn-prev { from { opacity: 0; transform: translateX(-36px); } to { opacity: 1; transform: none; } }
@keyframes slip-up { from { transform: translateY(40%); opacity: 0; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .slip, .chart { animation: none !important; } }
/* ------------------------------------------------------------- overlay: the captain's table */
.sail-pop {
position: absolute;
transform: translateX(-50%);
width: 200px;
padding: 10px 12px 12px;
background: var(--paper-warm);
border: 1px solid var(--ink);
box-shadow: 0 10px 22px rgb(36 43 58 / 0.25);
text-align: center;
z-index: 3;
}
.sp-title { margin: 0 0 8px; font: 600 19px var(--atlas-serif); }
.sp-line { margin: 0 0 8px; font-size: 12px; color: var(--ink-soft); }
.sp-sail { display: block; width: 100%; margin-bottom: 6px; padding: 7px 0; border: 0; border-radius: 2px; background: var(--carmine); color: #fff; font-weight: 600; font-size: 13px; }
.sp-open { background: none; border: 0; font-size: 12px; text-decoration: underline; color: var(--ink); }
.sail-note {
position: absolute; left: 50%; top: 22px; transform: translateX(-50%);
margin: 0; padding: 6px 14px; background: var(--carmine); color: #fff; font-size: 12.5px; border-radius: 2px;
}
body.is-overlay {
justify-content: center;
background:
radial-gradient(ellipse at 50% 120%, rgb(240 190 120 / 0.25), transparent 55%),
repeating-linear-gradient(172deg, rgb(255 255 255 / 0.035) 0 1px, transparent 1px 22px),
linear-gradient(180deg, #1c3742 0%, #12262f 60%, #0b171d 100%);
}
body.is-overlay::before {
content: "3D voyage continues behind the table";
position: fixed; left: 24px; top: 18px;
font: italic 500 15px var(--atlas-serif); color: rgb(239 241 235 / 0.45);
}
body.is-overlay .atlas {
flex: none;
width: 1160px;
margin: auto;
padding: 4px 22px 18px;
border-radius: 10px;
background:
repeating-linear-gradient(90deg, rgb(0 0 0 / 0.07) 0 3px, transparent 3px 11px),
linear-gradient(180deg, #6c4a32, var(--wood));
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.4), 0 40px 90px rgb(0 0 0 / 0.65);
}
body.is-overlay .atlas-head { grid-template-columns: 220px 1fr auto auto; padding: 10px 0 8px; }
body.is-overlay .brand-title { font-size: 25px; }
body.is-overlay .atlas-body { grid-template-columns: 1fr 220px; }
body.is-overlay .plate { width: 100%; transform: rotate(-0.4deg); }
body.is-overlay .themes { font-size: 12px; padding: 12px; }
body.is-overlay .th-key { display: none; }
.here-tag { margin: 0; display: flex; border: 1px solid var(--carmine); border-radius: 2px; overflow: hidden; font-size: 12.5px; }
.here-k { padding: 5px 9px; background: var(--carmine); color: #fff; }
.here-v { padding: 5px 10px; font: 600 16px/1.1 var(--atlas-serif); background: var(--paper); color: var(--ink); }
/* ------------------------------------------------------------- credits */
.fan-credits { color: rgb(239 241 235 / 0.82); opacity: 1; padding: 0.7rem 1.5rem 0.85rem; font-size: 12.5px; }
body.is-overlay .fan-credits { margin-top: auto; }
+260
View File
@@ -0,0 +1,260 @@
// 12 · Vellum atlas: geometry for the general chart (overview) and each saga's inset plate.
// Navigation geometry comes from 11; the plates are re-laid for 10's portolan look (land along the top).
import { motifs, sagas, songs } from "../../data/sample";
import type { Motif, Saga, Song } from "../../data/types";
export const W = 1100;
export const H = 700;
export type Pt = { x: number; y: number };
/* ------------------------------------------------------------- utilities */
export function rng(seed: number) {
let t = seed >>> 0;
return () => {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
/** Closed smooth path through points (Catmull-Rom → cubic Bézier). */
export function smoothClosed(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n]!, p1 = pts[i]!, p2 = pts[(i + 1) % n]!, p3 = pts[(i + 2) % n]!;
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d + "Z";
}
/** Open smooth path through points. */
export function smoothOpen(pts: Pt[]): string {
if (pts.length < 2) return "";
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)]!, p1 = pts[i]!, p2 = pts[i + 1]!, p3 = pts[Math.min(pts.length - 1, i + 2)]!;
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d;
}
/** Irregular island outline around an ellipse. */
export function blob(cx: number, cy: number, rx: number, ry: number, seed: number, wobble = 0.16, n = 22): Pt[] {
const r = rng(seed);
const pts: Pt[] = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const k = 1 + (r() - 0.5) * wobble * 2;
pts.push({ x: cx + Math.cos(a) * rx * k, y: cy + Math.sin(a) * ry * k });
}
return pts;
}
/* The shared motif colours are tuned for dark pages; on vellum they are re-inked
to darker iron-gall versions of the same hue (as in 10). */
const INK: Record<string, string> = {
warrior: "#93650b",
"open-arms": "#b2472c",
"just-a-man": "#2e5d8b",
remember: "#3b7347",
odysseus: "#5b3c20",
athena: "#6a4a9d",
polyphemus: "#8e1e24",
};
export const inkOf = (id: string): string => INK[id] ?? motifs.find((m) => m.id === id)?.color ?? "#2b2118";
/* ------------------------------------------------------------- places */
export interface Place {
sagaId: string;
place: string; // place name on the chart
roman: string; // plate number
label: Pt; // top-left of label block on the general chart
focus: { x: number; y: number; w: number; h: number }; // zoom box on the general chart
}
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
const PLACE_INFO: Record<string, Omit<Place, "sagaId" | "roman">> = {
troy: { place: "Troy", label: { x: 30, y: 70 }, focus: { x: 0, y: 150, w: 470, h: 430 } },
cyclops: { place: "Cyclops' Island", label: { x: 455, y: 92 }, focus: { x: 380, y: 200, w: 420, h: 380 } },
ocean: { place: "Open sea and storm", label: { x: 818, y: 500 }, focus: { x: 790, y: 190, w: 310, h: 300 } },
};
export const places: Place[] = [...sagas]
.sort((a, b) => a.order - b.order)
.map((s, i) => ({
sagaId: s.id,
roman: ROMAN[i] ?? String(i + 1),
...(PLACE_INFO[s.id] ?? {
place: s.name,
label: { x: 40 + i * 120, y: 560 },
focus: { x: 0, y: 0, w: W, h: H },
}),
}));
export const placeOf = (sagaId: string) => places.find((p) => p.sagaId === sagaId)!;
export const sagaById = (id: string): Saga => sagas.find((s) => s.id === id)!;
export const songsIn = (sagaId: string): Song[] => songs.filter((s) => s.sagaId === sagaId);
export function yearSpan(list: Song[]): string {
const ys = list.map((s) => s.storyTime.yearsAfterTroy);
const a = Math.min(...ys), b = Math.max(...ys);
return a === b ? `Year ${a}` : `Years ${a} to ${b}`;
}
export function motifsPlaying(song: Song): Set<string> {
return new Set(song.parts.flatMap((p) => p.motifIds));
}
/* ------------------------------------------------------------- general chart */
// Troy: mainland on the west edge with a headland. Cyclops' island mid-chart. Storm out east.
export const TROY_CENTER = { x: 230, y: 360 };
export const CYCLOPS = { cx: 585, cy: 405, rx: 135, ry: 92 };
export const STORM = { cx: 945, cy: 335, r: 118 };
export function troyMainland(width = W, height = H, scale = 1): string {
const r = rng(7);
const pts: Pt[] = [];
for (let y = -10; y <= height + 10; y += 26) {
const yy = y / scale;
let x = 248 + (r() - 0.5) * 18;
const dy = yy - TROY_CENTER.y;
if (Math.abs(dy) < 125) x = Math.max(x, TROY_CENTER.x + Math.sqrt(125 * 125 - dy * dy) * 0.9 + (r() - 0.5) * 12);
pts.push({ x: x * scale, y });
}
void width;
const path = smoothOpen(pts);
return `${path} L-20,${height + 20} L-20,-20 Z`;
}
/** Port positions on the general chart, one per song in story order. */
export function overviewPorts(): Pt[] {
const out: Pt[] = [];
for (const place of places) {
const list = songsIn(place.sagaId);
const n = list.length;
list.forEach((_, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
if (place.sagaId === "troy") {
const a = (-62 + t * 112) * (Math.PI / 180);
out.push({ x: TROY_CENTER.x + Math.cos(a) * 172, y: TROY_CENTER.y + Math.sin(a) * 172 });
} else if (place.sagaId === "cyclops") {
const a = (165 - t * 150) * (Math.PI / 180);
out.push({ x: CYCLOPS.cx + Math.cos(a) * (CYCLOPS.rx + 30), y: CYCLOPS.cy + Math.sin(a) * (CYCLOPS.ry + 30) });
} else if (place.sagaId === "ocean") {
const a = (200 + t * 140) * (Math.PI / 180);
const rr = n === 1 ? 0 : 50;
out.push({ x: STORM.cx + Math.cos(a) * rr, y: STORM.cy + Math.sin(a) * rr });
} else {
out.push({ x: place.label.x + 40 + i * 30, y: place.label.y - 30 });
}
});
}
return out;
}
/** Route through the overview ports with a few sea waypoints between sagas. */
export function overviewRoute(ports: Pt[]): Pt[] {
const pts: Pt[] = [];
songs.forEach((s, i) => {
const prev = songs[i - 1];
if (prev && prev.sagaId !== s.sagaId) {
const a = ports[i - 1]!, b = ports[i]!;
if (prev.sagaId === "troy") pts.push({ x: (a.x + b.x) / 2, y: Math.max(a.y, b.y) + 40 });
else if (s.sagaId === "ocean") pts.push({ x: (a.x + b.x) / 2 + 20, y: (a.y + b.y) / 2 + 60 });
}
pts.push(ports[i]!);
});
return pts;
}
/** Storm: a spiral of squall lines. */
export function stormSpiral(cx: number, cy: number, r: number, turns = 2.6): string {
const pts: Pt[] = [];
const steps = 90;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const a = t * turns * Math.PI * 2;
const rr = 14 + t * (r - 14);
pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr * 0.78 });
}
return smoothOpen(pts);
}
/* ------------------------------------------------------------- inset plates */
export interface InsetPort { song: Song; x: number; y: number }
export interface InsetLayout {
ports: InsetPort[];
/** Year gaps between consecutive ports (for "a year passes" notes). */
gaps: number[];
motifs: Motif[]; // motifs that play in this saga, in legend order
route: Pt[]; // control points of the course, edge to edge
lead: Pt | null; // where the course enters from the west edge (null if first plate)
tail: Pt | null; // where it leaves to the east edge (null if last plate)
maxY: number; // lowest port
}
export const ROUTE_Y = 312;
export const YEAR_DROP = 30;
/** Motif threads (treatment A) hang in rows below the lowest port. */
export const THREAD_GAP = 24; // below the lowest story-time line
export const THREAD_STEP = 13;
export const THREAD_X0 = 160; // threads that arrive from another plate start here; names sit to the left
export const SCALE_Y = 612;
export function insetLayout(sagaId: string): InsetLayout {
const list = songsIn(sagaId);
const n = list.length;
const y0 = list[0]?.storyTime.yearsAfterTroy ?? 0;
const gaps = list.slice(1).map((s, i) => s.storyTime.yearsAfterTroy - list[i]!.storyTime.yearsAfterTroy);
const units = Math.max(1, n - 1 + gaps.reduce((a, g) => a + Math.min(g, 2) * 0.9, 0));
const sagaIdx = places.findIndex((p) => p.sagaId === sagaId);
const hasLead = sagaIdx > 0, hasTail = sagaIdx < places.length - 1;
const left = hasLead ? 250 : 178, right = hasTail ? 838 : 960;
const step = Math.min(230, (right - left) / units);
const used = step * units;
let x = n === 1 ? (left + right) / 2 : left + (right - left - used) / 2;
const ports: InsetPort[] = list.map((song, i) => {
if (i > 0) x += step * (1 + Math.min(gaps[i - 1]!, 2) * 0.9);
const dyYears = Math.min(song.storyTime.yearsAfterTroy - y0, 2);
return { song, x, y: ROUTE_Y + dyYears * YEAR_DROP };
});
const first = ports[0]!, last = ports[ports.length - 1]!;
const lead = hasLead ? { x: 30, y: first.y - 18 } : null;
const tail = hasTail ? { x: W - 30, y: last.y + 14 } : null;
// The course: through every port, with a gentle hand-drawn swell between them.
const stops: Pt[] = [...(lead ? [lead] : []), ...ports.map((p) => ({ x: p.x, y: p.y })), ...(tail ? [tail] : [])];
const route: Pt[] = [];
stops.forEach((p, i) => {
if (i > 0) {
const a = stops[i - 1]!;
route.push({ x: (a.x + p.x) / 2, y: (a.y + p.y) / 2 + (i % 2 ? 9 : -8) });
}
route.push(p);
});
const ids = new Set(list.flatMap((s) => [...motifsPlaying(s)]));
return {
ports, gaps, route, lead, tail,
maxY: Math.max(...ports.map((p) => p.y)),
motifs: motifs.filter((m) => ids.has(m.id)),
};
}
export const polyD = (pts: Pt[]) => pts.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
/** Wrap a title into at most two lines of ~maxChars. */
export function wrap(text: string, maxChars: number): string[] {
if (text.length <= maxChars) return [text];
const words = text.split(" ");
let best = [text];
let bestScore = Infinity;
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
const score = Math.max(a.length, b.length);
if (score < bestScore) { bestScore = score; best = [a, b]; }
}
return best;
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vellum atlas — EPIC Timeline mockup</title>
<link rel="stylesheet" href="../../shared/credits.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main id="app"></main>
<footer data-fan-credits></footer>
<script type="module" src="./main.ts"></script>
</body>
</html>
+880
View File
@@ -0,0 +1,880 @@
// 12 · Vellum atlas: 11's atlas navigation (general chart → island plates) drawn in 10's vellum
// portolan style, with three ways to show themes on a plate (A threads, B pennants, C ink only).
import { motifs, songs } from "../../data/sample";
import { motifsInSong, songsWithMotif } from "../../data/query";
import type { Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import {
CYCLOPS, H, SCALE_Y, STORM, THREAD_GAP, THREAD_STEP, THREAD_X0, TROY_CENTER, W, blob, inkOf,
insetLayout, motifsPlaying, overviewPorts, overviewRoute, placeOf, places, polyD, sagaById, smoothClosed,
smoothOpen, songsIn, troyMainland, wrap, yearSpan, type InsetLayout, type InsetPort, type Pt,
} from "./geo";
mountCredits();
/* ------------------------------------------------------------------ state */
type Mode = "a" | "b" | "c";
const MODES: { id: Mode; letter: string; name: string }[] = [
{ id: "a", letter: "A", name: "Threads" },
{ id: "b", letter: "B", name: "Pennants" },
{ id: "c", letter: "C", name: "Ink only" },
];
const params = new URLSearchParams(location.search);
const overlay = params.get("overlay") === "1";
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const initialSong = songs.find((s) => s.id === params.get("song")) ?? null;
const modeParam = params.get("motifs");
const state = {
sagaId: (initialSong?.sagaId ?? places.find((p) => p.sagaId === params.get("saga"))?.sagaId ?? null) as string | null,
songId: (overlay ? null : initialSong?.id ?? null) as string | null,
motifId: motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null,
mode: (modeParam === "b" || modeParam === "c" ? modeParam : "a") as Mode,
shipAt: "survive",
sailPick: (overlay ? initialSong?.id ?? null : null) as string | null,
note: "" as string,
};
if (overlay) document.body.classList.add("is-overlay");
const motifName = (id: string) => motifs.find((m) => m.id === id)!.name;
const partsWith = (song: Song, id: string) => song.parts.filter((p) => p.motifIds.includes(id)).map((p) => p.name);
/* ------------------------------------------------------------------ dom helpers */
const SVG = "http://www.w3.org/2000/svg";
type Attrs = Record<string, string | number | undefined>;
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] {
const el = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
parent?.append(el);
return el;
}
function h<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text?: string, parent?: Element): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
if (cls) el.className = cls;
if (text !== undefined) el.textContent = text;
parent?.append(el);
return el;
}
function txt(parent: Element, x: number, y: number, text: string, cls: string, attrs: Attrs = {}) {
const t = s("text", { x, y, class: cls, ...attrs }, parent);
t.textContent = text;
return t;
}
function activate(el: Element, label: string, fn: () => void) {
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", label);
el.addEventListener("click", (e) => { e.stopPropagation(); fn(); });
el.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") { e.preventDefault(); e.stopPropagation(); fn(); }
});
}
/* ------------------------------------------------------------------ skeleton */
const app = document.getElementById("app")!;
app.className = "atlas";
const head = h("header", "atlas-head", undefined, app);
const body = h("div", "atlas-body", undefined, app);
const plateWrap = h("section", "plate-wrap", undefined, body);
const roll = h("div", "roll", undefined, plateWrap);
const plate = h("div", "plate", undefined, roll);
h("span", "rod rod--west", undefined, roll).setAttribute("aria-hidden", "true");
h("span", "rod rod--east", undefined, roll).setAttribute("aria-hidden", "true");
const index = h("aside", "themes", undefined, body);
index.setAttribute("aria-label", "Themes");
let svg: SVGSVGElement = s("svg", {});
let uid = 0;
/* ------------------------------------------------------------------ vellum pieces (after 10) */
function vellum(root: SVGElement) {
const id = `grain${++uid}`;
const f = s("filter", { id, x: 0, y: 0, width: "100%", height: "100%" }, root);
s("feTurbulence", { type: "fractalNoise", baseFrequency: "0.9 0.75", numOctaves: 2, seed: 4, result: "n" }, f);
s("feColorMatrix", { in: "n", type: "matrix", values: "0 0 0 0 0.36 0 0 0 0 0.28 0 0 0 0 0.16 0 0 0 0.55 -0.1" }, f);
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
const blot = s("radialGradient", { id: `${id}b`, cx: "50%", cy: "45%", r: "75%" }, root);
s("stop", { offset: "55%", "stop-color": "#e3d6b4", "stop-opacity": 0 }, blot);
s("stop", { offset: "100%", "stop-color": "#a88c5c", "stop-opacity": 0.38 }, blot);
s("rect", { x: 0, y: 0, width: W, height: H, fill: `url(#${id}b)`, "pointer-events": "none" }, root);
s("rect", { x: 0, y: 0, width: W, height: H, filter: `url(#${id})`, class: "grain", "pointer-events": "none" }, root);
}
function rhumbs(root: SVGElement, cx: number, cy: number, len = 1500) {
const g = s("g", { class: "rhumbs", "pointer-events": "none" }, root);
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const cls = i % 4 === 0 ? "rhumb rhumb--ink" : i % 2 === 0 ? "rhumb rhumb--green" : "rhumb rhumb--red";
s("line", { x1: cx, y1: cy, x2: cx + Math.cos(a) * len, y2: cy + Math.sin(a) * len, class: cls }, g);
}
}
function rose(root: SVGElement, cx: number, cy: number, r = 44) {
const g = s("g", { class: "rose", transform: `translate(${cx} ${cy}) scale(${r / 44})`, "pointer-events": "none" }, root);
s("circle", { r: 44, class: "rose__ring" }, g);
s("circle", { r: 36, class: "rose__ring rose__ring--thin" }, g);
for (let i = 0; i < 8; i++) {
const len = i % 2 === 0 ? 52 : 30;
const cls = i % 2 === 0 ? "rose__point" : "rose__point rose__point--minor";
s("path", { d: `M0,${-len} L6,0 L0,0 Z`, class: cls, transform: `rotate(${i * 45})` }, g);
s("path", { d: `M0,${-len} L-6,0 L0,0 Z`, class: `${cls} rose__point--shade`, transform: `rotate(${i * 45})` }, g);
}
txt(g, 0, -58, "N", "rose__n", { "text-anchor": "middle" });
}
/** Dashed water-lines outside a coast: ink stroke, then paper laid over its inner half. */
function waterLines(root: SVGElement, d: string, widths = [30, 16]) {
const g = s("g", { class: "waterlines", "pointer-events": "none" }, root);
for (const w of widths) {
s("path", { d, class: "wl-ink", "stroke-width": w }, g);
s("path", { d, class: "wl-paper", "stroke-width": w - 1.8 }, g);
}
}
function land(root: SVGElement, d: string) {
const id = `clip${++uid}`;
const cp = s("clipPath", { id }, root);
s("path", { d }, cp);
s("path", { d, class: "landmass" }, root);
s("path", { d, class: "coastwash", "clip-path": `url(#${id})` }, root);
s("path", { d, class: "coast" }, root);
}
function towers(root: SVGElement, x: number, y: number, k = 1) {
const g = s("g", { class: "towers", transform: `translate(${x} ${y}) scale(${k})`, "pointer-events": "none" }, root);
for (const [dx, hgt] of [[0, 18], [12, 26], [24, 20], [36, 14]] as const) s("rect", { x: dx, y: 26 - hgt, width: 9, height: hgt }, g);
}
function cave(root: SVGElement, x: number, y: number, k = 1) {
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})`, "pointer-events": "none" }, root);
s("path", { d: "M-30,4 C-24,-22 -8,-30 0,-30 C10,-30 24,-20 30,4", class: "hill" }, g);
s("path", { d: "M-9,4 C-9,-10 9,-10 9,4 Z" }, g);
}
function hills(root: SVGElement, cx: number, cy: number, n: number, spread: number, seed: number) {
let r = seed;
const g = s("g", { class: "hills", "pointer-events": "none" }, root);
for (let i = 0; i < n; i++) {
r = (r * 9301 + 49297) % 233280;
const x = cx + ((r / 233280) - 0.5) * spread;
r = (r * 9301 + 49297) % 233280;
const y = cy + ((r / 233280) - 0.5) * spread * 0.3;
s("path", { d: `M${x - 8},${y + 3} Q${x},${y - 7} ${x + 8},${y + 3}` }, g);
}
}
/** 10's storm: three ink arms swirling out, with rain beneath. */
function storm(root: SVGElement, cx: number, cy: number, k = 1, rain = true) {
const g = s("g", { class: "storm", transform: `translate(${cx} ${cy}) scale(${k})`, "pointer-events": "none" }, root);
for (let a = 0; a < 3; a++) {
let d = "";
for (let t = 0; t < 4.2 * Math.PI; t += 0.2) {
const r = 6 + t * 5.2;
d += `${d ? "L" : "M"}${(Math.cos(t + a * 2.1) * r * 1.5).toFixed(1)},${(Math.sin(t + a * 2.1) * r * 0.75).toFixed(1)}`;
}
s("path", { d, class: "storm__arm" }, g);
}
if (rain) for (let i = 0; i < 9; i++) s("line", { x1: -110 + i * 26, y1: 70, x2: -126 + i * 26, y2: 108, class: "storm__rain" }, g);
}
function rock(root: SVGElement, x: number, y: number, r: number, seed: number) {
const d = smoothClosed(blob(x, y, r, r * 0.7, seed, 0.2, 14));
waterLines(root, d, [14]);
s("path", { d, class: "landmass" }, root);
}
/** 10's ship, with a "You are here" tag above it. */
function ship(root: SVGElement, x: number, y: number, label: string, tagY = -40) {
const g = s("g", { class: "ship", transform: `translate(${x} ${y})`, "pointer-events": "none" }, root);
s("ellipse", { cx: 0, cy: 9, rx: 26, ry: 4, class: "ship__wake" }, g);
const b = s("g", { transform: "scale(0.8)" }, g);
s("path", { d: "M-20,-2 L20,-2 L13,8 L-14,8 Z", class: "ship__hull" }, b);
s("path", { d: "M0,-2 L0,-34", class: "ship__mast" }, b);
s("path", { d: "M-13,-31 Q0,-24 13,-31 L12,-9 Q0,-4 -12,-9 Z", class: "ship__sail" }, b);
s("path", { d: "M0,-34 L10,-38 L0,-41 Z", class: "ship__pennant" }, b);
const w = label.length * 6.7 + 18;
s("rect", { x: -w / 2, y: tagY - 15, width: w, height: 21, rx: 3, class: "ship-tag" }, g);
txt(g, 0, tagY, label, "ship-label", { "text-anchor": "middle" });
}
function frame(root: SVGElement) {
const g = s("g", { class: "neatline", "pointer-events": "none" }, root);
s("rect", { x: 6, y: 6, width: W - 12, height: H - 12, class: "nl-outer" }, g);
s("rect", { x: 12, y: 12, width: W - 24, height: H - 24, class: "nl-inner" }, g);
}
/* ------------------------------------------------------------------ general chart */
const OV_PORTS = overviewPorts();
const OV_ROUTE = overviewRoute(OV_PORTS);
const CYC_SHAPE = smoothClosed(blob(CYCLOPS.cx, CYCLOPS.cy, CYCLOPS.rx, CYCLOPS.ry, 11, 0.13));
const TROY_SHAPE = troyMainland();
const OV_ROSE = { x: 690, y: 160 };
function drawGeneral(root: SVGElement, mini = false) {
if (mini) s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
else { vellum(root); rhumbs(root, OV_ROSE.x, OV_ROSE.y); }
const motifSongs = state.motifId ? new Set(songsWithMotif(state.motifId).map((x) => x.id)) : null;
// Halo under islands that carry the selected theme.
const lit = (sagaId: string) => !!motifSongs && songsIn(sagaId).some((x) => motifSongs.has(x.id));
if (state.motifId && !mini) {
const c = inkOf(state.motifId);
if (lit("troy")) s("path", { d: TROY_SHAPE, class: "halo", stroke: c }, root);
if (lit("cyclops")) s("path", { d: CYC_SHAPE, class: "halo", stroke: c }, root);
if (lit("ocean")) s("ellipse", { cx: STORM.cx, cy: STORM.cy, rx: STORM.r, ry: STORM.r * 0.78, class: "halo halo--sea", stroke: c }, root);
}
if (!mini) { waterLines(root, TROY_SHAPE); waterLines(root, CYC_SHAPE); }
for (const [x, y, rx, seed] of [[420, 120, 26, 3], [600, 668, 20, 5], [700, 268, 13, 13]] as const) {
if (mini) s("path", { d: smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14)), class: "landmass" }, root);
else rock(root, x, y, rx, seed);
}
const groups: Record<string, SVGGElement> = {};
for (const p of places) {
const g = s("g", { class: "isle", "data-saga": p.sagaId }, root);
if (state.motifId && !lit(p.sagaId) && !mini) g.classList.add("is-dim");
groups[p.sagaId] = g;
}
land(groups.troy!, TROY_SHAPE);
towers(groups.troy!, TROY_CENTER.x - 74, TROY_CENTER.y - 10, mini ? 1.8 : 1.2);
land(groups.cyclops!, CYC_SHAPE);
if (!mini) { hills(groups.cyclops!, CYCLOPS.cx - 20, CYCLOPS.cy - 22, 8, 150, 4); cave(groups.cyclops!, CYCLOPS.cx + 46, CYCLOPS.cy + 34); }
// The open sea has no coast to click, so give it a quiet hit area under its storm.
s("ellipse", { cx: STORM.cx, cy: STORM.cy, rx: STORM.r, ry: STORM.r * 0.78, class: "sea-hit" }, groups.ocean!);
storm(groups.ocean!, STORM.cx, STORM.cy - 20, mini ? 1.3 : 0.95, false);
s("path", { d: smoothOpen(OV_ROUTE), class: "course" }, root);
if (mini) return;
// Elapsed time on the long crossings, set clear of the course.
songs.forEach((song, i) => {
const prev = songs[i - 1];
if (!prev) return;
const g = song.storyTime.yearsAfterTroy - prev.storyTime.yearsAfterTroy;
if (g <= 0) return;
const note = g === 1 ? "a year passes" : `${g === 2 ? "two" : g} years at sea`;
// Troy's year passes on the loop below port 5; the long crossing is written on open water.
if (song.sagaId === "troy") txt(root, 410, 578, note, "time-note", { "text-anchor": "middle" });
else {
const a = OV_PORTS[i - 1]!, b = OV_PORTS[i]!;
txt(root, (a.x + b.x) / 2 - 22, (a.y + b.y) / 2 + 24, note, "time-note", { "text-anchor": "middle" });
}
});
songs.forEach((song, i) => {
const p = OV_PORTS[i]!;
const on = motifSongs?.has(song.id);
const c = s("circle", { cx: p.x, cy: p.y, r: on ? 7.5 : 5.5, class: "ov-port" + (motifSongs && !on ? " is-dim" : "") }, root);
if (on) c.setAttribute("fill", inkOf(state.motifId!));
txt(root, p.x + (song.sagaId === "troy" ? 12 : 0), p.y + (song.sagaId === "troy" ? 4 : song.sagaId === "ocean" ? 24 : -12), String(song.track), "ov-num", { "text-anchor": song.sagaId === "troy" ? "start" : "middle" });
});
// Label blocks with a track list; each row opens its plate and its song in one click.
for (const p of places) {
const g = groups[p.sagaId]!;
const list = songsIn(p.sagaId);
const saga = sagaById(p.sagaId);
const lb = s("g", { class: "isle-label", transform: `translate(${p.label.x} ${p.label.y})` }, g);
txt(lb, 0, 0, p.place, "place-name");
txt(lb, 0, 22, `${saga.name}, plate ${p.roman}`, "place-sub");
txt(lb, 0, 40, `${list.length} ${list.length === 1 ? "song" : "songs"}, ${yearSpan(list).toLowerCase()}`, "place-meta");
list.forEach((song, j) => {
const on = motifSongs?.has(song.id);
const row = s("g", { class: "track-row" + (motifSongs && !on ? " is-dim" : "") + (on ? " is-on" : "") }, lb);
if (on) row.style.setProperty("--c", inkOf(state.motifId!));
s("rect", { x: -6, y: 64 + j * 20 - 15, width: Math.max(150, song.title.length * 8.6 + 30), height: 20, class: "track-hit" }, row);
const t = txt(row, 0, 64 + j * 20, "", "track-text");
const n = s("tspan", { class: "track-n" }, t); n.textContent = String(song.track).padStart(2, " ") + " ";
const tt = s("tspan", { class: "track-title" }, t); tt.textContent = song.title;
activate(row, `Open ${song.title}, track ${song.track}, on plate ${p.roman}`, () => openSongFromChart(song));
});
if (state.motifId && motifSongs) {
const count = list.filter((x) => motifSongs.has(x.id)).length;
const m = motifs.find((x) => x.id === state.motifId)!;
const y = 64 + list.length * 20 - 4;
const label = count ? `${m.name} plays in ${count} of ${list.length}` : `${m.name} is silent here`;
// The badge sits outside the island group so a dimmed island still says it is silent.
const badge = s("g", { class: "motif-badge" + (count ? "" : " is-silent"), transform: `translate(${p.label.x} ${p.label.y + y})` }, root);
s("rect", { x: 0, y: 0, width: label.length * 6.5 + 22, height: 22, rx: 11, fill: count ? inkOf(m.id) : "none" }, badge);
txt(badge, 11, 15, label, "badge-text");
}
activate(g, `Open plate ${p.roman}: ${p.place}, ${list.length} songs`, () => openInset(p.sagaId));
}
rose(root, 762, 650, 28);
if (overlay) {
const i = songs.findIndex((x) => x.id === state.shipAt);
const p = OV_PORTS[i]!;
ship(root, p.x - 24, p.y + 2, `You are here: ${songs[i]!.title}`, -42);
}
}
/* ------------------------------------------------------------------ inset plate */
/** Land and open-water dressing per plate. Land runs along the top so the song slip never covers it. */
function plateDressing(root: SVGElement, sagaId: string, L: InsetLayout) {
if (sagaId === "troy") {
rhumbs(root, 650, 520);
const pts: Pt[] = [];
const rnd = (() => { let t = 21; return () => ((t = (t * 9301 + 49297) % 233280) / 233280); })();
for (let x = -20; x <= W + 20; x += 50) pts.push({ x, y: 168 + Math.sin(x / 150) * 16 + Math.sin(x / 61 + 1) * 9 + (rnd() - 0.5) * 12 - (x > 900 ? (x - 900) * 0.35 : 0) });
const d = `${smoothOpen(pts)} L${W + 20},-20 L-20,-20 Z`;
waterLines(root, d);
land(root, d);
hills(root, 640, 70, 10, 300, 8);
txt(root, 440, 118, "Troy", "plate-land-name");
towers(root, 560, 88, 1.3);
rose(root, 1010, 556, 34);
} else if (sagaId === "cyclops") {
rhumbs(root, 960, 540);
const d = smoothClosed(blob(585, 108, 245, 82, 17, 0.09, 26));
waterLines(root, d);
land(root, d);
hills(root, 690, 92, 9, 200, 12);
cave(root, 470, 120, 1.3);
txt(root, 585, 160, "Island of the Cyclopes", "plate-land-name plate-land-name--small", { "text-anchor": "middle" });
rock(root, 96, 222, 20, 4);
rose(root, 1010, 556, 34);
} else {
// Open water, dressed the way 10 dresses its long crossings.
rhumbs(root, 190, 540);
rhumbs(root, 960, 150);
txt(root, 545, 172, "The open sea", "plate-sea-name", { "text-anchor": "middle" });
storm(root, 830, 284, 1.0);
rock(root, 420, 540, 18, 9);
rock(root, 446, 556, 9, 13);
rock(root, 880, 470, 22, 3);
rose(root, 190, 540, 46);
}
void L;
}
function portBlock(root: SVGElement, p: InsetPort, timeY: number) {
const sel = state.motifId;
const playing = motifsPlaying(p.song);
const g = s("g", { class: "port" + (sel && !playing.has(sel) ? " is-dim" : "") + (state.songId === p.song.id || state.sailPick === p.song.id ? " is-open" : ""), "data-song": p.song.id }, root);
s("rect", { x: p.x - 66, y: p.y - 96, width: 132, height: 140 + (timeY - 34), class: "port-hit" }, g);
s("circle", { cx: p.x, cy: p.y, r: 9, class: "port-ring" }, g);
s("circle", { cx: p.x, cy: p.y, r: 3, class: "port-core" }, g);
const lines = wrap(p.song.title, 15);
txt(g, p.x, p.y - 22 - (lines.length - 1) * 22 - 25, `Track ${p.song.track}`, "port-track", { "text-anchor": "middle" });
lines.forEach((ln, k) => txt(g, p.x, p.y - 22 - (lines.length - 1 - k) * 22, ln, "port-title", { "text-anchor": "middle" }));
wrap(p.song.storyTime.label, 20).forEach((ln, k) => txt(g, p.x, p.y + timeY + k * 18, ln, "port-time", { "text-anchor": "middle" }));
activate(g, `${p.song.title}, track ${p.song.track}. ${p.song.storyTime.label}`, () => pickSong(p.song.id));
return g;
}
const timeLines = (p: InsetPort) => wrap(p.song.storyTime.label, 20).length;
function drawInset(root: SVGElement, sagaId: string) {
const place = placeOf(sagaId);
const L = insetLayout(sagaId);
const pIdx = places.indexOf(place);
vellum(root);
plateDressing(root, sagaId, L);
const sel = state.motifId;
const earlier = new Set(songs.filter((x) => places.indexOf(placeOf(x.sagaId)) < pIdx).flatMap((x) => [...motifsPlaying(x)]));
const later = new Set(songs.filter((x) => places.indexOf(placeOf(x.sagaId)) > pIdx).flatMap((x) => [...motifsPlaying(x)]));
// The course: 10's red dashed rhumb, running in from one plate edge and out the other.
s("path", { d: smoothOpen(L.route), class: "course" }, root);
if (sagaId === "ocean" && L.lead) {
const id = `elapsed${++uid}`;
const p0 = L.ports[0]!;
s("path", { id, d: `M60,${L.lead.y - 16} Q${(60 + p0.x) / 2},${L.lead.y - 30} ${p0.x - 90},${p0.y - 14}`, fill: "none" }, root);
const t = s("text", { class: "route-elapsed" }, root);
const tp = s("textPath", { href: `#${id}`, startOffset: "50%", "text-anchor": "middle" }, t);
tp.textContent = "two years at sea";
}
const marks = s("g", { class: `marks marks--${state.mode}` + (sel ? " has-sel" : "") }, root);
if (state.mode === "a") drawThreads(marks, L, earlier, later);
if (state.mode === "b") drawPennants(marks, L, earlier, later);
// Ports, then ink notes on top (C)
for (const p of L.ports) portBlock(root, p, 34);
if (state.mode === "c" && sel) drawInk(root, L);
if (sel && !L.motifs.some((m) => m.id === sel)) {
txt(root, W / 2, SCALE_Y - 58, `${motifName(sel)} is silent on this plate`, "silent-note", { "text-anchor": "middle" });
}
// Edge notes: "continued on plate…", above the course where it meets the edge.
if (L.lead) {
const prev = places[pIdx - 1]!;
const g = s("g", { class: "plate-link" }, root);
const y = L.lead.y - 46;
s("rect", { x: 20, y: y - 18, width: 170, height: 60, class: "port-hit" }, g);
txt(g, 30, y, `From plate ${prev.roman}`, "edge-note");
txt(g, 30, y + 19, `‹ ${prev.place}`, "edge-note edge-place");
activate(g, `Back to plate ${prev.roman}: ${prev.place}`, () => stepInset(-1));
}
if (L.tail) {
const next = places[pIdx + 1]!;
const g = s("g", { class: "plate-link" }, root);
const y = L.tail.y - 46;
s("rect", { x: W - 200, y: y - 18, width: 180, height: 60, class: "port-hit" }, g);
txt(g, W - 30, y, `Continued on plate ${next.roman}`, "edge-note", { "text-anchor": "end" });
txt(g, W - 30, y + 19, `${next.place} ›`, "edge-note edge-place", { "text-anchor": "end" });
activate(g, `Continue to plate ${next.roman}: ${next.place}`, () => stepInset(1));
}
// 10's nautical scale bar, here a scale of story time under the ports.
const sc = s("g", { class: "scale", transform: `translate(0 ${SCALE_Y})`, "pointer-events": "none" }, root);
const xs = L.ports.map((p) => p.x);
const x0 = xs.length > 1 ? xs[0]! : xs[0]! - 70, x1 = xs.length > 1 ? xs[xs.length - 1]! : xs[0]! + 70;
txt(sc, x0, 30, "Scale of story time", "scale-cap");
const cells = Math.max(2, Math.round((x1 - x0) / 60));
for (let i = 0; i < cells; i++) s("rect", { x: x0 + ((x1 - x0) * i) / cells, y: -3, width: (x1 - x0) / cells, height: 6, class: i % 2 ? "scale-w" : "scale-b" }, sc);
let lastYear = -1;
L.ports.forEach((p, i) => {
const y = p.song.storyTime.yearsAfterTroy;
if (y !== lastYear || i === 0) {
s("line", { x1: p.x, x2: p.x, y1: -10, y2: 10, class: "scale-tick" }, sc);
txt(sc, p.x + (i === 0 ? 0 : 0), -16, y === 0 ? "The year Troy falls" : `Year ${y} after Troy`, "scale-year", { "text-anchor": L.ports.length === 1 ? "middle" : i === 0 ? "start" : "middle" });
}
lastYear = y;
});
L.gaps.forEach((gap, i) => {
if (gap <= 0) return;
const a = L.ports[i]!, b = L.ports[i + 1]!;
txt(sc, (a.x + b.x) / 2, 26, gap === 1 ? "a year passes" : `${gap} years pass`, "time-note", { "text-anchor": "middle" });
});
// Locator: where this plate sits on the general chart.
const loc = s("g", { class: "locator", transform: "translate(870 30)" }, root);
s("rect", { x: -5, y: -5, width: 220, height: 148, class: "loc-frame" }, loc);
const inner = s("svg", { x: 0, y: 0, width: 210, height: 134, viewBox: `0 0 ${W} ${H}` }, loc);
drawGeneral(inner, true);
const f = place.focus;
s("rect", { x: f.x, y: f.y, width: f.w, height: f.h, class: "loc-focus" }, inner);
txt(loc, 0, 160, "Key to the general chart", "loc-cap");
activate(loc, "Back to the general chart of the Aegean", () => closeInset());
if (overlay) {
const at = L.ports.find((p) => p.song.id === state.shipAt);
if (at) {
const lines = wrap(at.song.title, 15).length;
ship(root, at.x - 58, at.y + 1, `You are here: ${at.song.title}`, -22 - (lines - 1) * 22 - 25 - 24 + 1);
}
}
}
/* ---- A. Threads under the sea: soft wavy threads beneath the course, knots where the theme plays. */
function drawThreads(root: SVGElement, L: InsetLayout, earlier: Set<string>, later: Set<string>) {
const sel = state.motifId;
const top = Math.max(...L.ports.map((p) => p.y + 34 + (timeLines(p) - 1) * 18)) + THREAD_GAP;
L.motifs.forEach((m, k) => {
const y = top + k * THREAD_STEP;
const c = inkOf(m.id);
const hits = L.ports.filter((p) => motifsPlaying(p.song).has(m.id));
const g = s("g", { class: "thread" + (sel === m.id ? " is-on" : sel ? " is-dim" : ""), style: `--c:${c}` }, root);
const a = earlier.has(m.id) && L.lead ? THREAD_X0 : hits[0]!.x - (hits.length === 1 ? 26 : 0);
const b = later.has(m.id) && L.tail ? W - 34 : hits[hits.length - 1]!.x + (hits.length === 1 ? 26 : 0);
const wav = (x: number) => y + Math.sin(x / 44 + k * 1.7) * 2.2;
let d = "";
for (let x = a; x < b; x += 8) d += `${d ? "L" : "M"}${x.toFixed(1)},${wav(x).toFixed(1)}`;
d += `L${b.toFixed(1)},${wav(b).toFixed(1)}`;
s("path", { d, class: "thread__line" }, g);
txt(g, THREAD_X0 - 12, y + 4, m.name, "thread__name", { "text-anchor": "end" });
for (const p of hits) {
s("circle", { cx: p.x, cy: wav(p.x), r: 4.5, class: "thread__knot" }, g);
if (sel === m.id) txt(g, p.x + 10, wav(p.x) - 7, partsWith(p.song, m.id).join(", "), "thread__part");
}
});
}
/* ---- B. Pennants at ports: bunting under each port; a picked theme draws one sea current. */
function drawPennants(root: SVGElement, L: InsetLayout, earlier: Set<string>, later: Set<string>) {
const sel = state.motifId;
const order = L.motifs.map((m) => m.id);
const flagY = (p: InsetPort) => p.y + 34 + (timeLines(p) - 1) * 18 + 20;
if (sel && order.includes(sel)) {
// One current, running beneath the bunting: tethered up to each port that flies the theme,
// sagging between them, and leaving the plate edge when the theme plays on other plates.
const hits = L.ports.filter((p) => motifsPlaying(p.song).has(sel));
const Y = Math.max(...L.ports.map(flagY)) + 46;
const pts: Pt[] = [];
const from = earlier.has(sel) && L.lead, to = later.has(sel) && L.tail;
if (from) pts.push({ x: 24, y: Y - 8 });
hits.forEach((p) => {
const prev = pts[pts.length - 1];
if (prev) pts.push({ x: (prev.x + p.x) / 2, y: Y + 16 });
pts.push({ x: p.x, y: Y });
});
if (to) {
const lx = pts[pts.length - 1]!.x;
pts.push({ x: (lx + W - 24) / 2, y: Y + 16 }, { x: W - 24, y: Y - 8 });
}
const c = inkOf(sel);
const g = s("g", { class: "current", style: `--c:${c}` }, root);
for (const p of hits) s("line", { x1: p.x, x2: p.x, y1: flagY(p) + 24, y2: Y - 4, class: "current__tether" }, g);
if (pts.length > 1) {
const d = smoothOpen(pts);
s("path", { d, class: "current__wide" }, g);
s("path", { d, class: "current__line" }, g);
for (let i = 0; i < pts.length - 1; i += 1) {
const a = pts[i]!, b = pts[i + 1]!;
if (b.y <= a.y || b.x - a.x < 50) continue; // one chevron per sag, on its downstream half
s("path", { d: "M-5,-4 L1,0 L-5,4", class: "current__chev", transform: `translate(${b.x} ${b.y})` }, g);
}
} else {
s("path", { d: `M${hits[0]!.x - 40},${Y + 6} Q${hits[0]!.x},${Y - 6} ${hits[0]!.x + 40},${Y + 6}`, class: "current__line" }, g);
}
if (from) txt(g, 30, Y + 30, "from earlier plates", "current__note");
if (to) txt(g, W - 30, Y + 30, "on to later plates", "current__note", { "text-anchor": "end" });
}
for (const p of L.ports) {
const ids = order.filter((id) => motifsPlaying(p.song).has(id) && (!sel || sel === id));
if (!ids.length) continue;
const big = !!sel;
const fw = big ? 16 : 12, fh = big ? 22 : 16, gap = 3;
const w = ids.length * (fw + gap) - gap;
const y = flagY(p);
const g = s("g", { class: "bunting" }, root);
s("path", { d: `M${p.x - w / 2 - 8},${y - 3} Q${p.x},${y + 4} ${p.x + w / 2 + 8},${y - 3}`, class: "bunting__line" }, g);
ids.forEach((id, i) => {
const x = p.x - w / 2 + i * (fw + gap);
const sag = 3 * (1 - Math.pow((x + fw / 2 - p.x) / (w / 2 + 8), 2));
const f = s("path", { d: `M${x},${y + sag - 1} L${x + fw},${y + sag - 1} L${x + fw / 2 + 1},${y + sag + fh} Z`, class: "pennant", fill: inkOf(id) }, g);
const t = s("title", {}, f);
t.textContent = `${motifName(id)}: ${partsWith(p.song, id).join(", ")}`;
});
}
}
/* ---- C. Ink only: nothing until a theme is picked; then ringed ports and a navigator's note. */
function drawInk(root: SVGElement, L: InsetLayout) {
const sel = state.motifId!;
const c = inkOf(sel);
const g = s("g", { class: "ink", style: `--c:${c}` }, root);
L.ports.forEach((p, i) => {
if (!motifsPlaying(p.song).has(sel)) return;
s("ellipse", { cx: p.x, cy: p.y, rx: 17, ry: 15, transform: `rotate(${-12 + i * 7} ${p.x} ${p.y})`, class: "ink__ring" }, g);
s("path", { d: `M${p.x - 19},${p.y + 4} A19 17 0 1 1 ${p.x - 8},${p.y + 17}`, transform: `rotate(${20 + i * 23} ${p.x} ${p.y})`, class: "ink__ring ink__ring--thin" }, g);
const y = p.y + 34 + (timeLines(p) - 1) * 18 + 30;
const note = partsWith(p.song, sel).join(" & ");
txt(g, p.x, y, note, "ink__note", { "text-anchor": "middle", transform: `rotate(-3 ${p.x} ${y})` });
s("path", { d: `M${p.x - note.length * 3.6},${y + 6} q${note.length * 1.8},4 ${note.length * 3.6},0 t${note.length * 3.6},0`, class: "ink__flourish" }, g);
});
}
/* ------------------------------------------------------------------ html chrome */
let pop: HTMLElement | null = null;
function cartouche() {
const c = h("div", "cartouche", undefined, plate);
if (!state.sagaId) {
h("p", "c-kicker", "General chart", c);
h("h2", "c-title", "The voyage from Troy", c);
h("p", "c-line", `${songs.length} songs across ${places.length} sagas, Year 0 to Year ${Math.max(...songs.map((x) => x.storyTime.yearsAfterTroy))}.`, c);
h("p", "c-line c-hint", "Choose an island, or a song by name.", c);
} else {
const p = placeOf(state.sagaId);
const list = songsIn(state.sagaId);
h("p", "c-kicker", `Plate ${p.roman}`, c);
h("h2", "c-title", p.place, c);
h("p", "c-line", `${sagaById(p.sagaId).name}, tracks ${list[0]!.track} to ${list[list.length - 1]!.track}`.replace(/tracks (\d+) to \1$/, "track $1"), c);
h("p", "c-line", yearSpan(list), c);
}
}
function songSlip(song: Song) {
const sheet = h("section", "slip", undefined, plate);
sheet.setAttribute("aria-label", `Song: ${song.title}`);
const close = h("button", "slip-close", "Close song", sheet);
close.type = "button";
close.addEventListener("click", () => { state.songId = null; render(); });
const a = h("div", "slip-col slip-id", undefined, sheet);
h("p", "slip-track", `Track ${song.track}, ${sagaById(song.sagaId).name}`, a);
h("h3", "slip-title", song.title, a);
h("p", "slip-time", `${song.storyTime.label}. Year ${song.storyTime.yearsAfterTroy} after Troy.`, a);
if (song.odyssey) {
const l = h("a", "slip-odyssey", `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`, a) as HTMLAnchorElement;
l.href = song.odyssey.url; l.target = "_blank"; l.rel = "noopener noreferrer";
} else h("p", "slip-odyssey is-none", "No counterpart in Homer's Odyssey", a);
const b = h("div", "slip-col slip-parts", undefined, sheet);
h("h4", "slip-h", "Parts", b);
const ol = h("ol", "parts", undefined, b);
for (const part of song.parts) {
const li = h("li", "", undefined, ol);
h("span", "part-name", part.name, li);
h("span", "part-singers", part.singers.join(", "), li);
const ticks = h("span", "part-motifs", undefined, li);
for (const id of part.motifIds) {
const t = h("i", "tick", undefined, ticks);
t.style.background = inkOf(id);
t.title = motifName(id);
}
}
const c = h("div", "slip-col slip-media", undefined, sheet);
h("h4", "slip-h", "Themes in this song", c);
const chips = h("div", "chips", undefined, c);
for (const m of motifsInSong(song)) {
const chip = h("button", "chip" + (state.motifId === m.id ? " is-on" : ""), m.name, chips);
chip.type = "button";
chip.style.setProperty("--c", inkOf(m.id));
chip.addEventListener("click", () => toggleMotif(m.id));
}
const media = h("div", "slip-media-row", undefined, c);
media.append(createAudioSlot(song), createVideoCards(song));
}
function sailPopover(song: Song) {
const g = svg.querySelector(`.port[data-song="${song.id}"] .port-ring`) as SVGGraphicsElement | null;
if (!g) return;
const box = g.getBoundingClientRect();
const host = plate.getBoundingClientRect();
pop = h("div", "sail-pop", undefined, plate);
pop.style.left = `${box.left + box.width / 2 - host.left}px`;
// Sit below the port's story-time caption, so the popover never covers it.
const times = [...(g.parentElement?.querySelectorAll(".port-time") ?? [])].map((e) => e.getBoundingClientRect().bottom);
const below = Math.max(box.bottom, ...times);
pop.style.top = `${below - host.top + 8}px`;
h("p", "sp-title", song.title, pop);
h("p", "sp-when", song.storyTime.label, pop);
if (song.id === state.shipAt) {
h("p", "sp-line", "The ship is anchored here.", pop);
} else {
const btn = h("button", "sp-sail", "Sail here", pop);
btn.type = "button";
btn.addEventListener("click", () => {
state.shipAt = song.id;
state.sailPick = null;
state.note = `Course set for ${song.title}. In the 3D voyage the ship sails there now.`;
render();
});
}
const open = h("button", "sp-open", "Open song", pop);
open.type = "button";
open.addEventListener("click", () => { state.sailPick = null; state.songId = song.id; render(); });
}
function header() {
head.replaceChildren();
const brand = h("div", "brand", undefined, head);
h("h1", "brand-title", overlay ? "Captain's table" : "The voyage, in plates", brand);
h("p", "brand-sub", overlay ? "The chart, laid out over the 3D voyage." : "A fan's vellum atlas of EPIC: The Musical.", brand);
const crumbs = h("nav", "crumbs", undefined, head);
crumbs.setAttribute("aria-label", "Breadcrumb");
const root = h(state.sagaId ? "button" : "span", "crumb", "Aegean", crumbs);
if (state.sagaId) { (root as HTMLButtonElement).type = "button"; root.addEventListener("click", () => closeInset()); }
else root.setAttribute("aria-current", "page");
if (state.sagaId) {
h("span", "crumb-sep", "›", crumbs).setAttribute("aria-hidden", "true");
const here = h("span", "crumb", placeOf(state.sagaId).place, crumbs);
here.setAttribute("aria-current", "page");
}
const nav = h("div", "plate-nav", undefined, head);
if (state.sagaId) {
const i = places.findIndex((p) => p.sagaId === state.sagaId);
const prev = places[i - 1], next = places[i + 1];
const pb = h("button", "pn", prev ? `‹ ${prev.roman}. ${prev.place}` : "‹", nav);
pb.type = "button"; pb.disabled = !prev; pb.addEventListener("click", () => stepInset(-1));
pb.setAttribute("aria-label", prev ? `Previous plate: ${prev.place}` : "No previous plate");
const nb = h("button", "pn", next ? `${next.roman}. ${next.place} ›` : "›", nav);
nb.type = "button"; nb.disabled = !next; nb.addEventListener("click", () => stepInset(1));
nb.setAttribute("aria-label", next ? `Next plate: ${next.place}` : "No next plate");
if (!overlay) h("p", "keys", "← → turn plates · Esc returns to the Aegean", nav);
} else if (!overlay) {
h("p", "keys", "Choose an island or a song, or press → to open plate I.", nav);
}
if (overlay) {
const here = songs.find((x) => x.id === state.shipAt)!;
const tag = h("p", "here-tag", undefined, head);
h("span", "here-k", "You are here", tag);
h("span", "here-v", here.title, tag);
}
}
function themes() {
index.replaceChildren();
const inSaga = state.sagaId ? songsIn(state.sagaId) : null;
if (state.sagaId) {
const box = h("div", "mode", undefined, index);
h("p", "mode-label", "Themes on the plate", box);
const seg = h("div", "mode-seg", undefined, box);
seg.setAttribute("role", "radiogroup");
seg.setAttribute("aria-label", "How themes are drawn on the plate");
for (const m of MODES) {
const b = h("button", "mode-btn" + (state.mode === m.id ? " is-on" : ""), undefined, seg);
b.type = "button";
b.setAttribute("role", "radio");
b.setAttribute("aria-checked", String(state.mode === m.id));
h("span", "mode-letter", m.letter, b);
h("span", "mode-name", m.name, b);
b.addEventListener("click", () => { state.mode = m.id; render(); });
}
}
h("h2", "th-title", "Themes", index);
const hint = !state.sagaId
? "Pick one to light the islands where it plays."
: state.mode === "c" && !state.motifId
? "Pick a theme to ink its ports on this plate."
: "Songs on this plate / in the whole voyage.";
h("p", "th-sub" + (state.mode === "c" && state.sagaId && !state.motifId ? " is-cue" : ""), hint, index);
for (const kind of ["motif", "character"] as const) {
h("h3", "th-group", kind === "motif" ? "Musical motifs" : "Character themes", index);
const ul = h("ul", "th-list", undefined, index);
for (const m of motifs.filter((x) => x.kind === kind)) {
const all = songsWithMotif(m.id);
const here = inSaga ? all.filter((x) => x.sagaId === state.sagaId).length : null;
const li = h("li", "", undefined, ul);
const b = h("button", "th-row" + (state.motifId === m.id ? " is-on" : "") + (here === 0 ? " is-absent" : ""), undefined, li);
b.type = "button";
b.setAttribute("aria-pressed", String(state.motifId === m.id));
b.style.setProperty("--c", inkOf(m.id));
h("span", "th-swatch", undefined, b);
h("span", "th-name", m.name, b);
h("span", "th-count", here === null ? String(all.length) : `${here} / ${all.length}`, b);
b.addEventListener("click", () => toggleMotif(m.id));
}
}
if (overlay) return;
const key = h("div", "th-key", undefined, index);
h("h3", "th-group", "Key", key);
const rows: [string, string][] = [["k-course", "The ship's course"], ["k-port", "Port: one song"]];
if (!state.sagaId) rows.push(["k-halo", "Island where a picked theme plays"]);
else if (state.mode === "a") rows.push(["k-thread", "Thread: a theme, knotted where it plays"]);
else if (state.mode === "b") rows.push(["k-pennant", "Pennant: a theme flown at this port"], ["k-current", "Current: links a picked theme's ports"]);
else rows.push(["k-ink", "Ink ring: a picked theme plays here"]);
for (const [cls, label] of rows) {
const r = h("p", "key-row", undefined, key);
h("i", `key-sym ${cls}`, undefined, r);
h("span", "", label, r);
}
}
/* ------------------------------------------------------------------ render */
function render(anim?: "in" | "out" | "next" | "prev") {
header();
themes();
plate.replaceChildren();
pop = null;
svg = s("svg", { viewBox: `0 0 ${W} ${H}`, class: "chart", role: "img" }, plate);
svg.setAttribute("aria-label", state.sagaId ? `Plate ${placeOf(state.sagaId).roman}: ${placeOf(state.sagaId).place}` : "General chart of the voyage");
if (state.sagaId) drawInset(svg, state.sagaId);
else drawGeneral(svg);
frame(svg);
cartouche();
plate.classList.toggle("is-inset", !!state.sagaId);
plate.classList.toggle("has-slip", !!state.songId);
const song = songs.find((x) => x.id === state.songId);
if (song && state.sagaId) songSlip(song);
const pick = songs.find((x) => x.id === state.sailPick);
if (overlay && pick && state.sagaId === pick.sagaId) requestAnimationFrame(() => sailPopover(pick));
if (state.note) {
const n = h("p", "sail-note", state.note, plate);
n.setAttribute("role", "status");
}
syncUrl();
if (anim && !reduceMotion) {
plate.dataset.anim = anim;
plate.addEventListener("animationend", () => delete plate.dataset.anim, { once: true });
}
}
function syncUrl() {
const q = new URLSearchParams();
if (overlay) q.set("overlay", "1");
if (state.songId) q.set("song", state.songId);
else if (state.sagaId) q.set("saga", state.sagaId);
if (state.motifId) q.set("motif", state.motifId);
if (state.mode !== "a") q.set("motifs", state.mode);
const str = q.toString();
history.replaceState(null, "", str ? `?${str}` : location.pathname);
}
/* ------------------------------------------------------------------ navigation */
let busy = false;
type Box = { x: number; y: number; w: number; h: number };
function zoomViewBox(to: Box, from: Box, ms: number, done: () => void) {
if (reduceMotion) return done();
busy = true;
const t0 = performance.now();
const step = (t: number) => {
const k = Math.min(1, (t - t0) / ms);
const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
const lerp = (a: number, b: number) => a + (b - a) * e;
svg.setAttribute("viewBox", `${lerp(from.x, to.x)} ${lerp(from.y, to.y)} ${lerp(from.w, to.w)} ${lerp(from.h, to.h)}`);
if (k < 1) requestAnimationFrame(step);
else { busy = false; done(); }
};
requestAnimationFrame(step);
}
const FULL = { x: 0, y: 0, w: W, h: H };
function fitFocus(f: Box): Box {
const a = W / H;
let w = f.w, hh = f.h;
if (w / hh > a) hh = w / a; else w = hh * a;
return { x: f.x + f.w / 2 - w / 2, y: f.y + f.h / 2 - hh / 2, w, h: hh };
}
function openInset(sagaId: string, songId: string | null = null) {
if (busy) return;
state.note = "";
if (state.sagaId) { state.sagaId = sagaId; state.songId = songId; render("next"); return; }
plate.classList.add("is-zooming");
zoomViewBox(fitFocus(placeOf(sagaId).focus), FULL, 520, () => {
plate.classList.remove("is-zooming");
state.sagaId = sagaId;
state.songId = songId;
render("in");
});
}
function openSongFromChart(song: Song) {
if (overlay) { state.sailPick = song.id; openInset(song.sagaId); return; }
openInset(song.sagaId, song.id);
}
function closeInset() {
if (busy || !state.sagaId) return;
const from = fitFocus(placeOf(state.sagaId).focus);
state.sagaId = null; state.songId = null; state.sailPick = null; state.note = "";
render("out");
svg.setAttribute("viewBox", `${from.x} ${from.y} ${from.w} ${from.h}`);
zoomViewBox(FULL, from, 480, () => svg.setAttribute("viewBox", `0 0 ${W} ${H}`));
}
function stepInset(dir: 1 | -1) {
if (busy) return;
if (!state.sagaId) { if (dir === 1) openInset(places[0]!.sagaId); return; }
const i = places.findIndex((p) => p.sagaId === state.sagaId);
const next = places[i + dir];
if (!next) return;
state.sagaId = next.sagaId; state.songId = null; state.sailPick = null; state.note = "";
render(dir === 1 ? "next" : "prev");
}
function pickSong(id: string) {
if (overlay) { state.sailPick = state.sailPick === id ? null : id; state.songId = null; state.note = ""; render(); return; }
state.songId = state.songId === id ? null : id;
render();
}
function toggleMotif(id: string) {
state.motifId = state.motifId === id ? null : id;
render();
}
document.addEventListener("keydown", (e) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "AUDIO") return;
if (e.key === "ArrowRight") { e.preventDefault(); stepInset(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); stepInset(-1); }
else if (e.key === "Escape") {
if (state.songId || state.sailPick) { state.songId = null; state.sailPick = null; render(); }
else closeInset();
}
});
plate.addEventListener("click", (e) => {
if (!state.sailPick) return;
if ((e.target as Element).closest(".sail-pop")) return;
state.sailPick = null; render();
});
render();
+423
View File
@@ -0,0 +1,423 @@
/* 12 · Vellum atlas: 11's atlas plates, drawn on 10's warm portolan vellum and wound on rods. */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/cormorant-garamond/latin-600-italic.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
:root {
color-scheme: light;
--vellum: #e3d6b4;
--vellum-hi: #ece2c6;
--vellum-lo: #d6c69e;
--land: #c9b07a;
--iron: #2b2118;
--iron-soft: #5e4d3a;
--iron-faint: #8c7b62;
--rhumb-red: #a23a2a;
--rhumb-green: #3f6b5c;
--desk: #3a2f24;
--desk-hi: #4a3c2e;
--brass: #cbbd9b;
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--vellum-hi);
font-family: var(--sans);
background: var(--desk) radial-gradient(ellipse at 45% 40%, var(--desk-hi) 0%, var(--desk) 72%);
}
button { font: inherit; color: inherit; cursor: pointer; }
:focus-visible { outline: 2px solid #e8c77d; outline-offset: 2px; }
/* ------------------------------------------------------------- frame */
.atlas {
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
padding: 0 30px;
}
.atlas-head {
display: grid;
grid-template-columns: 330px 1fr auto;
align-items: center;
gap: 24px;
padding: 12px 0 12px;
}
.brand-title { margin: 0; font: 600 31px/1 var(--serif); letter-spacing: 0.01em; }
.brand-sub { margin: 5px 0 0; font-size: 12.5px; color: var(--brass); }
.crumbs { white-space: nowrap; display: flex; align-items: baseline; gap: 10px; font: italic 500 24px/1.1 var(--serif); color: var(--vellum-hi); }
.crumb { background: none; border: 0; padding: 0; }
button.crumb { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 5px; color: var(--brass); }
button.crumb:hover { color: #f3e2b4; }
.crumb-sep { opacity: 0.55; font-style: normal; }
.plate-nav { display: flex; align-items: center; gap: 10px; justify-content: flex-end; flex-wrap: wrap; max-width: 560px; }
.pn {
background: rgb(236 226 198 / 0.07);
border: 1px solid rgb(203 189 155 / 0.55);
border-radius: 3px;
padding: 5px 12px 6px;
font: 500 17px/1.1 var(--serif);
color: var(--vellum-hi);
}
.pn:hover:not(:disabled) { background: rgb(236 226 198 / 0.16); }
.pn:disabled { opacity: 0.3; cursor: default; }
.keys { margin: 0; flex-basis: 100%; text-align: right; font-size: 11.5px; color: var(--brass); opacity: 0.85; }
.atlas-body {
min-height: 0;
display: grid;
grid-template-columns: 1fr 256px;
gap: 30px;
padding-bottom: 4px;
}
.plate-wrap { min-height: 0; min-width: 0; display: flex; align-items: flex-start; justify-content: center; padding: 0 12px; }
.roll { position: relative; width: min(100%, calc((100vh - 150px) * 1100 / 700)); }
.plate {
position: relative;
width: 100%;
aspect-ratio: 1100 / 700;
background: var(--vellum);
color: var(--iron);
box-shadow: 0 20px 44px rgb(14 9 4 / 0.5), 0 2px 5px rgb(14 9 4 / 0.4);
overflow: hidden;
}
/* Paper wound on a rod at each edge (10's rollers), with the curl shading the sheet. */
.rod { position: absolute; top: -9px; bottom: -9px; width: 22px; border-radius: 11px; z-index: 4; pointer-events: none; }
.rod--west { left: -12px; background: linear-gradient(90deg, #5d4a30, #b49d71 35%, #efe4c8 55%, #a68e62 80%, #5d4a30); box-shadow: 10px 0 18px -6px rgb(43 33 24 / 0.45); }
.rod--east { right: -12px; background: linear-gradient(90deg, #5d4a30, #a68e62 20%, #efe4c8 45%, #b49d71 65%, #5d4a30); box-shadow: -10px 0 18px -6px rgb(43 33 24 / 0.45); }
.plate::before, .plate::after { content: ""; position: absolute; top: 0; bottom: 0; width: 40px; z-index: 3; pointer-events: none; }
.plate::before { left: 0; background: linear-gradient(90deg, rgb(93 74 48 / 0.32), transparent); }
.plate::after { right: 0; background: linear-gradient(270deg, rgb(93 74 48 / 0.32), transparent); }
.chart { display: block; width: 100%; height: 100%; }
/* ------------------------------------------------------------- chart ink (after 10) */
.sea { fill: var(--vellum); }
.grain { opacity: 0.5; mix-blend-mode: multiply; }
.rhumb { stroke-width: 0.8; opacity: 0.32; }
.rhumb--ink { stroke: var(--iron); opacity: 0.2; }
.rhumb--green { stroke: var(--rhumb-green); }
.rhumb--red { stroke: var(--rhumb-red); }
.wl-ink { fill: none; stroke: var(--iron-soft); stroke-dasharray: 3 5; stroke-linejoin: round; opacity: 0.75; }
.wl-paper { fill: none; stroke: var(--vellum); stroke-linejoin: round; }
.landmass { fill: var(--land); stroke: var(--iron); stroke-width: 1.5; stroke-linejoin: round; }
.coastwash { fill: none; stroke: var(--rhumb-green); stroke-width: 14; opacity: 0.26; }
.coast { fill: none; stroke: var(--iron); stroke-width: 1.6; stroke-linejoin: round; }
.towers rect { fill: var(--iron); }
.glyph path { fill: var(--iron); }
.glyph .hill { fill: none; stroke: var(--iron); stroke-width: 1.6; }
.hills path { fill: none; stroke: rgb(43 33 24 / 0.5); stroke-width: 1.2; }
.storm__arm { fill: none; stroke: var(--iron); stroke-width: 1.4; opacity: 0.75; }
.storm__rain { stroke: var(--iron-soft); stroke-width: 1; opacity: 0.6; }
.sea-hit { fill: transparent; }
.halo { fill: none; stroke-width: 20; opacity: 0.42; stroke-linejoin: round; }
.halo--sea { stroke-width: 16; opacity: 0.3; }
.rose__ring { fill: none; stroke: var(--iron); stroke-width: 1; }
.rose__ring--thin { stroke-width: 0.6; stroke-dasharray: 2 2; }
.rose__point { fill: var(--rhumb-red); stroke: var(--iron); stroke-width: 0.6; }
.rose__point--minor { fill: var(--rhumb-green); }
.rose__point--shade { fill: var(--vellum-hi); }
.rose__n { font: 600 15px var(--serif); fill: var(--iron); }
.course { fill: none; stroke: var(--rhumb-red); stroke-width: 2.3; stroke-dasharray: 9 5; stroke-linecap: round; }
.time-note { font: italic 500 18px var(--serif); fill: var(--rhumb-red); letter-spacing: 0.04em; }
.route-elapsed { font: italic 500 21px var(--serif); fill: var(--rhumb-red); letter-spacing: 0.1em; }
.nl-outer { fill: none; stroke: var(--iron); stroke-width: 1.4; }
.nl-inner { fill: none; stroke: var(--iron); stroke-width: 0.6; }
.is-dim { opacity: 0.3; }
/* general chart */
.isle { cursor: pointer; outline: none; }
.isle:hover .coast, .isle:focus-visible .coast { stroke: var(--rhumb-red); stroke-width: 2.6; }
.isle:hover .place-name, .isle:focus-visible .place-name { fill: var(--rhumb-red); }
.isle:hover .storm__arm, .isle:focus-visible .storm__arm { stroke: var(--rhumb-red); }
.ov-port { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1.8; }
.ov-num { font: 600 11px var(--sans); fill: var(--iron-soft); paint-order: stroke; stroke: var(--vellum); stroke-width: 3px; }
.place-name { font: italic 600 34px var(--serif); fill: var(--iron); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; stroke-opacity: 0.6; }
.place-sub { font: 500 12.5px var(--sans); fill: var(--iron); }
.place-meta { font: 400 12.5px var(--sans); fill: var(--iron-soft); }
.track-row { cursor: pointer; outline: none; }
.track-hit { fill: transparent; }
.track-text { font: 600 17.5px var(--serif); fill: var(--iron); }
.track-n { font: 500 11px var(--sans); fill: var(--iron-soft); }
.track-row:hover .track-title, .track-row:focus-visible .track-title { fill: var(--rhumb-red); text-decoration: underline; }
.track-row:focus-visible .track-hit { stroke: var(--rhumb-red); stroke-width: 1.4; }
.track-row.is-on .track-title { fill: var(--c); }
.motif-badge rect { stroke: none; }
.motif-badge.is-silent rect { stroke: var(--iron-soft); stroke-dasharray: 3 3; }
.badge-text { font: 600 12px var(--sans); fill: var(--vellum-hi); }
.motif-badge.is-silent .badge-text { fill: var(--iron-soft); font-weight: 500; }
.ship { pointer-events: none; }
.ship__hull { fill: var(--iron); }
.ship__mast { stroke: var(--iron); stroke-width: 1.6; }
.ship__sail { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1.3; }
.ship__pennant { fill: var(--rhumb-red); }
.ship__wake { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 4 3; }
.ship-tag { fill: var(--iron); }
.ship-label { font: 500 12px var(--sans); fill: var(--vellum-hi); }
/* inset plates */
.plate-land-name { font: italic 500 40px var(--serif); letter-spacing: 0.14em; fill: rgb(43 33 24 / 0.72); }
.plate-land-name--small { font-size: 27px; letter-spacing: 0.1em; }
.plate-sea-name { font: italic 500 32px var(--serif); letter-spacing: 0.34em; fill: var(--rhumb-green); opacity: 0.8; }
.port { cursor: pointer; outline: none; }
.port-hit { fill: transparent; }
.port-ring { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 2.2; }
.port-core { fill: var(--iron); }
.port-title, .port-time, .port-track { paint-order: stroke; stroke: var(--vellum); stroke-linejoin: round; }
.port-title { font: 600 22px var(--serif); fill: var(--iron); stroke-width: 5px; }
.port-track { font: 500 11px var(--sans); fill: var(--iron-soft); letter-spacing: 0.03em; stroke-width: 4px; }
.port-time { font: italic 500 16px var(--serif); fill: var(--iron-soft); stroke-width: 4px; }
.port:hover .port-title, .port:focus-visible .port-title { fill: var(--rhumb-red); }
.port:hover .port-ring, .port:focus-visible .port-ring, .port.is-open .port-ring { stroke: var(--rhumb-red); stroke-width: 3.4; }
.port.is-open .port-core { fill: var(--rhumb-red); }
.port.is-dim { opacity: 1; }
.port.is-dim .port-title { fill: var(--iron-faint); }
.port.is-dim .port-time, .port.is-dim .port-track { opacity: 0.6; }
.port.is-dim .port-ring { stroke: var(--iron-faint); }
.silent-note { font: italic 500 19px var(--serif); fill: var(--iron-soft); }
.plate-link { cursor: pointer; outline: none; }
.edge-note { font: italic 500 16px var(--serif); fill: var(--iron-soft); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.edge-place { font-weight: 600; fill: var(--iron); }
.plate-link:hover .edge-note, .plate-link:focus-visible .edge-note { fill: var(--rhumb-red); text-decoration: underline; }
.scale-b { fill: var(--iron); }
.scale-w { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 0.8; }
.scale-tick { stroke: var(--iron); stroke-width: 1.4; }
.scale-year { font: italic 600 16px var(--serif); fill: var(--iron); }
.scale-cap { font: 500 11px var(--sans); fill: var(--iron-soft); letter-spacing: 0.04em; }
.locator { cursor: pointer; outline: none; }
.loc-frame { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1; }
.locator:hover .loc-frame, .locator:focus-visible .loc-frame { stroke: var(--rhumb-red); stroke-width: 2; }
.loc-focus { fill: rgb(162 58 42 / 0.08); stroke: var(--rhumb-red); stroke-width: 12; }
.loc-cap { font: 500 10.5px var(--sans); fill: var(--iron-soft); paint-order: stroke; stroke: var(--vellum); stroke-width: 3px; }
/* A · threads under the sea */
.thread__line { fill: none; stroke: var(--c); stroke-width: 2.2; stroke-linecap: round; opacity: 0.8; }
.thread__knot { fill: var(--vellum-hi); stroke: var(--c); stroke-width: 2.2; }
.thread__name { font: 500 11px var(--sans); fill: var(--c); }
.thread__part { font: 600 11px var(--sans); fill: var(--c); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.thread.is-on .thread__line { stroke-width: 3.6; opacity: 1; }
.thread.is-on .thread__knot { fill: var(--c); r: 6; }
.thread.is-on .thread__name { font-weight: 600; }
.thread.is-dim { opacity: 0.2; }
/* B · pennants at ports */
.bunting__line { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; }
.pennant { stroke: var(--iron); stroke-width: 0.6; stroke-linejoin: round; }
.current__wide { fill: none; stroke: var(--c); stroke-width: 9; opacity: 0.1; stroke-linecap: round; }
.current__line { fill: none; stroke: var(--c); stroke-width: 1.6; opacity: 0.6; stroke-dasharray: 16 6 2 6; stroke-linecap: round; }
.current__tether { stroke: var(--c); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
.current__chev { fill: none; stroke: var(--c); stroke-width: 1.6; opacity: 0.7; }
.current__note { font: italic 500 15px var(--serif); fill: var(--c); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
/* C · ink only */
.ink__ring { fill: none; stroke: var(--c); stroke-width: 2; }
.ink__ring--thin { stroke-width: 1.1; opacity: 0.75; }
.ink__note { font: italic 600 19px var(--serif); fill: var(--c); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.ink__flourish { fill: none; stroke: var(--c); stroke-width: 1; opacity: 0.6; }
/* ------------------------------------------------------------- cartouche */
.cartouche {
position: absolute;
z-index: 2;
left: 3.2%;
top: 4.2%;
max-width: 290px;
padding: 10px 18px 12px;
background: var(--vellum-hi);
border: 1px solid var(--iron);
outline: 1px solid var(--iron);
outline-offset: -6px;
box-shadow: 0 2px 0 rgb(43 33 24 / 0.08);
pointer-events: none;
}
.plate:not(.is-inset) .cartouche { left: auto; right: 3.2%; max-width: 320px; }
.c-kicker { margin: 0; font: italic 500 15px var(--serif); color: var(--iron-soft); }
.c-title { margin: 0 0 4px; font: 600 29px/1.05 var(--serif); }
.c-line { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--iron); }
.c-hint { margin-top: 4px; color: var(--rhumb-red); font-weight: 500; }
/* ------------------------------------------------------------- song slip (compact, so the island stays in view) */
.slip {
position: absolute;
z-index: 2;
left: 14px; right: 14px; bottom: 14px;
height: 29%;
display: grid;
grid-template-columns: 0.95fr 1fr 1.15fr;
gap: 26px;
padding: 16px 26px 14px;
background: var(--vellum-hi);
border-top: 4px double var(--iron);
box-shadow: 0 -14px 26px rgb(43 33 24 / 0.16);
animation: slip-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
overflow: hidden;
}
.slip-close {
position: absolute; right: 18px; top: 10px;
background: none; border: 1px solid var(--iron-soft); border-radius: 3px;
padding: 3px 10px; font-size: 12px; color: var(--iron);
}
.slip-close:hover { border-color: var(--rhumb-red); color: var(--rhumb-red); }
.slip-col { min-width: 0; overflow: auto; }
.slip-track { margin: 0; font-size: 12px; color: var(--iron-soft); }
.slip-title { margin: 2px 0 6px; font: 600 34px/1.02 var(--serif); }
.slip-time { margin: 0 0 8px; font: italic 500 18px/1.3 var(--serif); color: var(--iron-soft); }
.slip-odyssey { font-size: 13px; color: var(--rhumb-red); }
.slip-odyssey.is-none { margin: 0; color: var(--iron-soft); }
.slip-h { margin: 2px 0 6px; font: italic 600 18px var(--serif); }
.parts { list-style: none; margin: 0; padding: 0; border-top: 1px solid #b9a47a; }
.parts li {
display: grid; grid-template-columns: 70px 1fr auto; gap: 10px; align-items: baseline;
padding: 6px 0; border-bottom: 1px solid #b9a47a; font-size: 13px;
}
.part-name { font: 600 16px var(--serif); }
.part-motifs { display: flex; gap: 3px; }
.tick { display: inline-block; width: 14px; height: 5px; border-radius: 1px; }
.slip-media { padding-right: 96px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.chip {
border: 1.5px solid var(--c); background: transparent; border-radius: 999px;
padding: 3px 10px; font-size: 12px; color: var(--iron);
}
.chip:hover { background: color-mix(in srgb, var(--c) 14%, transparent); }
.chip.is-on { background: var(--c); color: var(--vellum-hi); }
.slip-media-row { display: grid; gap: 8px; }
.slip .audio-slot { font-size: 12.5px; padding: 7px 10px; border-radius: 3px; color: var(--iron-soft); }
.slip .video-card { font-size: 12.5px; border-radius: 3px; padding: 5px 10px; color: var(--iron); }
/* ------------------------------------------------------------- themes slip */
.themes {
align-self: start;
background: var(--vellum-hi);
color: var(--iron);
padding: 16px 16px 14px;
box-shadow: 0 14px 30px rgb(14 9 4 / 0.42);
border-top: 4px double var(--iron);
}
.mode { margin: -2px -2px 12px; padding: 0 0 12px; border-bottom: 1px solid #b9a47a; }
.mode-label { margin: 0 0 7px; font: italic 600 17px var(--serif); }
.mode-seg { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--iron); border-radius: 3px; overflow: hidden; }
.mode-btn {
display: grid; justify-items: center; gap: 1px;
padding: 5px 2px 6px; border: 0; border-left: 1px solid var(--iron); background: none; color: var(--iron);
}
.mode-btn:first-child { border-left: 0; }
.mode-btn:hover { background: rgb(43 33 24 / 0.07); }
.mode-btn.is-on { background: var(--iron); color: var(--vellum-hi); }
.mode-letter { font: 600 17px/1 var(--serif); }
.mode-name { font-size: 11px; }
.th-title { margin: 0; font: 600 25px var(--serif); }
.th-sub { margin: 2px 0 8px; font-size: 12px; line-height: 1.4; color: var(--iron-soft); }
.th-sub.is-cue { color: var(--rhumb-red); font-weight: 500; }
.th-group { margin: 10px 0 3px; font: italic 600 16px var(--serif); }
.th-list { list-style: none; margin: 0; padding: 0; }
.th-row {
width: 100%; display: grid; grid-template-columns: 22px 1fr auto; gap: 9px; align-items: center;
background: none; border: 0; border-left: 3px solid transparent; padding: 4px 6px 4px 5px; text-align: left; font-size: 13.5px; color: var(--iron);
}
.th-row:hover { background: rgb(43 33 24 / 0.07); }
.th-row.is-on { background: color-mix(in srgb, var(--c) 16%, transparent); border-left-color: var(--c); font-weight: 600; }
.th-row.is-absent { opacity: 0.42; }
.th-swatch { height: 4px; border-radius: 2px; background: var(--c); }
.th-count { font-size: 12px; color: var(--iron-soft); font-variant-numeric: tabular-nums; }
.th-key { margin-top: 10px; border-top: 1px solid #b9a47a; padding-top: 2px; }
.key-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; font-size: 12px; color: var(--iron-soft); }
.key-sym { width: 24px; flex: none; display: inline-block; }
.k-course { border-top: 2px dashed var(--rhumb-red); }
.k-port { width: 14px; height: 14px; margin: 0 5px; border: 2.2px solid var(--iron); border-radius: 50%; background: var(--vellum); }
.k-halo { height: 10px; border-radius: 5px; background: rgb(147 101 11 / 0.4); }
.k-thread { height: 9px; background: radial-gradient(circle at 50% 50%, var(--vellum-hi) 2px, #93650b 2.5px, #93650b 4px, transparent 4.5px), linear-gradient(transparent 3.5px, #93650b 3.5px, #93650b 5.5px, transparent 5.5px); }
.k-pennant { height: 14px; width: 12px; margin: 0 6px; background: #2e5d8b; clip-path: polygon(0 0, 100% 0, 50% 100%); }
.k-current { border-top: 2px dashed rgb(46 93 139 / 0.7); }
.k-ink { width: 18px; height: 16px; margin: 0 3px; border: 2px solid #8e1e24; border-radius: 50%; transform: rotate(-10deg); }
/* ------------------------------------------------------------- transitions */
.plate.is-zooming .cartouche { opacity: 0; transition: opacity 0.2s; }
.plate[data-anim="in"] .chart { animation: plate-in 0.36s ease-out; }
.plate[data-anim="next"] .chart { animation: turn-next 0.34s ease-out; }
.plate[data-anim="prev"] .chart { animation: turn-prev 0.34s ease-out; }
@keyframes plate-in { from { opacity: 0.2; transform: scale(1.04); } to { opacity: 1; transform: none; } }
@keyframes turn-next { from { opacity: 0; transform: translateX(36px); } to { opacity: 1; transform: none; } }
@keyframes turn-prev { from { opacity: 0; transform: translateX(-36px); } to { opacity: 1; transform: none; } }
@keyframes slip-up { from { transform: translateY(40%); opacity: 0; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .slip, .chart { animation: none !important; } }
/* ------------------------------------------------------------- overlay: chart over the 3D sea */
.sail-pop {
position: absolute;
z-index: 5;
transform: translateX(-50%);
width: 200px;
padding: 10px 12px 12px;
background: var(--vellum-hi);
border: 1.5px solid var(--iron);
border-radius: 4px;
box-shadow: 0 10px 22px rgb(20 14 8 / 0.35);
text-align: center;
}
.sp-title { margin: 0; font: 600 19px var(--serif); }
.sp-when { margin: 0 0 8px; font: italic 500 14px var(--serif); color: var(--iron-soft); }
.sp-line { margin: 0 0 8px; font-size: 12px; color: var(--rhumb-green); }
.sp-sail { display: block; width: 100%; margin-bottom: 6px; padding: 7px 0; border: 0; border-radius: 3px; background: var(--rhumb-red); color: var(--vellum-hi); font-weight: 600; font-size: 13px; }
.sp-open { background: none; border: 0; font-size: 12px; text-decoration: underline; color: var(--iron); }
.sail-note {
position: absolute; z-index: 5; left: 50%; top: 22px; transform: translateX(-50%);
margin: 0; padding: 6px 14px; background: var(--iron); color: var(--vellum-hi); font-size: 12.5px; border-radius: 3px;
}
body.is-overlay {
justify-content: center;
background:
linear-gradient(rgb(6 16 24 / 0.55), rgb(6 16 24 / 0.55)),
repeating-linear-gradient(172deg, transparent 0 22px, rgb(160 205 220 / 0.07) 22px 24px),
radial-gradient(ellipse at 50% 120%, #2c6b7a 0%, #133447 45%, #071521 100%);
}
body.is-overlay::before {
content: "3D sea voyage, paused";
position: fixed; left: 26px; top: 18px;
font: 400 12px var(--sans); color: #8fb3c0;
}
body.is-overlay .atlas {
flex: none;
width: 1180px;
margin: auto auto 0;
padding: 4px 26px 20px;
border-radius: 6px;
background: var(--desk-hi);
border: 1px solid #8c7752;
box-shadow: 0 30px 80px rgb(0 0 0 / 0.6);
}
body.is-overlay .atlas-head { grid-template-columns: 230px 1fr auto auto; padding: 12px 0 10px; }
body.is-overlay .brand-title { font-size: 25px; }
body.is-overlay .atlas-body { grid-template-columns: 1fr 210px; gap: 26px; }
body.is-overlay .roll { width: 100%; }
body.is-overlay .themes { padding: 12px 12px 10px; }
body.is-overlay .th-row { font-size: 12.5px; padding-block: 3px; }
body.is-overlay .mode-name { font-size: 10px; }
.here-tag { margin: 0; display: flex; border: 1px solid var(--iron); border-radius: 3px; overflow: hidden; font-size: 12.5px; }
.here-k { padding: 5px 9px; background: var(--iron); color: var(--vellum-hi); }
.here-v { padding: 5px 10px; font: 600 16px/1.1 var(--serif); background: var(--vellum-hi); color: var(--iron); }
/* ------------------------------------------------------------- credits */
.fan-credits { color: #d8ccae; opacity: 0.9; padding: 0.6rem 1.5rem 0.75rem; font-size: 12.5px; position: relative; z-index: 6; }
body.is-overlay .fan-credits { margin-top: auto; color: #b9cfd8; }
+65
View File
@@ -0,0 +1,65 @@
// A small, simplified redraw of 12's general chart: the captain's-table map, with "You are here".
// Coordinates are 12's general chart scaled into a 320×210 card.
const P = (x: number, y: number): [number, number] => [10 + ((x - 50) * 300) / 1050, 10 + ((y - 85) * 190) / 660];
const PORTS: [number, number][] = [
P(344, 277), P(404, 331), P(432, 407), P(420, 488), P(373, 552),
P(456, 498), P(543, 576), P(679, 576), P(766, 499), P(960, 400),
];
const LOOP = P(410, 592);
const AT_SEA = P(392, 584);
const pt = (p: [number, number]) => `${p[0].toFixed(1)},${p[1].toFixed(1)}`;
const port = (i: number) => PORTS[i]!;
/** The ship's course on the card, as one path (12's red dashed line). */
const COURSE = `M${pt(port(0))} Q${pt(P(380, 300))} ${pt(port(1))} Q${pt(P(440, 360))} ${pt(port(2))} L${pt(port(3))} L${pt(port(4))}
Q${pt(P(380, 600))} ${pt(LOOP)} Q${pt(P(440, 570))} ${pt(port(5))} Q${pt(P(480, 570))} ${pt(port(6))} L${pt(port(7))}
Q${pt(P(730, 560))} ${pt(port(8))} Q${pt(P(860, 530))} ${pt(port(9))}`;
/** Where "You are here" sits: k=0 out at sea after Troy, k=1 at the first Cyclops port. */
export function hereAt(k: number): [number, number] {
const a = AT_SEA, b = LOOP, c = port(5);
// two straight legs are close enough at this size
if (k < 0.4) { const s = k / 0.4; return [a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s]; }
const s = (k - 0.4) / 0.6;
return [b[0] + (c[0] - b[0]) * s, b[1] + (c[1] - b[1]) * s];
}
export function chartSvg(): string {
const [ix, iy] = P(605, 470);
const coast = `M0,0 L${pt(P(275, 85))} Q${pt(P(300, 200))} ${pt(P(290, 300))} Q${pt(P(390, 400))} ${pt(P(375, 430))} Q${pt(P(330, 520))} ${pt(P(285, 550))} Q${pt(P(240, 680))} ${pt(P(200, 745))} L0,210 Z`;
const ports = PORTS.map((p, i) => `<circle class="c-port" cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="2.6"/><text class="c-num" x="${(p[0] + 4).toFixed(1)}" y="${(p[1] - 3).toFixed(1)}">${i + 1}</text>`).join("");
let rhumbs = "";
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
rhumbs += `M206,48 L${(206 + Math.cos(a) * 260).toFixed(0)},${(48 + Math.sin(a) * 260).toFixed(0)} `;
}
let spiral = "";
for (let r = 3; r < 15; r += 3) spiral += `<ellipse class="c-ink" cx="${port(9)[0].toFixed(1)}" cy="${(port(9)[1] - 4).toFixed(1)}" rx="${r * 1.5}" ry="${r * 0.8}"/>`;
return `<svg class="chart__svg" viewBox="0 0 320 210" role="img" aria-label="General chart of the voyage from Troy, with the ship's position marked">
<defs><clipPath id="chart-clip"><rect x="6" y="6" width="308" height="198"/></clipPath></defs>
<rect class="c-vellum" x="0" y="0" width="320" height="210"/>
<g clip-path="url(#chart-clip)">
<path class="c-rhumb" d="${rhumbs}"/>
<path class="c-land" d="${coast}"/>
<ellipse class="c-land" cx="${ix.toFixed(1)}" cy="${iy.toFixed(1)}" rx="38" ry="22"/>
<path class="c-ink" d="M${(ix - 6).toFixed(1)},${(iy + 6).toFixed(1)} a8,8 0 0 1 16,0" />
<ellipse class="c-land" cx="${P(450, 195)[0]}" cy="${P(450, 195)[1]}" rx="9" ry="6"/>
<ellipse class="c-land" cx="${P(722, 335)[0]}" cy="${P(722, 335)[1]}" rx="5" ry="3.5"/>
${spiral}
<path class="c-course" d="${COURSE}"/>
${ports}
<text class="c-place" x="16" y="34">Troy</text>
<text class="c-place" x="${(ix - 30).toFixed(1)}" y="${(iy - 28).toFixed(1)}">Cyclops' Island</text>
<text class="c-sea" x="${P(870, 440)[0].toFixed(1)}" y="${P(870, 440)[1].toFixed(1)}">two years at sea</text>
<g class="c-rose" transform="translate(292,178)"><circle r="11"/><path d="M0,-15 L3,0 L0,15 L-3,0 Z M-15,0 L0,3 L15,0 L0,-3 Z"/></g>
</g>
<g class="c-here" id="chart-here">
<circle class="c-here__ring" r="7"/>
<circle class="c-here__dot" r="3"/>
<text class="c-here__label" x="10" y="-8">You are here</text>
</g>
<rect class="c-frame" x="4" y="4" width="312" height="202"/>
</svg>`;
}
+269
View File
@@ -0,0 +1,269 @@
// Copied from 04-greek-frieze/figures.ts (mockups never import across folders); helpers exported for 13.
// Black-figure painting kit: glaze (fill: var(--ink)) with "incised" detail lines scratched back to the clay (stroke: var(--inc)).
// Every panel shares one 300-unit-tall coordinate space with the ground line at GROUND.
export const PANEL_H = 300;
export const GROUND = 284;
type Arms = "spear" | "open" | "down" | "raise" | "reach" | "mourn" | "club" | "throw";
interface Person {
x: number; y?: number; s?: number; f?: 1 | -1; arms?: Arms;
helmet?: boolean; shield?: boolean; robe?: boolean; cyclops?: boolean; kneel?: boolean;
beard?: boolean; infant?: boolean; cup?: boolean;
}
export const g = (t: string, body: string) => `<g transform="${t}">${body}</g>`;
export const inc = (d: string, w = 1.6) => `<path class="inc" d="${d}" stroke-width="${w}"/>`;
export const ink = (d: string) => `<path class="ink" d="${d}"/>`;
// A limb: glaze stroke over a wider clay stroke, so it reads as incised where it crosses glaze.
export const limb = (d: string, w = 6) =>
`<path class="limb-edge" d="${d}" stroke-width="${w + 3}"/><path class="limb" d="${d}" stroke-width="${w}"/>`;
export function person(p: Person): string {
const { x, y = GROUND, s = 1, f = 1, arms = "down" } = p;
const lift = p.kneel ? 36 : 0;
let legs = "";
if (p.kneel) {
legs = limb("M4,-44 L34,-40 L36,-2", 8) + limb("M-2,-44 L-24,-4 L-4,-2", 8);
} else if (!p.robe) {
legs = limb("M-2,-80 L-10,-40 L-24,-2 L-14,-2", 8) + limb("M4,-80 L14,-40 L30,-2 L40,-2", 8);
}
let upper = "";
// back arm sits behind the torso
if (arms === "open") upper += limb("M-8,-148 L-36,-176 L-44,-196");
if (arms === "mourn") upper += limb("M-8,-148 L-14,-120 L-6,-104");
upper += ink("M-3,-160 h8 v10 h-8 Z");
upper += ink("M-13,-154 L15,-154 L9,-104 L-5,-104 Z");
if (p.robe) {
upper += ink("M-9,-108 L11,-108 L28,-2 L-24,-2 Z");
upper += inc("M1,-100 L2,-8 M-5,-96 L-14,-8 M7,-96 L18,-8 M-20,-30 L24,-30");
} else {
upper += ink("M-8,-108 L10,-108 L20,-74 L-18,-74 Z");
upper += inc("M1,-104 L1,-77 M-5,-100 L-10,-77 M7,-100 L12,-77");
}
upper += inc("M-9,-146 L11,-146 M-2,-150 L1,-112");
// head
upper += `<circle class="ink" cx="2" cy="-168" r="11"/>`;
upper += ink("M10,-173 L18,-166 L10,-162 Z");
if (p.beard) upper += ink("M-2,-162 L13,-162 L5,-146 Z") + inc("M2,-158 L5,-150", 1.2);
if (p.cyclops) {
upper += `<circle class="clay" cx="7" cy="-171" r="5"/><circle class="ink" cx="8.5" cy="-171" r="2.4"/>`;
} else {
upper += inc("M5,-171 h5", 1.4);
}
if (p.helmet) {
upper += ink("M-12,-166 A14 14 0 0 1 15,-170 L15,-160 L9,-160 L9,-166 Z");
upper += ink("M-18,-178 Q-8,-214 24,-194 Q4,-200 -8,-176 Z");
upper += inc("M-10,-166 L13,-168 M4,-176 L4,-166");
} else if (!p.cyclops) {
upper += inc("M-6,-176 Q2,-182 10,-176", 1.3);
}
if (p.shield) {
upper += `<circle class="ink" cx="12" cy="-124" r="32"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="26" stroke-width="1.6"/>`;
upper += `<circle class="inc" cx="12" cy="-124" r="7" stroke-width="1.6"/>`;
}
// front arm
switch (arms) {
case "spear":
upper += limb("M10,-148 L30,-132 L40,-150");
upper += limb("M-4,-52 L72,-222", 3.5) + ink("M72,-222 L66,-244 L80,-226 Z");
break;
case "open": upper += limb("M10,-148 L40,-178 L48,-198"); break;
case "raise": upper += limb("M10,-148 L22,-182 L26,-214"); break;
case "reach": upper += limb("M10,-148 L36,-140 L56,-146"); break;
case "mourn": upper += limb("M10,-148 L26,-136 L12,-172"); break;
case "club":
upper += limb("M10,-148 L30,-130 L40,-142");
upper += limb("M18,-96 L66,-214", 10) + ink("M60,-218 Q66,-236 78,-214 Z");
break;
case "throw":
upper += limb("M10,-148 L-6,-182 L-12,-214");
upper += `<circle class="ink" cx="-16" cy="-232" r="18"/>` + inc("M-26,-238 Q-16,-246 -6,-236 M-24,-226 l10,2", 1.4);
break;
default: upper += limb("M10,-148 L16,-104");
}
if (p.infant) {
upper += `<ellipse class="ink" cx="58" cy="-150" rx="7.5" ry="17" transform="rotate(-14 58 -150)"/><circle class="ink" cx="63" cy="-174" r="7"/>`;
upper += inc("M52,-158 l12,-3 M53,-148 l12,-3 M54,-138 l11,-3", 1.3);
}
if (p.cup) {
upper += ink("M48,-162 h26 q-2,12 -13,12 q-11,0 -13,-12 Z M60,-150 v5 h-5 h10");
upper += inc("M52,-158 h18", 1.2);
}
const body = legs + (lift ? g(`translate(0,${lift})`, upper) : upper);
return g(`translate(${x},${y}) scale(${f * s},${s})`, body);
}
export function trojanHorse(x: number, s: number): string {
let b = "";
b += ink("M-92,-32 h176 v12 h-176 Z");
for (const lx of [-60, -42, 30, 48]) b += limb(`M${lx},-112 L${lx},-30`, 10);
b += limb("M-80,-150 Q-108,-128 -98,-88", 6);
b += ink("M-84,-164 h150 q10,0 10,12 v36 q0,14 -14,14 h-132 q-14,0 -14,-14 v-36 q0,-12 12,-12 Z");
b += ink("M40,-156 L60,-236 L88,-230 L68,-140 Z");
b += ink("M58,-238 Q70,-256 100,-244 L124,-208 Q120,-196 106,-202 L84,-224 Z M62,-246 L68,-266 L76,-244 Z");
b += inc("M-76,-146 H62 M-76,-130 H62 M-76,-116 H62 M-40,-160 V-104 M0,-160 V-104 M36,-160 V-104");
b += inc("M62,-228 l6,-6 l2,8 l6,-6 l2,8 l6,-6 M50,-196 l6,-6 l2,8 l6,-6", 1.4);
b += `<circle class="inc" cx="90" cy="-232" r="3" stroke-width="1.6"/>`;
b += inc("M-6,-150 h28 v22 h-28 Z", 1.4);
for (const wx of [-66, 56]) {
b += `<circle class="ink" cx="${wx}" cy="-12" r="13"/><circle class="inc" cx="${wx}" cy="-12" r="8" stroke-width="1.4"/>`;
b += inc(`M${wx - 8},-12 h16 M${wx},-20 v16`, 1.2);
}
return g(`translate(${x},${GROUND}) scale(${s})`, b);
}
export function ship(x: number, y: number, s: number, o: { sail?: boolean; tilt?: number; f?: 1 | -1 } = {}): string {
const f = o.f ?? 1;
let b = "";
for (let i = 0; i < 9; i++) {
const ox = -96 + i * 24;
b += `<path class="oar" d="M${ox},-44 L${ox - 18},6" />`;
}
b += `<path class="rig" d="M-84,-218 L-130,-60 M84,-218 L120,-48 M0,-232 L150,-90"/>`;
b += limb("M0,-50 L0,-236", 5);
if (o.sail) {
b += ink("M-78,-222 h156 v104 q-78,-18 -156,0 Z");
let grid = "";
for (let gx = -58; gx < 78; gx += 20) grid += `M${gx},-220 V-122 `;
grid += "M-76,-196 H76 M-76,-172 H76 M-76,-148 H76";
b += inc(grid, 1.3);
}
b += limb("M-86,-222 L86,-222", 4);
for (let i = 0; i < 8; i++) b += `<circle class="ink" cx="${-84 + i * 24}" cy="-62" r="6.5"/>`;
b += ink("M-160,-104 Q-150,-52 -108,-30 L118,-30 L178,-36 L132,-46 L140,-84 L128,-86 Q122,-54 100,-54 L-100,-54 Q-140,-58 -150,-104 Z");
b += inc("M-112,-40 H120 M-118,-48 H104", 1.4);
b += `<circle class="clay" cx="122" cy="-44" r="4.5"/><circle class="ink" cx="123" cy="-44" r="2"/>`;
return g(`translate(${x},${y}) rotate(${o.tilt ?? 0}) scale(${f * s},${s})`, b);
}
export function waves(x0: number, x1: number, y: number, size = 1): string {
let d = "";
const step = 36 * size;
for (let x = x0; x < x1 - step / 2; x += step) {
d += `M${x},${y} C${x + 8 * size},${y - 22 * size} ${x + 34 * size},${y - 24 * size} ${x + 34 * size},${y - 8 * size} C${x + 34 * size},${y} ${x + 22 * size},${y + 2 * size} ${x + 20 * size},${y - 6 * size} `;
}
return `<path class="wave" d="${d}" stroke-width="${4 * size}"/><path class="wave" d="M${x0},${y + 2} H${x1}" stroke-width="3"/>`;
}
function stele(x: number): string {
return g(`translate(${x},${GROUND})`,
ink("M-36,0 v-12 h72 v12 Z M-24,-12 V-150 L0,-178 L24,-150 V-12 Z") +
inc("M-18,-146 H18 M-14,-60 H14 M-14,-48 H14 M-14,-36 H14") +
`<circle class="inc" cx="0" cy="-112" r="12" stroke-width="1.6"/><circle class="inc" cx="0" cy="-112" r="5" stroke-width="1.2"/>`);
}
const bolt = (x: number, y: number, s: number) =>
g(`translate(${x},${y}) scale(${s})`, ink("M0,0 L22,0 L10,34 L24,34 L-6,92 L2,48 L-12,48 Z"));
function cave(w: number): string {
return ink(`M0,0 H${w} V${PANEL_H} H${w - 30} Q${w - 20},${66} ${w / 2},${58} Q30,66 32,${PANEL_H} H0 Z`) +
inc(`M${w - 22},120 q-6,-30 -14,-48 M20,150 q6,-40 18,-70 M${w * 0.3},40 q30,-6 60,-2 M${w * 0.62},44 q24,0 46,10`, 1.6);
}
export interface PanelArt { w: number; art: string; inscription?: "dark" | "light"; inscrEnd?: boolean }
const ground = (w: number) => `<path class="ground" d="M0,${GROUND} H${w}"/>`;
export const PANELS: Record<string, PanelArt> = {
"the-horse-and-the-infant": { w: 370, art: trojanHorse(132, 0.9) + person({ x: 300, f: -1, arms: "reach", helmet: true, infant: true }) },
"just-a-man": {
w: 240,
art: person({ x: 96, arms: "mourn", helmet: true, beard: true }) +
limb("M178,284 L192,70", 3.5) + ink("M192,70 L186,48 L200,66 Z") +
`<circle class="ink" cx="176" cy="250" r="32"/><circle class="inc" cx="176" cy="250" r="26" stroke-width="1.6"/>` +
inc("M162,236 l28,28 M190,236 l-28,28", 1.4),
},
"full-speed-ahead": { w: 390, art: waves(0, 390, 290) + ship(190, 276, 1, { sail: true }) },
"open-arms": {
w: 300,
art: person({ x: 92, arms: "open", beard: true }) + person({ x: 214, f: -1, arms: "reach", robe: true }),
},
"warrior-of-the-mind": {
w: 340,
art: person({ x: 98, arms: "spear", helmet: true, shield: true, robe: true }) +
person({ x: 246, f: -1, arms: "spear", helmet: true, shield: true, beard: true }),
},
polyphemus: {
w: 340,
art: person({ x: 76, arms: "reach", beard: true, cup: true }) +
person({ x: 252, f: -1, s: 1.2, arms: "club", cyclops: true, beard: true }),
},
survive: {
w: 340, inscription: "light",
art: person({ x: 214, f: -1, s: 1.02, arms: "down", cyclops: true, beard: true }) +
person({ x: 82, s: 0.62, arms: "mourn", kneel: true }) + person({ x: 120, s: 0.62, arms: "reach" }) + cave(340),
},
"remember-them": {
w: 310,
art: stele(155) + person({ x: 62, arms: "mourn", kneel: true, beard: true }) + person({ x: 250, f: -1, arms: "mourn", robe: true }),
},
"my-goodbye": {
w: 400, inscrEnd: true,
art: waves(160, 400, 290) + person({ x: 78, s: 1.15, arms: "throw", cyclops: true, beard: true }) +
ship(292, 276, 0.62, { sail: true }),
},
storm: {
w: 350,
art: bolt(52, 40, 1) + bolt(282, 52, 0.8) + ship(176, 252, 0.86, { tilt: -9 }) + waves(0, 350, 278, 1.3) + waves(18, 350, 296, 0.9),
},
};
const escXml = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** The painted scene for a song: figures plus its name written in the field, as on a vase. */
export function panelSvg(id: string, title: string, withTitle = true): string {
const p = PANELS[id] ?? { w: 260, art: person({ x: 130 }) };
const light = p.inscription === "light";
const x = p.inscrEnd ? p.w - 16 : light ? 46 : 16;
const label = withTitle
? `<text class="inscr${light ? " inscr--light" : ""}" x="${x}" y="${light ? 32 : 28}"${p.inscrEnd ? ' text-anchor="end"' : ""}>${escXml(title.toUpperCase())}</text>`
: "";
return `<svg class="scene" viewBox="0 0 ${p.w} ${PANEL_H}" aria-hidden="true" focusable="false">${p.art}${ground(p.w)}${label}</svg>`;
}
export function panelWidth(id: string): number {
return PANELS[id]?.w ?? 260;
}
/** Narrow ladder band between consecutive songs of one saga. */
export function ladderSvg(): string {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return `<svg class="sep-art" viewBox="0 0 26 300" aria-hidden="true"><path class="rule" d="${d}"/></svg>`;
}
export function palmette(cx: number, cy: number, flip = false): string {
let leaves = "";
for (let i = -3; i <= 3; i++) {
leaves += `<ellipse class="ink" cx="0" cy="-26" rx="5.5" ry="18" transform="rotate(${i * 22})"/>`;
}
const volutes = `<path class="vol" d="M0,0 C-10,10 -28,8 -28,-4 C-28,-12 -18,-14 -16,-6 M0,0 C10,10 28,8 28,-4 C28,-12 18,-14 16,-6"/>`;
return g(`translate(${cx},${cy}) scale(1,${flip ? -1 : 1})`, leaves + `<circle class="ink" cx="0" cy="-2" r="7"/>` + volutes);
}
/** A saga boundary: palmettes framing the saga's name, painted vertically. */
export function sagaSvg(name: string, gap: number): string {
const tally = gap > 0
? `<text class="tally" x="66" y="296" text-anchor="middle">${gap} ${gap === 1 ? "year" : "years"} later</text>`
: "";
return `<svg class="sep-art" viewBox="0 0 132 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M128,0 V300"/>
${palmette(66, 52)}${palmette(66, gap > 0 ? 226 : 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${escXml(name.replace(/^The /, "").toUpperCase())}</text>
${tally}
</svg>`;
}
/** Elapsed story time between two songs, painted as a tally of years. */
export function gapSvg(gap: number): string {
const w = 40 + gap * 24;
let bars = "";
for (let i = 0; i < gap; i++) bars += `M${20 + i * 24 + 6},120 V180 `;
return `<svg class="sep-art" viewBox="0 0 ${w} 300" aria-hidden="true">
<path class="rule" d="M4,0 V300 M${w - 4},0 V300"/>
<path class="tally-bars" d="${bars}"/>
<text class="tally" transform="translate(${w / 2 + 4},250) rotate(-90)" >+${gap} yr</text>
</svg>`;
}
+166
View File
@@ -0,0 +1,166 @@
// The two frieze "curtains": full-stage SVGs drawn in the 04 black-figure kit. Colours come from CSS
// custom properties, so look 1 redraws the same figures as ink on vellum.
import { GROUND, PANEL_H, g, inc, ink, limb, palmette, person, PANELS, ship, waves } from "./figures";
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
const BOT_Y = GROUND_Y + 14;
const BAND_BOT = BOT_Y + 100;
/** Centre of the cave mouth in the Cyclops frieze, in stage units: the zoom goes through here. */
export const MOUTH = { x: 720, y: FIELD_Y + (GROUND - 100) * SCALE };
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
function defs(id: string): string {
const meander = (key: string, y: number) =>
`<pattern id="${id}-${key}" y="${y}" width="28" height="28" patternUnits="userSpaceOnUse"><path class="mean" d="M0 25.2H28M4.2 25.2V3H24V20.3H9.8V8.4H18.2V14.7" /></pattern>`;
return `<defs>
${meander("mt", BAND_TOP + 14)}${meander("mb", BOT_Y + 8)}
<pattern id="${id}-tongues" y="${BAND_TOP + 50}" width="18" height="22" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 0H16V13A7 7 0 0 1 2 13Z" />
</pattern>
<pattern id="${id}-rays" y="${BOT_Y + 46}" width="32" height="54" patternUnits="userSpaceOnUse">
<path class="ink" d="M2 54L16 6L30 54Z" />
</pattern>
</defs>`;
}
/**
* Shared chrome, split at the ground line into a top half (surround, band, top border) and a bottom half,
* so the morph can part them along the line that becomes the horizon.
*/
function chrome(id: string) {
const X0 = -200, W = VB_W + 400;
const rule = (y: number) => `<path class="rule" d="M${X0} ${y}H${X0 + W}"/>`;
const frame = (y: number) => `<path class="frame-rule" d="M${X0} ${y}H${X0 + W}M${X0} ${y + 5}H${X0 + W}"/>`;
const top = `<g class="f-top">
<rect class="f-surround" x="${X0}" y="-600" width="${W}" height="${GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${BAND_TOP}" width="${W}" height="${GROUND_Y - BAND_TOP}"/>
${frame(BAND_TOP - 12)}${rule(BAND_TOP + 8)}
<rect x="${X0}" y="${BAND_TOP + 14}" width="${W}" height="28" fill="url(#${id}-mt)"/>
${rule(BAND_TOP + 46)}
<rect x="${X0}" y="${BAND_TOP + 50}" width="${W}" height="22" fill="url(#${id}-tongues)"/>
</g>`;
const bottom = `<g class="f-bottom">
<rect class="f-surround" x="${X0}" y="${GROUND_Y}" width="${W}" height="${VB_H - GROUND_Y + 600}"/>
<rect class="f-band" x="${X0}" y="${GROUND_Y}" width="${W}" height="${BAND_BOT - GROUND_Y}"/>
${rule(BOT_Y + 2)}
<rect x="${X0}" y="${BOT_Y + 8}" width="${W}" height="28" fill="url(#${id}-mb)"/>
${rule(BOT_Y + 40)}
<rect x="${X0}" y="${BOT_Y + 46}" width="${W}" height="54" fill="url(#${id}-rays)"/>
${frame(BAND_BOT + 6)}
</g>`;
return { top, bottom };
}
const placed = (x: number, art: string) => g(`translate(${x},${FIELD_Y}) scale(${SCALE})`, art);
const inscription = (x: number, text: string, anchor: "start" | "middle" | "end" = "start") =>
`<text class="inscr" x="${x}" y="${FIELD_Y + 34}" text-anchor="${anchor}">${esc(text.toUpperCase())}</text>`;
/** A painted saga column (palmettes framing the saga's name), as in 04. */
function sagaColumn(x: number, name: string): string {
return placed(x, `<path class="rule" d="M4,0 V${PANEL_H} M128,0 V${PANEL_H}"/>${palmette(66, 52)}${palmette(66, 248, true)}
<text class="saga-name" transform="translate(71,150) rotate(-90)" text-anchor="middle">${esc(name.toUpperCase())}</text>`);
}
const ladder = (x: number) => {
let d = "M5,0 V300 M21,0 V300 ";
for (let y = 8; y < 300; y += 12) d += `M5,${y} H21 `;
return placed(x, `<path class="rule" d="${d}"/>`);
};
/** Beat 1: the fall of Troy. */
export function troyFrieze(): string {
const id = "fz1";
const c = chrome(id);
const u = (n: number) => n * SCALE;
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");
x += u(370);
figs += ladder(x);
x += u(26) + 6;
figs += placed(x, PANELS["just-a-man"]!.art) + inscription(x + 18, "Just a Man");
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");
return `<svg class="frieze frieze--troy" viewBox="0 0 ${VB_W} ${VB_H}" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
${defs(id)}
${c.top}${c.bottom}
<g class="f-figs">${figs}</g>
<path class="ground horizon-line" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
</svg>`;
}
/* ---------- Cyclops frieze: crew, cave mouth, Polyphemus, sheep ---------- */
function sheep(x: number, s: number, f: 1 | -1 = 1): string {
let b = "";
b += limb("M-14,-14 L-16,0", 4) + limb("M-4,-14 L-3,0", 4) + limb("M12,-14 L13,0", 4) + limb("M20,-14 L22,0", 4);
b += ink("M-24,-30 Q-26,-46 -10,-48 Q2,-58 14,-48 Q30,-50 28,-32 Q32,-16 16,-14 L-14,-14 Q-30,-14 -24,-30 Z");
b += inc("M-14,-38 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 M-16,-26 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0 q4,-5 8,0", 1.3);
b += ink("M26,-40 L40,-44 Q46,-38 40,-30 L28,-30 Z");
b += `<circle class="clay" cx="38" cy="-39" r="1.6"/>`;
return g(`translate(${x},${GROUND}) scale(${f * s},${s})`, b);
}
/** The rock and the arch of the cave mouth; the mouth itself is cut out with a mask so the 3D cave shows through. */
const MOUTH_W = 120; // half-width of the arch, panel units
const MOUTH_H = 200; // height of the arch above the ground line
function mouthPath(cx: number): string {
return `M${cx - MOUTH_W},${GROUND} V${GROUND - 92} Q${cx - MOUTH_W},${GROUND - MOUTH_H} ${cx},${GROUND - MOUTH_H} Q${cx + MOUTH_W},${GROUND - MOUTH_H} ${cx + MOUTH_W},${GROUND - 92} V${GROUND} Z`;
}
function caveRock(cx: number): string {
const r = MOUTH_W + 46;
let b = ink(`M${cx - r - 40},${GROUND} Q${cx - r - 30},${GROUND - 170} ${cx - r + 20},${GROUND - 222} Q${cx - 60},${GROUND - 268} ${cx + 10},${GROUND - 262} Q${cx + r - 10},${GROUND - 270} ${cx + r + 18},${GROUND - 200} Q${cx + r + 46},${GROUND - 110} ${cx + r + 40},${GROUND} Z`);
b += `<path class="inc" d="${mouthPath(cx)}" stroke-width="5" transform="translate(${cx},${GROUND}) scale(1.06) translate(${-cx},${-GROUND})"/>`;
b += inc(`M${cx - r - 18},${GROUND - 120} q18,-30 46,-40 M${cx - r + 4},${GROUND - 190} q34,-24 70,-26 M${cx + r - 30},${GROUND - 214} q30,6 44,40 M${cx + r + 10},${GROUND - 80} q8,-30 4,-60 M${cx - 40},${GROUND - 246} q40,-8 84,2`, 2);
return b;
}
export function cyclopsFrieze(): string {
const id = "fz4";
const c = chrome(id);
const u = (n: number) => n * SCALE;
const panelX = (stageX: number) => (stageX - 0) / SCALE; // field art is placed at x=0
const cx = panelX(MOUTH.x);
let art = "";
// crew on the left: Odysseus offers the cup, two men behind him
art += person({ x: panelX(250), s: 0.82, arms: "spear", helmet: true, shield: true });
art += person({ x: panelX(326), s: 0.82, arms: "down", helmet: true });
art += person({ x: panelX(398), s: 0.94, arms: "reach", beard: true, cup: true });
art += caveRock(cx);
// Polyphemus at his door, then the flock
art += person({ x: panelX(1060), f: -1, s: 1.16, arms: "club", cyclops: true, beard: true });
art += sheep(panelX(1180), 0.95) + sheep(panelX(1275), 0.85, -1) + sheep(panelX(1370), 0.92);
let figs = sagaColumn(22, "Cyclops Saga");
figs += placed(0, art);
figs += inscription(u(132) + 60, "Polyphemus");
figs += inscription(VB_W - 34, "Survive", "end");
const mouthStage = `translate(0,${FIELD_Y}) scale(${SCALE})`;
return `<svg class="frieze frieze--cyclops" viewBox="0 0 ${VB_W} ${VB_H}" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
${defs(id)}
<mask id="${id}-mouth" maskUnits="userSpaceOnUse" x="-4000" y="-4000" width="10000" height="10000">
<rect x="-4000" y="-4000" width="10000" height="10000" fill="#fff"/>
<path d="${mouthPath(cx)}" transform="${mouthStage}" fill="#000"/>
</mask>
<radialGradient id="${id}-glow" cx="0.5" cy="0.92" r="0.75">
<stop offset="0" class="glow-a"/><stop offset="0.55" class="glow-b"/><stop offset="1" class="glow-c"/>
</radialGradient>
<g class="f-zoom">
<g mask="url(#${id}-mouth)">
${c.top}${c.bottom}
<g class="f-figs">${figs}</g>
<path class="ground" d="M-200 ${GROUND_Y}H${VB_W + 200}"/>
</g>
<path class="mouth-fill" d="${mouthPath(cx)}" transform="${mouthStage}" fill="url(#${id}-glow)"/>
</g>
</svg>`;
}

Some files were not shown because too many files have changed in this diff Show More