wip(mockups): 12 vellum atlas paused at usage limit; pause report and notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 22:58:08 +01:00
1 parent c7d8b8c38a
commit 89a30e5479
5 files changed
+1632 -1

No files matched your search

+12
View File
@@ -149,6 +149,18 @@ This file exists because of that request. It gets updated after every user messa
- **C. Ink only**: no motif marks on the chart until a motif is picked; then its ports are ringed
and named parts are written beside them in the motif's ink colour.
### 10. Paused again at the usage limit
> "We are going to hit 5 hour limit soon, wind down and wait for my go to continue."
- 12 Vellum atlas was being built by one agent from `docs/design/002-sea-chart-spikes/12-vellum-atlas-brief.md`.
It was told to stop and write a pause report (what's done / checklist / next step) to
`docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md`; its partial work is in
`src/mockups/12-vellum-atlas/` and committed as WIP on `feat/sea-chart-round` (not pushed).
- **To resume (only on the user's go):** read this file, the 12 brief and the 12 report; dispatch one
agent to continue from the report's checklist; check its screenshots; rebuild the preview
container (command in §7); tell the user 12 is live and ask A/B/C.
## Open questions (in priority order)
1. ~~Which sea-chart approach (08–11) reads best, or which mix?~~ 11's navigation + 10's style (§9).
@@ -0,0 +1,63 @@
# 12-vellum-atlas: PAUSE report (work in progress)
Status: **paused** to stay under the usage limit. Nothing is committed. The server on port 4112 is stopped.
## What is done
The code is in `src/mockups/12-vellum-atlas/` (`geo.ts`, `main.ts` and `style.css`). It typechecks, and the last edits did too.
- **Navigation is 11's, unchanged in structure.** That covers:
- the overview and plate zoom;
- the cartouche, the locator, the breadcrumbs and prev/next;
- "continued on plate" notes;
- the scale of story time;
- the ←/→/Esc keys;
- the slip;
- `?saga= ?song= ?motif= ?overlay=1`, kept in sync with the URL;
- the overlay with "You are here" and "Sail here".
- **The look is 10's vellum portolan.** That includes:
- the paper, made from an feTurbulence grain and edge foxing;
- rods at both plate edges with curl shading;
- the red dashed course, rhumb lines and compass roses;
- the tan land with a green coast-wash and dashed water-lines;
- 10's storm swirl and ship glyph, the iron-gall motif inks and the dark desk.
- **Plates are re-laid with the land along the top.** This keeps the song slip from hiding the island. The order from top to bottom is:
- the land (Troy's coast, or the Island of the Cyclopes);
- the titles, the course and the story time;
- the motif marks;
- the scale bar, with a rose at the bottom right.
- **Motif toggle.** It is in the themes panel ("Themes on the plate: A Threads / B Pennants / C Ink only"), it accepts `?motifs=a|b|c` with `a` as the default, and the key changes per mode.
- **A:** wavy threads beneath the course, with knots at ports and names at the left. A thread runs off the plate edge when its theme plays on other plates. A selected thread is lit, its knots are labelled with part names, and the other threads fade.
- **B:** bunting pennants under each port. A selected theme hides the other pennants and draws one current under the bunting, with dotted tethers, chevrons, and "from earlier plates" or "on to later plates" at the edges.
- **C:** nothing shows until a theme is picked. Then its ports get hand-drawn ink rings and an italic part-name note with a flourish.
- **The overview keeps the halo, the "plays in N of M" badges and "silent here".** The silent badge now sits outside the dimmed island group, so it stays readable.
- **Cheap fixes from the 11 report:**
- Overview track rows are clickable, so one click opens the plate and the song.
- The Ocean plate is dressed with two rhumb roses, a big rose, the storm, rocks, "The open sea" and "two years at sea" along the incoming course.
- The slip is compact (29% height).
- **Screenshot review so far:** I checked default, motif, plate-a, plate-b, plate-c, song, overlay, ocean, troy, troy-b and troy-b-sel. Every collision I found has been fixed except the ones under "Still open" below.
## Left to do (checklist)
- [ ] **Look at the last batch.** It was rebuilt and shot, but I had not looked at it yet. The files are under `screenshots/12-vellum-atlas-`:
- `default`, `song`, `troy-song`, `plate-a`, `ocean`, `overlay-plate` and `plate-b-plain`.
- **Check that:**
- the thread rows now start just under the story-time labels and none is clipped by the slip on the Cyclops plate (7 themes) or the Troy plate;
- the overview "a year passes" now sits under the loop at port 5;
- the B key pennant icon renders;
- the overlay plate's ship tag and Sail-here popover are clear of titles.
- [ ] **Shoot the required set with the final names:** `default`, `motif` (`?motif=polyphemus`), `plate-a`, `plate-b` and `plate-c` (`?saga=cyclops&motif=polyphemus&motifs=a|b|c`), `song` (`?song=survive`), `overlay` and `ocean` (`?saga=ocean`). Delete the extras I made, or list them as extra: `troy`, `troy-b`, `troy-b-sel`, `troy-song`, `ocean-b`, `overlay-plate` and `plate-b-plain`.
- [ ] Check that the credits footer is fully visible in every state. It was visible in all the shots so far.
- [ ] Run `bun test` (not yet run this session) and `bun run typecheck`.
- [ ] Replace this file with the final report: what was built, what works, what doesn't, the screenshots, and a one-paragraph comparison of A/B/C.
## Exact next step
Run `bash <scratchpad>/rebuild.sh`, where the scratchpad is `/tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/`. It builds to `r3-12/` and restarts only the 4112 server. Then run `bash <scratchpad>/shots.sh <name> "<query>" ...` and open the PNGs.
## Gotchas
- A new build needs a server restart, because the asset hashes change. `rebuild.sh` kills only the PID listening on :4112.
- `rm -rf` on the outdir was denied by permissions, so just build over it.
- Plate vertical budget (viewBox 1100×700): ROUTE_Y is 312, and a new year drops the course by 30. The slip top sits at about y 483. Threads use `THREAD_STEP` 13, so 7 rows must end by y 470.
- `docs/design/002-direction-round-notes.md` shows as modified in `git status`. I did not edit it; it was probably changed by the coordinator or by the notes rule.
## Early impressions of A/B/C (to firm up)
- **A** reads best as a system and carries 10's identity. Its band is busy on plates with 7 themes.
- **B** is the calmest at rest and charming. The current only reads once a theme is picked, and the meaning of a pennant colour needs the legend.
- **C** is the cleanest and most "navigator's note"-like, but shows nothing until you pick a theme.
+260
View File
@@ -0,0 +1,260 @@
// 12 · Vellum atlas: geometry for the general chart (overview) and each saga's inset plate.
// Navigation geometry comes from 11; the plates are re-laid for 10's portolan look (land along the top).
import { motifs, sagas, songs } from "../../data/sample";
import type { Motif, Saga, Song } from "../../data/types";
export const W = 1100;
export const H = 700;
export type Pt = { x: number; y: number };
/* ------------------------------------------------------------- utilities */
export function rng(seed: number) {
let t = seed >>> 0;
return () => {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
/** Closed smooth path through points (Catmull-Rom → cubic Bézier). */
export function smoothClosed(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
for (let i = 0; i < n; i++) {
const p0 = pts[(i - 1 + n) % n]!, p1 = pts[i]!, p2 = pts[(i + 1) % n]!, p3 = pts[(i + 2) % n]!;
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d + "Z";
}
/** Open smooth path through points. */
export function smoothOpen(pts: Pt[]): string {
if (pts.length < 2) return "";
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)]!, p1 = pts[i]!, p2 = pts[i + 1]!, p3 = pts[Math.min(pts.length - 1, i + 2)]!;
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
}
return d;
}
/** Irregular island outline around an ellipse. */
export function blob(cx: number, cy: number, rx: number, ry: number, seed: number, wobble = 0.16, n = 22): Pt[] {
const r = rng(seed);
const pts: Pt[] = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const k = 1 + (r() - 0.5) * wobble * 2;
pts.push({ x: cx + Math.cos(a) * rx * k, y: cy + Math.sin(a) * ry * k });
}
return pts;
}
/* The shared motif colours are tuned for dark pages; on vellum they are re-inked
to darker iron-gall versions of the same hue (as in 10). */
const INK: Record<string, string> = {
warrior: "#93650b",
"open-arms": "#b2472c",
"just-a-man": "#2e5d8b",
remember: "#3b7347",
odysseus: "#5b3c20",
athena: "#6a4a9d",
polyphemus: "#8e1e24",
};
export const inkOf = (id: string): string => INK[id] ?? motifs.find((m) => m.id === id)?.color ?? "#2b2118";
/* ------------------------------------------------------------- places */
export interface Place {
sagaId: string;
place: string; // place name on the chart
roman: string; // plate number
label: Pt; // top-left of label block on the general chart
focus: { x: number; y: number; w: number; h: number }; // zoom box on the general chart
}
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
const PLACE_INFO: Record<string, Omit<Place, "sagaId" | "roman">> = {
troy: { place: "Troy", label: { x: 30, y: 70 }, focus: { x: 0, y: 150, w: 470, h: 430 } },
cyclops: { place: "Cyclops' Island", label: { x: 455, y: 92 }, focus: { x: 380, y: 200, w: 420, h: 380 } },
ocean: { place: "Open sea and storm", label: { x: 818, y: 500 }, focus: { x: 790, y: 190, w: 310, h: 300 } },
};
export const places: Place[] = [...sagas]
.sort((a, b) => a.order - b.order)
.map((s, i) => ({
sagaId: s.id,
roman: ROMAN[i] ?? String(i + 1),
...(PLACE_INFO[s.id] ?? {
place: s.name,
label: { x: 40 + i * 120, y: 560 },
focus: { x: 0, y: 0, w: W, h: H },
}),
}));
export const placeOf = (sagaId: string) => places.find((p) => p.sagaId === sagaId)!;
export const sagaById = (id: string): Saga => sagas.find((s) => s.id === id)!;
export const songsIn = (sagaId: string): Song[] => songs.filter((s) => s.sagaId === sagaId);
export function yearSpan(list: Song[]): string {
const ys = list.map((s) => s.storyTime.yearsAfterTroy);
const a = Math.min(...ys), b = Math.max(...ys);
return a === b ? `Year ${a}` : `Years ${a} to ${b}`;
}
export function motifsPlaying(song: Song): Set<string> {
return new Set(song.parts.flatMap((p) => p.motifIds));
}
/* ------------------------------------------------------------- general chart */
// Troy: mainland on the west edge with a headland. Cyclops' island mid-chart. Storm out east.
export const TROY_CENTER = { x: 230, y: 360 };
export const CYCLOPS = { cx: 585, cy: 405, rx: 135, ry: 92 };
export const STORM = { cx: 945, cy: 335, r: 118 };
export function troyMainland(width = W, height = H, scale = 1): string {
const r = rng(7);
const pts: Pt[] = [];
for (let y = -10; y <= height + 10; y += 26) {
const yy = y / scale;
let x = 248 + (r() - 0.5) * 18;
const dy = yy - TROY_CENTER.y;
if (Math.abs(dy) < 125) x = Math.max(x, TROY_CENTER.x + Math.sqrt(125 * 125 - dy * dy) * 0.9 + (r() - 0.5) * 12);
pts.push({ x: x * scale, y });
}
void width;
const path = smoothOpen(pts);
return `${path} L-20,${height + 20} L-20,-20 Z`;
}
/** Port positions on the general chart, one per song in story order. */
export function overviewPorts(): Pt[] {
const out: Pt[] = [];
for (const place of places) {
const list = songsIn(place.sagaId);
const n = list.length;
list.forEach((_, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
if (place.sagaId === "troy") {
const a = (-62 + t * 112) * (Math.PI / 180);
out.push({ x: TROY_CENTER.x + Math.cos(a) * 172, y: TROY_CENTER.y + Math.sin(a) * 172 });
} else if (place.sagaId === "cyclops") {
const a = (165 - t * 150) * (Math.PI / 180);
out.push({ x: CYCLOPS.cx + Math.cos(a) * (CYCLOPS.rx + 30), y: CYCLOPS.cy + Math.sin(a) * (CYCLOPS.ry + 30) });
} else if (place.sagaId === "ocean") {
const a = (200 + t * 140) * (Math.PI / 180);
const rr = n === 1 ? 0 : 50;
out.push({ x: STORM.cx + Math.cos(a) * rr, y: STORM.cy + Math.sin(a) * rr });
} else {
out.push({ x: place.label.x + 40 + i * 30, y: place.label.y - 30 });
}
});
}
return out;
}
/** Route through the overview ports with a few sea waypoints between sagas. */
export function overviewRoute(ports: Pt[]): Pt[] {
const pts: Pt[] = [];
songs.forEach((s, i) => {
const prev = songs[i - 1];
if (prev && prev.sagaId !== s.sagaId) {
const a = ports[i - 1]!, b = ports[i]!;
if (prev.sagaId === "troy") pts.push({ x: (a.x + b.x) / 2, y: Math.max(a.y, b.y) + 40 });
else if (s.sagaId === "ocean") pts.push({ x: (a.x + b.x) / 2 + 20, y: (a.y + b.y) / 2 + 60 });
}
pts.push(ports[i]!);
});
return pts;
}
/** Storm: a spiral of squall lines. */
export function stormSpiral(cx: number, cy: number, r: number, turns = 2.6): string {
const pts: Pt[] = [];
const steps = 90;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const a = t * turns * Math.PI * 2;
const rr = 14 + t * (r - 14);
pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr * 0.78 });
}
return smoothOpen(pts);
}
/* ------------------------------------------------------------- inset plates */
export interface InsetPort { song: Song; x: number; y: number }
export interface InsetLayout {
ports: InsetPort[];
/** Year gaps between consecutive ports (for "a year passes" notes). */
gaps: number[];
motifs: Motif[]; // motifs that play in this saga, in legend order
route: Pt[]; // control points of the course, edge to edge
lead: Pt | null; // where the course enters from the west edge (null if first plate)
tail: Pt | null; // where it leaves to the east edge (null if last plate)
maxY: number; // lowest port
}
export const ROUTE_Y = 312;
export const YEAR_DROP = 30;
/** Motif threads (treatment A) hang in rows below the lowest port. */
export const THREAD_GAP = 24; // below the lowest story-time line
export const THREAD_STEP = 13;
export const THREAD_X0 = 160; // threads that arrive from another plate start here; names sit to the left
export const SCALE_Y = 612;
export function insetLayout(sagaId: string): InsetLayout {
const list = songsIn(sagaId);
const n = list.length;
const y0 = list[0]?.storyTime.yearsAfterTroy ?? 0;
const gaps = list.slice(1).map((s, i) => s.storyTime.yearsAfterTroy - list[i]!.storyTime.yearsAfterTroy);
const units = Math.max(1, n - 1 + gaps.reduce((a, g) => a + Math.min(g, 2) * 0.9, 0));
const sagaIdx = places.findIndex((p) => p.sagaId === sagaId);
const hasLead = sagaIdx > 0, hasTail = sagaIdx < places.length - 1;
const left = hasLead ? 250 : 178, right = hasTail ? 838 : 960;
const step = Math.min(230, (right - left) / units);
const used = step * units;
let x = n === 1 ? (left + right) / 2 : left + (right - left - used) / 2;
const ports: InsetPort[] = list.map((song, i) => {
if (i > 0) x += step * (1 + Math.min(gaps[i - 1]!, 2) * 0.9);
const dyYears = Math.min(song.storyTime.yearsAfterTroy - y0, 2);
return { song, x, y: ROUTE_Y + dyYears * YEAR_DROP };
});
const first = ports[0]!, last = ports[ports.length - 1]!;
const lead = hasLead ? { x: 30, y: first.y - 18 } : null;
const tail = hasTail ? { x: W - 30, y: last.y + 14 } : null;
// The course: through every port, with a gentle hand-drawn swell between them.
const stops: Pt[] = [...(lead ? [lead] : []), ...ports.map((p) => ({ x: p.x, y: p.y })), ...(tail ? [tail] : [])];
const route: Pt[] = [];
stops.forEach((p, i) => {
if (i > 0) {
const a = stops[i - 1]!;
route.push({ x: (a.x + p.x) / 2, y: (a.y + p.y) / 2 + (i % 2 ? 9 : -8) });
}
route.push(p);
});
const ids = new Set(list.flatMap((s) => [...motifsPlaying(s)]));
return {
ports, gaps, route, lead, tail,
maxY: Math.max(...ports.map((p) => p.y)),
motifs: motifs.filter((m) => ids.has(m.id)),
};
}
export const polyD = (pts: Pt[]) => pts.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
/** Wrap a title into at most two lines of ~maxChars. */
export function wrap(text: string, maxChars: number): string[] {
if (text.length <= maxChars) return [text];
const words = text.split(" ");
let best = [text];
let bestScore = Infinity;
for (let i = 1; i < words.length; i++) {
const a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
const score = Math.max(a.length, b.length);
if (score < bestScore) { bestScore = score; best = [a, b]; }
}
return best;
}
+874
View File
@@ -1,3 +1,877 @@
// 12 · Vellum atlas: 11's atlas navigation (general chart → island plates) drawn in 10's vellum
// portolan style, with three ways to show themes on a plate (A threads, B pennants, C ink only).
import { motifs, songs } from "../../data/sample";
import { motifsInSong, songsWithMotif } from "../../data/query";
import type { Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import {
CYCLOPS, H, SCALE_Y, STORM, THREAD_GAP, THREAD_STEP, THREAD_X0, TROY_CENTER, W, blob, inkOf,
insetLayout, motifsPlaying, overviewPorts, overviewRoute, placeOf, places, polyD, sagaById, smoothClosed,
smoothOpen, songsIn, troyMainland, wrap, yearSpan, type InsetLayout, type InsetPort, type Pt,
} from "./geo";
mountCredits();
/* ------------------------------------------------------------------ state */
type Mode = "a" | "b" | "c";
const MODES: { id: Mode; letter: string; name: string }[] = [
{ id: "a", letter: "A", name: "Threads" },
{ id: "b", letter: "B", name: "Pennants" },
{ id: "c", letter: "C", name: "Ink only" },
];
const params = new URLSearchParams(location.search);
const overlay = params.get("overlay") === "1";
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const initialSong = songs.find((s) => s.id === params.get("song")) ?? null;
const modeParam = params.get("motifs");
const state = {
sagaId: (initialSong?.sagaId ?? places.find((p) => p.sagaId === params.get("saga"))?.sagaId ?? null) as string | null,
songId: (overlay ? null : initialSong?.id ?? null) as string | null,
motifId: motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null,
mode: (modeParam === "b" || modeParam === "c" ? modeParam : "a") as Mode,
shipAt: "survive",
sailPick: (overlay ? initialSong?.id ?? null : null) as string | null,
note: "" as string,
};
if (overlay) document.body.classList.add("is-overlay");
const motifName = (id: string) => motifs.find((m) => m.id === id)!.name;
const partsWith = (song: Song, id: string) => song.parts.filter((p) => p.motifIds.includes(id)).map((p) => p.name);
/* ------------------------------------------------------------------ dom helpers */
const SVG = "http://www.w3.org/2000/svg";
type Attrs = Record<string, string | number | undefined>;
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] {
const el = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
parent?.append(el);
return el;
}
function h<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text?: string, parent?: Element): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
if (cls) el.className = cls;
if (text !== undefined) el.textContent = text;
parent?.append(el);
return el;
}
function txt(parent: Element, x: number, y: number, text: string, cls: string, attrs: Attrs = {}) {
const t = s("text", { x, y, class: cls, ...attrs }, parent);
t.textContent = text;
return t;
}
function activate(el: Element, label: string, fn: () => void) {
el.setAttribute("tabindex", "0");
el.setAttribute("role", "button");
el.setAttribute("aria-label", label);
el.addEventListener("click", (e) => { e.stopPropagation(); fn(); });
el.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") { e.preventDefault(); e.stopPropagation(); fn(); }
});
}
/* ------------------------------------------------------------------ skeleton */
const app = document.getElementById("app")!;
app.className = "atlas";
const head = h("header", "atlas-head", undefined, app);
const body = h("div", "atlas-body", undefined, app);
const plateWrap = h("section", "plate-wrap", undefined, body);
const roll = h("div", "roll", undefined, plateWrap);
const plate = h("div", "plate", undefined, roll);
h("span", "rod rod--west", undefined, roll).setAttribute("aria-hidden", "true");
h("span", "rod rod--east", undefined, roll).setAttribute("aria-hidden", "true");
const index = h("aside", "themes", undefined, body);
index.setAttribute("aria-label", "Themes");
let svg: SVGSVGElement = s("svg", {});
let uid = 0;
/* ------------------------------------------------------------------ vellum pieces (after 10) */
function vellum(root: SVGElement) {
const id = `grain${++uid}`;
const f = s("filter", { id, x: 0, y: 0, width: "100%", height: "100%" }, root);
s("feTurbulence", { type: "fractalNoise", baseFrequency: "0.9 0.75", numOctaves: 2, seed: 4, result: "n" }, f);
s("feColorMatrix", { in: "n", type: "matrix", values: "0 0 0 0 0.36 0 0 0 0 0.28 0 0 0 0 0.16 0 0 0 0.55 -0.1" }, f);
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
const blot = s("radialGradient", { id: `${id}b`, cx: "50%", cy: "45%", r: "75%" }, root);
s("stop", { offset: "55%", "stop-color": "#e3d6b4", "stop-opacity": 0 }, blot);
s("stop", { offset: "100%", "stop-color": "#a88c5c", "stop-opacity": 0.38 }, blot);
s("rect", { x: 0, y: 0, width: W, height: H, fill: `url(#${id}b)`, "pointer-events": "none" }, root);
s("rect", { x: 0, y: 0, width: W, height: H, filter: `url(#${id})`, class: "grain", "pointer-events": "none" }, root);
}
function rhumbs(root: SVGElement, cx: number, cy: number, len = 1500) {
const g = s("g", { class: "rhumbs", "pointer-events": "none" }, root);
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const cls = i % 4 === 0 ? "rhumb rhumb--ink" : i % 2 === 0 ? "rhumb rhumb--green" : "rhumb rhumb--red";
s("line", { x1: cx, y1: cy, x2: cx + Math.cos(a) * len, y2: cy + Math.sin(a) * len, class: cls }, g);
}
}
function rose(root: SVGElement, cx: number, cy: number, r = 44) {
const g = s("g", { class: "rose", transform: `translate(${cx} ${cy}) scale(${r / 44})`, "pointer-events": "none" }, root);
s("circle", { r: 44, class: "rose__ring" }, g);
s("circle", { r: 36, class: "rose__ring rose__ring--thin" }, g);
for (let i = 0; i < 8; i++) {
const len = i % 2 === 0 ? 52 : 30;
const cls = i % 2 === 0 ? "rose__point" : "rose__point rose__point--minor";
s("path", { d: `M0,${-len} L6,0 L0,0 Z`, class: cls, transform: `rotate(${i * 45})` }, g);
s("path", { d: `M0,${-len} L-6,0 L0,0 Z`, class: `${cls} rose__point--shade`, transform: `rotate(${i * 45})` }, g);
}
txt(g, 0, -58, "N", "rose__n", { "text-anchor": "middle" });
}
/** Dashed water-lines outside a coast: ink stroke, then paper laid over its inner half. */
function waterLines(root: SVGElement, d: string, widths = [30, 16]) {
const g = s("g", { class: "waterlines", "pointer-events": "none" }, root);
for (const w of widths) {
s("path", { d, class: "wl-ink", "stroke-width": w }, g);
s("path", { d, class: "wl-paper", "stroke-width": w - 1.8 }, g);
}
}
function land(root: SVGElement, d: string) {
const id = `clip${++uid}`;
const cp = s("clipPath", { id }, root);
s("path", { d }, cp);
s("path", { d, class: "landmass" }, root);
s("path", { d, class: "coastwash", "clip-path": `url(#${id})` }, root);
s("path", { d, class: "coast" }, root);
}
function towers(root: SVGElement, x: number, y: number, k = 1) {
const g = s("g", { class: "towers", transform: `translate(${x} ${y}) scale(${k})`, "pointer-events": "none" }, root);
for (const [dx, hgt] of [[0, 18], [12, 26], [24, 20], [36, 14]] as const) s("rect", { x: dx, y: 26 - hgt, width: 9, height: hgt }, g);
}
function cave(root: SVGElement, x: number, y: number, k = 1) {
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})`, "pointer-events": "none" }, root);
s("path", { d: "M-30,4 C-24,-22 -8,-30 0,-30 C10,-30 24,-20 30,4", class: "hill" }, g);
s("path", { d: "M-9,4 C-9,-10 9,-10 9,4 Z" }, g);
}
function hills(root: SVGElement, cx: number, cy: number, n: number, spread: number, seed: number) {
let r = seed;
const g = s("g", { class: "hills", "pointer-events": "none" }, root);
for (let i = 0; i < n; i++) {
r = (r * 9301 + 49297) % 233280;
const x = cx + ((r / 233280) - 0.5) * spread;
r = (r * 9301 + 49297) % 233280;
const y = cy + ((r / 233280) - 0.5) * spread * 0.3;
s("path", { d: `M${x - 8},${y + 3} Q${x},${y - 7} ${x + 8},${y + 3}` }, g);
}
}
/** 10's storm: three ink arms swirling out, with rain beneath. */
function storm(root: SVGElement, cx: number, cy: number, k = 1, rain = true) {
const g = s("g", { class: "storm", transform: `translate(${cx} ${cy}) scale(${k})`, "pointer-events": "none" }, root);
for (let a = 0; a < 3; a++) {
let d = "";
for (let t = 0; t < 4.2 * Math.PI; t += 0.2) {
const r = 6 + t * 5.2;
d += `${d ? "L" : "M"}${(Math.cos(t + a * 2.1) * r * 1.5).toFixed(1)},${(Math.sin(t + a * 2.1) * r * 0.75).toFixed(1)}`;
}
s("path", { d, class: "storm__arm" }, g);
}
if (rain) for (let i = 0; i < 9; i++) s("line", { x1: -110 + i * 26, y1: 70, x2: -126 + i * 26, y2: 108, class: "storm__rain" }, g);
}
function rock(root: SVGElement, x: number, y: number, r: number, seed: number) {
const d = smoothClosed(blob(x, y, r, r * 0.7, seed, 0.2, 14));
waterLines(root, d, [14]);
s("path", { d, class: "landmass" }, root);
}
/** 10's ship, with a "You are here" tag above it. */
function ship(root: SVGElement, x: number, y: number, label: string, tagY = -40) {
const g = s("g", { class: "ship", transform: `translate(${x} ${y})`, "pointer-events": "none" }, root);
s("ellipse", { cx: 0, cy: 9, rx: 26, ry: 4, class: "ship__wake" }, g);
const b = s("g", { transform: "scale(0.8)" }, g);
s("path", { d: "M-20,-2 L20,-2 L13,8 L-14,8 Z", class: "ship__hull" }, b);
s("path", { d: "M0,-2 L0,-34", class: "ship__mast" }, b);
s("path", { d: "M-13,-31 Q0,-24 13,-31 L12,-9 Q0,-4 -12,-9 Z", class: "ship__sail" }, b);
s("path", { d: "M0,-34 L10,-38 L0,-41 Z", class: "ship__pennant" }, b);
const w = label.length * 6.7 + 18;
s("rect", { x: -w / 2, y: tagY - 15, width: w, height: 21, rx: 3, class: "ship-tag" }, g);
txt(g, 0, tagY, label, "ship-label", { "text-anchor": "middle" });
}
function frame(root: SVGElement) {
const g = s("g", { class: "neatline", "pointer-events": "none" }, root);
s("rect", { x: 6, y: 6, width: W - 12, height: H - 12, class: "nl-outer" }, g);
s("rect", { x: 12, y: 12, width: W - 24, height: H - 24, class: "nl-inner" }, g);
}
/* ------------------------------------------------------------------ general chart */
const OV_PORTS = overviewPorts();
const OV_ROUTE = overviewRoute(OV_PORTS);
const CYC_SHAPE = smoothClosed(blob(CYCLOPS.cx, CYCLOPS.cy, CYCLOPS.rx, CYCLOPS.ry, 11, 0.13));
const TROY_SHAPE = troyMainland();
const OV_ROSE = { x: 690, y: 160 };
function drawGeneral(root: SVGElement, mini = false) {
if (mini) s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
else { vellum(root); rhumbs(root, OV_ROSE.x, OV_ROSE.y); }
const motifSongs = state.motifId ? new Set(songsWithMotif(state.motifId).map((x) => x.id)) : null;
// Halo under islands that carry the selected theme.
const lit = (sagaId: string) => !!motifSongs && songsIn(sagaId).some((x) => motifSongs.has(x.id));
if (state.motifId && !mini) {
const c = inkOf(state.motifId);
if (lit("troy")) s("path", { d: TROY_SHAPE, class: "halo", stroke: c }, root);
if (lit("cyclops")) s("path", { d: CYC_SHAPE, class: "halo", stroke: c }, root);
if (lit("ocean")) s("ellipse", { cx: STORM.cx, cy: STORM.cy, rx: STORM.r, ry: STORM.r * 0.78, class: "halo halo--sea", stroke: c }, root);
}
if (!mini) { waterLines(root, TROY_SHAPE); waterLines(root, CYC_SHAPE); }
for (const [x, y, rx, seed] of [[420, 120, 26, 3], [600, 668, 20, 5], [700, 268, 13, 13]] as const) {
if (mini) s("path", { d: smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14)), class: "landmass" }, root);
else rock(root, x, y, rx, seed);
}
const groups: Record<string, SVGGElement> = {};
for (const p of places) {
const g = s("g", { class: "isle", "data-saga": p.sagaId }, root);
if (state.motifId && !lit(p.sagaId) && !mini) g.classList.add("is-dim");
groups[p.sagaId] = g;
}
land(groups.troy!, TROY_SHAPE);
towers(groups.troy!, TROY_CENTER.x - 74, TROY_CENTER.y - 10, mini ? 1.8 : 1.2);
land(groups.cyclops!, CYC_SHAPE);
if (!mini) { hills(groups.cyclops!, CYCLOPS.cx - 20, CYCLOPS.cy - 22, 8, 150, 4); cave(groups.cyclops!, CYCLOPS.cx + 46, CYCLOPS.cy + 34); }
// The open sea has no coast to click, so give it a quiet hit area under its storm.
s("ellipse", { cx: STORM.cx, cy: STORM.cy, rx: STORM.r, ry: STORM.r * 0.78, class: "sea-hit" }, groups.ocean!);
storm(groups.ocean!, STORM.cx, STORM.cy - 20, mini ? 1.3 : 0.95, false);
s("path", { d: smoothOpen(OV_ROUTE), class: "course" }, root);
if (mini) return;
// Elapsed time on the long crossings, set clear of the course.
songs.forEach((song, i) => {
const prev = songs[i - 1];
if (!prev) return;
const g = song.storyTime.yearsAfterTroy - prev.storyTime.yearsAfterTroy;
if (g <= 0) return;
const note = g === 1 ? "a year passes" : `${g === 2 ? "two" : g} years at sea`;
// Troy's year passes on the loop below port 5; the long crossing is written on open water.
if (song.sagaId === "troy") txt(root, 410, 630, note, "time-note", { "text-anchor": "middle" });
else {
const a = OV_PORTS[i - 1]!, b = OV_PORTS[i]!;
txt(root, (a.x + b.x) / 2 - 4, (a.y + b.y) / 2 + 34, note, "time-note", { "text-anchor": "middle" });
}
});
songs.forEach((song, i) => {
const p = OV_PORTS[i]!;
const on = motifSongs?.has(song.id);
const c = s("circle", { cx: p.x, cy: p.y, r: on ? 7.5 : 5.5, class: "ov-port" + (motifSongs && !on ? " is-dim" : "") }, root);
if (on) c.setAttribute("fill", inkOf(state.motifId!));
txt(root, p.x + (song.sagaId === "troy" ? 12 : 0), p.y + (song.sagaId === "troy" ? 4 : song.sagaId === "ocean" ? 24 : -12), String(song.track), "ov-num", { "text-anchor": song.sagaId === "troy" ? "start" : "middle" });
});
// Label blocks with a track list; each row opens its plate and its song in one click.
for (const p of places) {
const g = groups[p.sagaId]!;
const list = songsIn(p.sagaId);
const saga = sagaById(p.sagaId);
const lb = s("g", { class: "isle-label", transform: `translate(${p.label.x} ${p.label.y})` }, g);
txt(lb, 0, 0, p.place, "place-name");
txt(lb, 0, 22, `${saga.name}, plate ${p.roman}`, "place-sub");
txt(lb, 0, 40, `${list.length} ${list.length === 1 ? "song" : "songs"}, ${yearSpan(list).toLowerCase()}`, "place-meta");
list.forEach((song, j) => {
const on = motifSongs?.has(song.id);
const row = s("g", { class: "track-row" + (motifSongs && !on ? " is-dim" : "") + (on ? " is-on" : "") }, lb);
if (on) row.style.setProperty("--c", inkOf(state.motifId!));
s("rect", { x: -6, y: 64 + j * 20 - 15, width: Math.max(150, song.title.length * 8.6 + 30), height: 20, class: "track-hit" }, row);
const t = txt(row, 0, 64 + j * 20, "", "track-text");
const n = s("tspan", { class: "track-n" }, t); n.textContent = String(song.track).padStart(2, " ") + " ";
const tt = s("tspan", { class: "track-title" }, t); tt.textContent = song.title;
activate(row, `Open ${song.title}, track ${song.track}, on plate ${p.roman}`, () => openSongFromChart(song));
});
if (state.motifId && motifSongs) {
const count = list.filter((x) => motifSongs.has(x.id)).length;
const m = motifs.find((x) => x.id === state.motifId)!;
const y = 64 + list.length * 20 - 4;
const label = count ? `${m.name} plays in ${count} of ${list.length}` : `${m.name} is silent here`;
// The badge sits outside the island group so a dimmed island still says it is silent.
const badge = s("g", { class: "motif-badge" + (count ? "" : " is-silent"), transform: `translate(${p.label.x} ${p.label.y + y})` }, root);
s("rect", { x: 0, y: 0, width: label.length * 6.5 + 22, height: 22, rx: 11, fill: count ? inkOf(m.id) : "none" }, badge);
txt(badge, 11, 15, label, "badge-text");
}
activate(g, `Open plate ${p.roman}: ${p.place}, ${list.length} songs`, () => openInset(p.sagaId));
}
rose(root, 762, 650, 28);
if (overlay) {
const i = songs.findIndex((x) => x.id === state.shipAt);
const p = OV_PORTS[i]!;
ship(root, p.x - 24, p.y + 2, `You are here: ${songs[i]!.title}`, -42);
}
}
/* ------------------------------------------------------------------ inset plate */
/** Land and open-water dressing per plate. Land runs along the top so the song slip never covers it. */
function plateDressing(root: SVGElement, sagaId: string, L: InsetLayout) {
if (sagaId === "troy") {
rhumbs(root, 650, 520);
const pts: Pt[] = [];
const rnd = (() => { let t = 21; return () => ((t = (t * 9301 + 49297) % 233280) / 233280); })();
for (let x = -20; x <= W + 20; x += 50) pts.push({ x, y: 168 + Math.sin(x / 150) * 16 + Math.sin(x / 61 + 1) * 9 + (rnd() - 0.5) * 12 - (x > 900 ? (x - 900) * 0.35 : 0) });
const d = `${smoothOpen(pts)} L${W + 20},-20 L-20,-20 Z`;
waterLines(root, d);
land(root, d);
hills(root, 640, 70, 10, 300, 8);
txt(root, 440, 118, "Troy", "plate-land-name");
towers(root, 560, 88, 1.3);
rose(root, 1010, 556, 34);
} else if (sagaId === "cyclops") {
rhumbs(root, 960, 540);
const d = smoothClosed(blob(585, 108, 245, 82, 17, 0.09, 26));
waterLines(root, d);
land(root, d);
hills(root, 690, 92, 9, 200, 12);
cave(root, 470, 120, 1.3);
txt(root, 585, 160, "Island of the Cyclopes", "plate-land-name plate-land-name--small", { "text-anchor": "middle" });
rock(root, 96, 222, 20, 4);
rose(root, 1010, 556, 34);
} else {
// Open water, dressed the way 10 dresses its long crossings.
rhumbs(root, 190, 540);
rhumbs(root, 960, 150);
txt(root, 545, 172, "The open sea", "plate-sea-name", { "text-anchor": "middle" });
storm(root, 840, 232, 1.0);
rock(root, 420, 540, 18, 9);
rock(root, 446, 556, 9, 13);
rock(root, 880, 470, 22, 3);
rose(root, 190, 540, 46);
}
void L;
}
function portBlock(root: SVGElement, p: InsetPort, timeY: number) {
const sel = state.motifId;
const playing = motifsPlaying(p.song);
const g = s("g", { class: "port" + (sel && !playing.has(sel) ? " is-dim" : "") + (state.songId === p.song.id || state.sailPick === p.song.id ? " is-open" : ""), "data-song": p.song.id }, root);
s("rect", { x: p.x - 66, y: p.y - 96, width: 132, height: 140 + (timeY - 34), class: "port-hit" }, g);
s("circle", { cx: p.x, cy: p.y, r: 9, class: "port-ring" }, g);
s("circle", { cx: p.x, cy: p.y, r: 3, class: "port-core" }, g);
const lines = wrap(p.song.title, 15);
txt(g, p.x, p.y - 22 - (lines.length - 1) * 22 - 25, `Track ${p.song.track}`, "port-track", { "text-anchor": "middle" });
lines.forEach((ln, k) => txt(g, p.x, p.y - 22 - (lines.length - 1 - k) * 22, ln, "port-title", { "text-anchor": "middle" }));
wrap(p.song.storyTime.label, 20).forEach((ln, k) => txt(g, p.x, p.y + timeY + k * 18, ln, "port-time", { "text-anchor": "middle" }));
activate(g, `${p.song.title}, track ${p.song.track}. ${p.song.storyTime.label}`, () => pickSong(p.song.id));
return g;
}
const timeLines = (p: InsetPort) => wrap(p.song.storyTime.label, 20).length;
function drawInset(root: SVGElement, sagaId: string) {
const place = placeOf(sagaId);
const L = insetLayout(sagaId);
const pIdx = places.indexOf(place);
vellum(root);
plateDressing(root, sagaId, L);
const sel = state.motifId;
const earlier = new Set(songs.filter((x) => places.indexOf(placeOf(x.sagaId)) < pIdx).flatMap((x) => [...motifsPlaying(x)]));
const later = new Set(songs.filter((x) => places.indexOf(placeOf(x.sagaId)) > pIdx).flatMap((x) => [...motifsPlaying(x)]));
// The course: 10's red dashed rhumb, running in from one plate edge and out the other.
s("path", { d: smoothOpen(L.route), class: "course" }, root);
if (sagaId === "ocean" && L.lead) {
const id = `elapsed${++uid}`;
const p0 = L.ports[0]!;
s("path", { id, d: `M60,${L.lead.y - 16} Q${(60 + p0.x) / 2},${L.lead.y - 30} ${p0.x - 90},${p0.y - 14}`, fill: "none" }, root);
const t = s("text", { class: "route-elapsed" }, root);
const tp = s("textPath", { href: `#${id}`, startOffset: "50%", "text-anchor": "middle" }, t);
tp.textContent = "two years at sea";
}
const marks = s("g", { class: `marks marks--${state.mode}` + (sel ? " has-sel" : "") }, root);
if (state.mode === "a") drawThreads(marks, L, earlier, later);
if (state.mode === "b") drawPennants(marks, L, earlier, later);
// Ports, then ink notes on top (C)
for (const p of L.ports) portBlock(root, p, 34);
if (state.mode === "c" && sel) drawInk(root, L);
if (sel && !L.motifs.some((m) => m.id === sel)) {
txt(root, W / 2, SCALE_Y - 58, `${motifName(sel)} is silent on this plate`, "silent-note", { "text-anchor": "middle" });
}
// Edge notes: "continued on plate…", above the course where it meets the edge.
if (L.lead) {
const prev = places[pIdx - 1]!;
const g = s("g", { class: "plate-link" }, root);
const y = L.lead.y - 46;
s("rect", { x: 20, y: y - 18, width: 170, height: 60, class: "port-hit" }, g);
txt(g, 30, y, `From plate ${prev.roman}`, "edge-note");
txt(g, 30, y + 19, `‹ ${prev.place}`, "edge-note edge-place");
activate(g, `Back to plate ${prev.roman}: ${prev.place}`, () => stepInset(-1));
}
if (L.tail) {
const next = places[pIdx + 1]!;
const g = s("g", { class: "plate-link" }, root);
const y = L.tail.y - 46;
s("rect", { x: W - 200, y: y - 18, width: 180, height: 60, class: "port-hit" }, g);
txt(g, W - 30, y, `Continued on plate ${next.roman}`, "edge-note", { "text-anchor": "end" });
txt(g, W - 30, y + 19, `${next.place} ›`, "edge-note edge-place", { "text-anchor": "end" });
activate(g, `Continue to plate ${next.roman}: ${next.place}`, () => stepInset(1));
}
// 10's nautical scale bar, here a scale of story time under the ports.
const sc = s("g", { class: "scale", transform: `translate(0 ${SCALE_Y})`, "pointer-events": "none" }, root);
const xs = L.ports.map((p) => p.x);
const x0 = xs.length > 1 ? xs[0]! : xs[0]! - 70, x1 = xs.length > 1 ? xs[xs.length - 1]! : xs[0]! + 70;
txt(sc, x0, 30, "Scale of story time", "scale-cap");
const cells = Math.max(2, Math.round((x1 - x0) / 60));
for (let i = 0; i < cells; i++) s("rect", { x: x0 + ((x1 - x0) * i) / cells, y: -3, width: (x1 - x0) / cells, height: 6, class: i % 2 ? "scale-w" : "scale-b" }, sc);
let lastYear = -1;
L.ports.forEach((p, i) => {
const y = p.song.storyTime.yearsAfterTroy;
if (y !== lastYear || i === 0) {
s("line", { x1: p.x, x2: p.x, y1: -10, y2: 10, class: "scale-tick" }, sc);
txt(sc, p.x + (i === 0 ? 0 : 0), -16, y === 0 ? "The year Troy falls" : `Year ${y} after Troy`, "scale-year", { "text-anchor": L.ports.length === 1 ? "middle" : i === 0 ? "start" : "middle" });
}
lastYear = y;
});
L.gaps.forEach((gap, i) => {
if (gap <= 0) return;
const a = L.ports[i]!, b = L.ports[i + 1]!;
txt(sc, (a.x + b.x) / 2, 26, gap === 1 ? "a year passes" : `${gap} years pass`, "time-note", { "text-anchor": "middle" });
});
// Locator: where this plate sits on the general chart.
const loc = s("g", { class: "locator", transform: "translate(870 30)" }, root);
s("rect", { x: -5, y: -5, width: 220, height: 148, class: "loc-frame" }, loc);
const inner = s("svg", { x: 0, y: 0, width: 210, height: 134, viewBox: `0 0 ${W} ${H}` }, loc);
drawGeneral(inner, true);
const f = place.focus;
s("rect", { x: f.x, y: f.y, width: f.w, height: f.h, class: "loc-focus" }, inner);
txt(loc, 0, 160, "Key to the general chart", "loc-cap");
activate(loc, "Back to the general chart of the Aegean", () => closeInset());
if (overlay) {
const at = L.ports.find((p) => p.song.id === state.shipAt);
if (at) {
const lines = wrap(at.song.title, 15).length;
ship(root, at.x - 34, at.y + 1, `You are here: ${at.song.title}`, -22 - (lines - 1) * 22 - 25 - 24 + 1);
}
}
}
/* ---- A. Threads under the sea: soft wavy threads beneath the course, knots where the theme plays. */
function drawThreads(root: SVGElement, L: InsetLayout, earlier: Set<string>, later: Set<string>) {
const sel = state.motifId;
const top = Math.max(...L.ports.map((p) => p.y + 34 + (timeLines(p) - 1) * 18)) + THREAD_GAP;
L.motifs.forEach((m, k) => {
const y = top + k * THREAD_STEP;
const c = inkOf(m.id);
const hits = L.ports.filter((p) => motifsPlaying(p.song).has(m.id));
const g = s("g", { class: "thread" + (sel === m.id ? " is-on" : sel ? " is-dim" : ""), style: `--c:${c}` }, root);
const a = earlier.has(m.id) && L.lead ? THREAD_X0 : hits[0]!.x - (hits.length === 1 ? 26 : 0);
const b = later.has(m.id) && L.tail ? W - 34 : hits[hits.length - 1]!.x + (hits.length === 1 ? 26 : 0);
const wav = (x: number) => y + Math.sin(x / 44 + k * 1.7) * 2.2;
let d = "";
for (let x = a; x < b; x += 8) d += `${d ? "L" : "M"}${x.toFixed(1)},${wav(x).toFixed(1)}`;
d += `L${b.toFixed(1)},${wav(b).toFixed(1)}`;
s("path", { d, class: "thread__line" }, g);
txt(g, THREAD_X0 - 12, y + 4, m.name, "thread__name", { "text-anchor": "end" });
for (const p of hits) {
s("circle", { cx: p.x, cy: wav(p.x), r: 4.5, class: "thread__knot" }, g);
if (sel === m.id) txt(g, p.x + 10, wav(p.x) - 7, partsWith(p.song, m.id).join(", "), "thread__part");
}
});
}
/* ---- B. Pennants at ports: bunting under each port; a picked theme draws one sea current. */
function drawPennants(root: SVGElement, L: InsetLayout, earlier: Set<string>, later: Set<string>) {
const sel = state.motifId;
const order = L.motifs.map((m) => m.id);
const flagY = (p: InsetPort) => p.y + 34 + (timeLines(p) - 1) * 18 + 20;
if (sel && order.includes(sel)) {
// One current, running beneath the bunting: tethered up to each port that flies the theme,
// sagging between them, and leaving the plate edge when the theme plays on other plates.
const hits = L.ports.filter((p) => motifsPlaying(p.song).has(sel));
const Y = Math.max(...L.ports.map(flagY)) + 46;
const pts: Pt[] = [];
const from = earlier.has(sel) && L.lead, to = later.has(sel) && L.tail;
if (from) pts.push({ x: 24, y: Y - 8 });
hits.forEach((p) => {
const prev = pts[pts.length - 1];
if (prev) pts.push({ x: (prev.x + p.x) / 2, y: Y + 16 });
pts.push({ x: p.x, y: Y });
});
if (to) {
const lx = pts[pts.length - 1]!.x;
pts.push({ x: (lx + W - 24) / 2, y: Y + 16 }, { x: W - 24, y: Y - 8 });
}
const c = inkOf(sel);
const g = s("g", { class: "current", style: `--c:${c}` }, root);
for (const p of hits) s("line", { x1: p.x, x2: p.x, y1: flagY(p) + 24, y2: Y - 4, class: "current__tether" }, g);
if (pts.length > 1) {
const d = smoothOpen(pts);
s("path", { d, class: "current__wide" }, g);
s("path", { d, class: "current__line" }, g);
for (let i = 0; i < pts.length - 1; i += 1) {
const a = pts[i]!, b = pts[i + 1]!;
if (b.y <= a.y || b.x - a.x < 50) continue; // one chevron per sag, on its downstream half
s("path", { d: "M-5,-4 L1,0 L-5,4", class: "current__chev", transform: `translate(${b.x} ${b.y})` }, g);
}
} else {
s("path", { d: `M${hits[0]!.x - 40},${Y + 6} Q${hits[0]!.x},${Y - 6} ${hits[0]!.x + 40},${Y + 6}`, class: "current__line" }, g);
}
if (from) txt(g, 30, Y + 30, "from earlier plates", "current__note");
if (to) txt(g, W - 30, Y + 30, "on to later plates", "current__note", { "text-anchor": "end" });
}
for (const p of L.ports) {
const ids = order.filter((id) => motifsPlaying(p.song).has(id) && (!sel || sel === id));
if (!ids.length) continue;
const big = !!sel;
const fw = big ? 16 : 12, fh = big ? 22 : 16, gap = 3;
const w = ids.length * (fw + gap) - gap;
const y = flagY(p);
const g = s("g", { class: "bunting" }, root);
s("path", { d: `M${p.x - w / 2 - 8},${y - 3} Q${p.x},${y + 4} ${p.x + w / 2 + 8},${y - 3}`, class: "bunting__line" }, g);
ids.forEach((id, i) => {
const x = p.x - w / 2 + i * (fw + gap);
const sag = 3 * (1 - Math.pow((x + fw / 2 - p.x) / (w / 2 + 8), 2));
const f = s("path", { d: `M${x},${y + sag - 1} L${x + fw},${y + sag - 1} L${x + fw / 2 + 1},${y + sag + fh} Z`, class: "pennant", fill: inkOf(id) }, g);
const t = s("title", {}, f);
t.textContent = `${motifName(id)}: ${partsWith(p.song, id).join(", ")}`;
});
}
}
/* ---- C. Ink only: nothing until a theme is picked; then ringed ports and a navigator's note. */
function drawInk(root: SVGElement, L: InsetLayout) {
const sel = state.motifId!;
const c = inkOf(sel);
const g = s("g", { class: "ink", style: `--c:${c}` }, root);
L.ports.forEach((p, i) => {
if (!motifsPlaying(p.song).has(sel)) return;
s("ellipse", { cx: p.x, cy: p.y, rx: 17, ry: 15, transform: `rotate(${-12 + i * 7} ${p.x} ${p.y})`, class: "ink__ring" }, g);
s("path", { d: `M${p.x - 19},${p.y + 4} A19 17 0 1 1 ${p.x - 8},${p.y + 17}`, transform: `rotate(${20 + i * 23} ${p.x} ${p.y})`, class: "ink__ring ink__ring--thin" }, g);
const y = p.y + 34 + (timeLines(p) - 1) * 18 + 30;
const note = partsWith(p.song, sel).join(" & ");
txt(g, p.x, y, note, "ink__note", { "text-anchor": "middle", transform: `rotate(-3 ${p.x} ${y})` });
s("path", { d: `M${p.x - note.length * 3.6},${y + 6} q${note.length * 1.8},4 ${note.length * 3.6},0 t${note.length * 3.6},0`, class: "ink__flourish" }, g);
});
}
/* ------------------------------------------------------------------ html chrome */
let pop: HTMLElement | null = null;
function cartouche() {
const c = h("div", "cartouche", undefined, plate);
if (!state.sagaId) {
h("p", "c-kicker", "General chart", c);
h("h2", "c-title", "The voyage from Troy", c);
h("p", "c-line", `${songs.length} songs across ${places.length} sagas, Year 0 to Year ${Math.max(...songs.map((x) => x.storyTime.yearsAfterTroy))}.`, c);
h("p", "c-line c-hint", "Choose an island, or a song by name.", c);
} else {
const p = placeOf(state.sagaId);
const list = songsIn(state.sagaId);
h("p", "c-kicker", `Plate ${p.roman}`, c);
h("h2", "c-title", p.place, c);
h("p", "c-line", `${sagaById(p.sagaId).name}, tracks ${list[0]!.track} to ${list[list.length - 1]!.track}`.replace(/tracks (\d+) to \1$/, "track $1"), c);
h("p", "c-line", yearSpan(list), c);
}
}
function songSlip(song: Song) {
const sheet = h("section", "slip", undefined, plate);
sheet.setAttribute("aria-label", `Song: ${song.title}`);
const close = h("button", "slip-close", "Close song", sheet);
close.type = "button";
close.addEventListener("click", () => { state.songId = null; render(); });
const a = h("div", "slip-col slip-id", undefined, sheet);
h("p", "slip-track", `Track ${song.track}, ${sagaById(song.sagaId).name}`, a);
h("h3", "slip-title", song.title, a);
h("p", "slip-time", `${song.storyTime.label}. Year ${song.storyTime.yearsAfterTroy} after Troy.`, a);
if (song.odyssey) {
const l = h("a", "slip-odyssey", `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`, a) as HTMLAnchorElement;
l.href = song.odyssey.url; l.target = "_blank"; l.rel = "noopener noreferrer";
} else h("p", "slip-odyssey is-none", "No counterpart in Homer's Odyssey", a);
const b = h("div", "slip-col slip-parts", undefined, sheet);
h("h4", "slip-h", "Parts", b);
const ol = h("ol", "parts", undefined, b);
for (const part of song.parts) {
const li = h("li", "", undefined, ol);
h("span", "part-name", part.name, li);
h("span", "part-singers", part.singers.join(", "), li);
const ticks = h("span", "part-motifs", undefined, li);
for (const id of part.motifIds) {
const t = h("i", "tick", undefined, ticks);
t.style.background = inkOf(id);
t.title = motifName(id);
}
}
const c = h("div", "slip-col slip-media", undefined, sheet);
h("h4", "slip-h", "Themes in this song", c);
const chips = h("div", "chips", undefined, c);
for (const m of motifsInSong(song)) {
const chip = h("button", "chip" + (state.motifId === m.id ? " is-on" : ""), m.name, chips);
chip.type = "button";
chip.style.setProperty("--c", inkOf(m.id));
chip.addEventListener("click", () => toggleMotif(m.id));
}
const media = h("div", "slip-media-row", undefined, c);
media.append(createAudioSlot(song), createVideoCards(song));
}
function sailPopover(song: Song) {
const g = svg.querySelector(`.port[data-song="${song.id}"] .port-ring`) as SVGGraphicsElement | null;
if (!g) return;
const box = g.getBoundingClientRect();
const host = plate.getBoundingClientRect();
pop = h("div", "sail-pop", undefined, plate);
pop.style.left = `${box.left + box.width / 2 - host.left}px`;
pop.style.top = `${box.top - host.top + 34}px`;
h("p", "sp-title", song.title, pop);
h("p", "sp-when", song.storyTime.label, pop);
if (song.id === state.shipAt) {
h("p", "sp-line", "The ship is anchored here.", pop);
} else {
const btn = h("button", "sp-sail", "Sail here", pop);
btn.type = "button";
btn.addEventListener("click", () => {
state.shipAt = song.id;
state.sailPick = null;
state.note = `Course set for ${song.title}. In the 3D voyage the ship sails there now.`;
render();
});
}
const open = h("button", "sp-open", "Open song", pop);
open.type = "button";
open.addEventListener("click", () => { state.sailPick = null; state.songId = song.id; render(); });
}
function header() {
head.replaceChildren();
const brand = h("div", "brand", undefined, head);
h("h1", "brand-title", overlay ? "Captain's table" : "The voyage, in plates", brand);
h("p", "brand-sub", overlay ? "The chart, laid out over the 3D voyage." : "A fan's vellum atlas of EPIC: The Musical.", brand);
const crumbs = h("nav", "crumbs", undefined, head);
crumbs.setAttribute("aria-label", "Breadcrumb");
const root = h(state.sagaId ? "button" : "span", "crumb", "Aegean", crumbs);
if (state.sagaId) { (root as HTMLButtonElement).type = "button"; root.addEventListener("click", () => closeInset()); }
else root.setAttribute("aria-current", "page");
if (state.sagaId) {
h("span", "crumb-sep", "›", crumbs).setAttribute("aria-hidden", "true");
const here = h("span", "crumb", placeOf(state.sagaId).place, crumbs);
here.setAttribute("aria-current", "page");
}
const nav = h("div", "plate-nav", undefined, head);
if (state.sagaId) {
const i = places.findIndex((p) => p.sagaId === state.sagaId);
const prev = places[i - 1], next = places[i + 1];
const pb = h("button", "pn", prev ? `‹ ${prev.roman}. ${prev.place}` : "‹", nav);
pb.type = "button"; pb.disabled = !prev; pb.addEventListener("click", () => stepInset(-1));
pb.setAttribute("aria-label", prev ? `Previous plate: ${prev.place}` : "No previous plate");
const nb = h("button", "pn", next ? `${next.roman}. ${next.place} ›` : "›", nav);
nb.type = "button"; nb.disabled = !next; nb.addEventListener("click", () => stepInset(1));
nb.setAttribute("aria-label", next ? `Next plate: ${next.place}` : "No next plate");
if (!overlay) h("p", "keys", "← → turn plates · Esc returns to the Aegean", nav);
} else if (!overlay) {
h("p", "keys", "Choose an island or a song, or press → to open plate I.", nav);
}
if (overlay) {
const here = songs.find((x) => x.id === state.shipAt)!;
const tag = h("p", "here-tag", undefined, head);
h("span", "here-k", "You are here", tag);
h("span", "here-v", here.title, tag);
}
}
function themes() {
index.replaceChildren();
const inSaga = state.sagaId ? songsIn(state.sagaId) : null;
if (state.sagaId) {
const box = h("div", "mode", undefined, index);
h("p", "mode-label", "Themes on the plate", box);
const seg = h("div", "mode-seg", undefined, box);
seg.setAttribute("role", "radiogroup");
seg.setAttribute("aria-label", "How themes are drawn on the plate");
for (const m of MODES) {
const b = h("button", "mode-btn" + (state.mode === m.id ? " is-on" : ""), undefined, seg);
b.type = "button";
b.setAttribute("role", "radio");
b.setAttribute("aria-checked", String(state.mode === m.id));
h("span", "mode-letter", m.letter, b);
h("span", "mode-name", m.name, b);
b.addEventListener("click", () => { state.mode = m.id; render(); });
}
}
h("h2", "th-title", "Themes", index);
const hint = !state.sagaId
? "Pick one to light the islands where it plays."
: state.mode === "c" && !state.motifId
? "Pick a theme to ink its ports on this plate."
: "Songs on this plate / in the whole voyage.";
h("p", "th-sub" + (state.mode === "c" && state.sagaId && !state.motifId ? " is-cue" : ""), hint, index);
for (const kind of ["motif", "character"] as const) {
h("h3", "th-group", kind === "motif" ? "Musical motifs" : "Character themes", index);
const ul = h("ul", "th-list", undefined, index);
for (const m of motifs.filter((x) => x.kind === kind)) {
const all = songsWithMotif(m.id);
const here = inSaga ? all.filter((x) => x.sagaId === state.sagaId).length : null;
const li = h("li", "", undefined, ul);
const b = h("button", "th-row" + (state.motifId === m.id ? " is-on" : "") + (here === 0 ? " is-absent" : ""), undefined, li);
b.type = "button";
b.setAttribute("aria-pressed", String(state.motifId === m.id));
b.style.setProperty("--c", inkOf(m.id));
h("span", "th-swatch", undefined, b);
h("span", "th-name", m.name, b);
h("span", "th-count", here === null ? String(all.length) : `${here} / ${all.length}`, b);
b.addEventListener("click", () => toggleMotif(m.id));
}
}
if (overlay) return;
const key = h("div", "th-key", undefined, index);
h("h3", "th-group", "Key", key);
const rows: [string, string][] = [["k-course", "The ship's course"], ["k-port", "Port: one song"]];
if (!state.sagaId) rows.push(["k-halo", "Island where a picked theme plays"]);
else if (state.mode === "a") rows.push(["k-thread", "Thread: a theme, knotted where it plays"]);
else if (state.mode === "b") rows.push(["k-pennant", "Pennant: a theme flown at this port"], ["k-current", "Current: links a picked theme's ports"]);
else rows.push(["k-ink", "Ink ring: a picked theme plays here"]);
for (const [cls, label] of rows) {
const r = h("p", "key-row", undefined, key);
h("i", `key-sym ${cls}`, undefined, r);
h("span", "", label, r);
}
}
/* ------------------------------------------------------------------ render */
function render(anim?: "in" | "out" | "next" | "prev") {
header();
themes();
plate.replaceChildren();
pop = null;
svg = s("svg", { viewBox: `0 0 ${W} ${H}`, class: "chart", role: "img" }, plate);
svg.setAttribute("aria-label", state.sagaId ? `Plate ${placeOf(state.sagaId).roman}: ${placeOf(state.sagaId).place}` : "General chart of the voyage");
if (state.sagaId) drawInset(svg, state.sagaId);
else drawGeneral(svg);
frame(svg);
cartouche();
plate.classList.toggle("is-inset", !!state.sagaId);
plate.classList.toggle("has-slip", !!state.songId);
const song = songs.find((x) => x.id === state.songId);
if (song && state.sagaId) songSlip(song);
const pick = songs.find((x) => x.id === state.sailPick);
if (overlay && pick && state.sagaId === pick.sagaId) requestAnimationFrame(() => sailPopover(pick));
if (state.note) {
const n = h("p", "sail-note", state.note, plate);
n.setAttribute("role", "status");
}
syncUrl();
if (anim && !reduceMotion) {
plate.dataset.anim = anim;
plate.addEventListener("animationend", () => delete plate.dataset.anim, { once: true });
}
}
function syncUrl() {
const q = new URLSearchParams();
if (overlay) q.set("overlay", "1");
if (state.songId) q.set("song", state.songId);
else if (state.sagaId) q.set("saga", state.sagaId);
if (state.motifId) q.set("motif", state.motifId);
if (state.mode !== "a") q.set("motifs", state.mode);
const str = q.toString();
history.replaceState(null, "", str ? `?${str}` : location.pathname);
}
/* ------------------------------------------------------------------ navigation */
let busy = false;
type Box = { x: number; y: number; w: number; h: number };
function zoomViewBox(to: Box, from: Box, ms: number, done: () => void) {
if (reduceMotion) return done();
busy = true;
const t0 = performance.now();
const step = (t: number) => {
const k = Math.min(1, (t - t0) / ms);
const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
const lerp = (a: number, b: number) => a + (b - a) * e;
svg.setAttribute("viewBox", `${lerp(from.x, to.x)} ${lerp(from.y, to.y)} ${lerp(from.w, to.w)} ${lerp(from.h, to.h)}`);
if (k < 1) requestAnimationFrame(step);
else { busy = false; done(); }
};
requestAnimationFrame(step);
}
const FULL = { x: 0, y: 0, w: W, h: H };
function fitFocus(f: Box): Box {
const a = W / H;
let w = f.w, hh = f.h;
if (w / hh > a) hh = w / a; else w = hh * a;
return { x: f.x + f.w / 2 - w / 2, y: f.y + f.h / 2 - hh / 2, w, h: hh };
}
function openInset(sagaId: string, songId: string | null = null) {
if (busy) return;
state.note = "";
if (state.sagaId) { state.sagaId = sagaId; state.songId = songId; render("next"); return; }
plate.classList.add("is-zooming");
zoomViewBox(fitFocus(placeOf(sagaId).focus), FULL, 520, () => {
plate.classList.remove("is-zooming");
state.sagaId = sagaId;
state.songId = songId;
render("in");
});
}
function openSongFromChart(song: Song) {
if (overlay) { state.sailPick = song.id; openInset(song.sagaId); return; }
openInset(song.sagaId, song.id);
}
function closeInset() {
if (busy || !state.sagaId) return;
const from = fitFocus(placeOf(state.sagaId).focus);
state.sagaId = null; state.songId = null; state.sailPick = null; state.note = "";
render("out");
svg.setAttribute("viewBox", `${from.x} ${from.y} ${from.w} ${from.h}`);
zoomViewBox(FULL, from, 480, () => svg.setAttribute("viewBox", `0 0 ${W} ${H}`));
}
function stepInset(dir: 1 | -1) {
if (busy) return;
if (!state.sagaId) { if (dir === 1) openInset(places[0]!.sagaId); return; }
const i = places.findIndex((p) => p.sagaId === state.sagaId);
const next = places[i + dir];
if (!next) return;
state.sagaId = next.sagaId; state.songId = null; state.sailPick = null; state.note = "";
render(dir === 1 ? "next" : "prev");
}
function pickSong(id: string) {
if (overlay) { state.sailPick = state.sailPick === id ? null : id; state.songId = null; state.note = ""; render(); return; }
state.songId = state.songId === id ? null : id;
render();
}
function toggleMotif(id: string) {
state.motifId = state.motifId === id ? null : id;
render();
}
document.addEventListener("keydown", (e) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "AUDIO") return;
if (e.key === "ArrowRight") { e.preventDefault(); stepInset(1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); stepInset(-1); }
else if (e.key === "Escape") {
if (state.songId || state.sailPick) { state.songId = null; state.sailPick = null; render(); }
else closeInset();
}
});
plate.addEventListener("click", (e) => {
if (!state.sailPick) return;
if ((e.target as Element).closest(".sail-pop")) return;
state.sailPick = null; render();
});
render();
+423 -1
View File
@@ -1 +1,423 @@
body { margin: 0; --stub-12: 1; }
/* 12 · Vellum atlas: 11's atlas plates, drawn on 10's warm portolan vellum and wound on rods. */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/cormorant-garamond/latin-600-italic.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-600.css";
:root {
color-scheme: light;
--vellum: #e3d6b4;
--vellum-hi: #ece2c6;
--vellum-lo: #d6c69e;
--land: #c9b07a;
--iron: #2b2118;
--iron-soft: #5e4d3a;
--iron-faint: #8c7b62;
--rhumb-red: #a23a2a;
--rhumb-green: #3f6b5c;
--desk: #3a2f24;
--desk-hi: #4a3c2e;
--brass: #cbbd9b;
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--vellum-hi);
font-family: var(--sans);
background: var(--desk) radial-gradient(ellipse at 45% 40%, var(--desk-hi) 0%, var(--desk) 72%);
}
button { font: inherit; color: inherit; cursor: pointer; }
:focus-visible { outline: 2px solid #e8c77d; outline-offset: 2px; }
/* ------------------------------------------------------------- frame */
.atlas {
flex: 1;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
padding: 0 30px;
}
.atlas-head {
display: grid;
grid-template-columns: 330px 1fr auto;
align-items: center;
gap: 24px;
padding: 12px 0 12px;
}
.brand-title { margin: 0; font: 600 31px/1 var(--serif); letter-spacing: 0.01em; }
.brand-sub { margin: 5px 0 0; font-size: 12.5px; color: var(--brass); }
.crumbs { white-space: nowrap; display: flex; align-items: baseline; gap: 10px; font: italic 500 24px/1.1 var(--serif); color: var(--vellum-hi); }
.crumb { background: none; border: 0; padding: 0; }
button.crumb { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 5px; color: var(--brass); }
button.crumb:hover { color: #f3e2b4; }
.crumb-sep { opacity: 0.55; font-style: normal; }
.plate-nav { display: flex; align-items: center; gap: 10px; justify-content: flex-end; flex-wrap: wrap; max-width: 560px; }
.pn {
background: rgb(236 226 198 / 0.07);
border: 1px solid rgb(203 189 155 / 0.55);
border-radius: 3px;
padding: 5px 12px 6px;
font: 500 17px/1.1 var(--serif);
color: var(--vellum-hi);
}
.pn:hover:not(:disabled) { background: rgb(236 226 198 / 0.16); }
.pn:disabled { opacity: 0.3; cursor: default; }
.keys { margin: 0; flex-basis: 100%; text-align: right; font-size: 11.5px; color: var(--brass); opacity: 0.85; }
.atlas-body {
min-height: 0;
display: grid;
grid-template-columns: 1fr 256px;
gap: 30px;
padding-bottom: 4px;
}
.plate-wrap { min-height: 0; min-width: 0; display: flex; align-items: flex-start; justify-content: center; padding: 0 12px; }
.roll { position: relative; width: min(100%, calc((100vh - 150px) * 1100 / 700)); }
.plate {
position: relative;
width: 100%;
aspect-ratio: 1100 / 700;
background: var(--vellum);
color: var(--iron);
box-shadow: 0 20px 44px rgb(14 9 4 / 0.5), 0 2px 5px rgb(14 9 4 / 0.4);
overflow: hidden;
}
/* Paper wound on a rod at each edge (10's rollers), with the curl shading the sheet. */
.rod { position: absolute; top: -9px; bottom: -9px; width: 22px; border-radius: 11px; z-index: 4; pointer-events: none; }
.rod--west { left: -12px; background: linear-gradient(90deg, #5d4a30, #b49d71 35%, #efe4c8 55%, #a68e62 80%, #5d4a30); box-shadow: 10px 0 18px -6px rgb(43 33 24 / 0.45); }
.rod--east { right: -12px; background: linear-gradient(90deg, #5d4a30, #a68e62 20%, #efe4c8 45%, #b49d71 65%, #5d4a30); box-shadow: -10px 0 18px -6px rgb(43 33 24 / 0.45); }
.plate::before, .plate::after { content: ""; position: absolute; top: 0; bottom: 0; width: 40px; z-index: 3; pointer-events: none; }
.plate::before { left: 0; background: linear-gradient(90deg, rgb(93 74 48 / 0.32), transparent); }
.plate::after { right: 0; background: linear-gradient(270deg, rgb(93 74 48 / 0.32), transparent); }
.chart { display: block; width: 100%; height: 100%; }
/* ------------------------------------------------------------- chart ink (after 10) */
.sea { fill: var(--vellum); }
.grain { opacity: 0.5; mix-blend-mode: multiply; }
.rhumb { stroke-width: 0.8; opacity: 0.32; }
.rhumb--ink { stroke: var(--iron); opacity: 0.2; }
.rhumb--green { stroke: var(--rhumb-green); }
.rhumb--red { stroke: var(--rhumb-red); }
.wl-ink { fill: none; stroke: var(--iron-soft); stroke-dasharray: 3 5; stroke-linejoin: round; opacity: 0.75; }
.wl-paper { fill: none; stroke: var(--vellum); stroke-linejoin: round; }
.landmass { fill: var(--land); stroke: var(--iron); stroke-width: 1.5; stroke-linejoin: round; }
.coastwash { fill: none; stroke: var(--rhumb-green); stroke-width: 14; opacity: 0.26; }
.coast { fill: none; stroke: var(--iron); stroke-width: 1.6; stroke-linejoin: round; }
.towers rect { fill: var(--iron); }
.glyph path { fill: var(--iron); }
.glyph .hill { fill: none; stroke: var(--iron); stroke-width: 1.6; }
.hills path { fill: none; stroke: rgb(43 33 24 / 0.5); stroke-width: 1.2; }
.storm__arm { fill: none; stroke: var(--iron); stroke-width: 1.4; opacity: 0.75; }
.storm__rain { stroke: var(--iron-soft); stroke-width: 1; opacity: 0.6; }
.sea-hit { fill: transparent; }
.halo { fill: none; stroke-width: 20; opacity: 0.42; stroke-linejoin: round; }
.halo--sea { stroke-width: 16; opacity: 0.3; }
.rose__ring { fill: none; stroke: var(--iron); stroke-width: 1; }
.rose__ring--thin { stroke-width: 0.6; stroke-dasharray: 2 2; }
.rose__point { fill: var(--rhumb-red); stroke: var(--iron); stroke-width: 0.6; }
.rose__point--minor { fill: var(--rhumb-green); }
.rose__point--shade { fill: var(--vellum-hi); }
.rose__n { font: 600 15px var(--serif); fill: var(--iron); }
.course { fill: none; stroke: var(--rhumb-red); stroke-width: 2.3; stroke-dasharray: 9 5; stroke-linecap: round; }
.time-note { font: italic 500 18px var(--serif); fill: var(--rhumb-red); letter-spacing: 0.04em; }
.route-elapsed { font: italic 500 21px var(--serif); fill: var(--rhumb-red); letter-spacing: 0.1em; }
.nl-outer { fill: none; stroke: var(--iron); stroke-width: 1.4; }
.nl-inner { fill: none; stroke: var(--iron); stroke-width: 0.6; }
.is-dim { opacity: 0.3; }
/* general chart */
.isle { cursor: pointer; outline: none; }
.isle:hover .coast, .isle:focus-visible .coast { stroke: var(--rhumb-red); stroke-width: 2.6; }
.isle:hover .place-name, .isle:focus-visible .place-name { fill: var(--rhumb-red); }
.isle:hover .storm__arm, .isle:focus-visible .storm__arm { stroke: var(--rhumb-red); }
.ov-port { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1.8; }
.ov-num { font: 600 11px var(--sans); fill: var(--iron-soft); paint-order: stroke; stroke: var(--vellum); stroke-width: 3px; }
.place-name { font: italic 600 34px var(--serif); fill: var(--iron); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; stroke-opacity: 0.6; }
.place-sub { font: 500 12.5px var(--sans); fill: var(--iron); }
.place-meta { font: 400 12.5px var(--sans); fill: var(--iron-soft); }
.track-row { cursor: pointer; outline: none; }
.track-hit { fill: transparent; }
.track-text { font: 600 17.5px var(--serif); fill: var(--iron); }
.track-n { font: 500 11px var(--sans); fill: var(--iron-soft); }
.track-row:hover .track-title, .track-row:focus-visible .track-title { fill: var(--rhumb-red); text-decoration: underline; }
.track-row:focus-visible .track-hit { stroke: var(--rhumb-red); stroke-width: 1.4; }
.track-row.is-on .track-title { fill: var(--c); }
.motif-badge rect { stroke: none; }
.motif-badge.is-silent rect { stroke: var(--iron-soft); stroke-dasharray: 3 3; }
.badge-text { font: 600 12px var(--sans); fill: var(--vellum-hi); }
.motif-badge.is-silent .badge-text { fill: var(--iron-soft); font-weight: 500; }
.ship { pointer-events: none; }
.ship__hull { fill: var(--iron); }
.ship__mast { stroke: var(--iron); stroke-width: 1.6; }
.ship__sail { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1.3; }
.ship__pennant { fill: var(--rhumb-red); }
.ship__wake { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 4 3; }
.ship-tag { fill: var(--iron); }
.ship-label { font: 500 12px var(--sans); fill: var(--vellum-hi); }
/* inset plates */
.plate-land-name { font: italic 500 40px var(--serif); letter-spacing: 0.14em; fill: rgb(43 33 24 / 0.72); }
.plate-land-name--small { font-size: 27px; letter-spacing: 0.1em; }
.plate-sea-name { font: italic 500 32px var(--serif); letter-spacing: 0.34em; fill: var(--rhumb-green); opacity: 0.8; }
.port { cursor: pointer; outline: none; }
.port-hit { fill: transparent; }
.port-ring { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 2.2; }
.port-core { fill: var(--iron); }
.port-title, .port-time, .port-track { paint-order: stroke; stroke: var(--vellum); stroke-linejoin: round; }
.port-title { font: 600 22px var(--serif); fill: var(--iron); stroke-width: 5px; }
.port-track { font: 500 11px var(--sans); fill: var(--iron-soft); letter-spacing: 0.03em; stroke-width: 4px; }
.port-time { font: italic 500 16px var(--serif); fill: var(--iron-soft); stroke-width: 4px; }
.port:hover .port-title, .port:focus-visible .port-title { fill: var(--rhumb-red); }
.port:hover .port-ring, .port:focus-visible .port-ring, .port.is-open .port-ring { stroke: var(--rhumb-red); stroke-width: 3.4; }
.port.is-open .port-core { fill: var(--rhumb-red); }
.port.is-dim { opacity: 1; }
.port.is-dim .port-title { fill: var(--iron-faint); }
.port.is-dim .port-time, .port.is-dim .port-track { opacity: 0.6; }
.port.is-dim .port-ring { stroke: var(--iron-faint); }
.silent-note { font: italic 500 19px var(--serif); fill: var(--iron-soft); }
.plate-link { cursor: pointer; outline: none; }
.edge-note { font: italic 500 16px var(--serif); fill: var(--iron-soft); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.edge-place { font-weight: 600; fill: var(--iron); }
.plate-link:hover .edge-note, .plate-link:focus-visible .edge-note { fill: var(--rhumb-red); text-decoration: underline; }
.scale-b { fill: var(--iron); }
.scale-w { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 0.8; }
.scale-tick { stroke: var(--iron); stroke-width: 1.4; }
.scale-year { font: italic 600 16px var(--serif); fill: var(--iron); }
.scale-cap { font: 500 11px var(--sans); fill: var(--iron-soft); letter-spacing: 0.04em; }
.locator { cursor: pointer; outline: none; }
.loc-frame { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1; }
.locator:hover .loc-frame, .locator:focus-visible .loc-frame { stroke: var(--rhumb-red); stroke-width: 2; }
.loc-focus { fill: rgb(162 58 42 / 0.08); stroke: var(--rhumb-red); stroke-width: 12; }
.loc-cap { font: 500 10.5px var(--sans); fill: var(--iron-soft); paint-order: stroke; stroke: var(--vellum); stroke-width: 3px; }
/* A · threads under the sea */
.thread__line { fill: none; stroke: var(--c); stroke-width: 2.2; stroke-linecap: round; opacity: 0.8; }
.thread__knot { fill: var(--vellum-hi); stroke: var(--c); stroke-width: 2.2; }
.thread__name { font: 500 11px var(--sans); fill: var(--c); }
.thread__part { font: 600 11px var(--sans); fill: var(--c); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.thread.is-on .thread__line { stroke-width: 3.6; opacity: 1; }
.thread.is-on .thread__knot { fill: var(--c); r: 6; }
.thread.is-on .thread__name { font-weight: 600; }
.thread.is-dim { opacity: 0.2; }
/* B · pennants at ports */
.bunting__line { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; }
.pennant { stroke: var(--iron); stroke-width: 0.6; stroke-linejoin: round; }
.current__wide { fill: none; stroke: var(--c); stroke-width: 9; opacity: 0.1; stroke-linecap: round; }
.current__line { fill: none; stroke: var(--c); stroke-width: 1.6; opacity: 0.6; stroke-dasharray: 16 6 2 6; stroke-linecap: round; }
.current__tether { stroke: var(--c); stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
.current__chev { fill: none; stroke: var(--c); stroke-width: 1.6; opacity: 0.7; }
.current__note { font: italic 500 15px var(--serif); fill: var(--c); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
/* C · ink only */
.ink__ring { fill: none; stroke: var(--c); stroke-width: 2; }
.ink__ring--thin { stroke-width: 1.1; opacity: 0.75; }
.ink__note { font: italic 600 19px var(--serif); fill: var(--c); paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.ink__flourish { fill: none; stroke: var(--c); stroke-width: 1; opacity: 0.6; }
/* ------------------------------------------------------------- cartouche */
.cartouche {
position: absolute;
z-index: 2;
left: 3.2%;
top: 4.2%;
max-width: 290px;
padding: 10px 18px 12px;
background: var(--vellum-hi);
border: 1px solid var(--iron);
outline: 1px solid var(--iron);
outline-offset: -6px;
box-shadow: 0 2px 0 rgb(43 33 24 / 0.08);
pointer-events: none;
}
.plate:not(.is-inset) .cartouche { left: auto; right: 3.2%; max-width: 320px; }
.c-kicker { margin: 0; font: italic 500 15px var(--serif); color: var(--iron-soft); }
.c-title { margin: 0 0 4px; font: 600 29px/1.05 var(--serif); }
.c-line { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--iron); }
.c-hint { margin-top: 4px; color: var(--rhumb-red); font-weight: 500; }
/* ------------------------------------------------------------- song slip (compact, so the island stays in view) */
.slip {
position: absolute;
z-index: 2;
left: 14px; right: 14px; bottom: 14px;
height: 29%;
display: grid;
grid-template-columns: 0.95fr 1fr 1.15fr;
gap: 26px;
padding: 16px 26px 14px;
background: var(--vellum-hi);
border-top: 4px double var(--iron);
box-shadow: 0 -14px 26px rgb(43 33 24 / 0.16);
animation: slip-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
overflow: hidden;
}
.slip-close {
position: absolute; right: 18px; top: 10px;
background: none; border: 1px solid var(--iron-soft); border-radius: 3px;
padding: 3px 10px; font-size: 12px; color: var(--iron);
}
.slip-close:hover { border-color: var(--rhumb-red); color: var(--rhumb-red); }
.slip-col { min-width: 0; overflow: auto; }
.slip-track { margin: 0; font-size: 12px; color: var(--iron-soft); }
.slip-title { margin: 2px 0 6px; font: 600 34px/1.02 var(--serif); }
.slip-time { margin: 0 0 8px; font: italic 500 18px/1.3 var(--serif); color: var(--iron-soft); }
.slip-odyssey { font-size: 13px; color: var(--rhumb-red); }
.slip-odyssey.is-none { margin: 0; color: var(--iron-soft); }
.slip-h { margin: 2px 0 6px; font: italic 600 18px var(--serif); }
.parts { list-style: none; margin: 0; padding: 0; border-top: 1px solid #b9a47a; }
.parts li {
display: grid; grid-template-columns: 70px 1fr auto; gap: 10px; align-items: baseline;
padding: 6px 0; border-bottom: 1px solid #b9a47a; font-size: 13px;
}
.part-name { font: 600 16px var(--serif); }
.part-motifs { display: flex; gap: 3px; }
.tick { display: inline-block; width: 14px; height: 5px; border-radius: 1px; }
.slip-media { padding-right: 96px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.chip {
border: 1.5px solid var(--c); background: transparent; border-radius: 999px;
padding: 3px 10px; font-size: 12px; color: var(--iron);
}
.chip:hover { background: color-mix(in srgb, var(--c) 14%, transparent); }
.chip.is-on { background: var(--c); color: var(--vellum-hi); }
.slip-media-row { display: grid; gap: 8px; }
.slip .audio-slot { font-size: 12.5px; padding: 7px 10px; border-radius: 3px; color: var(--iron-soft); }
.slip .video-card { font-size: 12.5px; border-radius: 3px; padding: 5px 10px; color: var(--iron); }
/* ------------------------------------------------------------- themes slip */
.themes {
align-self: start;
background: var(--vellum-hi);
color: var(--iron);
padding: 16px 16px 14px;
box-shadow: 0 14px 30px rgb(14 9 4 / 0.42);
border-top: 4px double var(--iron);
}
.mode { margin: -2px -2px 12px; padding: 0 0 12px; border-bottom: 1px solid #b9a47a; }
.mode-label { margin: 0 0 7px; font: italic 600 17px var(--serif); }
.mode-seg { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--iron); border-radius: 3px; overflow: hidden; }
.mode-btn {
display: grid; justify-items: center; gap: 1px;
padding: 5px 2px 6px; border: 0; border-left: 1px solid var(--iron); background: none; color: var(--iron);
}
.mode-btn:first-child { border-left: 0; }
.mode-btn:hover { background: rgb(43 33 24 / 0.07); }
.mode-btn.is-on { background: var(--iron); color: var(--vellum-hi); }
.mode-letter { font: 600 17px/1 var(--serif); }
.mode-name { font-size: 11px; }
.th-title { margin: 0; font: 600 25px var(--serif); }
.th-sub { margin: 2px 0 8px; font-size: 12px; line-height: 1.4; color: var(--iron-soft); }
.th-sub.is-cue { color: var(--rhumb-red); font-weight: 500; }
.th-group { margin: 10px 0 3px; font: italic 600 16px var(--serif); }
.th-list { list-style: none; margin: 0; padding: 0; }
.th-row {
width: 100%; display: grid; grid-template-columns: 22px 1fr auto; gap: 9px; align-items: center;
background: none; border: 0; border-left: 3px solid transparent; padding: 4px 6px 4px 5px; text-align: left; font-size: 13.5px; color: var(--iron);
}
.th-row:hover { background: rgb(43 33 24 / 0.07); }
.th-row.is-on { background: color-mix(in srgb, var(--c) 16%, transparent); border-left-color: var(--c); font-weight: 600; }
.th-row.is-absent { opacity: 0.42; }
.th-swatch { height: 4px; border-radius: 2px; background: var(--c); }
.th-count { font-size: 12px; color: var(--iron-soft); font-variant-numeric: tabular-nums; }
.th-key { margin-top: 10px; border-top: 1px solid #b9a47a; padding-top: 2px; }
.key-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; font-size: 12px; color: var(--iron-soft); }
.key-sym { width: 24px; flex: none; display: inline-block; }
.k-course { border-top: 2px dashed var(--rhumb-red); }
.k-port { width: 14px; height: 14px; margin: 0 5px; border: 2.2px solid var(--iron); border-radius: 50%; background: var(--vellum); }
.k-halo { height: 10px; border-radius: 5px; background: rgb(147 101 11 / 0.4); }
.k-thread { height: 9px; background: radial-gradient(circle at 50% 50%, var(--vellum-hi) 2px, #93650b 2.5px, #93650b 4px, transparent 4.5px), linear-gradient(transparent 3.5px, #93650b 3.5px, #93650b 5.5px, transparent 5.5px); }
.k-pennant { height: 14px; width: 12px; margin: 0 6px; background: #2e5d8b; clip-path: polygon(0 0, 100% 0, 50% 100%); }
.k-current { border-top: 2px dashed rgb(46 93 139 / 0.7); }
.k-ink { width: 18px; height: 16px; margin: 0 3px; border: 2px solid #8e1e24; border-radius: 50%; transform: rotate(-10deg); }
/* ------------------------------------------------------------- transitions */
.plate.is-zooming .cartouche { opacity: 0; transition: opacity 0.2s; }
.plate[data-anim="in"] .chart { animation: plate-in 0.36s ease-out; }
.plate[data-anim="next"] .chart { animation: turn-next 0.34s ease-out; }
.plate[data-anim="prev"] .chart { animation: turn-prev 0.34s ease-out; }
@keyframes plate-in { from { opacity: 0.2; transform: scale(1.04); } to { opacity: 1; transform: none; } }
@keyframes turn-next { from { opacity: 0; transform: translateX(36px); } to { opacity: 1; transform: none; } }
@keyframes turn-prev { from { opacity: 0; transform: translateX(-36px); } to { opacity: 1; transform: none; } }
@keyframes slip-up { from { transform: translateY(40%); opacity: 0; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .slip, .chart { animation: none !important; } }
/* ------------------------------------------------------------- overlay: chart over the 3D sea */
.sail-pop {
position: absolute;
z-index: 5;
transform: translateX(-50%);
width: 200px;
padding: 10px 12px 12px;
background: var(--vellum-hi);
border: 1.5px solid var(--iron);
border-radius: 4px;
box-shadow: 0 10px 22px rgb(20 14 8 / 0.35);
text-align: center;
}
.sp-title { margin: 0; font: 600 19px var(--serif); }
.sp-when { margin: 0 0 8px; font: italic 500 14px var(--serif); color: var(--iron-soft); }
.sp-line { margin: 0 0 8px; font-size: 12px; color: var(--rhumb-green); }
.sp-sail { display: block; width: 100%; margin-bottom: 6px; padding: 7px 0; border: 0; border-radius: 3px; background: var(--rhumb-red); color: var(--vellum-hi); font-weight: 600; font-size: 13px; }
.sp-open { background: none; border: 0; font-size: 12px; text-decoration: underline; color: var(--iron); }
.sail-note {
position: absolute; z-index: 5; left: 50%; top: 22px; transform: translateX(-50%);
margin: 0; padding: 6px 14px; background: var(--iron); color: var(--vellum-hi); font-size: 12.5px; border-radius: 3px;
}
body.is-overlay {
justify-content: center;
background:
linear-gradient(rgb(6 16 24 / 0.55), rgb(6 16 24 / 0.55)),
repeating-linear-gradient(172deg, transparent 0 22px, rgb(160 205 220 / 0.07) 22px 24px),
radial-gradient(ellipse at 50% 120%, #2c6b7a 0%, #133447 45%, #071521 100%);
}
body.is-overlay::before {
content: "3D sea voyage, paused";
position: fixed; left: 26px; top: 18px;
font: 400 12px var(--sans); color: #8fb3c0;
}
body.is-overlay .atlas {
flex: none;
width: 1180px;
margin: auto auto 0;
padding: 4px 26px 20px;
border-radius: 6px;
background: var(--desk-hi);
border: 1px solid #8c7752;
box-shadow: 0 30px 80px rgb(0 0 0 / 0.6);
}
body.is-overlay .atlas-head { grid-template-columns: 230px 1fr auto auto; padding: 12px 0 10px; }
body.is-overlay .brand-title { font-size: 25px; }
body.is-overlay .atlas-body { grid-template-columns: 1fr 210px; gap: 26px; }
body.is-overlay .roll { width: 100%; }
body.is-overlay .themes { padding: 12px 12px 10px; }
body.is-overlay .th-row { font-size: 12.5px; padding-block: 3px; }
body.is-overlay .mode-name { font-size: 10px; }
.here-tag { margin: 0; display: flex; border: 1px solid var(--iron); border-radius: 3px; overflow: hidden; font-size: 12.5px; }
.here-k { padding: 5px 9px; background: var(--iron); color: var(--vellum-hi); }
.here-v { padding: 5px 10px; font: 600 16px/1.1 var(--serif); background: var(--vellum-hi); color: var(--iron); }
/* ------------------------------------------------------------- credits */
.fan-credits { color: #d8ccae; opacity: 0.9; padding: 0.6rem 1.5rem 0.75rem; font-size: 12.5px; position: relative; z-index: 6; }
body.is-overlay .fan-credits { margin-top: auto; color: #b9cfd8; }