From 89a30e5479ccc7df5a318352f80546e35bc26c42 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 22:58:08 +0100 Subject: [PATCH] wip(mockups): 12 vellum atlas paused at usage limit; pause report and notes Co-Authored-By: Claude Opus 5.5 --- docs/design/002-direction-round-notes.md | 12 + .../12-vellum-atlas-report.md | 63 ++ src/mockups/12-vellum-atlas/geo.ts | 260 ++++++ src/mockups/12-vellum-atlas/main.ts | 874 ++++++++++++++++++ src/mockups/12-vellum-atlas/style.css | 424 ++++++++- 5 files changed, 1632 insertions(+), 1 deletion(-) create mode 100644 docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md create mode 100644 src/mockups/12-vellum-atlas/geo.ts diff --git a/docs/design/002-direction-round-notes.md b/docs/design/002-direction-round-notes.md index 6eec698..8ce5573 100644 --- a/docs/design/002-direction-round-notes.md +++ b/docs/design/002-direction-round-notes.md @@ -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). diff --git a/docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md b/docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md new file mode 100644 index 0000000..ffe4a46 --- /dev/null +++ b/docs/design/002-sea-chart-spikes/12-vellum-atlas-report.md @@ -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 /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 /shots.sh "" ...` 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. diff --git a/src/mockups/12-vellum-atlas/geo.ts b/src/mockups/12-vellum-atlas/geo.ts new file mode 100644 index 0000000..a61e9a8 --- /dev/null +++ b/src/mockups/12-vellum-atlas/geo.ts @@ -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 = { + 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> = { + 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 { + 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; +} diff --git a/src/mockups/12-vellum-atlas/main.ts b/src/mockups/12-vellum-atlas/main.ts index 88ded7e..6e76267 100644 --- a/src/mockups/12-vellum-atlas/main.ts +++ b/src/mockups/12-vellum-atlas/main.ts @@ -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; +function s(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(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 = {}; + 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, later: Set) { + 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, later: Set) { + 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(); diff --git a/src/mockups/12-vellum-atlas/style.css b/src/mockups/12-vellum-atlas/style.css index 8319160..4f91a68 100644 --- a/src/mockups/12-vellum-atlas/style.css +++ b/src/mockups/12-vellum-atlas/style.css @@ -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; }