feat(mockups): 09 schematic sea chart spike

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 02:34:37 +01:00
co-authored by Claude Opus 5.5
parent e880a69177
commit a422234970
4 changed files with 1107 additions and 93 deletions
@@ -1,102 +1,97 @@
# 09 — Schematic sea chart (`09-chart-transit`): interrupted
# 09 — Schematic sea chart (`09-chart-transit`): report
Status: **stopped before any code was written** (the coordinator called a halt for the usage limit).
`src/mockups/09-chart-transit/` is still the untouched stub. No files were left half-edited.
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.
## What's done
Files: `src/mockups/09-chart-transit/layout.ts` (new), `main.ts`, `style.css`. `index.html` is unchanged.
- I read the brief (`round2-brief.md`), `AGENTS.md`, `docs/design/002-direction-round-notes.md`, the shared contract (`src/data/*.ts`, `src/shared/*.ts`), the layout code for mockups 03 (`map.ts`) and 07 (`route.ts`), and the screenshots for 01, 03 and 07.
- I loaded the `frontend-design` skill and settled the design plan below. It is not built yet.
## The navigation idea
## Design plan (settled, ready to build)
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.
**Look:** this is an Admiralty-chart palette, not cream parchment.
- Deep water `#f4f7f5` is the background.
- Shoal bands `#d3e5ea`.
- Buff land `#ecdca4`.
- Navy chart ink `#1f3346`.
- Chart magenta `#a8326e` marks the ship's course and "you are here". This is the one bold element.
- Fonts are Cormorant Garamond and Inter, both via `@fontsource`:
- Cormorant: upright for land and saga names, italic for sea areas and in-story time.
- Inter: port labels and UI.
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.
**Schematic layout** (to go in `layout.ts`):
- The x-axis is story time:
- hop 92 between songs;
- +110 per year;
- +50 when the saga changes;
- X0 = 110.
- The rows are octilinear:
- Troy row at y≈290;
- a 45° diagonal down to the Cyclops row at y≈410;
- a long diagonal down to Storm at y≈590.
- The diagonals sit only in the gaps between ports, so every port is on a horizontal.
- Labels are horizontal, centred above each port, and wrap to two lines (max ~88px).
- The `storyTime.label` goes in italic below the lanes, with a year tag wherever the year changes.
- Gaps of a year or more carry an italic note on the course, such as "a year passes".
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.
**Motif lines:**
- Each motif gets a fixed lane parallel to the course: character themes on the left (north) side, musical motifs on the right (south) side, spaced 10 apart.
- A lane is drawn only over runs of consecutive songs that contain that motif. A single-song run gets a ±30 stub.
- Between runs, a faint dotted lane shows the motif returning.
- Lanes are built as offset polylines with miter joins, plus `roundedPath` (re-implement it locally; don't import from 03).
- Pale data colours are darkened toward a warm dark until they reach about 2.6:1 contrast on the paper. Each line has a paper-coloured casing.
- At each port, a capsule perpendicular to the course spans the lanes present. A port with 2 or more motifs is an interchange and gets a heavier border.
## What works
**Chart layer:**
- A compass rose in the empty top-right, around (1210, 190), with faint rhumb lines radiating across the chart.
- Schematic islands: soft blobs drawn as closed Catmull-Rom curves.
- A Troy blob under the Troy row.
- A Cyclops blob under the Cyclops row.
- A storm cell (dashed rings) at Storm.
- Dashed depth contours, drawn as scaled copies of each blob.
- **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.
**Morph slider (schematic ↔ true geography):**
- Sample the course densely by schematic parameter `u`.
- The geo course is a polyline through the songs' lon/lat in an equirectangular projection (lon 11–28.5, lat 35–42, x scaled by cos 38.5°).
- Point = lerp(schematic(u), geo(u), t). Recompute the lanes and ports for every `t`.
- Land crossfades between the schematic blobs and rough Mediterranean polygons: Italy, Sicily, Greece with the Peloponnese, Asia Minor, Crete and Ithaca. I drafted the coordinates in my head; re-derive them.
- Geo positions:
- songs 1–4 near Troy/Ismarus (26.2,40.0 → 24.6,40.3);
- Warrior of the Mind off Cape Malea (22.0,36.0), with via-points so the route avoids Attica;
- the Cyclops songs on Sicily (~15,37.7);
- Storm NW of Sicily (13.0,38.9).
- As `t` rises, the time labels fade out and port labels shrink to track numbers, which shows the trade-off.
- Support `?morph=0..1`.
## What doesn't work (or is weak)
**Page:**
- Title cartouche at top-left, morph control at top-centre, motif key at bottom-left.
- The detail drawer is about 400px on the right and pans the chart when the port would be hidden.
- Contents: title, story time, Odyssey link, parts with singers, motif chips, `createAudioSlot`, `createVideoCards`.
- Selecting a motif fades the other lanes and rings the ports where it plays.
- The footer is styled to be always visible.
- URL params: `?song=`, `?motif=`, `?overlay=1`.
- **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.
**Overlay (`?overlay=1`):**
- A dimmed CSS-gradient sea backdrop.
- The chart as a compact card, about 1180×650: story-time labels hidden, key as compact chips, slider hidden.
- A magenta ship marker reading "You are here: Survive".
- Clicking a port opens a popover with "Sail here", which animates the ship along `u`.
## Screenshots
## What's left
- [ ] Write `layout.ts`: schematic trunk, port positions, geo coordinates and polygons, morph sampling, offset lanes, `roundedPath`, blobs.
- [ ] Write `main.ts`: SVG render (static chart layer plus a dynamic layer rebuilt on morph), key, motif select, detail drawer, URL params, overlay mode with Sail here, `mountCredits()`.
- [ ] Write `style.css`. It must stay unique, not byte-identical to any other mockup's.
- [ ] Update the `index.html` title if needed. Keep `<footer data-fan-credits>` and the `credits.css` link.
- [ ] Build to `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r2-09-chart-transit` and serve on port 4109.
- [ ] Take screenshots: `screenshots/09-chart-transit-{default,motif,song,overlay}.png`, plus optionally `-geo.png`. Review them.
- [ ] Run `bun test` and `bun run typecheck`.
- [ ] Rewrite this file as the final report: the idea, what works, what doesn't, and the screenshot paths.
## Known broken bits
- None in the code, since nothing has been written. The stub still renders "Schematic sea chart (stub)".
- Expected risks:
- Port labels may be cramped at hop 92.
- Lanes may self-intersect in geo mode where ports cluster. That is acceptable and shows the trade-off.
- The pale Odysseus colour may sit close to Warrior gold once darkened.
## Next step to resume
Start with `src/mockups/09-chart-transit/layout.ts`, built from the plan above. Then write `main.ts` and `style.css`, then build, take the screenshots and run the checks.
- `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`)
+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`);
+510 -1
View File
@@ -1,4 +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();
document.getElementById("app")!.textContent = "Schematic sea chart (stub)";
// ---------- 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 -2
View File
@@ -1,2 +1,252 @@
/* 09-chart-transit stub */
body { margin: 0; }
/* 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; }
}