feat(mockups): 11 atlas with insets spike; round notes updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-29 02:50:33 +01:00
co-authored by Claude Opus 5.5
parent a422234970
commit 65b5aa0d00
5 changed files with 1379 additions and 3 deletions
+291
View File
@@ -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];
}
+676 -1
View File
@@ -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();
+346 -2
View File
@@ -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; }