feat(mockups): 10 unrolling chart spike; unique stub css for 11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 02:31:50 +01:00
1 parent 42b51983c1
commit e880a69177
6 files changed
+1158 -61

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ or `AGENTS.md` Commands.
| `bun install --frozen-lockfile` | worked (no changes) |
| `bun test` | worked — 34 pass, 0 fail, 3 files |
| `bun test tests/data.test.ts` | worked — 10 pass |
| `bun test -t "registry has 7"` | worked — 1 pass |
| `bun test -t "registry has 11"` | worked — 1 pass |
| `bun run typecheck` | worked (`tsc --noEmit`, exit 0) |
| `bun run build` | worked — `dist/server.js` + hashed page assets |
| `bun run dev` (`PORT=3113`) | worked — `/` and all mockup routes 200, no-slash form 302 |
@@ -1,61 +1,56 @@
# 10-chart-scroll: progress report (stopped early at coordinator's request)
# 10-chart-scroll: Unrolling chart (final report)
## What's done
- Read the round 2 brief, AGENTS.md, DESIGN.md, `docs/design/002-direction-round-notes.md`, the shared contract (`src/data/*.ts`, `src/shared/*.ts`), mockup 01's `main.ts`, and the 01, 03 and 07 screenshots.
- Loaded the `frontend-design` skill.
- Wrote the design plan (below).
- **No code written yet.** `src/mockups/10-chart-scroll/` still holds the original stub, untouched. No server running, no screenshots taken.
Status: done. The spike is built and was checked in headless Chrome at 1440×900. Nothing is committed.
## Design plan (ready to build)
- **Palette:** portolan vellum sea `#e3d6b4`, land `#c9b07a` with a green coastal wash, iron-gall ink `#2b2118`, rhumb red `#a23a2a`, rhumb green `#3f6b5c`. Motif colours re-inked darker for vellum, as in 01's `INK` map, but with distinct values.
- **Type:** Cormorant Garamond for song titles and italic place names; Inter for small UI text.
- **Layout (1440×900):** a top row with a small title on the left and a thin minimap (about 56px) on the right. Below it, a fixed motif legend column on the left, its rows aligned to the thread rows, and the horizontal chart scroller on the right. Then a caption line ("Now passing: …"), then the credits footer.
- **Chart (SVG, all labels as SVG text, scale factor S: 1 by default, about 0.62 in the overlay):**
- Song x positions as in 01: STEP 220, YEAR 600, START 300, width about 4600.
- y 0–44: saga band with brackets and names.
- y 44–400: sea.
- Land masses: Troy's coast along the top edge, and the Cyclops island above the route.
- The storm: a swirl glyph near x 4080.
- Two compass roses (x ≈1350 and ≈3300) with faint radiating rhumb lines.
- Dashed water lines offset around the coasts.
- Coastlines are seeded procedural blobs.
- The route is a dense Catmull-Rom polyline through the ports, with y between 250 and 300. Sampling it gives the ship's y and its tangent, and lets a motif's route segments be sliced out.
- Each port has a title above it (split onto two lines when long, with a vellum halo) and an italic story-time label below.
- Elapsed time ("a year at sea") is written along the route with `textPath`.
- A nautical scale bar at y ≈395 serves as the time axis.
- y 430–620: motif threads with knots at each song's x, and faint meridians running down from each port.
- **Ship:** x = first port + (last − first) × scrollLeft / maxScroll. It rotates with the route tangent.
- **Unrolling:** shaded roller gradients at both edges of the viewport, plus one unroll animation on load (clip from the left, about 900ms). When taking screenshots, use `--virtual-time-budget=3000`.
- **Minimap:** a second SVG with the whole chart's viewBox and `preserveAspectRatio="none"`, showing the land, route and port dots plus a viewport rectangle. Click or drag to jump.
- **Motif select** (legend click or `?motif=`):
- highlight the thread and dim the others;
- give the matching ports a ring in the motif's colour;
- overlay the route segments between appearances in the motif's ink;
- show part names at the knots.
- **Song panel** (`?song=`): a right-hand drawer that stops above the footer. It shows:
- track and saga;
- the title;
- the story time;
- the Odyssey link, or "no direct counterpart";
- the parts with their singers and motif chips;
- `createAudioSlot` and `createVideoCards`.
- **Overlay** (`?overlay=1`):
- a dark sea-gradient backdrop;
- the same chart in a centred frame at S ≈ 0.62, with the minimap;
- the ship at Survive with the tag "You are here: Survive";
- clicking a port opens a popover with a "Sail here" button, which animates the ship there.
- **Input:** drag to pan, the vertical mouse wheel scrolls sideways, and the arrow keys step from port to port.
## Navigation idea
This spike uses mockup 01's reading model, drawn as a portolan sea chart on a vellum roll.
- **The route.** Songs sit as ports on one dashed red route that runs left to right. Each song is 220 units after the last, and every in-story year adds 600 more, so the length of open water stands for elapsed time.
- **The route curve.** The route is a cubic Hermite spline, y = f(x). It curves gently but can never double back.
- **Time labels.** "A year passes" and "two years pass at sea" are written along the water. A nautical scale bar doubles as the time axis, with labels such as "The year Troy falls" and "Year 1 after Troy".
- **Motif threads.** Threads sit beneath the sea, as in 01. A fixed legend on the left has rows aligned to the threads.
- **The ship.** The ship rides the route at the scroll position, so x = first port + span × scroll fraction, and it turns with the route's tangent. At any given scroll position the ship is always in view and sits exactly on a port.
- **The minimap.** A thin minimap at the top shows the land, the route, the saga folds, a viewport frame and the ship marker. Click or drag it to jump.
- **Unrolling.** Rods sit at both edges and cast shadows, and the chart unrolls once from the west on load.
## What's left
- [ ] Write `src/mockups/10-chart-scroll/main.ts` (plus an optional `chart.ts`) and `style.css`. Keep the HTML stub as it is, with the credits footer and the `credits.css` link.
- [ ] Check that `style.css` is unique (it will be, once written).
- [ ] Build: `bun build src/server.ts --target=bun --production --outdir /tmp/claude-1000/-home-user-epic-the-musical-timeline/fb650f6d-47a0-47e4-8f6b-197d02417f3b/scratchpad/r2-10-chart-scroll`, then serve on port 4110.
- [ ] Take the screenshots `screenshots/10-chart-scroll-{default,motif,song,overlay}.png` and review them.
- [ ] Run `bun test` and `bun run typecheck`.
- [ ] Rewrite this report into the final format the brief asks for: the idea, what works, what doesn't, and the screenshot paths.
## What works
- **Reading at a glance.** In the default view, the titles, the italic story-time labels, the saga names and the year scale can all be read without hunting.
- **Time as distance.** Elapsed time reads as distance sailed. The two-year gap before Storm is a long, empty stretch of open sea with rocks, a compass rose and the storm swirl.
- **Saying where you are.** The ship and the caption ("Moored at Polyphemus. The island of the Cyclopes, in The Cyclops Saga.") answer "where am I?" more clearly than a scrollbar does. The minimap ship marker carries the same answer to the whole-voyage view.
- **Selecting a motif.** Choosing a motif from the legend, or with `?motif=`, does the following:
- lights its thread and labels each knot with its part names (for example "Verse 1, Verse 2");
- rings the matching ports;
- thickens the route around each port where it appears;
- tints those ports on the minimap;
- scrolls to the motif's first appearance.
- **One chart, two uses.** The `?overlay=1` view is the same component at scale 0.62, in a frame over a dimmed CSS sea. It shows a "You are here: Survive" tag, and clicking a port opens a popover with "Sail here", which animates the ship along the route.
- **Input.** Dragging, the vertical wheel (it scrolls sideways), the left and right arrow keys (they step from port to port) and Enter (it opens the port) all work.
## Known broken bits
- None in code: nothing has been changed. The page at `/mockups/10-chart-scroll/` still shows "Unrolling chart (stub)".
## What doesn't
- **Overlay text is too small.** At 0.62 scale, the overlay's port titles and story times are about 11–12px. They are legible but cramped, so a real in-3D overlay would need labels that don't scale down with the chart, or a shorter crop.
- **Little of the voyage fits on screen.** Only about 4 to 5 ports fit across the viewport at a time. A big story gap, such as the one before Storm, can fill most of the screen with empty sea. That is honest about time, but you see little at once, and the minimap has to do the work of the overview.
- **The ship's speed isn't constant.** The ship moves linearly with scroll, so it seems to "lag" or "lead" the visible centre at the ends of the roll. That makes sense, but it surprises people at first.
- **The drawer covers part of the chart.** The song drawer covers the right third of the chart. The ship stays visible because it moors at the port, but ports to the east are hidden while it's open.
- **The unroll animation isn't reliable everywhere.**
- In headless Chrome it sometimes catches mid-unroll, or it doesn't play at all inside the overlay frame. For that reason the overlay skips the animation.
- Use `--virtual-time-budget=4000` for screenshots.
- In a real browser it should behave, but I didn't verify that interactively.
- **Unverified geography.** The geography is decorative, as in 07. The islands are seeded blobs and the coast of Troy is a wave function. That makes it portolan-flavoured, not accurate.
## Next step to resume
Implement `main.ts` and `style.css` from the design plan above. Start with the geometry, the route sampling and the static chart, then add the ship and minimap, then the motif, song and overlay states. After that, build, screenshot and run the checks.
## Screenshots
- `screenshots/10-chart-scroll-default.png`: the Troy Saga, with the ship moored at the first port.
- `screenshots/10-chart-scroll-motif.png`: `?motif=warrior`.
- `screenshots/10-chart-scroll-song.png`: `?song=polyphemus`, with the drawer open.
- `screenshots/10-chart-scroll-overlay.png`: `?overlay=1`, with "You are here: Survive".
- `screenshots/10-chart-scroll-overlay-sail.png`: `?overlay=1&song=remember-them`, showing the "Sail here" popover. This one is extra.
- `screenshots/10-chart-scroll-storm.png`: `?song=storm`, showing the open-sea end and the storm swirl. This one is extra.
## Files
- `src/mockups/10-chart-scroll/chart.ts`: the geometry, the route spline and the coastlines.
- `src/mockups/10-chart-scroll/main.ts`: the page.
- `src/mockups/10-chart-scroll/style.css`
- `index.html` is unchanged and keeps the credits footer and the `credits.css` link.
## Checks
- `bun run typecheck` passes.
- `bun test` gives 41 passing and 1 failing test. The failure is `registry has 7 mockups`. It predates this spike: the registry already had 11 entries at HEAD (commit 52feed5 added slots 08–11), and the test lives in a shared file I'm not allowed to touch.
- **The full build fails, but not because of this spike.** The repo-wide `bun build` currently fails with "Multiple files share the same output path". The cause is that the 09 and 11 `style.css` stubs minify to identical output, since only their comments differ. To verify this spike, I built from a scratchpad copy where only those stubs were disambiguated. The 10 stylesheet is unique.
+119
View File
@@ -0,0 +1,119 @@
import { songs } from "../../data/sample";
import { gapYears } from "../../data/query";
/* Chart geometry in chart units (1 unit = 1px at scale 1). Mockup 01's reading
model: every song is one STEP after the last, and every in-story year adds
YEAR units, so elapsed time reads as distance sailed. */
export const STEP = 220;
export const YEAR = 600;
export const START = 300;
export const W = 4600;
export const H = 626;
export const SEA_TOP = 44;
export const SEA_BOTTOM = 412;
export const SCALE_Y = 396;
export type Pt = readonly [number, number];
export const xs: number[] = [];
songs.forEach((s, i) => {
xs.push(i === 0 ? START : xs[i - 1]! + STEP + Math.max(0, gapYears(songs[i - 1]!, s)) * YEAR);
});
/* Port heights: gentle, deterministic undulation; the Cyclops ports sit a
little lower so the island has room above them. */
const PORT_Y = [262, 282, 266, 288, 280, 296, 304, 292, 300, 276];
export const ports: Pt[] = xs.map((x, i) => [x, PORT_Y[i] ?? 280] as const);
/* Extra control points keep the long open-water gaps from being straight. */
const EXTRA: Pt[] = [
[130, 246],
[1370, 322],
[3040, 250],
[3460, 318],
[3800, 258],
[4470, 262],
];
const CONTROL: Pt[] = [...EXTRA, ...ports].sort((a, b) => a[0] - b[0]);
/* The route is y = f(x): a cubic Hermite spline over x, so it can never loop
back on itself and stays left-to-right. */
const tangents = CONTROL.map((p, i) => {
const a = CONTROL[Math.max(0, i - 1)]!;
const b = CONTROL[Math.min(CONTROL.length - 1, i + 1)]!;
return (b[1] - a[1]) / (b[0] - a[0]);
});
export function routeY(x: number): number {
const c = CONTROL;
if (x <= c[0]![0]) return c[0]![1];
if (x >= c[c.length - 1]![0]) return c[c.length - 1]![1];
let i = 0;
while (c[i + 1]![0] < x) i++;
const [x0, y0] = c[i]!;
const [x1, y1] = c[i + 1]!;
const h = x1 - x0;
const t = (x - x0) / h;
const t2 = t * t, t3 = t2 * t;
return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * tangents[i]! + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * tangents[i + 1]!;
}
export const routeAngle = (x: number) => (Math.atan2(routeY(x + 4) - routeY(x - 4), 8) * 180) / Math.PI;
export const ROUTE_START = CONTROL[0]![0];
export const ROUTE_END = CONTROL[CONTROL.length - 1]![0];
/** Path along the route between two x positions, optionally offset vertically. */
export function routePath(a: number, b: number, dy = 0): string {
let d = "";
for (let x = a; x < b; x += 6) d += `${d ? "L" : "M"}${x.toFixed(1)},${(routeY(x) + dy).toFixed(1)}`;
return d + `L${b.toFixed(1)},${(routeY(b) + dy).toFixed(1)}`;
}
/* ---------------------------------------------------------------- coastlines */
function rand(seed: number) {
let s = seed >>> 0;
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
}
function seg(p0: Pt, p1: Pt, p2: Pt, p3: Pt): string {
const t = 1 / 6;
return ` C${(p1[0] + (p2[0] - p0[0]) * t).toFixed(1)},${(p1[1] + (p2[1] - p0[1]) * t).toFixed(1)} ${(p2[0] - (p3[0] - p1[0]) * t).toFixed(1)},${(p2[1] - (p3[1] - p1[1]) * t).toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
}
export function closedSmooth(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`;
for (let i = 0; i < n; i++) d += seg(pts[(i - 1 + n) % n]!, pts[i]!, pts[(i + 1) % n]!, pts[(i + 2) % n]!);
return d + "Z";
}
export function openSmooth(pts: Pt[]): string {
let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) d += seg(pts[Math.max(0, i - 1)]!, pts[i]!, pts[i + 1]!, pts[Math.min(pts.length - 1, i + 2)]!);
return d;
}
/** A seeded, irregular island outline; `grow` inflates it for offset water lines. */
export function island(cx: number, cy: number, rx: number, ry: number, seed: number, grow = 0, n = 30): string {
const r = rand(seed);
const jitter = Array.from({ length: n }, () => r());
const pts: Pt[] = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const k = 1 + 0.13 * Math.sin(3 * a + seed) + 0.08 * Math.sin(7 * a + seed * 2) + (jitter[i]! - 0.5) * 0.12;
pts.push([cx + Math.cos(a) * (rx * k + grow), cy + Math.sin(a) * (ry * k + grow)]);
}
return closedSmooth(pts);
}
/** Troy's mainland: a coast hanging from the top edge of the sea. */
export function troyCoast(dy = 0): Pt[] {
const r = rand(7);
const pts: Pt[] = [];
for (let x = -20; x <= 1180; x += 40) {
const fall = x > 900 ? ((x - 900) / 280) ** 2 * 120 : 0;
pts.push([x, 112 + 24 * Math.sin(x / 150) + 15 * Math.sin(x / 61 + 1) + 8 * Math.sin(x / 23 + 2) + (r() - 0.5) * 18 - fall + dy]);
}
pts.push([1240, SEA_TOP - 6 + dy]);
return pts;
}
+605 -1
View File
@@ -1,4 +1,608 @@
import { motifs, sagas, songs } from "../../data/sample";
import { gapYears, motifsInSong, sagaOf, songsWithMotif } from "../../data/query";
import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import {
H, ROUTE_END, ROUTE_START, SCALE_Y, SEA_BOTTOM, SEA_TOP, STEP, W,
island, openSmooth, ports, routeAngle, routePath, routeY, troyCoast, xs,
} from "./chart";
mountCredits();
document.getElementById("app")!.textContent = "Unrolling chart (stub)";
const params = new URLSearchParams(location.search);
const OVERLAY = params.get("overlay") === "1";
const S = OVERLAY ? 0.62 : 1.1;
const SVG_NS = "http://www.w3.org/2000/svg";
/* Shared motif colours are tuned for dark pages; on vellum they are re-inked
to darker iron-gall versions of the same hue. */
const INK: Record<string, string> = {
warrior: "#93650b",
"open-arms": "#b2472c",
"just-a-man": "#2e5d8b",
remember: "#3b7347",
odysseus: "#5b3c20",
athena: "#6a4a9d",
polyphemus: "#8e1e24",
};
const ink = (m: Motif) => INK[m.id] ?? m.color;
/* Thread rows beneath the sea: motifs, a small gap, then characters. */
const threadY = new Map<string, number>();
motifs.filter((m) => m.kind === "motif").forEach((m, i) => threadY.set(m.id, 448 + i * 24));
motifs.filter((m) => m.kind === "character").forEach((m, i) => threadY.set(m.id, 554 + i * 24));
/* ------------------------------------------------------------------ helpers */
type Attrs = Record<string, string | number | undefined>;
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, ...kids: (Node | string)[]) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
el.append(...kids);
return el;
}
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, text?: string) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
if (text !== undefined) el.textContent = text;
return el;
}
/** Break a label into at most two lines near the middle. */
function twoLines(text: string, max: number): string[] {
if (text.length <= max) return [text];
const words = text.split(" ");
let best = 1, bestDiff = Infinity;
for (let i = 1; i < words.length; i++) {
const d = Math.abs(words.slice(0, i).join(" ").length - words.slice(i).join(" ").length);
if (d < bestDiff) { bestDiff = d; best = i; }
}
return [words.slice(0, best).join(" "), words.slice(best).join(" ")];
}
const WORDS = ["no time", "a year", "two years", "three years", "four years"];
const yearsPass = (n: number) => (n === 1 ? "a year passes" : `${WORDS[n] ?? `${n} years`} pass at sea`);
const yearName = (y: number) => (y === 0 ? "The year Troy falls" : `Year ${y} after Troy`);
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ------------------------------------------------------------------ skeleton */
const app = document.getElementById("app")!;
document.body.classList.toggle("is-overlay", OVERLAY);
const minimap = h("div", { class: "minimap", role: "slider", tabindex: 0, "aria-label": "Whole chart. Click to jump.", "aria-valuemin": 0, "aria-valuemax": 100 });
const masthead = h(
"header",
{ class: "masthead" },
h(
"div",
{ class: "masthead__words" },
h("h1", { class: "masthead__title" }, OVERLAY ? "Sea chart" : "The voyage, unrolled"),
h("p", { class: "masthead__dek" }, OVERLAY ? "Pick a port to set sail." : "A chart of EPIC: The Musical. Pan east to sail; the longer the water, the more years pass."),
),
minimap,
);
const scroller = h("section", { class: "roll", tabindex: 0, "aria-label": "Sea chart of the songs. Drag, scroll or use the arrow keys to sail." });
const track = h("div", { class: "roll__track", style: `width:${W * S}px;height:${H * S}px` });
const svg = s("svg", { class: "chart", width: W * S, height: H * S, viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Sea chart" });
track.append(svg);
scroller.append(track);
const rollers = h("div", { class: "rollers", "aria-hidden": "true" }, h("span", { class: "roller roller--west" }), h("span", { class: "roller roller--east" }));
const sheet = h("div", { class: "sheet" }, scroller, rollers);
const legend = h("nav", { class: "legend", "aria-label": "Motifs", style: `height:${H * S}px` });
const stage = h("div", { class: "stage" }, legend, sheet);
const caption = h("p", { class: "caption", "aria-live": "polite" });
const panel = h("aside", { class: "drawer", "aria-label": "Song", "aria-hidden": "true" });
if (OVERLAY) {
const frame = h("div", { class: "frame" }, masthead, stage, caption);
app.append(h("div", { class: "backdrop", "aria-hidden": "true" }, h("span", { class: "backdrop__note" }, "3D sea voyage, paused")), frame);
} else {
app.append(masthead, stage, caption, panel);
}
/* ------------------------------------------------------------------ svg layers */
const defs = s("defs");
defs.append(s("clipPath", { id: "sea-clip" }));
defs.lastElementChild!.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: SEA_BOTTOM - SEA_TOP }));
svg.append(defs);
// Vellum ground with a faint fold every saga.
svg.append(s("rect", { x: 0, y: 0, width: W, height: H, class: "vellum" }));
svg.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: SEA_BOTTOM - SEA_TOP, class: "sea" }));
// Rhumb lines radiating from two compass roses, beneath everything.
const gRhumb = s("g", { class: "rhumbs", "clip-path": "url(#sea-clip)" });
const roses: [number, number][] = [[1370, 168], [3060, 150]];
for (const [cx, cy] of roses) {
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const cls = i % 4 === 0 ? "rhumb rhumb--ink" : i % 2 === 0 ? "rhumb rhumb--green" : "rhumb rhumb--red";
gRhumb.append(s("line", { x1: cx, y1: cy, x2: cx + Math.cos(a) * 1400, y2: cy + Math.sin(a) * 1400, class: cls }));
}
}
svg.append(gRhumb);
// Land.
const gLand = s("g", { class: "land", "clip-path": "url(#sea-clip)" });
const coast = troyCoast();
const coastPath = `${openSmooth(coast)} L1260,0 L-40,0 Z`;
for (const g of [26, 14]) gLand.append(s("path", { d: openSmooth(troyCoast(g)), class: "waterline" }));
gLand.append(s("path", { d: coastPath, class: "landmass" }));
gLand.append(s("path", { d: openSmooth(coast), class: "coastwash" }));
const cyclops = { cx: 2330, cy: 132, rx: 380, ry: 64, seed: 11 };
for (const g of [24, 12]) gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed, g), class: "waterline" }));
gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "landmass" }));
gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "coastwash" }));
// A few rocks in the open water.
for (const [x, y, r, seed] of [[1660, 360, 16, 3], [2860, 356, 12, 5], [3600, 140, 22, 9], [3720, 176, 10, 13]] as const) {
gLand.append(s("path", { d: island(x, y, r, r * 0.7, seed, 7, 14), class: "waterline" }));
gLand.append(s("path", { d: island(x, y, r, r * 0.7, seed, 0, 14), class: "landmass" }));
}
svg.append(gLand);
// Place names, written the way a portolan writes them: italic, along the coast.
const gNames = s("g", { class: "places" });
gNames.append(s("text", { x: 300, y: 82, class: "place place--big" }, "Troy"));
// Towers of Troy.
const towers = s("g", { class: "towers", transform: "translate(420 64)" });
for (const [dx, hgt] of [[0, 18], [12, 26], [24, 20], [36, 14]] as const) towers.append(s("rect", { x: dx, y: 26 - hgt, width: 9, height: hgt }));
gNames.append(towers);
gNames.append(s("text", { x: cyclops.cx, y: cyclops.cy + 8, class: "place place--big", "text-anchor": "middle" }, "Island of the Cyclopes"));
gNames.append(s("text", { x: 3300, y: 214, class: "place place--sea", "text-anchor": "middle" }, "The open sea"));
svg.append(gNames);
// Compass roses.
for (const [cx, cy] of roses) {
const g = s("g", { class: "rose", transform: `translate(${cx} ${cy})` });
g.append(s("circle", { r: 44, class: "rose__ring" }));
g.append(s("circle", { r: 36, class: "rose__ring rose__ring--thin" }));
for (let i = 0; i < 8; i++) {
const len = i % 2 === 0 ? 52 : 30;
const cls = i % 2 === 0 ? "rose__point" : "rose__point rose__point--minor";
g.append(s("path", { d: `M0,${-len} L6,0 L0,0 Z`, class: cls, transform: `rotate(${i * 45})` }));
g.append(s("path", { d: `M0,${-len} L-6,0 L0,0 Z`, class: `${cls} rose__point--shade`, transform: `rotate(${i * 45})` }));
}
g.append(s("text", { y: -58, class: "rose__n", "text-anchor": "middle" }, "N"));
svg.append(g);
}
// The storm: a swirl of ink above Storm's port.
const storm = s("g", { class: "storm", transform: `translate(${xs[9]! + 150} 150)` });
for (let k = 0; k < 3; k++) {
let d = "";
for (let t = 0; t < 4.2 * Math.PI; t += 0.2) {
const r = 6 + t * 5.2;
const x = Math.cos(t + k * 2.1) * r * 1.5;
const y = Math.sin(t + k * 2.1) * r * 0.75;
d += `${d ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`;
}
storm.append(s("path", { d, class: "storm__arm" }));
}
for (let i = 0; i < 9; i++) storm.append(s("line", { x1: -110 + i * 26, y1: 70, x2: -126 + i * 26, y2: 108, class: "storm__rain" }));
svg.append(storm);
// Saga band.
const gSaga = s("g", { class: "sagas" });
for (const saga of sagas) {
const idx = songs.map((so, i) => (so.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (!idx.length) continue;
const a = xs[idx[0]!]! - 90;
const b = xs[idx[idx.length - 1]!]! + 90;
gSaga.append(s("path", { d: `M${a} 38 v-7 H${b} v7`, class: "saga__bracket" }));
gSaga.append(s("text", { x: a + 8, y: 24, class: "saga__name" }, saga.name));
}
svg.append(gSaga);
// Nautical scale bar doubling as the time axis.
const gScale = s("g", { class: "scale" });
for (let x = ROUTE_START, k = 0; x < ROUTE_END; x += 110, k++) {
gScale.append(s("rect", { x, y: SCALE_Y, width: Math.min(110, ROUTE_END - x), height: 6, class: k % 2 ? "scale__cell" : "scale__cell scale__cell--ink" }));
}
songs.forEach((so, i) => {
const gy = i === 0 ? 1 : gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
const x = i === 0 ? ROUTE_START : xs[i]! - STEP / 2;
gScale.append(s("line", { x1: x, x2: x, y1: SCALE_Y - 8, y2: SCALE_Y + 14, class: "scale__tick" }));
gScale.append(s("text", { x: x + 6, y: SCALE_Y - 6, class: "scale__label" }, yearName(so.storyTime.yearsAfterTroy)));
});
svg.append(gScale);
// Meridians from each port down through the motif threads.
const gMer = s("g", { class: "meridians" });
xs.forEach((x) => gMer.append(s("line", { x1: x, x2: x, y1: SCALE_Y + 16, y2: H - 8 })));
svg.append(gMer);
// Motif threads.
const gThreads = s("g", { class: "threads" });
const threadEls = new Map<string, SVGGElement>();
for (const m of motifs) {
const y = threadY.get(m.id)!;
const g = s("g", { class: "thread", "data-motif": m.id, style: `--ink:${ink(m)}` });
g.append(s("line", { x1: ROUTE_START, x2: ROUTE_END, y1: y, y2: y, class: "thread__ghost" }));
const hits = songs.map((so, i) => [so, i] as const).filter(([so]) => so.parts.some((p) => p.motifIds.includes(m.id)));
if (hits.length) {
g.append(s("line", { x1: xs[hits[0]![1]]!, x2: xs[hits[hits.length - 1]![1]]!, y1: y, y2: y, class: "thread__line" }));
for (const [so, i] of hits) {
const parts = so.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name);
g.append(s("circle", { cx: xs[i]!, cy: y, r: 5, class: "thread__knot" }));
g.append(s("text", { x: xs[i]! + 9, y: y - 7, class: "thread__part" }, parts.join(", ")));
}
}
threadEls.set(m.id, g);
gThreads.append(g);
}
svg.append(gThreads);
// The route itself.
const gRoute = s("g", { class: "route" });
gRoute.append(s("path", { d: routePath(ROUTE_START, ROUTE_END), class: "route__line" }));
const gMotifRoute = s("g", { class: "route__motif" });
gRoute.append(gMotifRoute);
// Elapsed time written along the water.
songs.forEach((so, i) => {
if (i === 0) return;
const gy = gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
const id = `gap-${i}`;
defs.append(s("path", { id, d: routePath(xs[i - 1]! + 120, xs[i]! - 120, -12) }));
const t = s("text", { class: "route__elapsed" });
const tp = s("textPath", { href: `#${id}`, startOffset: "50%", "text-anchor": "middle" }, yearsPass(gy));
t.append(tp);
gRoute.append(t);
});
svg.append(gRoute);
// Ports.
const gPorts = s("g", { class: "ports" });
const portEls: SVGGElement[] = [];
songs.forEach((so, i) => {
const [x, y] = ports[i]!;
const g = s("g", { class: "port", transform: `translate(${x} ${y})`, tabindex: -1, role: "button", "aria-label": `${so.title}, ${so.storyTime.label}`, "data-song": so.id });
g.append(s("circle", { r: 18, class: "port__hit" }));
g.append(s("circle", { r: 13, class: "port__ring" }));
g.append(s("circle", { r: 7, class: "port__dot" }));
g.append(s("circle", { r: 2.2, class: "port__pin" }));
const title = twoLines(so.title, 15);
title.forEach((line, k) => g.append(s("text", { y: -38 - (title.length - 1 - k) * 20, class: "port__title", "text-anchor": "middle" }, line)));
const when = twoLines(so.storyTime.label, 22);
when.forEach((line, k) => g.append(s("text", { y: 32 + k * 16, class: "port__when", "text-anchor": "middle" }, line)));
portEls.push(g);
gPorts.append(g);
});
svg.append(gPorts);
// The ship.
const ship = s("g", { class: "ship" });
const shipBody = s("g", { class: "ship__body", transform: "scale(0.72)" });
shipBody.append(s("path", { d: "M-20,-2 L20,-2 L13,8 L-14,8 Z", class: "ship__hull" }));
shipBody.append(s("path", { d: "M0,-2 L0,-34", class: "ship__mast" }));
shipBody.append(s("path", { d: "M-13,-31 Q0,-24 13,-31 L12,-9 Q0,-4 -12,-9 Z", class: "ship__sail" }));
shipBody.append(s("path", { d: "M0,-34 L10,-38 L0,-41 Z", class: "ship__pennant" }));
ship.append(s("ellipse", { cx: 0, cy: 9, rx: 26, ry: 4, class: "ship__wake" }), shipBody);
svg.append(ship);
const youAreHere = h("div", { class: "here", "aria-live": "polite" });
if (OVERLAY) track.append(youAreHere);
/* ------------------------------------------------------------------ legend */
const legendKey = h(
"div",
{ class: "legend__key", style: `height:${(SEA_BOTTOM - 4) * S}px` },
h("p", {}, "Each port is a song, in story order."),
h("p", {}, "Open water between ports is time passing: the wider the gap, the more years lost."),
h("p", {}, "Threads below show where each motif plays. Pick one to follow it."),
);
legend.append(legendKey);
const legendBtns = new Map<string, HTMLButtonElement>();
for (const m of motifs) {
const b = h("button", { type: "button", class: `legend__item legend__item--${m.kind}`, style: `top:${(threadY.get(m.id)! - 11) * S}px;--ink:${ink(m)}`, "aria-pressed": "false", title: m.description }, h("span", { class: "legend__swatch" }), m.name);
b.addEventListener("click", () => setMotif(state.motif === m.id ? null : m.id));
legendBtns.set(m.id, b);
legend.append(b);
}
/* ------------------------------------------------------------------ minimap */
const MINI_H = SEA_BOTTOM;
const mini = s("svg", { class: "minimap__svg", viewBox: `0 ${SEA_TOP} ${W} ${MINI_H - SEA_TOP}`, preserveAspectRatio: "none", "aria-hidden": "true" });
mini.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: MINI_H - SEA_TOP, class: "mini__sea" }));
mini.append(s("path", { d: coastPath, class: "mini__land" }));
mini.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "mini__land" }));
for (const saga of sagas.slice(1)) {
const i = songs.findIndex((so) => so.sagaId === saga.id);
if (i > 0) mini.append(s("line", { x1: xs[i]! - 110, x2: xs[i]! - 110, y1: SEA_TOP, y2: MINI_H, class: "mini__fold" }));
}
mini.append(s("path", { d: routePath(ROUTE_START, ROUTE_END), class: "mini__route" }));
const miniDots = ports.map(([x, y]) => {
const r = s("rect", { x: x - 14, y: y - 30, width: 28, height: 60, class: "mini__port" });
mini.append(r);
return r;
});
const miniShip = h("span", { class: "minimap__ship" });
const miniView = h("span", { class: "minimap__view" });
minimap.append(mini, miniView, miniShip);
/* ------------------------------------------------------------------ state */
const state = { motif: null as string | null, song: null as string | null, shipX: xs[0]!, here: 6 };
const FIRST = xs[0]!, LAST = xs[xs.length - 1]!;
const maxScroll = () => Math.max(1, scroller.scrollWidth - scroller.clientWidth);
function placeShip(x: number) {
state.shipX = x;
ship.setAttribute("transform", `translate(${x.toFixed(1)} ${routeY(x).toFixed(1)}) rotate(${routeAngle(x).toFixed(1)})`);
miniShip.style.left = `${(x / W) * 100}%`;
if (OVERLAY) {
youAreHere.style.left = `${x * S}px`;
youAreHere.style.top = `${(routeY(x) - 64) * S}px`;
}
}
function nearestPort(x: number) {
let best = 0;
xs.forEach((px, i) => { if (Math.abs(px - x) < Math.abs(xs[best]! - x)) best = i; });
return best;
}
/** Scroll position at which the ship sits exactly on port i. */
const scrollForPort = (i: number) => ((xs[i]! - FIRST) / (LAST - FIRST)) * maxScroll();
function onScroll() {
const f = scroller.scrollLeft / maxScroll();
const vw = scroller.clientWidth / S;
miniView.style.left = `${((scroller.scrollLeft / S) / W) * 100}%`;
miniView.style.width = `${(vw / W) * 100}%`;
minimap.setAttribute("aria-valuenow", String(Math.round(f * 100)));
if (OVERLAY) return;
placeShip(FIRST + (LAST - FIRST) * f);
const i = nearestPort(state.shipX);
const so = songs[i]!;
const atPort = Math.abs(xs[i]! - state.shipX) < 40;
caption.replaceChildren(
h("span", { class: "caption__lead" }, atPort ? "Moored at " : "Sailing past "),
h("strong", {}, so.title),
`. ${so.storyTime.label}, in ${sagaOf(so).name}.`,
);
portEls.forEach((p, k) => p.classList.toggle("is-near", k === i));
}
scroller.addEventListener("scroll", onScroll, { passive: true });
/* ------------------------------------------------------------------ motif */
function setMotif(id: string | null) {
state.motif = id;
svg.classList.toggle("has-motif", !!id);
for (const [mid, g] of threadEls) g.classList.toggle("is-on", mid === id);
for (const [mid, b] of legendBtns) b.setAttribute("aria-pressed", String(mid === id));
legend.classList.toggle("has-motif", !!id);
gMotifRoute.replaceChildren();
const hitIds = new Set(id ? songsWithMotif(id).map((so) => so.id) : []);
portEls.forEach((p, i) => {
p.classList.toggle("is-motif", hitIds.has(songs[i]!.id));
p.style.setProperty("--motif", id ? ink(motifs.find((m) => m.id === id)!) : "");
});
miniDots.forEach((r, i) => r.classList.toggle("is-on", hitIds.has(songs[i]!.id)));
if (!id) return;
const m = motifs.find((mm) => mm.id === id)!;
const idx = songs.map((so, i) => (hitIds.has(so.id) ? i : -1)).filter((i) => i >= 0);
gMotifRoute.style.setProperty("--ink", ink(m));
if (idx.length > 1) gMotifRoute.append(s("path", { d: routePath(xs[idx[0]!]!, xs[idx[idx.length - 1]!]!), class: "route__motif-span" }));
for (const i of idx) gMotifRoute.append(s("path", { d: routePath(xs[i]! - 80, xs[i]! + 80), class: "route__motif-run" }));
mini.style.setProperty("--ink", ink(m));
}
/* ------------------------------------------------------------------ song drawer */
function openSong(id: string | null) {
state.song = id;
portEls.forEach((p) => p.classList.toggle("is-open", p.dataset.song === id));
const so = songs.find((x) => x.id === id);
if (!so) {
panel.setAttribute("aria-hidden", "true");
panel.classList.remove("is-open");
return;
}
panel.replaceChildren(...songDetail(so));
panel.setAttribute("aria-hidden", "false");
panel.classList.add("is-open");
}
function songDetail(so: Song): Node[] {
const close = h("button", { type: "button", class: "drawer__close", "aria-label": "Close song" }, "Close");
close.addEventListener("click", () => openSong(null));
const odyssey = so.odyssey
? h("a", { href: so.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, `Odyssey, Book ${so.odyssey.book}, lines ${so.odyssey.lines}`)
: h("span", {}, "No direct counterpart in the Odyssey");
const parts = h(
"ol",
{ class: "drawer__parts" },
...so.parts.map((p) =>
h(
"li",
{},
h("span", { class: "drawer__part" }, p.name),
h("span", { class: "drawer__singers" }, p.singers.join(", ")),
h(
"span",
{ class: "drawer__chips" },
...p.motifIds.map((mid) => {
const m = motifs.find((x) => x.id === mid)!;
const chip = h("button", { type: "button", class: "chip", style: `--ink:${ink(m)}` }, m.name);
chip.addEventListener("click", () => setMotif(mid));
return chip;
}),
),
),
),
);
return [
close,
h("p", { class: "drawer__meta" }, `Track ${so.track}, ${sagaOf(so).name}`),
h("h2", { class: "drawer__title" }, so.title),
h("p", { class: "drawer__when" }, `${so.storyTime.label}. ${yearName(so.storyTime.yearsAfterTroy)}.`),
h("p", { class: "drawer__odyssey" }, odyssey),
parts,
h("p", { class: "drawer__motifs" }, `Motifs here: ${motifsInSong(so).map((m) => m.name).join(", ")}.`),
createAudioSlot(so),
createVideoCards(so),
];
}
/* ------------------------------------------------------------------ overlay: sail here */
const pop = h("div", { class: "pop", hidden: "" });
if (OVERLAY) track.append(pop);
function setHere(i: number) {
state.here = i;
youAreHere.textContent = `You are here: ${songs[i]!.title}`;
portEls.forEach((p, k) => p.classList.toggle("is-here", k === i));
caption.textContent = `The ship is at ${songs[i]!.title}. ${songs[i]!.storyTime.label}. Pick another port to sail there.`;
}
function sailTo(i: number) {
const from = state.shipX, to = xs[i]!;
pop.hidden = true;
youAreHere.textContent = `Sailing to ${songs[i]!.title}`;
const dur = reduceMotion ? 0 : Math.min(2600, 500 + Math.abs(to - from) * 0.9);
const t0 = performance.now();
const step = (now: number) => {
const t = dur ? Math.min(1, (now - t0) / dur) : 1;
const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
placeShip(from + (to - from) * e);
if (t < 1) requestAnimationFrame(step);
else setHere(i);
};
requestAnimationFrame(step);
}
function showPop(i: number) {
const so = songs[i]!;
const [x, y] = ports[i]!;
const sail = h("button", { type: "button", class: "pop__sail" }, "Sail here");
sail.addEventListener("click", () => sailTo(i));
const here = i === state.here;
pop.replaceChildren(
h("strong", { class: "pop__title" }, so.title),
h("span", { class: "pop__when" }, so.storyTime.label),
here ? h("span", { class: "pop__now" }, "The ship is here") : sail,
);
pop.style.left = `${x * S}px`;
pop.style.top = `${(y + 22) * S}px`;
pop.hidden = false;
if (!here) sail.focus({ preventScroll: true });
}
/* ------------------------------------------------------------------ input */
function activatePort(i: number) {
if (OVERLAY) showPop(i);
else {
openSong(songs[i]!.id);
goToPort(i);
}
}
function goToPort(i: number, smooth = true) {
if (OVERLAY) {
scroller.scrollTo({ left: xs[i]! * S - scroller.clientWidth / 2, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
} else {
scroller.scrollTo({ left: scrollForPort(i), behavior: smooth && !reduceMotion ? "smooth" : "auto" });
}
}
// Drag to pan; a press that barely moves is a click on whatever is under it.
let drag: { x: number; left: number; moved: boolean } | null = null;
scroller.addEventListener("pointerdown", (e) => {
if ((e.target as Element).closest(".pop")) return;
drag = { x: e.clientX, left: scroller.scrollLeft, moved: false };
});
window.addEventListener("pointermove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
if (Math.abs(dx) > 4) {
drag.moved = true;
scroller.classList.add("is-dragging");
}
if (drag.moved) scroller.scrollLeft = drag.left - dx;
});
window.addEventListener("pointerup", (e) => {
const d = drag;
drag = null;
scroller.classList.remove("is-dragging");
if (!d || d.moved) return;
const port = (e.target as Element).closest?.(".port") as SVGGElement | null;
if (port) activatePort(portEls.indexOf(port));
else if (OVERLAY && !(e.target as Element).closest?.(".pop")) pop.hidden = true;
});
// A vertical wheel sails sideways.
scroller.addEventListener(
"wheel",
(e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
e.preventDefault();
scroller.scrollLeft += e.deltaY;
},
{ passive: false },
);
// Arrow keys step from port to port.
scroller.addEventListener("keydown", (e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const cur = OVERLAY ? nearestPort((scroller.scrollLeft + scroller.clientWidth / 2) / S) : nearestPort(state.shipX);
const next = Math.max(0, Math.min(songs.length - 1, cur + (e.key === "ArrowRight" ? 1 : -1)));
goToPort(next);
});
scroller.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
activatePort(OVERLAY ? nearestPort((scroller.scrollLeft + scroller.clientWidth / 2) / S) : nearestPort(state.shipX));
});
// Minimap: click or drag to jump; the clicked spot becomes the middle of the view.
function miniJump(e: PointerEvent) {
const r = minimap.getBoundingClientRect();
const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
scroller.scrollLeft = f * W * S - scroller.clientWidth / 2;
}
let miniDrag = false;
minimap.addEventListener("pointerdown", (e) => {
miniDrag = true;
minimap.setPointerCapture(e.pointerId);
miniJump(e);
});
minimap.addEventListener("pointermove", (e) => { if (miniDrag) miniJump(e); });
minimap.addEventListener("pointerup", () => { miniDrag = false; });
minimap.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") scroller.scrollLeft += scroller.clientWidth * 0.8;
if (e.key === "ArrowLeft") scroller.scrollLeft -= scroller.clientWidth * 0.8;
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (OVERLAY) pop.hidden = true;
else openSong(null);
});
/* ------------------------------------------------------------------ boot */
const motifParam = params.get("motif");
if (motifParam && threadEls.has(motifParam)) setMotif(motifParam);
const songParam = songs.findIndex((so) => so.id === params.get("song"));
if (OVERLAY) {
const here = songs.findIndex((so) => so.id === "survive");
placeShip(xs[here]!);
setHere(here);
goToPort(here, false);
if (songParam >= 0) showPop(songParam);
} else if (songParam >= 0) {
openSong(songs[songParam]!.id);
goToPort(songParam, false);
} else if (state.motif) {
const first = songs.findIndex((so) => so.parts.some((p) => p.motifIds.includes(state.motif!)));
if (first > 0) goToPort(first, false);
}
onScroll();
// One orchestrated moment: the chart unrolls from the west on load.
if (!reduceMotion && !OVERLAY) {
sheet.classList.add("is-unrolling");
sheet.addEventListener("animationend", () => sheet.classList.remove("is-unrolling"), { once: true });
}
+381 -2
View File
@@ -1,2 +1,381 @@
/* 10-chart-scroll stub */
body { margin: 0; }
/* 10-chart-scroll: a portolan chart unrolled on vellum */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
:root {
--vellum: #e3d6b4;
--vellum-hi: #ece2c6;
--land: #c9b07a;
--iron: #2b2118;
--iron-soft: #5e4d3a;
--rhumb-red: #a23a2a;
--rhumb-green: #3f6b5c;
--desk: #3a2f24;
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--desk) radial-gradient(ellipse at 50% 40%, #4a3c2e 0%, var(--desk) 70%);
color: var(--iron);
font-family: var(--sans);
}
#app { flex: 1; display: flex; flex-direction: column; padding: 18px 28px 0; gap: 12px; }
/* ---------------------------------------------------------------- masthead */
.masthead { display: flex; align-items: flex-end; gap: 36px; color: var(--vellum-hi); }
.masthead__words { flex: 0 0 360px; }
.masthead__title { margin: 0; font: 600 32px/1 var(--serif); letter-spacing: 0.01em; }
.masthead__dek { margin: 6px 0 0; font: 400 12.5px/1.45 var(--sans); color: #cbbd9b; max-width: 46ch; }
.minimap {
position: relative;
flex: 1;
height: 56px;
border: 1px solid #8c7752;
background: var(--vellum);
cursor: pointer;
touch-action: none;
}
.minimap:focus-visible { outline: 2px solid var(--vellum-hi); outline-offset: 3px; }
.minimap__svg { display: block; width: 100%; height: 100%; }
.mini__sea { fill: var(--vellum); }
.mini__land { fill: var(--land); stroke: var(--iron-soft); stroke-width: 3; vector-effect: non-scaling-stroke; }
.mini__route { fill: none; stroke: var(--rhumb-red); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.mini__fold { stroke: var(--iron-soft); stroke-dasharray: 2 3; stroke-width: 1; vector-effect: non-scaling-stroke; opacity: 0.6; }
.mini__port { fill: var(--iron); transform-box: fill-box; transform-origin: center; transform: scale(0.35, 0.12); }
.mini__port.is-on { fill: var(--ink, var(--rhumb-red)); transform: scale(0.7, 0.25); }
.minimap__view {
position: absolute;
top: -4px;
bottom: -4px;
border: 2px solid var(--iron);
background: rgb(43 33 24 / 0.08);
pointer-events: none;
}
.minimap__ship {
position: absolute;
top: -9px;
width: 0;
height: 0;
margin-left: -6px;
border: 6px solid transparent;
border-top: 8px solid var(--rhumb-red);
pointer-events: none;
}
/* ---------------------------------------------------------------- stage */
.stage { display: flex; align-items: flex-start; gap: 0; }
.legend { position: relative; flex: 0 0 210px; background: var(--vellum-hi); border-right: 1px solid #b9a47a; }
.legend__key { padding: 58px 18px 0 20px; font: 400 12px/1.5 var(--sans); color: var(--iron-soft); }
.legend__key p { margin: 0 0 10px; }
.legend__item {
position: absolute;
left: 10px;
right: 8px;
display: flex;
align-items: center;
gap: 9px;
height: 22px;
padding: 0 8px;
border: 0;
border-radius: 3px;
background: none;
color: var(--iron);
font: 500 13px/1 var(--sans);
text-align: left;
cursor: pointer;
}
.legend__item--character { font: 600 15px/1 var(--serif); }
.legend__swatch { flex: 0 0 22px; height: 3px; background: var(--ink); border-radius: 2px; }
.legend__item:hover { background: rgb(43 33 24 / 0.07); }
.legend__item:focus-visible { outline: 2px solid var(--iron); }
.legend__item[aria-pressed="true"] { background: var(--ink); color: var(--vellum-hi); }
.legend__item[aria-pressed="true"] .legend__swatch { background: var(--vellum-hi); }
.legend.has-motif .legend__item[aria-pressed="false"] { opacity: 0.5; }
.sheet { position: relative; flex: 1; min-width: 0; }
.roll {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
cursor: grab;
outline: none;
background: var(--vellum);
}
.roll::-webkit-scrollbar { display: none; }
.roll.is-dragging { cursor: grabbing; user-select: none; }
.roll:focus-visible { box-shadow: inset 0 0 0 2px var(--rhumb-red); }
.roll__track { position: relative; }
.chart { display: block; }
/* Paper rollers: the chart is wound on to a rod at each edge. */
.rollers { position: absolute; inset: 0; pointer-events: none; }
.roller { position: absolute; top: -8px; bottom: -8px; width: 22px; border-radius: 11px; }
.roller--west {
left: -11px;
background: linear-gradient(90deg, #5d4a30, #b49d71 35%, #efe4c8 55%, #a68e62 80%, #5d4a30);
box-shadow: 10px 0 18px -6px rgb(43 33 24 / 0.45);
}
.roller--east {
right: -11px;
background: linear-gradient(90deg, #5d4a30, #a68e62 20%, #efe4c8 45%, #b49d71 65%, #5d4a30);
box-shadow: -10px 0 18px -6px rgb(43 33 24 / 0.45);
}
.sheet::before,
.sheet::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 44px;
z-index: 1;
pointer-events: none;
}
.sheet::before { left: 0; background: linear-gradient(90deg, rgb(93 74 48 / 0.35), transparent); }
.sheet::after { right: 0; background: linear-gradient(270deg, rgb(93 74 48 / 0.35), transparent); }
.sheet.is-unrolling .roll { animation: unroll 1000ms cubic-bezier(0.3, 0.7, 0.2, 1) both; }
.sheet.is-unrolling .roller--east { animation: roll-east 1000ms cubic-bezier(0.3, 0.7, 0.2, 1) both; }
@keyframes unroll { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes roll-east { from { right: calc(100% - 11px); } to { right: -11px; } }
/* ---------------------------------------------------------------- chart ink */
.vellum { fill: var(--vellum); }
.sea { fill: var(--vellum); }
.rhumb { stroke-width: 0.8; opacity: 0.35; }
.rhumb--ink { stroke: var(--iron); opacity: 0.22; }
.rhumb--green { stroke: var(--rhumb-green); }
.rhumb--red { stroke: var(--rhumb-red); }
.landmass { fill: var(--land); stroke: var(--iron); stroke-width: 1.6; }
.coastwash { fill: none; stroke: var(--rhumb-green); stroke-width: 9; opacity: 0.28; }
.waterline { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 3 5; opacity: 0.7; }
.place { font-family: var(--serif); font-style: italic; fill: var(--iron); }
.place--big { font-size: 26px; font-weight: 500; letter-spacing: 0.04em; }
.place--sea { font-size: 30px; letter-spacing: 0.32em; fill: var(--rhumb-green); opacity: 0.8; }
.towers rect { fill: var(--iron); }
.rose__ring { fill: none; stroke: var(--iron); stroke-width: 1; }
.rose__ring--thin { stroke-width: 0.6; stroke-dasharray: 2 2; }
.rose__point { fill: var(--rhumb-red); stroke: var(--iron); stroke-width: 0.6; }
.rose__point--minor { fill: var(--rhumb-green); }
.rose__point--shade { fill: var(--vellum-hi); }
.rose__n { font: 600 15px var(--serif); fill: var(--iron); }
.storm__arm { fill: none; stroke: var(--iron); stroke-width: 1.4; opacity: 0.75; }
.storm__rain { stroke: var(--iron-soft); stroke-width: 1; opacity: 0.6; }
.saga__bracket { fill: none; stroke: var(--iron); stroke-width: 1; }
.saga__name { font: italic 500 21px var(--serif); fill: var(--iron); letter-spacing: 0.02em; }
.scale__cell { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 0.8; }
.scale__cell--ink { fill: var(--iron); }
.scale__tick { stroke: var(--iron); stroke-width: 1.4; }
.scale__label { font: italic 500 16px var(--serif); fill: var(--iron); }
.meridians line { stroke: var(--iron-soft); stroke-width: 0.8; stroke-dasharray: 1 4; opacity: 0.6; }
.thread__ghost { stroke: var(--iron-soft); stroke-width: 0.6; stroke-dasharray: 1 6; opacity: 0.5; }
.thread__line { stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; }
.thread__knot { fill: var(--vellum); stroke: var(--ink); stroke-width: 2.4; }
.thread__part { font: 500 10.5px var(--sans); fill: var(--ink); opacity: 0; paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.chart.has-motif .thread { opacity: 0.18; }
.chart.has-motif .thread.is-on { opacity: 1; }
.thread.is-on .thread__line { stroke-width: 4; }
.thread.is-on .thread__knot { fill: var(--ink); r: 6.5; }
.thread.is-on .thread__part { opacity: 1; }
.route__line { fill: none; stroke: var(--rhumb-red); stroke-width: 2.2; stroke-dasharray: 9 5; }
.route__motif-span { fill: none; stroke: var(--ink); stroke-width: 2; opacity: 0.55; }
.route__motif-run { fill: none; stroke: var(--ink); stroke-width: 7; stroke-linecap: round; opacity: 0.85; }
.route__elapsed { font: italic 500 19px var(--serif); fill: var(--rhumb-red); letter-spacing: 0.08em; }
.port { cursor: pointer; outline: none; }
.port__hit { fill: transparent; }
.port__ring { fill: none; stroke: transparent; stroke-width: 3; }
.port__dot { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 2; transition: r 160ms; }
.port__pin { fill: var(--iron); }
.port__title, .port__when { paint-order: stroke; stroke: var(--vellum); stroke-width: 5px; stroke-linejoin: round; }
.port__title { font: 600 19px var(--serif); fill: var(--iron); }
.port__when { font: italic 500 14px var(--serif); fill: var(--iron-soft); }
.port:hover .port__dot, .port:focus-visible .port__dot, .port.is-near .port__dot { r: 9; }
.port.is-open .port__dot { fill: var(--rhumb-red); }
.chart.has-motif .port:not(.is-motif) .port__title { fill: #8c7b62; }
.port.is-motif .port__ring { stroke: var(--motif); }
.port.is-motif .port__dot { stroke: var(--motif); }
.ship { pointer-events: none; transition: none; }
.ship__hull { fill: var(--iron); }
.ship__mast { stroke: var(--iron); stroke-width: 1.6; }
.ship__sail { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1.3; }
.ship__pennant { fill: var(--rhumb-red); }
.ship__wake { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 4 3; }
/* ---------------------------------------------------------------- caption */
.caption { margin: 0; padding-left: 210px; font: 400 20px/1.3 var(--serif); color: var(--vellum-hi); min-height: 26px; }
.caption strong { font-weight: 600; }
.caption__lead { font-style: italic; color: #cbbd9b; }
/* ---------------------------------------------------------------- drawer */
.drawer {
position: fixed;
top: 100px;
right: 0;
bottom: 56px;
width: 400px;
z-index: 5;
padding: 22px 26px 24px;
overflow-y: auto;
background: var(--vellum-hi);
border-left: 3px double var(--iron);
box-shadow: -18px 0 30px -12px rgb(20 14 8 / 0.55);
transform: translateX(105%);
transition: transform 260ms ease-out;
}
.drawer.is-open { transform: none; }
.drawer__close {
float: right;
border: 1px solid var(--iron);
background: none;
font: 500 12px var(--sans);
color: var(--iron);
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
}
.drawer__meta { margin: 0; font: 400 12px var(--sans); color: var(--iron-soft); }
.drawer__title { margin: 6px 0 4px; font: 600 34px/1.05 var(--serif); }
.drawer__when { margin: 0 0 10px; font: italic 500 17px/1.35 var(--serif); color: var(--iron-soft); }
.drawer__odyssey { margin: 0 0 14px; font: 400 13px var(--sans); }
.drawer__odyssey a { color: var(--rhumb-red); }
.drawer__parts { margin: 0 0 12px; padding: 0; list-style: none; border-top: 1px solid #b9a47a; }
.drawer__parts li { display: grid; grid-template-columns: 70px 1fr; gap: 2px 10px; padding: 9px 0; border-bottom: 1px solid #b9a47a; }
.drawer__part { font: 600 16px var(--serif); }
.drawer__singers { font: 400 13px/1.4 var(--sans); }
.drawer__chips { grid-column: 2; display: flex; flex-wrap: wrap; gap: 5px; }
.chip {
border: 1.5px solid var(--ink);
border-radius: 999px;
background: none;
color: var(--ink);
font: 500 11px var(--sans);
padding: 2px 8px;
cursor: pointer;
}
.chip:hover { background: var(--ink); color: var(--vellum-hi); }
.drawer__motifs { margin: 0 0 14px; font: italic 500 15px var(--serif); color: var(--iron-soft); }
.drawer .audio-slot { margin-bottom: 12px; font-size: 13px; }
.drawer .video-card { font-size: 13px; }
/* ---------------------------------------------------------------- credits */
.fan-credits { color: #d8ccae; opacity: 0.9; position: relative; z-index: 6; }
/* ---------------------------------------------------------------- overlay (in-3D map) */
body.is-overlay { background: #0d2230; }
.is-overlay #app { align-items: center; justify-content: center; padding: 24px; }
.backdrop {
position: fixed;
inset: 0;
background:
linear-gradient(rgb(6 16 24 / 0.55), rgb(6 16 24 / 0.55)),
repeating-linear-gradient(172deg, transparent 0 22px, rgb(160 205 220 / 0.07) 22px 24px),
radial-gradient(ellipse at 50% 120%, #2c6b7a 0%, #133447 45%, #071521 100%);
}
.backdrop__note { position: absolute; left: 28px; top: 22px; font: 400 12px var(--sans); color: #8fb3c0; }
.frame {
position: relative;
width: 1200px;
padding: 16px 22px 14px;
display: flex;
flex-direction: column;
gap: 10px;
background: #4a3c2e;
border: 1px solid #8c7752;
border-radius: 6px;
box-shadow: 0 30px 80px rgb(0 0 0 / 0.6);
}
.is-overlay .masthead { align-items: center; }
.is-overlay .masthead__words { flex-basis: 220px; }
.is-overlay .masthead__title { font-size: 26px; }
.is-overlay .masthead__dek { font-size: 12px; }
.is-overlay .minimap { height: 40px; }
.is-overlay .legend { flex-basis: 150px; }
.is-overlay .legend__key { padding: 30px 10px 0 12px; font-size: 10.5px; line-height: 1.4; }
.is-overlay .legend__key p:last-child { display: none; }
.is-overlay .legend__item { height: 15px; font-size: 10.5px; left: 4px; padding: 0 5px; gap: 6px; }
.is-overlay .legend__item--character { font-size: 12.5px; }
.is-overlay .legend__swatch { flex-basis: 14px; }
.is-overlay .caption { padding-left: 150px; font-size: 17px; }
.is-overlay .fan-credits { color: #b9cfd8; }
.here {
position: absolute;
transform: translate(-50%, -100%);
padding: 4px 9px;
background: var(--iron);
color: var(--vellum-hi);
font: 500 12px var(--sans);
white-space: nowrap;
border-radius: 3px;
pointer-events: none;
}
.here::after {
content: "";
position: absolute;
left: 50%;
bottom: -5px;
margin-left: -5px;
border: 5px solid transparent;
border-bottom: 0;
border-top-color: var(--iron);
}
.port.is-here .port__dot { fill: var(--iron); }
.pop {
position: absolute;
transform: translate(-50%, 8px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
min-width: 170px;
padding: 10px 12px;
background: var(--vellum-hi);
border: 1.5px solid var(--iron);
border-radius: 4px;
box-shadow: 0 8px 18px rgb(20 14 8 / 0.35);
z-index: 3;
}
.pop[hidden] { display: none; }
.pop__title { font: 600 19px/1.1 var(--serif); }
.pop__when { font: italic 500 14px var(--serif); color: var(--iron-soft); }
.pop__sail {
margin-top: 6px;
border: 0;
border-radius: 3px;
padding: 6px 12px;
background: var(--rhumb-red);
color: var(--vellum-hi);
font: 500 13px var(--sans);
cursor: pointer;
}
.pop__sail:focus-visible { outline: 2px solid var(--iron); outline-offset: 2px; }
.pop__now { margin-top: 4px; font: 500 12px var(--sans); color: var(--rhumb-green); }
@media (prefers-reduced-motion: reduce) {
.drawer { transition: none; }
.port__dot { transition: none; }
}
+1 -1
View File
@@ -1,2 +1,2 @@
/* 11-chart-atlas stub */
body { margin: 0; }
body { margin: 0; --stub-11: 1; }