feat(mockups): 11 atlas with insets spike; round notes updated
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a422234970
commit
65b5aa0d00
@@ -124,6 +124,12 @@ This file exists because of that request. It gets updated after every user messa
|
||||
|
||||
- Resumed from section 7. Spikes 08, 09 and 10 were redispatched (3 concurrent), each continuing from its saved report.
|
||||
- Spike 11 follows when a slot frees.
|
||||
- **All four spikes are finished and committed on `feat/sea-chart-round`.** Each final report is in `docs/design/002-sea-chart-spikes/<slug>-report.md`.
|
||||
- 08 Sea chart + log line: a full Admiralty-style chart on top, synced to an editorial log line below.
|
||||
- 09 Schematic sea chart: an octilinear transit diagram over a faint chart, with a slider that morphs it into rough true geography.
|
||||
- 10 Unrolling chart: the editorial scroll drawn on a vellum portolan, with a ship marker and a minimap.
|
||||
- 11 Atlas with insets: an overview of the whole voyage on one screen; each island opens an atlas plate.
|
||||
- The preview at http://10.20.20.2:8099/ was rebuilt from this branch so the user can compare them.
|
||||
|
||||
## Open questions (in priority order)
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 11-chart-atlas: Atlas with insets (final report)
|
||||
|
||||
Status: done. Built into my own outdir, served on port 4111, checked in headless Chrome at 1440×900 and driven with Playwright (clicks, arrow keys, Esc, Sail here). `bun test` (42 pass) and `bun run typecheck` pass. The style.css is unique even with comments stripped. Nothing is committed.
|
||||
|
||||
## Navigation idea
|
||||
Two-level navigation, like a bound atlas: a general chart of the whole voyage, then one inset plate per saga. This mirrors the planned 3D landings.
|
||||
- **Look.** Slate book cloth around a cool paper plate. The plate has a checkered degree neatline, engraved water-lining around the coasts, and hand-tinted islands (Troy rose, Cyclops' Island sap green, the storm a prussian wash). It deliberately avoids 08's pale Admiralty look, 09's white schematic and 10's brown scroll.
|
||||
- **General chart (overview).** The whole voyage is on screen at once, with no scrolling: the Troy mainland, Cyclops' Island, and the open sea with its storm spiral, joined by a dashed course with numbered ports.
|
||||
- Each place has a label block with the place name, saga and plate number, song count and year span ("5 songs, years 0 to 1"), plus a numbered track list. That way every song title can be read from the overview.
|
||||
- "A year passes" and "two years at sea" are written on the crossings.
|
||||
- A cartouche reads "The voyage from Troy … Choose an island to open its plate."
|
||||
- **Motif at overview level.** Islands where the selected motif plays get a halo in the motif colour and a count badge ("Polyphemus plays in 2 of 4"). Places where it never plays dim and get a dashed "… is silent here" badge. Matching ports and track rows are tinted.
|
||||
- **Inset plates.** Clicking an island animates the SVG viewBox into that island (about 0.5s, skipped under reduced motion), then swaps in its plate.
|
||||
- Each plate has a title cartouche ("Plate II / Cyclops' Island / The Cyclops Saga, tracks 6 to 9 / Year 1") and a locator key map of the general chart, top right, with the plate's area boxed. Clicking the locator takes you back out.
|
||||
- The **scale bar is a scale of story time**: alternating bar segments sit under the ports, with year ticks where the year changes and "a year passes" written on the gap.
|
||||
- Songs are ports on a straight local course, each with its track number, title and italic story time. A new year drops the course by one diagonal step, and the year gap is stretched.
|
||||
- **Motif lines, transit-style.** Each theme that plays on a plate runs parallel to the course. A line is solid next to the ports where it plays and dotted where it is quiet between them. Dotted stubs follow the course to the plate edge when the theme plays on another plate. A station stem with coloured dots shows which themes play at each port. Selecting a theme thickens its line and dims the other lines and the ports that lack it. The themes panel shows counts as "on this plate / in the whole voyage", and themes absent from the plate are greyed.
|
||||
- **Navigation.**
|
||||
- Breadcrumbs ("Aegean › Cyclops' Island") take you back out.
|
||||
- Prev and next buttons in the header are labelled with the plate ("‹ I. Troy", "III. Open sea and storm ›").
|
||||
- ←/→ turn plates with a slide; → from the overview opens plate I.
|
||||
- Esc closes a song, then returns to the Aegean.
|
||||
- The course runs off the plate edge with "From plate I, Troy" and "Continued on plate III, Open sea and storm", and both notes are clickable.
|
||||
- **Song detail.** A bottom "slip" with three columns (identity, parts with singers and motif ticks, then theme chips, `createAudioSlot` and `createVideoCards`) slides up over the lower third of the inset. The course and ports stay visible above it.
|
||||
- **Overlay (`?overlay=1`).**
|
||||
- The overview sits compact on a wooden captain's table over a dimmed CSS sea.
|
||||
- The header carries a "You are here | Survive" tag, and a ship marker on the chart reads "You are here: Survive".
|
||||
- Clicking an island opens its inset inside the table.
|
||||
- Clicking a port opens a popover with "Sail here" and "Open song". Sailing moves the ship and the tag, and shows "Course set for Storm. In the 3D voyage the ship sails there now."
|
||||
- **URL state.** `?saga=`, `?song=` (opens its plate and the slip), `?motif=` and `?overlay=1` are supported, and the URL stays in sync as you navigate.
|
||||
|
||||
## What works
|
||||
- **The overview answers "what is in this voyage?" in one look.** It shows three places, ten titles, year spans and the passage of time, with no scrolling and no hunting. This is the strongest part of the spike.
|
||||
- **Motif selection at two levels.** On the overview you see which sagas a theme touches and how often. On a plate you see exactly which songs, transit-style. The two levels complement each other rather than repeat.
|
||||
- **The atlas furniture is real wayfinding, not decoration.**
|
||||
- The locator key map, the breadcrumbs, the "continued on plate" notes and the plate numbers all say where you are and how to move.
|
||||
- The scale bar doubles as the time axis.
|
||||
- **Insets map well to 3D landings.** Each plate is the 2D twin of a landing. The overlay reuses the same component with an island click leading to a plate, which is the flow the 3D would need.
|
||||
- **Legibility.** Port titles on the plates are 23px Cormorant, and story times are 16.5px italic.
|
||||
|
||||
## What doesn't
|
||||
- **Uneven plates.** The Ocean plate holds a single song (Storm), so it is mostly empty sea. Sagas vary a lot in size, and with the full album some plates would hold 6 or more songs. The inset spacing caps out at about 6 ports per plate before titles crowd.
|
||||
- **Most motif lines read as dotted.** With the sample data, few themes play in consecutive songs, so most of the band is quiet. The half-solid treatment helps, but the band is busier and less striking than mockup 03.
|
||||
- **The slip covers the island art.** The slip hides the lower half of the plate (the land shape and the scale bar). The course stays visible, but the plate loses its "map" feel while a song is open.
|
||||
- **Overlay text is small.** On the table the overview's track lists are about 12px: readable but cramped. The plates read fine in the overlay.
|
||||
- **One collision left.** On the overview, the "a year passes" note brushes the course loop between ports 5 and 6. The decorative geography (seeded blobs) is not accurate.
|
||||
- **Two clicks to reach any song from the overview** (island, then port). This is the cost of the two-level model. The overview track list could be made directly clickable to skip a step; I did not do that.
|
||||
- **Zoom transition not seen live.** The zoom was only verified headless (the end state is correct); I did not judge the motion's feel in a real browser.
|
||||
|
||||
## Screenshots
|
||||
- `screenshots/11-chart-atlas-default.png`: the general chart.
|
||||
- `screenshots/11-chart-atlas-motif.png`: the general chart with `?motif=polyphemus` (halos, counts, the silent badge on Troy).
|
||||
- `screenshots/11-chart-atlas-song.png`: the Cyclops' Island plate with the Survive slip open.
|
||||
- `screenshots/11-chart-atlas-overlay.png`: the captain's table, "You are here: Survive".
|
||||
- Extra:
|
||||
- `screenshots/11-chart-atlas-inset.png`: plate I, Troy.
|
||||
- `screenshots/11-chart-atlas-ocean.png`: plate III with `?motif=warrior`.
|
||||
- `screenshots/11-chart-atlas-overlay-inset.png`: the Cyclops plate inside the overlay.
|
||||
- `screenshots/11-chart-atlas-overlay-sail.png`: the Sail here popover.
|
||||
- `screenshots/11-chart-atlas-overlay-sailed.png`: after sailing to Storm.
|
||||
@@ -0,0 +1,291 @@
|
||||
// 11 · Atlas with insets: geometry for the general chart (overview) and each saga's inset plate.
|
||||
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;
|
||||
}
|
||||
|
||||
/** Hand-tinted colours on paper: the data colours are made for dark grounds, so deepen them. */
|
||||
export function inkTone(hex: string, t = 0.32): string {
|
||||
const ink = [0x24, 0x2b, 0x3a];
|
||||
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
// Odysseus' pale gold needs extra weight to read on paper.
|
||||
const lum = (0.299 * c[0]! + 0.587 * c[1]! + 0.114 * c[2]!) / 255;
|
||||
const k = lum > 0.8 ? 0.55 : t;
|
||||
return "#" + c.map((v, i) => Math.round(v * (1 - k) + ink[i]! * k).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- places */
|
||||
export interface Place {
|
||||
sagaId: string;
|
||||
place: string; // place name on the chart
|
||||
roman: string; // plate number
|
||||
tint: string; // watercolour wash
|
||||
label: Pt; // top-left of label block on the general chart
|
||||
focus: { x: number; y: number; w: number; h: number }; // zoom box on the general chart
|
||||
}
|
||||
|
||||
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
|
||||
|
||||
const PLACE_INFO: Record<string, Omit<Place, "sagaId" | "roman">> = {
|
||||
troy: { place: "Troy", tint: "#e7b9a9", label: { x: 30, y: 70 }, focus: { x: 0, y: 150, w: 470, h: 430 } },
|
||||
cyclops: { place: "Cyclops' Island", tint: "#bfd3a0", label: { x: 455, y: 92 }, focus: { x: 380, y: 200, w: 420, h: 380 } },
|
||||
ocean: { place: "Open sea and storm", tint: "#a9c0dc", 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,
|
||||
tint: "#d8d0c0",
|
||||
label: { x: 40 + i * 120, y: 560 },
|
||||
focus: { x: 0, y: 0, w: W, h: H },
|
||||
}),
|
||||
}));
|
||||
|
||||
export const placeOf = (sagaId: string) => places.find((p) => p.sagaId === sagaId)!;
|
||||
export const sagaById = (id: string): Saga => sagas.find((s) => s.id === id)!;
|
||||
export const songsIn = (sagaId: string): Song[] => songs.filter((s) => s.sagaId === sagaId);
|
||||
|
||||
export function yearSpan(list: Song[]): string {
|
||||
const ys = list.map((s) => s.storyTime.yearsAfterTroy);
|
||||
const a = Math.min(...ys), b = Math.max(...ys);
|
||||
return a === b ? `Year ${a}` : `Years ${a} to ${b}`;
|
||||
}
|
||||
|
||||
export function motifsPlaying(song: Song): Set<string> {
|
||||
return new Set(song.parts.flatMap((p) => p.motifIds));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- general chart */
|
||||
// Troy: mainland on the west edge with a headland. Cyclops' island mid-chart. Storm out east.
|
||||
export const TROY_CENTER = { x: 230, y: 360 };
|
||||
export const CYCLOPS = { cx: 585, cy: 405, rx: 135, ry: 92 };
|
||||
export const STORM = { cx: 945, cy: 335, r: 118 };
|
||||
|
||||
export function troyMainland(width = W, height = H, scale = 1): string {
|
||||
const r = rng(7);
|
||||
const pts: Pt[] = [];
|
||||
for (let y = -10; y <= height + 10; y += 26) {
|
||||
const yy = y / scale;
|
||||
let x = 248 + (r() - 0.5) * 18;
|
||||
const dy = yy - TROY_CENTER.y;
|
||||
if (Math.abs(dy) < 125) x = Math.max(x, TROY_CENTER.x + Math.sqrt(125 * 125 - dy * dy) * 0.9 + (r() - 0.5) * 12);
|
||||
pts.push({ x: x * scale, y });
|
||||
}
|
||||
void width;
|
||||
const path = smoothOpen(pts);
|
||||
return `${path} L-20,${height + 20} L-20,-20 Z`;
|
||||
}
|
||||
|
||||
/** Port positions on the general chart, one per song in story order. */
|
||||
export function overviewPorts(): Pt[] {
|
||||
const out: Pt[] = [];
|
||||
for (const place of places) {
|
||||
const list = songsIn(place.sagaId);
|
||||
const n = list.length;
|
||||
list.forEach((_, i) => {
|
||||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||||
if (place.sagaId === "troy") {
|
||||
const a = (-62 + t * 112) * (Math.PI / 180);
|
||||
out.push({ x: TROY_CENTER.x + Math.cos(a) * 172, y: TROY_CENTER.y + Math.sin(a) * 172 });
|
||||
} else if (place.sagaId === "cyclops") {
|
||||
const a = (165 - t * 150) * (Math.PI / 180);
|
||||
out.push({ x: CYCLOPS.cx + Math.cos(a) * (CYCLOPS.rx + 30), y: CYCLOPS.cy + Math.sin(a) * (CYCLOPS.ry + 30) });
|
||||
} else if (place.sagaId === "ocean") {
|
||||
const a = (200 + t * 140) * (Math.PI / 180);
|
||||
const rr = n === 1 ? 0 : 50;
|
||||
out.push({ x: STORM.cx + Math.cos(a) * rr, y: STORM.cy + Math.sin(a) * rr });
|
||||
} else {
|
||||
out.push({ x: place.label.x + 40 + i * 30, y: place.label.y - 30 });
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Route through the overview ports with a few sea waypoints between sagas. */
|
||||
export function overviewRoute(ports: Pt[]): Pt[] {
|
||||
const pts: Pt[] = [];
|
||||
songs.forEach((s, i) => {
|
||||
const prev = songs[i - 1];
|
||||
if (prev && prev.sagaId !== s.sagaId) {
|
||||
const a = ports[i - 1]!, b = ports[i]!;
|
||||
if (prev.sagaId === "troy") pts.push({ x: (a.x + b.x) / 2, y: Math.max(a.y, b.y) + 40 });
|
||||
else if (s.sagaId === "ocean") pts.push({ x: (a.x + b.x) / 2 + 20, y: (a.y + b.y) / 2 + 60 });
|
||||
}
|
||||
pts.push(ports[i]!);
|
||||
});
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Storm: a spiral of squall lines. */
|
||||
export function stormSpiral(cx: number, cy: number, r: number, turns = 2.6): string {
|
||||
const pts: Pt[] = [];
|
||||
const steps = 90;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const a = t * turns * Math.PI * 2;
|
||||
const rr = 14 + t * (r - 14);
|
||||
pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr * 0.78 });
|
||||
}
|
||||
return smoothOpen(pts);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- inset plates */
|
||||
export interface InsetPort { song: Song; x: number; y: number }
|
||||
export interface InsetLayout {
|
||||
ports: InsetPort[];
|
||||
/** Route polyline per segment i → i+1, starting and ending horizontally. */
|
||||
segments: Pt[][];
|
||||
/** Year gaps between consecutive ports (for "a year passes" notes). */
|
||||
gaps: number[];
|
||||
motifs: Motif[]; // motifs that play in this saga, in legend order
|
||||
lead: Pt[]; // route entering from the west edge (empty if first plate)
|
||||
tail: Pt[]; // route leaving to the east edge (empty if last plate)
|
||||
}
|
||||
|
||||
export const ROUTE_Y = 268;
|
||||
export const BAND_TOP = 18;
|
||||
export const BAND_STEP = 9;
|
||||
|
||||
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 : 130, right = hasTail ? 840 : 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 * 46 };
|
||||
});
|
||||
const segments: Pt[][] = [];
|
||||
for (let i = 0; i < ports.length - 1; i++) {
|
||||
const a = ports[i]!, b = ports[i + 1]!;
|
||||
const dy = b.y - a.y;
|
||||
if (Math.abs(dy) < 1) segments.push([{ x: a.x, y: a.y }, { x: b.x, y: b.y }]);
|
||||
else {
|
||||
const mid = (a.x + b.x) / 2;
|
||||
segments.push([
|
||||
{ x: a.x, y: a.y },
|
||||
{ x: mid - Math.abs(dy) / 2, y: a.y },
|
||||
{ x: mid + Math.abs(dy) / 2, y: b.y },
|
||||
{ x: b.x, y: b.y },
|
||||
]);
|
||||
}
|
||||
}
|
||||
const first = ports[0], last = ports[ports.length - 1];
|
||||
const lead = hasLead && first ? [{ x: 34, y: first.y }, { x: first.x, y: first.y }] : [];
|
||||
const tail = hasTail && last ? [{ x: last.x, y: last.y }, { x: W - 34, y: last.y }] : [];
|
||||
const ids = new Set(list.flatMap((s) => [...motifsPlaying(s)]));
|
||||
return { ports, segments, gaps, motifs: motifs.filter((m) => ids.has(m.id)), lead, tail };
|
||||
}
|
||||
|
||||
/** Offset an open polyline sideways by d (positive = to the right of travel, i.e. south when heading east). */
|
||||
export function offsetPolyline(pts: Pt[], d: number): Pt[] {
|
||||
const normals = pts.slice(1).map((p, i) => {
|
||||
const q = pts[i]!;
|
||||
const dx = p.x - q.x, dy = p.y - q.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
return { x: -dy / len, y: dx / len };
|
||||
});
|
||||
return pts.map((p, i) => {
|
||||
const n1 = normals[Math.max(0, i - 1)]!, n2 = normals[Math.min(normals.length - 1, i)]!;
|
||||
const dot = n1.x * n2.x + n1.y * n2.y;
|
||||
const k = d / (1 + dot);
|
||||
return { x: p.x + (n1.x + n2.x) * k, y: p.y + (n1.y + n2.y) * k };
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** The part of a polyline between fractions t0..t1 of its length. */
|
||||
export function subPolyline(pts: Pt[], t0: number, t1: number): Pt[] {
|
||||
const lens = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i]!.x, p.y - pts[i]!.y));
|
||||
const total = lens.reduce((a, b) => a + b, 0);
|
||||
const at = (d: number): { p: Pt; i: number } => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < lens.length; i++) {
|
||||
if (acc + lens[i]! >= d || i === lens.length - 1) {
|
||||
const k = lens[i]! ? Math.min(1, (d - acc) / lens[i]!) : 0;
|
||||
const a = pts[i]!, b = pts[i + 1]!;
|
||||
return { p: { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k }, i };
|
||||
}
|
||||
acc += lens[i]!;
|
||||
}
|
||||
return { p: pts[pts.length - 1]!, i: lens.length - 1 };
|
||||
};
|
||||
const A = at(t0 * total), B = at(t1 * total);
|
||||
return [A.p, ...pts.slice(A.i + 1, B.i + 1), B.p];
|
||||
}
|
||||
@@ -1,4 +1,679 @@
|
||||
// 11 · Atlas with insets: a general chart of the whole voyage, and one inset plate per saga.
|
||||
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 {
|
||||
BAND_STEP, BAND_TOP, CYCLOPS, H, STORM, TROY_CENTER, W, blob, inkTone, insetLayout, motifsPlaying,
|
||||
offsetPolyline, overviewPorts, subPolyline, overviewRoute, placeOf, places, polyD, sagaById, smoothClosed, smoothOpen,
|
||||
songsIn, stormSpiral, troyMainland, wrap, yearSpan, type Pt,
|
||||
} from "./geo";
|
||||
|
||||
mountCredits();
|
||||
document.getElementById("app")!.textContent = "Atlas with insets (stub)";
|
||||
|
||||
/* ------------------------------------------------------------------ state */
|
||||
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 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,
|
||||
shipAt: "survive",
|
||||
sailPick: (overlay ? initialSong?.id ?? null : null) as string | null,
|
||||
note: "" as string,
|
||||
};
|
||||
|
||||
if (overlay) document.body.classList.add("is-overlay");
|
||||
|
||||
const tone = (id: string) => inkTone(motifs.find((m) => m.id === id)!.color);
|
||||
|
||||
/* ------------------------------------------------------------------ dom helpers */
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
type Attrs = Record<string, string | number | undefined>;
|
||||
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] {
|
||||
const el = document.createElementNS(SVG, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
|
||||
parent?.append(el);
|
||||
return el;
|
||||
}
|
||||
function h<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text?: string, parent?: Element): HTMLElementTagNameMap[K] {
|
||||
const el = document.createElement(tag);
|
||||
if (cls) el.className = cls;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
parent?.append(el);
|
||||
return el;
|
||||
}
|
||||
function txt(parent: Element, x: number, y: number, text: string, cls: string, attrs: Attrs = {}) {
|
||||
const t = s("text", { x, y, class: cls, ...attrs }, parent);
|
||||
t.textContent = text;
|
||||
return t;
|
||||
}
|
||||
function activate(el: Element, label: string, fn: () => void) {
|
||||
el.setAttribute("tabindex", "0");
|
||||
el.setAttribute("role", "button");
|
||||
el.setAttribute("aria-label", label);
|
||||
el.addEventListener("click", (e) => { e.stopPropagation(); fn(); });
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "Enter" || k === " ") { e.preventDefault(); e.stopPropagation(); fn(); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ skeleton */
|
||||
const app = document.getElementById("app")!;
|
||||
app.className = "atlas";
|
||||
const head = h("header", "atlas-head", undefined, app);
|
||||
const body = h("div", "atlas-body", undefined, app);
|
||||
const plateWrap = h("section", "plate-wrap", undefined, body);
|
||||
const plate = h("div", "plate", undefined, plateWrap);
|
||||
const index = h("aside", "themes", undefined, body);
|
||||
index.setAttribute("aria-label", "Themes");
|
||||
|
||||
let svg: SVGSVGElement = s("svg", {});
|
||||
|
||||
/* ------------------------------------------------------------------ shared chart pieces */
|
||||
function neatline(root: SVGElement) {
|
||||
const g = s("g", { class: "neatline", "pointer-events": "none" }, root);
|
||||
s("rect", { x: 3, y: 3, width: W - 6, height: H - 6, class: "nl-outer" }, g);
|
||||
s("rect", { x: 9, y: 9, width: W - 18, height: H - 18, class: "nl-band" }, g);
|
||||
s("rect", { x: 13, y: 13, width: W - 26, height: H - 26, class: "nl-inner" }, g);
|
||||
}
|
||||
|
||||
/** Water-lining: concentric strokes outside a coast, the old engraver's way of showing shallows. */
|
||||
function waterLines(root: SVGElement, d: string) {
|
||||
const g = s("g", { class: "waterlines", "pointer-events": "none" }, root);
|
||||
for (const w of [34, 24, 15, 8]) {
|
||||
s("path", { d, class: "wl-ink", "stroke-width": w }, g);
|
||||
s("path", { d, class: "wl-paper", "stroke-width": w - 1.6 }, g);
|
||||
}
|
||||
}
|
||||
|
||||
let clipN = 0;
|
||||
function land(root: SVGElement, d: string, tint: string, extraCls = "") {
|
||||
const id = `clip${++clipN}`;
|
||||
const cp = s("clipPath", { id }, root);
|
||||
s("path", { d }, cp);
|
||||
s("path", { d, fill: tint, class: `land ${extraCls}` }, root);
|
||||
// Watercolour edge: a darker wash pooled just inside the coast.
|
||||
s("path", { d, stroke: tint, class: "land-edge", "clip-path": `url(#${id})` }, root);
|
||||
s("path", { d, class: "coast" }, root);
|
||||
}
|
||||
|
||||
function troyGlyph(root: SVGElement, x: number, y: number, k = 1) {
|
||||
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})` }, root);
|
||||
s("path", { d: "M-26,0 L-26,-14 L-20,-14 L-20,-22 L-14,-22 L-14,-14 L-4,-14 L-4,-30 L4,-30 L4,-14 L14,-14 L14,-22 L20,-22 L20,-14 L26,-14 L26,0 Z" }, g);
|
||||
s("path", { d: "M-2,-30 C-6,-40 4,-44 0,-54 C-4,-62 6,-66 2,-74", class: "smoke" }, g);
|
||||
}
|
||||
function caveGlyph(root: SVGElement, x: number, y: number, k = 1) {
|
||||
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})` }, 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 hachures(root: SVGElement, cx: number, cy: number, n: number, spread: number, seed: number) {
|
||||
let r = seed;
|
||||
const g = s("g", { class: "hachure", "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.35;
|
||||
s("path", { d: `M${x - 7},${y + 3} Q${x},${y - 6} ${x + 7},${y + 3}` }, g);
|
||||
}
|
||||
}
|
||||
function ship(root: SVGElement, x: number, y: number, label: string) {
|
||||
const g = s("g", { class: "ship", transform: `translate(${x} ${y})`, "pointer-events": "none" }, root);
|
||||
s("circle", { r: 15, class: "ship-ring" }, g);
|
||||
s("path", { d: "M-9,4 L9,4 L6,9 L-6,9 Z M0,4 L0,-11 L8,1 Z", class: "ship-hull" }, g);
|
||||
const t = txt(g, 0, -24, label, "ship-label", { "text-anchor": "middle" });
|
||||
const bb = { w: label.length * 6.6 + 16 };
|
||||
const bg = s("rect", { x: -bb.w / 2, y: -38, width: bb.w, height: 20, rx: 2, class: "ship-tag" });
|
||||
g.insertBefore(bg, t);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 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();
|
||||
|
||||
function drawGeneral(root: SVGElement, mini = false) {
|
||||
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
|
||||
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 = tone(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("circle", { cx: STORM.cx, cy: STORM.cy, r: STORM.r - 10, class: "halo", stroke: c }, root);
|
||||
}
|
||||
|
||||
if (!mini) { waterLines(root, TROY_SHAPE); waterLines(root, CYC_SHAPE); }
|
||||
// decorative islets
|
||||
for (const [x, y, rx, seed] of [[420, 120, 26, 3], [745, 625, 30, 5], [700, 150, 14, 13]] as const) {
|
||||
const d = smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14));
|
||||
if (!mini) waterLines(root, d);
|
||||
s("path", { d, class: "islet" }, root);
|
||||
}
|
||||
|
||||
const groups: Record<string, SVGGElement> = {};
|
||||
for (const p of places) {
|
||||
const g = s("g", { class: "isle", "data-saga": p.sagaId }, root);
|
||||
if (state.motifId && !lit(p.sagaId) && !mini) g.classList.add("is-dim");
|
||||
groups[p.sagaId] = g;
|
||||
}
|
||||
land(groups.troy!, TROY_SHAPE, placeOf("troy").tint);
|
||||
troyGlyph(groups.troy!, TROY_CENTER.x - 50, TROY_CENTER.y + 16, mini ? 1.4 : 1);
|
||||
land(groups.cyclops!, CYC_SHAPE, placeOf("cyclops").tint);
|
||||
if (!mini) { hachures(groups.cyclops!, CYCLOPS.cx - 10, CYCLOPS.cy - 10, 9, 150, 4); caveGlyph(groups.cyclops!, CYCLOPS.cx + 40, CYCLOPS.cy + 34); }
|
||||
s("circle", { cx: STORM.cx, cy: STORM.cy, r: STORM.r, class: "storm-wash" }, groups.ocean!);
|
||||
s("path", { d: stormSpiral(STORM.cx, STORM.cy, STORM.r - 6), class: "storm-spiral" }, groups.ocean!);
|
||||
|
||||
// Route
|
||||
s("path", { d: smoothOpen(OV_ROUTE), class: "course" }, root);
|
||||
if (mini) return;
|
||||
|
||||
// Year notes on long crossings
|
||||
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 a = OV_PORTS[i - 1]!, b = OV_PORTS[i]!;
|
||||
const note = g === 1 ? "a year passes" : `${g === 2 ? "two" : g} years at sea`;
|
||||
const east = song.sagaId !== "ocean";
|
||||
txt(root, (a.x + b.x) / 2 + (east ? 18 : -16), (a.y + b.y) / 2 + (east ? 10 : -4), note, "time-note", { "text-anchor": east ? "start" : "end" });
|
||||
});
|
||||
|
||||
// Ports
|
||||
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, class: "ov-port" + (motifSongs && !on ? " is-dim" : "") }, root);
|
||||
if (on) c.setAttribute("fill", tone(state.motifId!));
|
||||
txt(root, p.x + (song.sagaId === "troy" ? 12 : 0), p.y + (song.sagaId === "troy" ? 4 : song.sagaId === "ocean" ? 22 : -11), String(song.track), "ov-num", { "text-anchor": song.sagaId === "troy" ? "start" : "middle" });
|
||||
});
|
||||
|
||||
// Label blocks, with the track list so song titles are legible from the general chart
|
||||
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 = txt(lb, 0, 64 + j * 19, "", "track-row" + (motifSongs && !on ? " is-dim" : ""));
|
||||
const n = s("tspan", { class: "track-n" }, row); n.textContent = String(song.track).padStart(2, " ") + " ";
|
||||
const t = s("tspan", {}, row); t.textContent = song.title;
|
||||
if (on) row.setAttribute("fill", tone(state.motifId!));
|
||||
});
|
||||
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 * 19 + 8;
|
||||
const label = count ? `${m.name} plays in ${count} of ${list.length}` : `${m.name} is silent here`;
|
||||
const badge = s("g", { class: "motif-badge" + (count ? "" : " is-silent"), transform: `translate(0 ${y})` }, lb);
|
||||
s("rect", { x: 0, y: 0, width: label.length * 6.4 + 20, height: 22, rx: 11, fill: count ? tone(m.id) : "none" }, badge);
|
||||
txt(badge, 10, 15, label, "badge-text");
|
||||
}
|
||||
activate(g, `Open plate ${p.roman}: ${p.place}, ${list.length} songs`, () => openInset(p.sagaId));
|
||||
}
|
||||
|
||||
// Overlay: where the ship is now
|
||||
if (overlay) {
|
||||
const i = songs.findIndex((x) => x.id === state.shipAt);
|
||||
const p = OV_PORTS[i]!;
|
||||
ship(root, p.x, p.y, `You are here: ${songs[i]!.title}`);
|
||||
}
|
||||
|
||||
// North arrow
|
||||
const n = s("g", { class: "north", transform: "translate(330 652)", "pointer-events": "none" }, root);
|
||||
s("path", { d: "M0,-30 L8,8 L0,2 L-8,8 Z" }, n);
|
||||
txt(n, 0, -36, "N", "north-n", { "text-anchor": "middle" });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ inset plate */
|
||||
function insetLand(root: SVGElement, sagaId: string, tint: string) {
|
||||
if (sagaId === "troy") {
|
||||
const pts: Pt[] = [];
|
||||
const rnd = (() => { let t = 21; return () => ((t = (t * 9301 + 49297) % 233280) / 233280); })();
|
||||
for (let x = -20; x <= W + 20; x += 55) pts.push({ x, y: 478 + Math.sin(x / 140) * 22 + (rnd() - 0.5) * 16 - (x < 260 ? (260 - x) * 0.25 : 0) });
|
||||
const d = `${smoothOpen(pts)} L${W + 20},${H + 20} L-20,${H + 20} Z`;
|
||||
waterLines(root, d);
|
||||
land(root, d, tint);
|
||||
troyGlyph(root, 210, 588, 1.6);
|
||||
txt(root, 330, 590, "Troy", "inset-land-name");
|
||||
hachures(root, 720, 600, 10, 300, 8);
|
||||
} else if (sagaId === "cyclops") {
|
||||
const d = smoothClosed(blob(560, 600, 430, 120, 17, 0.08, 26));
|
||||
waterLines(root, d);
|
||||
land(root, d, tint);
|
||||
hachures(root, 720, 590, 12, 260, 12);
|
||||
caveGlyph(root, 380, 600, 1.6);
|
||||
txt(root, 470, 616, "Cyclops' Island", "inset-land-name");
|
||||
} else {
|
||||
s("circle", { cx: 545, cy: 330, r: 290, class: "storm-wash" }, root);
|
||||
s("path", { d: stormSpiral(545, 330, 280, 3.2), class: "storm-spiral" }, root);
|
||||
for (const [x, y, rx, seed] of [[180, 560, 30, 4], [880, 600, 22, 6]] as const) {
|
||||
const d = smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14));
|
||||
waterLines(root, d);
|
||||
s("path", { d, class: "islet" }, root);
|
||||
}
|
||||
txt(root, 545, 610, "Open sea", "inset-land-name", { "text-anchor": "middle" });
|
||||
}
|
||||
}
|
||||
|
||||
function drawInset(root: SVGElement, sagaId: string) {
|
||||
const place = placeOf(sagaId);
|
||||
const L = insetLayout(sagaId);
|
||||
const pIdx = places.indexOf(place);
|
||||
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
|
||||
insetLand(root, sagaId, place.tint);
|
||||
|
||||
const sel = state.motifId;
|
||||
const sagaSongs = L.ports.map((p) => p.song);
|
||||
const offsetOf = new Map(L.motifs.map((m, k) => [m.id, BAND_TOP + k * BAND_STEP]));
|
||||
const earlier = new Set(songs.filter((x) => placeOf(x.sagaId) && 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)]));
|
||||
|
||||
// Route (ship's course): dashed track
|
||||
const courseG = s("g", { class: "inset-course" }, root);
|
||||
if (L.lead.length) s("path", { d: polyD(L.lead), class: "course course--edge" }, courseG);
|
||||
if (L.tail.length) s("path", { d: polyD(L.tail), class: "course course--edge" }, courseG);
|
||||
for (const seg of L.segments) s("path", { d: polyD(seg), class: "course" }, courseG);
|
||||
|
||||
// Motif lines, transit-style, running alongside the course on its seaward side
|
||||
const lines = s("g", { class: "motif-lines" }, root);
|
||||
for (const m of L.motifs) {
|
||||
const d = offsetOf.get(m.id)!;
|
||||
const c = tone(m.id);
|
||||
const g = s("g", { class: "mline" + (sel && sel !== m.id ? " is-dim" : "") + (sel === m.id ? " is-on" : ""), stroke: c }, lines);
|
||||
const idx = sagaSongs.map((x, i) => (motifsPlaying(x).has(m.id) ? i : -1)).filter((i) => i >= 0);
|
||||
const first = idx[0]!, last = idx[idx.length - 1]!;
|
||||
for (let i = first; i < last; i++) {
|
||||
// A theme line is solid for the half-segment next to each port where it plays, quiet in between.
|
||||
const off = offsetPolyline(L.segments[i]!, d);
|
||||
const a = idx.includes(i), b = idx.includes(i + 1);
|
||||
if (a && b) { s("path", { d: polyD(off), class: "ml-solid" }, g); continue; }
|
||||
s("path", { d: polyD(subPolyline(off, a ? 0.42 : 0, b ? 0.58 : 1)), class: "ml-quiet" }, g);
|
||||
if (a) s("path", { d: polyD(subPolyline(off, 0, 0.42)), class: "ml-solid" }, g);
|
||||
if (b) s("path", { d: polyD(subPolyline(off, 0.58, 1)), class: "ml-solid" }, g);
|
||||
}
|
||||
// Quiet stubs follow the course out to the plate edge when the theme plays on another plate.
|
||||
const along = (segs: Pt[][]) => segs.flatMap((sg, k) => (k ? sg.slice(1) : sg));
|
||||
if (earlier.has(m.id) && L.lead.length) s("path", { d: polyD(offsetPolyline(along([L.lead, ...L.segments.slice(0, first)]), d)), class: "ml-quiet" }, g);
|
||||
if (later.has(m.id) && L.tail.length) s("path", { d: polyD(offsetPolyline(along([...L.segments.slice(last), L.tail]), d)), class: "ml-quiet" }, g);
|
||||
}
|
||||
|
||||
|
||||
// Ports (stations)
|
||||
for (const p of L.ports) {
|
||||
const playing = motifsPlaying(p.song);
|
||||
const offs = [...playing].map((id) => offsetOf.get(id)!).filter((v) => v !== undefined);
|
||||
const deepest = Math.max(0, ...offs);
|
||||
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 - 70, y: p.y - 92, width: 140, height: 190 + deepest, class: "port-hit" }, g);
|
||||
if (deepest) s("line", { x1: p.x, y1: p.y, x2: p.x, y2: p.y + deepest, class: "port-stem" }, g);
|
||||
for (const id of playing) {
|
||||
const off = offsetOf.get(id);
|
||||
if (off === undefined) continue;
|
||||
s("circle", { cx: p.x, cy: p.y + off, r: sel === id ? 5 : 3.6, fill: tone(id), class: "port-dot" + (sel && sel !== id ? " is-dim" : "") }, 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.2, class: "port-core" }, g);
|
||||
const lines2 = wrap(p.song.title, 15);
|
||||
txt(g, p.x, p.y - 22 - (lines2.length - 1) * 23 - 24, `Track ${p.song.track}`, "port-track", { "text-anchor": "middle" });
|
||||
lines2.forEach((ln, k) => txt(g, p.x, p.y - 22 - (lines2.length - 1 - k) * 23, ln, "port-title", { "text-anchor": "middle" }));
|
||||
wrap(p.song.storyTime.label, 20).forEach((ln, k) => txt(g, p.x, p.y + BAND_TOP + Math.max(0, L.motifs.length - 1) * BAND_STEP + 30 + 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));
|
||||
}
|
||||
|
||||
// Edge notes: "continued on plate…"
|
||||
if (L.lead.length) {
|
||||
const prev = places[pIdx - 1]!;
|
||||
const g = s("g", { class: "plate-link" }, root);
|
||||
const y = L.lead[0]!.y + BAND_TOP + L.motifs.length * BAND_STEP + 22;
|
||||
txt(g, 36, y, `From plate ${prev.roman}`, "edge-note");
|
||||
txt(g, 36, y + 19, prev.place, "edge-note edge-place");
|
||||
s("path", { d: `M28,${L.lead[0]!.y} l10,-6 v12 z`, class: "edge-arrow" }, g);
|
||||
activate(g, `Back to plate ${prev.roman}: ${prev.place}`, () => stepInset(-1));
|
||||
}
|
||||
if (L.tail.length) {
|
||||
const next = places[pIdx + 1]!;
|
||||
const g = s("g", { class: "plate-link" }, root);
|
||||
const y = L.tail[1]!.y + BAND_TOP + L.motifs.length * BAND_STEP + 22;
|
||||
txt(g, W - 36, y, `Continued on plate ${next.roman}`, "edge-note", { "text-anchor": "end" });
|
||||
txt(g, W - 36, y + 19, next.place, "edge-note edge-place", { "text-anchor": "end" });
|
||||
s("path", { d: `M${W - 28},${L.tail[1]!.y} l-10,-6 v12 z`, class: "edge-arrow" }, g);
|
||||
activate(g, `Continue to plate ${next.roman}: ${next.place}`, () => stepInset(1));
|
||||
}
|
||||
|
||||
// Scale of story time, aligned under the ports
|
||||
const sc = s("g", { class: "scale", transform: "translate(0 664)" }, root);
|
||||
const xs = L.ports.map((p) => p.x);
|
||||
const x0 = xs.length > 1 ? xs[0]! : xs[0]! - 60, x1 = xs.length > 1 ? xs[xs.length - 1]! : xs[0]! + 60;
|
||||
txt(sc, x0 - 14, 4, "Scale of story time", "scale-cap", { "text-anchor": "end" });
|
||||
const stops = xs.length > 1 ? xs : [x0, x1];
|
||||
for (let i = 0; i < stops.length - 1; i++) s("rect", { x: stops[i]!, y: -4, width: stops[i + 1]! - stops[i]!, height: 6, class: i % 2 ? "scale-w" : "scale-b" }, sc);
|
||||
let lastYear = -1;
|
||||
L.ports.forEach((p) => {
|
||||
const y = p.song.storyTime.yearsAfterTroy;
|
||||
s("line", { x1: p.x, x2: p.x, y1: -8, y2: 4, class: "scale-tick" }, sc);
|
||||
if (y !== lastYear) txt(sc, p.x, -14, `Year ${y}`, "scale-year", { "text-anchor": "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, -12, 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(866 34)" }, root);
|
||||
s("rect", { x: -4, y: -4, width: 218, height: 147, 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, 156, "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) ship(root, at.x, at.y, `You are here: ${at.song.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ html chrome */
|
||||
let sheet: HTMLElement | null = null;
|
||||
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 to open its plate.", 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 songSheet(song: Song) {
|
||||
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 = tone(id);
|
||||
t.title = motifs.find((m) => m.id === id)!.name;
|
||||
}
|
||||
}
|
||||
|
||||
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", tone(m.id));
|
||||
chip.addEventListener("click", () => toggleMotif(m.id));
|
||||
}
|
||||
c.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 + 40}px`;
|
||||
h("p", "sp-title", song.title, 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" : "EPIC, an atlas", brand);
|
||||
h("p", "brand-sub", overlay ? "The chart, laid out over the 3D voyage." : "A fan's chart of Odysseus's voyage in 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");
|
||||
h("p", "keys", "Arrow keys turn plates. Esc returns to the Aegean.", nav);
|
||||
} else {
|
||||
h("p", "keys", "Choose an island, 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;
|
||||
h("h2", "th-title", "Themes", index);
|
||||
h("p", "th-sub", state.sagaId ? "Songs on this plate, and in the whole voyage." : "Pick one to light the islands where it plays.", 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", tone(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));
|
||||
}
|
||||
}
|
||||
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"],
|
||||
["k-line", "Theme plays at both ports"],
|
||||
["k-quiet", "Theme is silent, returns later"],
|
||||
];
|
||||
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();
|
||||
sheet = null; 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);
|
||||
neatline(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) songSheet(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);
|
||||
const str = q.toString();
|
||||
history.replaceState(null, "", str ? `?${str}` : location.pathname);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ navigation */
|
||||
let busy = false;
|
||||
function zoomViewBox(to: { x: number; y: number; w: number; h: number }, from: { x: number; y: number; w: number; h: number }, 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: { x: number; y: number; w: number; h: number }) {
|
||||
// keep the plate's aspect so the zoom does not distort
|
||||
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) {
|
||||
if (busy) return;
|
||||
state.note = "";
|
||||
if (state.sagaId) { state.sagaId = sagaId; render("next"); return; }
|
||||
plate.classList.add("is-zooming");
|
||||
zoomViewBox(fitFocus(placeOf(sagaId).focus), FULL, 520, () => {
|
||||
plate.classList.remove("is-zooming");
|
||||
state.sagaId = sagaId;
|
||||
render("in");
|
||||
});
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -1,2 +1,346 @@
|
||||
/* 11-chart-atlas stub */
|
||||
body { margin: 0; --stub-11: 1; }
|
||||
/* 11 · Atlas with insets: a bound atlas. A general chart of the voyage, then one inset plate per saga. */
|
||||
@import "@fontsource/cormorant-garamond/latin-400.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-500.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-600.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-400-italic.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-600-italic.css";
|
||||
@import "@fontsource/inter/400.css";
|
||||
@import "@fontsource/inter/500.css";
|
||||
@import "@fontsource/inter/600.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--cloth: #2f3d4b;
|
||||
--cloth-deep: #26323e;
|
||||
--paper: #eff1eb;
|
||||
--paper-warm: #f7f7f1;
|
||||
--sea: #e3eae8;
|
||||
--ink: #242b3a;
|
||||
--ink-soft: #5a6372;
|
||||
--carmine: #a3303d;
|
||||
--wood: #5d3f2a;
|
||||
--atlas-serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
|
||||
--atlas-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(--paper);
|
||||
font-family: var(--atlas-sans);
|
||||
background:
|
||||
repeating-linear-gradient(45deg, rgb(255 255 255 / 0.018) 0 2px, transparent 2px 5px),
|
||||
repeating-linear-gradient(-45deg, rgb(0 0 0 / 0.05) 0 2px, transparent 2px 5px),
|
||||
var(--cloth);
|
||||
}
|
||||
button { font: inherit; color: inherit; cursor: pointer; }
|
||||
:focus-visible { outline: 2px solid #f0c36b; outline-offset: 2px; }
|
||||
|
||||
/* ------------------------------------------------------------- frame */
|
||||
.atlas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
padding: 0 28px;
|
||||
}
|
||||
.atlas-head {
|
||||
display: grid;
|
||||
grid-template-columns: 330px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 0 10px;
|
||||
}
|
||||
.brand-title { margin: 0; font: 600 30px/1 var(--atlas-serif); letter-spacing: 0.01em; }
|
||||
.brand-sub { margin: 5px 0 0; font-size: 12.5px; opacity: 0.72; }
|
||||
|
||||
.crumbs { white-space: nowrap; display: flex; align-items: baseline; gap: 10px; font: italic 500 24px/1.1 var(--atlas-serif); }
|
||||
.crumb { background: none; border: 0; padding: 0; }
|
||||
button.crumb { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 5px; opacity: 0.8; }
|
||||
button.crumb:hover { opacity: 1; color: #f0c36b; }
|
||||
.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(239 241 235 / 0.06);
|
||||
border: 1px solid rgb(239 241 235 / 0.38);
|
||||
border-radius: 2px;
|
||||
padding: 5px 12px 6px;
|
||||
font: 500 17px/1.1 var(--atlas-serif);
|
||||
}
|
||||
.pn:hover:not(:disabled) { background: rgb(239 241 235 / 0.16); }
|
||||
.pn:disabled { opacity: 0.3; cursor: default; }
|
||||
.keys { margin: 0; flex-basis: 100%; text-align: right; font-size: 11.5px; opacity: 0.62; }
|
||||
|
||||
.atlas-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 252px;
|
||||
gap: 20px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.plate-wrap { min-height: 0; min-width: 0; display: flex; align-items: flex-start; justify-content: center; }
|
||||
.plate {
|
||||
position: relative;
|
||||
width: min(100%, calc((100vh - 136px) * 1100 / 700));
|
||||
aspect-ratio: 1100 / 700;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 0.25) inset, 0 18px 40px rgb(10 16 24 / 0.45), 0 2px 4px rgb(10 16 24 / 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* ------------------------------------------------------------- chart ink */
|
||||
.sea { fill: var(--sea); }
|
||||
.wl-ink { fill: none; stroke: rgb(36 43 58 / 0.24); stroke-linejoin: round; }
|
||||
.wl-paper { fill: none; stroke: var(--sea); stroke-linejoin: round; }
|
||||
.land-edge { fill: none; stroke-width: 16; opacity: 0.75; mix-blend-mode: multiply; }
|
||||
.coast { fill: none; stroke: var(--ink); stroke-width: 1.3; stroke-linejoin: round; }
|
||||
.islet { fill: #dcd6c4; stroke: var(--ink); stroke-width: 1; }
|
||||
.halo { fill: none; stroke-width: 18; opacity: 0.6; stroke-linejoin: round; }
|
||||
.storm-wash { fill: rgb(96 128 176 / 0.16); }
|
||||
.storm-spiral { fill: none; stroke: #4f6587; stroke-width: 1.3; stroke-dasharray: 6 4; }
|
||||
.course { fill: none; stroke: var(--ink); stroke-width: 2; stroke-dasharray: 8 5; stroke-linecap: round; }
|
||||
.course--edge { opacity: 0.55; }
|
||||
.glyph path { fill: var(--ink); }
|
||||
.glyph .smoke { fill: none; stroke: var(--ink-soft); stroke-width: 1.4; }
|
||||
.glyph .hill { fill: none; stroke: var(--ink); stroke-width: 1.6; }
|
||||
.hachure path { fill: none; stroke: rgb(36 43 58 / 0.55); stroke-width: 1.1; }
|
||||
.north path { fill: var(--ink); }
|
||||
.north-n { font: 600 16px var(--atlas-serif); fill: var(--ink); }
|
||||
|
||||
.nl-outer { fill: none; stroke: var(--ink); stroke-width: 1.5; }
|
||||
.nl-band { fill: none; stroke: var(--ink); stroke-width: 6; stroke-dasharray: 44 44; }
|
||||
.nl-inner { fill: none; stroke: var(--ink); stroke-width: 0.8; }
|
||||
|
||||
.time-note { font: italic 500 18px var(--atlas-serif); fill: var(--carmine); }
|
||||
.is-dim { opacity: 0.3; }
|
||||
|
||||
/* general chart */
|
||||
.isle { cursor: pointer; outline: none; }
|
||||
.isle:hover .coast, .isle:focus-visible .coast { stroke: var(--carmine); stroke-width: 2.6; }
|
||||
.isle:hover .place-name, .isle:focus-visible .place-name { fill: var(--carmine); }
|
||||
.isle:focus-visible .storm-spiral { stroke: var(--carmine); }
|
||||
.ov-port { fill: var(--paper); stroke: var(--ink); stroke-width: 1.6; }
|
||||
.ov-num { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
.place-name { font: italic 600 34px var(--atlas-serif); fill: var(--ink); }
|
||||
.place-sub { font: 500 12.5px var(--atlas-sans); fill: var(--ink); }
|
||||
.place-meta { font: 400 12.5px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
.track-row { font: 500 17px var(--atlas-serif); fill: var(--ink); }
|
||||
.track-n { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
.motif-badge rect { stroke: none; }
|
||||
.motif-badge.is-silent rect { stroke: var(--ink-soft); stroke-dasharray: 3 3; }
|
||||
.badge-text { font: 600 12px var(--atlas-sans); fill: #fff; }
|
||||
.motif-badge.is-silent .badge-text { fill: var(--ink-soft); font-weight: 500; }
|
||||
|
||||
.ship-ring { fill: var(--paper); stroke: var(--carmine); stroke-width: 2.2; }
|
||||
.ship-hull { fill: var(--carmine); }
|
||||
.ship-tag { fill: var(--carmine); }
|
||||
.ship-label { font: 600 12px var(--atlas-sans); fill: #fff; }
|
||||
|
||||
/* inset plates */
|
||||
.inset-land-name { font: italic 500 46px var(--atlas-serif); letter-spacing: 0.14em; fill: rgb(36 43 58 / 0.6); }
|
||||
.mline .ml-solid { fill: none; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.mline .ml-quiet { fill: none; stroke-width: 2.2; stroke-dasharray: 0.5 6; stroke-linecap: round; }
|
||||
.mline.is-on .ml-solid { stroke-width: 7.5; }
|
||||
.mline.is-on .ml-quiet { stroke-width: 3.4; }
|
||||
.mline.is-dim { opacity: 0.16; }
|
||||
.port { cursor: pointer; outline: none; }
|
||||
.port-hit { fill: transparent; }
|
||||
.port-stem { stroke: var(--ink); stroke-width: 1.4; }
|
||||
.port-dot { stroke: var(--paper); stroke-width: 1.5; }
|
||||
.port-dot.is-dim { opacity: 0.25; }
|
||||
.port-ring { fill: var(--paper); stroke: var(--ink); stroke-width: 2.4; }
|
||||
.port-core { fill: var(--ink); }
|
||||
.port-title { font: 600 23px var(--atlas-serif); fill: var(--ink); }
|
||||
.port-track { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); letter-spacing: 0.02em; }
|
||||
.port-time { font: italic 500 16.5px var(--atlas-serif); fill: var(--ink-soft); }
|
||||
.port:hover .port-title, .port:focus-visible .port-title { fill: var(--carmine); }
|
||||
.port:focus-visible .port-ring, .port.is-open .port-ring { stroke: var(--carmine); stroke-width: 3.6; }
|
||||
.port.is-open .port-core { fill: var(--carmine); }
|
||||
.port.is-dim { opacity: 0.32; }
|
||||
|
||||
.plate-link { cursor: pointer; outline: none; }
|
||||
.edge-note { font: italic 500 16px var(--atlas-serif); fill: var(--ink); }
|
||||
.edge-arrow { fill: var(--ink); }
|
||||
.edge-place { font-weight: 600; }
|
||||
.plate-link:hover .edge-note, .plate-link:focus-visible .edge-note { fill: var(--carmine); text-decoration: underline; }
|
||||
|
||||
.scale-b { fill: var(--ink); }
|
||||
.scale-w { fill: var(--paper); stroke: var(--ink); stroke-width: 0.8; }
|
||||
.scale-tick { stroke: var(--ink); stroke-width: 1; }
|
||||
.scale-year { font: 600 11.5px var(--atlas-sans); fill: var(--ink); }
|
||||
.scale-cap { font: italic 500 16px var(--atlas-serif); fill: var(--ink); }
|
||||
|
||||
.locator { cursor: pointer; outline: none; }
|
||||
.loc-frame { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
|
||||
.locator:hover .loc-frame, .locator:focus-visible .loc-frame { stroke: var(--carmine); stroke-width: 2; }
|
||||
.loc-focus { fill: rgb(163 48 61 / 0.08); stroke: var(--carmine); stroke-width: 12; }
|
||||
.loc-cap { font: 500 10.5px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
|
||||
/* ------------------------------------------------------------- cartouche */
|
||||
.cartouche {
|
||||
position: absolute;
|
||||
left: 3.1%;
|
||||
top: 4.4%;
|
||||
max-width: 290px;
|
||||
padding: 10px 16px 11px;
|
||||
background: var(--paper-warm);
|
||||
border: 4px double var(--ink);
|
||||
pointer-events: none;
|
||||
}
|
||||
.plate:not(.is-inset) .cartouche { left: auto; right: 3.1%; max-width: 330px; }
|
||||
.c-kicker { margin: 0; font: italic 500 15px var(--atlas-serif); color: var(--ink-soft); }
|
||||
.c-title { margin: 0 0 4px; font: 600 28px/1.05 var(--atlas-serif); }
|
||||
.c-line { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--ink); }
|
||||
.c-hint { margin-top: 4px; color: var(--carmine); font-weight: 500; }
|
||||
|
||||
/* ------------------------------------------------------------- song slip */
|
||||
.slip {
|
||||
position: absolute;
|
||||
left: 14px; right: 14px; bottom: 14px;
|
||||
height: 36%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1.15fr;
|
||||
gap: 28px;
|
||||
padding: 18px 26px 16px;
|
||||
background: var(--paper-warm);
|
||||
border-top: 5px double var(--ink);
|
||||
box-shadow: 0 -14px 26px rgb(36 43 58 / 0.14);
|
||||
animation: slip-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.slip-close {
|
||||
position: absolute; right: 18px; top: 12px;
|
||||
background: none; border: 1px solid var(--ink-soft); border-radius: 2px;
|
||||
padding: 3px 10px; font-size: 12px; color: var(--ink);
|
||||
}
|
||||
.slip-close:hover { border-color: var(--carmine); color: var(--carmine); }
|
||||
.slip-col { min-width: 0; overflow: auto; }
|
||||
.slip-track { margin: 0; font-size: 12px; color: var(--ink-soft); }
|
||||
.slip-title { margin: 2px 0 6px; font: 600 36px/1.02 var(--atlas-serif); }
|
||||
.slip-time { margin: 0 0 10px; font: italic 500 18px/1.3 var(--atlas-serif); }
|
||||
.slip-odyssey { font-size: 13px; color: var(--carmine); }
|
||||
.slip-odyssey.is-none { margin: 0; color: var(--ink-soft); }
|
||||
.slip-h { margin: 4px 0 8px; font: italic 600 18px var(--atlas-serif); }
|
||||
.parts { list-style: none; margin: 0; padding: 0; }
|
||||
.parts li {
|
||||
display: grid; grid-template-columns: 78px 1fr auto; gap: 10px; align-items: baseline;
|
||||
padding: 6px 0; border-bottom: 1px solid rgb(36 43 58 / 0.14); font-size: 13px;
|
||||
}
|
||||
.part-name { font: 600 16px var(--atlas-serif); }
|
||||
.part-motifs { display: flex; gap: 3px; }
|
||||
.tick { display: inline-block; width: 14px; height: 5px; border-radius: 1px; }
|
||||
.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(--ink);
|
||||
}
|
||||
.chip.is-on { background: var(--c); color: #fff; }
|
||||
.slip .audio-slot { font-size: 12.5px; margin-bottom: 8px; padding: 8px 10px; border-radius: 2px; }
|
||||
.slip .video-card { font-size: 12.5px; border-radius: 2px; padding: 5px 10px; color: var(--ink); }
|
||||
|
||||
/* ------------------------------------------------------------- themes slip */
|
||||
.themes {
|
||||
align-self: start;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
padding: 18px 16px 16px;
|
||||
box-shadow: 0 12px 30px rgb(10 16 24 / 0.38);
|
||||
border-top: 4px double var(--ink);
|
||||
}
|
||||
.th-title { margin: 0; font: 600 26px var(--atlas-serif); }
|
||||
.th-sub { margin: 2px 0 10px; font-size: 12px; line-height: 1.4; color: var(--ink-soft); }
|
||||
.th-group { margin: 12px 0 4px; font: italic 600 16px var(--atlas-serif); }
|
||||
.th-list { list-style: none; margin: 0; padding: 0; }
|
||||
.th-row {
|
||||
width: 100%; display: grid; grid-template-columns: 24px 1fr auto; gap: 9px; align-items: center;
|
||||
background: none; border: 0; border-left: 3px solid transparent; padding: 5px 6px 5px 5px; text-align: left; font-size: 13.5px;
|
||||
}
|
||||
.th-row:hover { background: rgb(36 43 58 / 0.06); }
|
||||
.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: 5px; border-radius: 1px; background: var(--c); }
|
||||
.th-count { font-size: 12px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
|
||||
.th-key { margin-top: 10px; border-top: 1px solid rgb(36 43 58 / 0.18); padding-top: 2px; }
|
||||
.key-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; font-size: 12px; color: var(--ink-soft); }
|
||||
.key-sym { width: 24px; flex: none; display: inline-block; }
|
||||
.k-course { border-top: 2px dashed var(--ink); }
|
||||
.k-port { width: 14px; height: 14px; margin: 0 5px; border: 2.4px solid var(--ink); border-radius: 50%; background: var(--paper); }
|
||||
.k-line { height: 5px; background: #8d6b2e; border-radius: 3px; }
|
||||
.k-quiet { border-top: 3px dotted #8d6b2e; }
|
||||
|
||||
/* ------------------------------------------------------------- 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: the captain's table */
|
||||
.sail-pop {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
padding: 10px 12px 12px;
|
||||
background: var(--paper-warm);
|
||||
border: 1px solid var(--ink);
|
||||
box-shadow: 0 10px 22px rgb(36 43 58 / 0.25);
|
||||
text-align: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.sp-title { margin: 0 0 8px; font: 600 19px var(--atlas-serif); }
|
||||
.sp-line { margin: 0 0 8px; font-size: 12px; color: var(--ink-soft); }
|
||||
.sp-sail { display: block; width: 100%; margin-bottom: 6px; padding: 7px 0; border: 0; border-radius: 2px; background: var(--carmine); color: #fff; font-weight: 600; font-size: 13px; }
|
||||
.sp-open { background: none; border: 0; font-size: 12px; text-decoration: underline; color: var(--ink); }
|
||||
.sail-note {
|
||||
position: absolute; left: 50%; top: 22px; transform: translateX(-50%);
|
||||
margin: 0; padding: 6px 14px; background: var(--carmine); color: #fff; font-size: 12.5px; border-radius: 2px;
|
||||
}
|
||||
|
||||
body.is-overlay {
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 120%, rgb(240 190 120 / 0.25), transparent 55%),
|
||||
repeating-linear-gradient(172deg, rgb(255 255 255 / 0.035) 0 1px, transparent 1px 22px),
|
||||
linear-gradient(180deg, #1c3742 0%, #12262f 60%, #0b171d 100%);
|
||||
}
|
||||
body.is-overlay::before {
|
||||
content: "3D voyage continues behind the table";
|
||||
position: fixed; left: 24px; top: 18px;
|
||||
font: italic 500 15px var(--atlas-serif); color: rgb(239 241 235 / 0.45);
|
||||
}
|
||||
body.is-overlay .atlas {
|
||||
flex: none;
|
||||
width: 1160px;
|
||||
margin: auto;
|
||||
padding: 4px 22px 18px;
|
||||
border-radius: 10px;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, rgb(0 0 0 / 0.07) 0 3px, transparent 3px 11px),
|
||||
linear-gradient(180deg, #6c4a32, var(--wood));
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.4), 0 40px 90px rgb(0 0 0 / 0.65);
|
||||
}
|
||||
body.is-overlay .atlas-head { grid-template-columns: 220px 1fr auto auto; padding: 10px 0 8px; }
|
||||
body.is-overlay .brand-title { font-size: 25px; }
|
||||
body.is-overlay .atlas-body { grid-template-columns: 1fr 220px; }
|
||||
body.is-overlay .plate { width: 100%; transform: rotate(-0.4deg); }
|
||||
body.is-overlay .themes { font-size: 12px; padding: 12px; }
|
||||
body.is-overlay .th-key { display: none; }
|
||||
.here-tag { margin: 0; display: flex; border: 1px solid var(--carmine); border-radius: 2px; overflow: hidden; font-size: 12.5px; }
|
||||
.here-k { padding: 5px 9px; background: var(--carmine); color: #fff; }
|
||||
.here-v { padding: 5px 10px; font: 600 16px/1.1 var(--atlas-serif); background: var(--paper); color: var(--ink); }
|
||||
|
||||
/* ------------------------------------------------------------- credits */
|
||||
.fan-credits { color: rgb(239 241 235 / 0.82); opacity: 1; padding: 0.7rem 1.5rem 0.85rem; font-size: 12.5px; }
|
||||
body.is-overlay .fan-credits { margin-top: auto; }
|
||||
|
||||
Reference in New Issue
Block a user