feat(mockups): 09 schematic sea chart spike
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e880a69177
commit
a422234970
@@ -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`)
|
||||
|
||||
@@ -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`);
|
||||
@@ -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")));
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user