feat(mockups): 03 transit map

Night-navy SVG metro map: motifs and character themes as lines,
songs as stations, interchanges, legend, enamel station board.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-23 18:38:47 +02:00
co-authored by Claude Opus 5.5
parent 24f9ecaaad
commit 96cb4b536a
3 changed files with 779 additions and 4 deletions
+365 -2
View File
@@ -1,5 +1,368 @@
import { motifs, sagas, songs } from "../../data/sample";
import { motifsInSong, sagaOf } from "../../data/query";
import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import { GAP, LINE_W, buildLayout, type Layout, type Station } from "./map";
mountCredits();
const credits = mountCredits();
// The station board ends above the footer so the fan credit is never covered.
const syncFooter = () => document.documentElement.style.setProperty("--footer-h", `${credits.offsetHeight}px`);
syncFooter();
addEventListener("resize", syncFooter);
const esc = (s: string) =>
s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
const pad = (n: number) => String(n).padStart(2, "0");
const motifById = new Map(motifs.map((m) => [m.id, m]));
const songById = new Map(songs.map((s) => [s.id, s]));
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const layout: Layout = buildLayout(songs, motifs, sagas);
const yearLabel = (y: number) => (y === 0 ? "The fall of Troy" : `Year ${y} after Troy`);
let activeLine: string | null = null;
let openSong: string | null = null;
// ---------- shell ----------
const app = document.getElementById("app")!;
app.innerHTML = `<h1>Transit map</h1><p>stub</p>`;
app.innerHTML = `
<aside class="key" aria-label="Map key">
<header class="brand">
<svg class="brand__mark" viewBox="0 0 40 40" aria-hidden="true">
<path d="M2 30H10L20 20H38" fill="none" stroke="#d9a441" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 10H10L20 20" fill="none" stroke="#b69bd6" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 20H38" fill="none" stroke="#f2e3b3" stroke-width="5" stroke-linecap="round"/>
<rect x="13" y="11" width="14" height="18" rx="7" fill="currentColor" stroke="var(--ink-deep)" stroke-width="3"/>
</svg>
<div>
<h1>EPIC network map</h1>
<p>Every theme is a line, every song a station. Pick a line to ride it through the voyage.</p>
</div>
</header>
<nav class="lines" aria-label="Lines">
<h2>Character lines <span>run north</span></h2>
<ul data-group="character"></ul>
<h2>Motif lines <span>run south</span></h2>
<ul data-group="motif"></ul>
</nav>
<section class="route" hidden></section>
<section class="legend" aria-label="How to read the map">
<dl>
<div><dt><svg viewBox="0 0 44 32"><path d="M0 16H44" stroke="#7aa6c9" stroke-width="7"/><circle cx="22" cy="16" r="8" class="stop"/></svg></dt><dd>Station: one line calls here</dd></div>
<div><dt><svg viewBox="0 0 44 32"><path d="M0 9H44" stroke="#b69bd6" stroke-width="7"/><path d="M0 23H44" stroke="#d9a441" stroke-width="7"/><rect x="14" y="1" width="16" height="30" rx="8" class="stop"/></svg></dt><dd>Interchange: several themes meet in one song</dd></div>
<div><dt><svg viewBox="0 0 44 32"><rect x="12" y="4" width="22" height="22" rx="2" class="zone-swatch"/><text x="23" y="20">1</text></svg></dt><dd>Fare zone: one saga of the album</dd></div>
<div><dt><svg viewBox="0 0 44 32"><path d="M3 16H41" class="gap-swatch"/></svg></dt><dd>Station spacing grows with the years that pass in the story</dd></div>
</dl>
</section>
</aside>
<section class="network" aria-label="Network map">
<div class="network__hint" aria-hidden="true">Drag or scroll sideways to travel. Click a station to see its board.</div>
<div class="network__scroll" tabindex="0" aria-label="Scrollable map"></div>
<div class="sign" role="tooltip" hidden></div>
</section>
<aside class="board" aria-label="Station board" aria-hidden="true"></aside>
`;
const scroller = app.querySelector<HTMLDivElement>(".network__scroll")!;
const sign = app.querySelector<HTMLDivElement>(".sign")!;
const board = app.querySelector<HTMLElement>(".board")!;
const route = app.querySelector<HTMLElement>(".route")!;
// ---------- legend line buttons ----------
for (const m of motifs) {
const ul = app.querySelector(`ul[data-group="${m.kind}"]`)!;
const count = layout.lines.find((l) => l.motif.id === m.id)!.stations.length;
const li = document.createElement("li");
li.innerHTML = `<button type="button" class="line-btn" data-line="${m.id}" aria-pressed="false" style="--c:${m.color}">
<span class="line-btn__bar"></span><span class="line-btn__name">${esc(m.name)}</span>
<span class="line-btn__count" title="Stations on this line">${count}</span></button>`;
ul.append(li);
}
// ---------- the map ----------
function renderMap(): string {
const { width, height, stations, lines, zones, gaps, laneTop, rulerY } = layout;
const zoneSvg = zones.map((z) => `
<g class="zone zone--${z.index % 2 ? "odd" : "even"}">
<rect x="${z.x0}" y="-2000" width="${z.x1 - z.x0}" height="${height + 4000}"/>
<g class="zone__tag" transform="translate(${z.x0 + 28},${rulerY + 78})">
<rect x="0" y="-17" width="30" height="30" rx="3"/><text x="15" y="4" class="zone__num">${z.index}</text>
<text x="42" y="-2" class="zone__label">Zone ${z.index}</text>
<text x="42" y="14" class="zone__saga">${esc(z.saga.name)}</text>
</g>
</g>`).join("");
const lineSvg = lines.filter((l) => l.d).map((l) => `
<g class="line" data-line="${l.motif.id}" style="--c:${l.motif.color}">
<path class="line__casing" d="${l.d}"/>
<path class="line__track" id="track-${l.motif.id}" d="${l.d}" pathLength="1"/>
<path class="line__hit" d="${l.d}"><title>${esc(l.motif.name)} line</title></path>
</g>`).join("");
const stationSvg = stations.map((st) => {
const n = st.stack.length;
const h = st.bottom - st.top + GAP;
const shape = n > 1
? `<rect class="stop" x="-12" y="${st.top - GAP / 2 - 1}" width="24" height="${h + 2}" rx="12"/>`
: `<circle class="stop" cx="0" cy="${st.top}" r="11"/>`;
const ids = st.stack.map((s) => s.motif.id).join(" ");
const story = yearLabel(st.song.storyTime.yearsAfterTroy);
return `
<g class="station${n > 1 ? " station--interchange" : ""}" data-song="${st.song.id}" data-lines="${ids}"
transform="translate(${st.x},0)" tabindex="0" role="button"
aria-label="${esc(`Station ${st.song.track}: ${st.song.title}. ${story}. ${n} line${n > 1 ? "s" : ""}.`)}">
<line class="station__leader" x1="0" x2="0" y1="${laneTop - 18}" y2="${st.top - GAP / 2 - 4}"/>
<rect class="station__hit" x="-26" y="${laneTop - 150}" width="52" height="${st.bottom - laneTop + 172}"/>
${shape}
<g transform="translate(-4,${laneTop - 26}) rotate(-38)">
<text class="station__name"><tspan class="station__num">${pad(st.song.track)}</tspan><tspan dx="8">${esc(st.song.title)}</tspan></text>
</g>
</g>`;
}).join("");
// Ruler: in-story time. Station spacing grows with the years that pass between songs.
const first = stations[0]!.x, last = stations[stations.length - 1]!.x;
let prevYear = -1;
const ticks = stations.map((st) => {
const y = st.song.storyTime.yearsAfterTroy;
const label = y !== prevYear
? `<text class="ruler__year" x="${st.x}" y="${rulerY + 30}">${esc(yearLabel(y))}</text>`
: `<text class="ruler__year ruler__year--repeat" x="${st.x}" y="${rulerY + 30}">Year ${y}</text>`;
prevYear = y;
return `<line class="ruler__tick" x1="${st.x}" x2="${st.x}" y1="${rulerY - 6}" y2="${rulerY + 6}"/>${label}`;
}).join("");
const gapSvg = gaps.map((g) => {
const mid = (g.x0 + g.x1) / 2;
const txt = `${g.years} year${g.years > 1 ? "s" : ""} pass${g.years > 1 ? "" : "es"}`;
return `<g class="ruler__gap">
<path d="M${g.x0 + 18},${rulerY} H${g.x1 - 18}"/>
<rect x="${mid - 62}" y="${rulerY - 13}" width="124" height="26" rx="13"/>
<text x="${mid}" y="${rulerY + 4.5}">${txt}</text></g>`;
}).join("");
return `<svg class="map" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img"
aria-label="Transit-style map of EPIC songs as stations on motif lines">
<defs>
<pattern id="grid" width="${GAP}" height="${GAP}" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" class="grid-dot"/></pattern>
</defs>
${zoneSvg}
<rect class="grid" x="0" y="-2000" width="${width}" height="${height + 4000}" fill="url(#grid)"/>
<g class="ruler"><path class="ruler__axis" d="M${first},${rulerY} H${last}"/>${ticks}${gapSvg}
<text class="ruler__caption" x="${first - 40}" y="${rulerY - 22}">In-story time</text></g>
<g class="lines-layer">${lineSvg}</g>
<g class="train-layer"></g>
<g class="stations-layer">${stationSvg}</g>
</svg>`;
}
scroller.innerHTML = renderMap();
const svg = scroller.querySelector<SVGSVGElement>("svg")!;
svg.style.setProperty("--lw", String(LINE_W));
if (!reducedMotion) {
svg.classList.add("map--intro");
setTimeout(() => svg.classList.remove("map--intro"), 2400);
}
// ---------- state + rendering ----------
function chip(m: Motif): string {
return `<button type="button" class="chip${activeLine === m.id ? " is-on" : ""}" data-line="${m.id}" style="--c:${m.color}"
aria-pressed="${activeLine === m.id}"><span class="chip__bar"></span>${esc(m.name)}</button>`;
}
function applyLine() {
svg.classList.toggle("has-line", !!activeLine);
svg.querySelectorAll<SVGGElement>(".line").forEach((g) => g.classList.toggle("is-on", g.dataset.line === activeLine));
svg.querySelectorAll<SVGGElement>(".station").forEach((g) =>
g.classList.toggle("is-served", !!activeLine && (g.dataset.lines ?? "").split(" ").includes(activeLine)));
app.querySelectorAll<HTMLButtonElement>(".line-btn").forEach((b) => {
const on = b.dataset.line === activeLine;
b.classList.toggle("is-on", on);
b.setAttribute("aria-pressed", String(on));
});
board.querySelectorAll<HTMLElement>(".chip").forEach((c) => {
const on = c.dataset.line === activeLine;
c.classList.toggle("is-on", on);
c.setAttribute("aria-pressed", String(on));
});
board.classList.toggle("has-line", !!activeLine);
board.querySelectorAll<HTMLElement>(".platform").forEach((p) =>
p.classList.toggle("is-served", !!activeLine && (p.dataset.lines ?? "").split(" ").includes(activeLine)));
// Train: one small car rides the selected line.
const trainLayer = svg.querySelector(".train-layer")!;
trainLayer.innerHTML = "";
if (activeLine && !reducedMotion) {
const track = svg.querySelector<SVGPathElement>(`#track-${activeLine}`);
const len = track?.getTotalLength?.() ?? 1200;
const dur = Math.max(6, len / 230).toFixed(1);
trainLayer.innerHTML = `<g class="train"><rect x="-17" y="-6" width="34" height="12" rx="6"/>
<circle cx="9" cy="0" r="2.4"/>
<animateMotion dur="${dur}s" repeatCount="indefinite" rotate="auto"><mpath href="#track-${activeLine}"/></animateMotion></g>`;
}
if (!activeLine) { route.hidden = true; route.innerHTML = ""; return; }
const m = motifById.get(activeLine)!;
const geom = layout.lines.find((l) => l.motif.id === activeLine)!;
route.hidden = false;
route.style.setProperty("--c", m.color);
route.innerHTML = `
<div class="route__head"><span class="route__bar"></span>
<h2>${esc(m.name)} line</h2>
<button type="button" class="route__clear">Show all lines</button></div>
<p class="route__desc">${esc(m.description)}</p>
<p class="route__meta">Calls at ${geom.stations.length} stations</p>
<ol class="route__stops">${geom.stations.map((s) =>
`<li><button type="button" data-song="${s.id}"><span class="route__num">${pad(s.track)}</span>${esc(s.title)}</button></li>`).join("")}</ol>`;
}
function setLine(id: string | null) {
activeLine = activeLine === id ? null : id;
applyLine();
}
function renderBoard(song: Song) {
const saga = sagaOf(song);
const zone = layout.zones.find((z) => z.saga.id === saga.id)!;
const lines = motifsInSong(song);
const prev = songs.find((s) => s.track === song.track - 1);
const next = songs.find((s) => s.track === song.track + 1);
const odyssey = song.odyssey
? `<a class="board__homer" href="${esc(song.odyssey.url)}" target="_blank" rel="noopener noreferrer">
Homer's Odyssey, book ${song.odyssey.book}, lines ${esc(song.odyssey.lines.replace("-", "–"))}</a>`
: `<p class="board__homer board__homer--none">No counterpart in Homer's Odyssey</p>`;
board.innerHTML = `
<div class="board__strip">${lines.map((m) => `<span style="background:${m.color}"></span>`).join("")}</div>
<header class="board__head">
<div class="board__nav">
<button type="button" class="board__step" data-song="${prev?.id ?? ""}" ${prev ? "" : "disabled"} aria-label="Previous station">‹</button>
<span class="board__no">Station ${pad(song.track)}</span>
<button type="button" class="board__step" data-song="${next?.id ?? ""}" ${next ? "" : "disabled"} aria-label="Next station">›</button>
<button type="button" class="board__close" aria-label="Close station board">Close</button>
</div>
<h2 class="board__title">${esc(song.title)}</h2>
<p class="board__zone"><span class="board__zonebox">${zone.index}</span>${esc(saga.name)}</p>
</header>
<dl class="board__facts">
<div><dt>Story time</dt><dd><strong>${esc(yearLabel(song.storyTime.yearsAfterTroy))}</strong><span>${esc(song.storyTime.label)}</span></dd></div>
<div><dt>Source</dt><dd>${odyssey}</dd></div>
</dl>
<section class="board__sec"><h3>Lines calling here</h3><div class="chips">${lines.map(chip).join("")}</div></section>
<section class="board__sec"><h3>Platforms</h3>
<ol class="platforms">${song.parts.map((p, i) => `
<li class="platform" data-lines="${p.motifIds.join(" ")}">
<div class="platform__letter" aria-hidden="true">${String.fromCharCode(65 + i)}</div>
<div class="platform__body">
<h4>${esc(p.name)} <span>sung by ${esc(p.singers.join(" and "))}</span></h4>
<blockquote>${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</blockquote>
<div class="chips chips--small">${p.motifIds.map((id) => motifById.get(id)).filter((m): m is Motif => !!m).map(chip).join("")}</div>
</div>
</li>`).join("")}</ol></section>
<section class="board__sec board__fact"><h3>Worth knowing</h3>${song.facts.map((f) => `<p>${esc(f)}</p>`).join("")}</section>
<section class="board__sec board__watch"><h3>Watch</h3></section>
<section class="board__sec board__listen"><h3>Listen</h3></section>`;
board.querySelector(".board__watch")!.append(createVideoCards(song));
board.querySelector(".board__listen")!.append(createAudioSlot(song));
}
function setSong(id: string | null, focusBoard = false, instant = false) {
openSong = id;
svg.querySelectorAll<SVGGElement>(".station").forEach((g) => g.classList.toggle("is-open", g.dataset.song === id));
document.body.classList.toggle("board-open", !!id);
board.setAttribute("aria-hidden", String(!id));
if (!id) return;
const song = songById.get(id)!;
renderBoard(song);
applyLine();
board.scrollTop = 0;
const st = layout.stations.find((s) => s.song.id === id)!;
const visible = scroller.clientWidth - (innerWidth > 900 ? board.offsetWidth : 0);
scroller.scrollTo({ left: st.x * scale() - visible * 0.6, behavior: reducedMotion || instant ? "auto" : "smooth" });
if (focusBoard) board.querySelector<HTMLButtonElement>(".board__close")?.focus();
}
let fitScale = 1;
const scale = () => fitScale;
// Fit the diagram to the viewport height (gently, so stations stay legible) and let zones bleed top to bottom.
function fit() {
const H = scroller.clientHeight;
if (!H) return;
fitScale = Math.min(1.15, Math.max(0.8, H / layout.height));
const vbH = H / fitScale;
const pad = (vbH - layout.height) / 2;
svg.setAttribute("viewBox", `0 ${-pad} ${layout.width} ${vbH}`);
svg.setAttribute("height", String(H));
svg.setAttribute("width", String(Math.round(layout.width * fitScale)));
}
fit();
addEventListener("resize", fit);
// ---------- events ----------
app.addEventListener("click", (e) => {
const t = e.target as Element;
const lineBtn = t.closest<HTMLElement>("[data-line]:is(button, .line)");
if (lineBtn) return setLine(lineBtn.dataset.line!);
if (t.closest(".route__clear")) return setLine(activeLine);
if (t.closest(".board__close")) return setSong(null);
const songBtn = t.closest<HTMLElement>("[data-song]");
if (songBtn?.dataset.song && !dragged) return setSong(songBtn.dataset.song);
});
svg.addEventListener("keydown", (e) => {
const st = (e.target as Element).closest<SVGGElement>(".station");
if (st && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); setSong(st.dataset.song!, true); }
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (openSong) setSong(null);
else if (activeLine) setLine(activeLine);
});
// Hover sign
function showSign(st: Station, el: Element) {
const r = el.querySelector(".stop")!.getBoundingClientRect();
const host = sign.parentElement!.getBoundingClientRect();
sign.innerHTML = `<strong>${esc(st.song.title)}</strong><span>${esc(st.song.storyTime.label)}</span>
<span class="sign__lines">${st.stack.map((s) => `<i style="background:${s.motif.color}" title="${esc(s.motif.name)}"></i>`).join("")}
${st.stack.length > 1 ? `Interchange, ${st.stack.length} lines` : "1 line"}</span>`;
sign.hidden = false;
sign.style.left = `${r.left + r.width / 2 - host.left}px`;
sign.style.top = `${r.bottom - host.top + 14}px`;
}
svg.querySelectorAll<SVGGElement>(".station").forEach((g) => {
const st = layout.stations.find((s) => s.song.id === g.dataset.song)!;
g.addEventListener("pointerenter", () => showSign(st, g));
g.addEventListener("focus", () => showSign(st, g));
g.addEventListener("pointerleave", () => (sign.hidden = true));
g.addEventListener("blur", () => (sign.hidden = true));
});
scroller.addEventListener("scroll", () => (sign.hidden = true), { passive: true });
// Drag to pan, vertical wheel travels sideways.
let dragged = false;
let drag: { x: number; left: number; id: number } | null = null;
scroller.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || e.pointerType === "touch") return;
drag = { x: e.clientX, left: scroller.scrollLeft, id: e.pointerId };
dragged = false;
});
scroller.addEventListener("pointermove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
if (!dragged && Math.abs(dx) > 4) { dragged = true; scroller.setPointerCapture(drag.id); scroller.classList.add("is-dragging"); }
if (dragged) scroller.scrollLeft = drag.left - dx;
});
const endDrag = () => { drag = null; scroller.classList.remove("is-dragging"); setTimeout(() => (dragged = false), 0); };
scroller.addEventListener("pointerup", endDrag);
scroller.addEventListener("pointercancel", endDrag);
scroller.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX) && !e.shiftKey) { scroller.scrollLeft += e.deltaY; e.preventDefault(); }
}, { passive: false });
// ---------- initial state (query params exist only so screenshots can open a state) ----------
const params = new URLSearchParams(location.search);
const qLine = params.get("line");
if (qLine && motifById.has(qLine)) setLine(qLine);
const qSong = params.get("song");
if (qSong && songById.has(qSong)) {
if (!reducedMotion) svg.classList.remove("map--intro");
requestAnimationFrame(() => setSong(qSong, false, true));
}
+118
View File
@@ -0,0 +1,118 @@
import { gapYears } from "../../data/query";
import type { Motif, Saga, Song } from "../../data/types";
// Network-map geometry. Every motif is a line with a "home lane": character themes idle north of the
// trunk, musical motifs idle south of it. At a station the lines that call there squeeze into one
// stack centred on the trunk, so the white interchange capsule covers exactly the lines that stop.
export const GAP = 20; // distance between parallel lines
export const LINE_W = 11;
export const STEP = 168; // base spacing between consecutive songs
export const YEAR = 260; // extra spacing per in-story year between songs
export const LEFT = 150;
export const TRUNK = 300;
export interface Station {
song: Song;
x: number;
stack: { motif: Motif; y: number }[];
top: number;
bottom: number;
}
export interface LineGeom { motif: Motif; d: string; stations: Song[]; north: boolean }
export interface Zone { saga: Saga; x0: number; x1: number; index: number }
export interface Gap { x0: number; x1: number; years: number }
export interface Layout {
width: number; height: number; stations: Station[]; lines: LineGeom[]; zones: Zone[]; gaps: Gap[];
laneTop: number; laneBottom: number; rulerY: number;
}
const motifIdsOf = (s: Song) => new Set(s.parts.flatMap((p) => p.motifIds));
export function buildLayout(songs: Song[], motifs: Motif[], sagas: Saga[]): Layout {
const ordered = [...songs].sort((a, b) => a.track - b.track);
const north = motifs.filter((m) => m.kind === "character");
const south = motifs.filter((m) => m.kind !== "character");
const maxStack = Math.max(...ordered.map((s) => motifIdsOf(s).size));
const stackHalf = ((maxStack - 1) / 2) * GAP;
const home = new Map<string, number>();
north.forEach((m, k) => home.set(m.id, TRUNK - stackHalf - GAP * (1.75 + k)));
south.forEach((m, k) => home.set(m.id, TRUNK + stackHalf + GAP * (1.75 + k)));
// Stack order: outermost northern line on top, outermost southern line at the bottom.
const rank = new Map<string, number>();
[...north].reverse().forEach((m, i) => rank.set(m.id, i));
south.forEach((m, i) => rank.set(m.id, north.length + i));
const xs: number[] = [];
const gaps: Gap[] = [];
ordered.forEach((s, i) => {
if (i === 0) return void xs.push(LEFT);
const years = gapYears(ordered[i - 1]!, s);
const x = xs[i - 1]! + STEP + YEAR * Math.max(0, years);
if (years > 0) gaps.push({ x0: xs[i - 1]!, x1: x, years });
xs.push(x);
});
const stations: Station[] = ordered.map((song, i) => {
const ids = motifIdsOf(song);
const present = motifs.filter((m) => ids.has(m.id)).sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
const stack = present.map((motif, j) => ({ motif, y: TRUNK + (j - (present.length - 1) / 2) * GAP }));
const ys = stack.map((s) => s.y);
return { song, x: xs[i]!, stack, top: Math.min(...ys), bottom: Math.max(...ys) };
});
const lines: LineGeom[] = motifs.map((motif) => {
const yAt = (st: Station) => st.stack.find((s) => s.motif.id === motif.id)?.y ?? home.get(motif.id)!;
const serving = stations.filter((st) => st.stack.some((s) => s.motif.id === motif.id));
if (serving.length === 0) return { motif, d: "", stations: [], north: motif.kind === "character" };
const first = stations.indexOf(serving[0]!);
const last = stations.indexOf(serving[serving.length - 1]!);
const pts: [number, number][] = [[stations[first]!.x - (serving.length === 1 ? 40 : 0), yAt(stations[first]!)]];
for (let i = first; i < last; i++) {
const a = stations[i]!, b = stations[i + 1]!;
const ya = yAt(a), yb = yAt(b);
if (ya !== yb) {
const dy = Math.abs(yb - ya);
const mid = (a.x + b.x) / 2;
pts.push([mid - dy / 2, ya], [mid + dy / 2, yb]);
}
pts.push([b.x, yb]);
}
if (serving.length === 1) pts.push([stations[first]!.x + 40, yAt(stations[first]!)]);
return { motif, d: roundedPath(pts, 14), stations: serving.map((s) => s.song), north: motif.kind === "character" };
});
const zones: Zone[] = [...sagas].sort((a, b) => a.order - b.order).flatMap((saga, index) => {
const idx = stations.map((s, i) => (s.song.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (idx.length === 0) return [];
const fi = idx[0]!, li = idx[idx.length - 1]!;
const x0 = fi === 0 ? 0 : (stations[fi - 1]!.x + stations[fi]!.x) / 2;
const lastX = stations[stations.length - 1]!.x;
const x1 = li === stations.length - 1 ? lastX + LEFT + 40 : (stations[li]!.x + stations[li + 1]!.x) / 2;
return [{ saga, x0, x1, index: index + 1 }];
});
const laneTop = TRUNK - stackHalf - GAP * (1.75 + north.length - 1);
const laneBottom = TRUNK + stackHalf + GAP * (1.75 + south.length - 1);
const rulerY = laneBottom + 64;
return {
width: stations[stations.length - 1]!.x + LEFT + 40, height: rulerY + 130,
stations, lines, zones, gaps, laneTop, laneBottom, rulerY,
};
}
/** Polyline with rounded corners, the way a drafted transit map bends its lines. */
export function roundedPath(pts: [number, number][], r: number): string {
const p = pts.filter((q, i) => i === 0 || q[0] !== pts[i - 1]![0] || q[1] !== pts[i - 1]![1]);
if (p.length < 2) return "";
let d = `M${p[0]![0]},${p[0]![1]}`;
for (let i = 1; i < p.length - 1; i++) {
const [ax, ay] = p[i - 1]!, [bx, by] = p[i]!, [cx, cy] = p[i + 1]!;
const l1 = Math.hypot(bx - ax, by - ay), l2 = Math.hypot(cx - bx, cy - by);
const rr = Math.min(r, l1 / 2, l2 / 2);
const s: [number, number] = [bx - ((bx - ax) / l1) * rr, by - ((by - ay) / l1) * rr];
const e: [number, number] = [bx + ((cx - bx) / l2) * rr, by + ((cy - by) / l2) * rr];
d += ` L${s[0].toFixed(1)},${s[1].toFixed(1)} Q${bx},${by} ${e[0].toFixed(1)},${e[1].toFixed(1)}`;
}
const z = p[p.length - 1]!;
return d + ` L${z[0]},${z[1]}`;
}
+296 -2
View File
@@ -1,2 +1,296 @@
main { padding: 2rem; font-family: system-ui, sans-serif; }
h1::before { content: "03 · "; }
/* 03 Transit map: a night-time network diagram of the voyage, with enamel station boards. */
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
@import "@fontsource/inter/latin-700.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
:root {
--ink: #0f2335; /* map ground, Aegean at night */
--ink-zone: #132b41; /* alternating fare zone */
--ink-deep: #0a1a29; /* key column */
--rule: #27445e;
--text: #e3ebef;
--muted: #8ea4b6;
--enamel: #f1f3ee; /* station board */
--enamel-ink: #10263a;
--enamel-rule: #cfd6d3;
--stop: #f7f9f6;
--sans: "Inter", system-ui, sans-serif;
--serif: "Cormorant Garamond", Georgia, serif;
--board-w: 440px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--ink);
color: var(--text);
font-family: var(--sans);
font-size: 15px;
line-height: 1.5;
font-feature-settings: "tnum" 1, "cv11" 1;
display: grid;
grid-template-rows: 1fr auto;
overflow: hidden;
}
button { font: inherit; color: inherit; cursor: pointer; }
:focus-visible { outline: 2px solid #ffd977; outline-offset: 3px; }
#app {
display: grid;
grid-template-columns: 312px 1fr;
min-height: 0;
}
/* ---------- key column ---------- */
.key {
background: var(--ink-deep);
border-right: 1px solid var(--rule);
padding: 26px 22px 22px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 26px;
}
.brand { display: grid; grid-template-columns: 40px 1fr; gap: 14px; align-items: start; }
.brand__mark { width: 40px; height: 40px; color: var(--stop); }
.brand h1 { margin: 0; font-size: 21px; line-height: 1.15; font-weight: 700; letter-spacing: -0.01em; }
.brand p { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); line-height: 1.45; }
.lines h2 {
margin: 0 0 8px; font-size: 13px; font-weight: 700; color: var(--text);
display: flex; justify-content: space-between; align-items: baseline;
}
.lines h2 span { font-weight: 400; color: var(--muted); font-size: 12px; }
.lines ul { list-style: none; margin: 0 0 18px; padding: 0; display: grid; gap: 2px; }
.line-btn {
width: 100%; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 12px;
background: none; border: 0; border-radius: 4px; padding: 7px 8px; text-align: left;
transition: background-color .15s;
}
.line-btn:hover { background: #ffffff0d; }
.line-btn__bar { height: 9px; border-radius: 5px; background: var(--c); box-shadow: 0 0 0 2px var(--ink-deep); }
.line-btn__name { font-size: 14.5px; font-weight: 500; }
.line-btn__count {
font-size: 12px; color: var(--muted); min-width: 22px; height: 22px; border-radius: 11px;
display: grid; place-items: center; border: 1px solid var(--rule);
}
.line-btn.is-on { background: color-mix(in srgb, var(--c) 16%, transparent); }
.line-btn.is-on .line-btn__count { background: var(--c); color: var(--ink-deep); border-color: var(--c); font-weight: 700; }
.route {
border-left: 6px solid var(--c); padding: 2px 0 2px 14px;
animation: route-in .25s ease-out;
}
@keyframes route-in { from { opacity: 0; transform: translateX(-6px); } }
.route__head { display: grid; justify-items: start; gap: 2px; }
.route__bar { display: none; }
.route h2 { margin: 0; font-size: 16px; font-weight: 700; }
.route__head .route__clear { grid-row: 3; margin-top: 4px; }
.route__clear { background: none; border: 0; padding: 0; font-size: 12.5px; color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
.route__clear:hover { color: var(--text); }
.route__desc { margin: 6px 0 4px; font-size: 13.5px; color: var(--text); }
.route__meta { margin: 0 0 8px; font-size: 12.5px; color: var(--muted); }
.route__stops { list-style: none; margin: 0; padding: 0; position: relative; }
.route__stops::before { content: ""; position: absolute; left: 9px; top: 10px; bottom: 10px; width: 4px; background: var(--c); border-radius: 2px; }
.route__stops button {
position: relative; display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
background: none; border: 0; padding: 3px 0; font-size: 13.5px;
}
.route__stops button::before {
content: ""; width: 14px; height: 14px; border-radius: 50%; flex: none; margin-left: 4px;
background: var(--stop); border: 3px solid var(--ink-deep);
}
.route__stops button:hover { text-decoration: underline; text-underline-offset: 3px; }
.route__num { order: 2; color: var(--muted); font-size: 12px; }
.legend { margin-top: auto; border-top: 1px solid var(--rule); padding-top: 16px; }
.legend dl { margin: 0; display: grid; gap: 8px; }
.legend dl > div { display: grid; grid-template-columns: 44px 1fr; gap: 12px; align-items: center; }
.legend dt svg { width: 44px; height: 32px; display: block; overflow: visible; }
.legend dd { margin: 0; font-size: 12.5px; color: var(--muted); }
.legend .stop { fill: var(--stop); stroke: var(--ink-deep); stroke-width: 3; }
.map .stop { fill: var(--stop); stroke: var(--ink); stroke-width: 4; }
.legend .zone-swatch { fill: none; stroke: var(--text); stroke-width: 2; }
.legend text { fill: var(--text); font-size: 13px; font-weight: 700; text-anchor: middle; }
.legend .gap-swatch { stroke: var(--muted); stroke-width: 2; stroke-dasharray: 2 4; stroke-linecap: round; }
/* ---------- network ---------- */
.network { position: relative; min-width: 0; min-height: 0; display: grid; }
.network__hint {
position: absolute; left: 24px; top: 18px; z-index: 2; pointer-events: none;
font-size: 12.5px; color: var(--muted);
}
.network__scroll {
overflow-x: auto; overflow-y: hidden; display: flex; align-items: center;
cursor: grab; scrollbar-color: var(--rule) transparent; overscroll-behavior-x: contain;
}
.network__scroll.is-dragging { cursor: grabbing; user-select: none; }
.network__scroll:focus-visible { outline-offset: -4px; }
.map { display: block; flex: none; user-select: none; }
.sign[hidden] { display: none; }
@media (min-width: 901px) { .board-open .network__scroll::after { content: ""; flex: none; width: var(--board-w); height: 1px; } }
.zone rect { fill: var(--ink); }
.zone--even > rect { fill: var(--ink-zone); }
.grid-dot { fill: #ffffff10; }
.zone__tag rect { fill: none; stroke: var(--text); stroke-width: 2; }
.zone__num { fill: var(--text); font-size: 17px; font-weight: 700; text-anchor: middle; }
.zone__label { fill: var(--muted); font-size: 12px; }
.zone__saga { fill: var(--text); font-size: 15px; font-weight: 500; }
.ruler__axis { stroke: var(--rule); stroke-width: 2; }
.ruler__tick { stroke: var(--muted); stroke-width: 2; }
.ruler__year { fill: var(--text); font-size: 13px; font-weight: 500; text-anchor: middle; }
.ruler__year--repeat { fill: var(--muted); font-size: 11.5px; font-weight: 400; }
.ruler__caption { fill: var(--muted); font-size: 12px; }
.ruler__gap path { stroke: var(--muted); stroke-width: 2; stroke-dasharray: 2 6; stroke-linecap: round; }
.ruler__gap rect { fill: var(--ink); stroke: var(--muted); stroke-width: 1.5; }
.ruler__gap text { fill: var(--text); font-size: 12.5px; font-weight: 500; text-anchor: middle; }
.line path { fill: none; stroke-linecap: round; stroke-linejoin: round; transition: opacity .25s; }
.line__casing { stroke: var(--ink); stroke-width: calc(var(--lw) * 1px + 6px); }
.line__track { stroke: var(--c); stroke-width: calc(var(--lw) * 1px); }
.line__hit { stroke: transparent; stroke-width: 22px; cursor: pointer; pointer-events: stroke; }
.line:hover .line__track { filter: brightness(1.15); }
.map.has-line .line:not(.is-on) .line__track { opacity: .14; }
.map.has-line .line:not(.is-on) .line__casing { opacity: .4; }
.map.has-line .line.is-on .line__track { filter: drop-shadow(0 0 6px color-mix(in srgb, var(--c) 70%, transparent)); }
/* One orchestrated moment: the network draws itself in on load. */
.map--intro .line__track { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 1.6s cubic-bezier(.6,.1,.2,1) forwards; }
.map--intro .line:nth-child(2) .line__track { animation-delay: .08s; }
.map--intro .line:nth-child(3) .line__track { animation-delay: .16s; }
.map--intro .line:nth-child(4) .line__track { animation-delay: .24s; }
.map--intro .line:nth-child(5) .line__track { animation-delay: .32s; }
.map--intro .line:nth-child(6) .line__track { animation-delay: .4s; }
.map--intro .line:nth-child(7) .line__track { animation-delay: .48s; }
.map--intro .station { animation: pop .4s ease-out both; animation-delay: 1.2s; }
@keyframes draw { to { stroke-dashoffset: 0; } }
@keyframes pop { from { opacity: 0; } }
.station { cursor: pointer; outline: none; }
.station__hit { fill: transparent; }
.station__leader { stroke: var(--muted); stroke-width: 1.5; stroke-dasharray: 1 4; stroke-linecap: round; opacity: .55; }
.station .stop { transition: stroke .15s, fill .15s; }
.station__name { fill: var(--text); font-size: 15px; font-weight: 500; transition: fill .2s, opacity .2s; }
.station__num { fill: var(--muted); font-size: 12px; font-weight: 700; }
.station:hover .stop, .station:focus-visible .stop { stroke: #ffd977; }
.station:hover .station__name, .station:focus-visible .station__name { fill: #fff; font-weight: 700; }
.station:focus-visible .stop { stroke-width: 5; }
.station.is-open .stop { fill: #ffd977; }
.station.is-open .station__name { fill: #ffd977; font-weight: 700; }
.map.has-line .station:not(.is-served) .station__name { opacity: .3; }
.map.has-line .station:not(.is-served) .stop { fill: #6f8394; }
.map.has-line .station.is-served .station__leader { opacity: 1; stroke: var(--text); }
.train rect { fill: var(--stop); stroke: var(--ink); stroke-width: 2.5; }
.train circle { fill: #ffd977; }
.sign {
position: absolute; z-index: 5; transform: translateX(-50%); pointer-events: none;
background: var(--enamel); color: var(--enamel-ink); border-radius: 4px; padding: 8px 12px 9px;
display: grid; gap: 1px; min-width: 180px; max-width: 260px; font-size: 13px;
box-shadow: 0 10px 30px -8px #00000099;
}
.sign strong { font-size: 14.5px; }
.sign span { color: #4a5f70; }
.sign__lines { display: flex; align-items: center; gap: 4px; margin-top: 4px; font-size: 12px; }
.sign__lines i { width: 16px; height: 6px; border-radius: 3px; display: inline-block; }
.sign__lines i:last-of-type { margin-right: 6px; }
/* ---------- station board ---------- */
.board {
position: fixed; top: 0; right: 0; bottom: var(--footer-h, 42px); width: var(--board-w); z-index: 10;
background: var(--enamel); color: var(--enamel-ink);
overflow-y: auto; transform: translateX(102%); visibility: hidden;
transition: transform .35s cubic-bezier(.2,.8,.2,1), visibility 0s .35s;
box-shadow: -24px 0 60px -20px #000000aa;
padding-bottom: 32px;
}
.board-open .board { transform: none; visibility: visible; transition: transform .35s cubic-bezier(.2,.8,.2,1); }
.board__strip { display: flex; height: 12px; position: sticky; top: 0; z-index: 1; }
.board__strip span { flex: 1; }
.board__head { background: var(--enamel-ink); color: var(--enamel); padding: 16px 28px 22px; }
.board__nav { display: flex; align-items: center; gap: 6px; margin-bottom: 14px; }
.board__no { font-size: 13px; font-weight: 700; padding: 0 6px; }
.board__step {
width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid #ffffff55; background: none;
font-size: 18px; line-height: 1; display: grid; place-items: center; padding: 0 0 2px;
}
.board__step:disabled { opacity: .3; cursor: default; }
.board__step:not(:disabled):hover { border-color: #fff; }
.board__close { margin-left: auto; background: none; border: 1.5px solid #ffffff55; border-radius: 14px; padding: 2px 12px; font-size: 13px; }
.board__close:hover { border-color: #fff; }
.board__title { margin: 0; font-size: 34px; line-height: 1.05; font-weight: 700; letter-spacing: -0.02em; text-wrap: balance; }
.board__zone { margin: 10px 0 0; display: flex; align-items: center; gap: 10px; font-size: 14px; color: #c6d2da; }
.board__zonebox { width: 24px; height: 24px; border: 2px solid currentColor; border-radius: 3px; display: grid; place-items: center; font-weight: 700; font-size: 13px; color: var(--enamel); }
.board__facts { margin: 0; padding: 18px 28px; display: grid; gap: 12px; border-bottom: 1px solid var(--enamel-rule); }
.board__facts > div { display: grid; grid-template-columns: 92px 1fr; gap: 12px; }
.board__facts dt { font-size: 12.5px; color: #5b6f7e; padding-top: 2px; }
.board__facts dd { margin: 0; display: grid; }
.board__facts dd strong { font-size: 15px; }
.board__facts dd span { font-size: 13.5px; color: #4a5f70; }
.board__homer { font-family: var(--serif); font-style: italic; font-weight: 500; font-size: 19px; line-height: 1.2; color: var(--enamel-ink); text-decoration-color: #9fb0bb; text-underline-offset: 3px; margin: 0; }
.board__homer:hover { text-decoration-color: currentColor; }
.board__homer--none { color: #5b6f7e; }
.board__sec { padding: 18px 28px 4px; }
.board__sec h3 { margin: 0 0 10px; font-size: 13px; font-weight: 700; color: #5b6f7e; }
.board__fact p { margin: 0; font-size: 15px; max-width: 34em; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
display: inline-flex; align-items: center; gap: 8px; padding: 4px 11px 4px 8px; border-radius: 15px;
background: #fff; border: 1.5px solid var(--enamel-rule); font-size: 13px; font-weight: 500; color: var(--enamel-ink);
}
.chip__bar { width: 18px; height: 7px; border-radius: 4px; background: var(--c); box-shadow: inset 0 0 0 1px #00000026; }
.chip:hover { border-color: var(--enamel-ink); }
.chip.is-on { background: var(--enamel-ink); color: var(--enamel); border-color: var(--enamel-ink); }
.chips--small .chip { font-size: 12px; padding: 2px 9px 2px 7px; }
.platforms { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.platform {
display: grid; grid-template-columns: 34px 1fr; gap: 12px; padding: 12px 12px 12px 10px;
background: #fff; border-radius: 6px; border: 1.5px solid var(--enamel-rule); transition: opacity .2s, border-color .2s;
}
.platform__letter {
width: 32px; height: 32px; border-radius: 50%; background: var(--enamel-ink); color: var(--enamel);
display: grid; place-items: center; font-weight: 700; font-size: 15px;
}
.platform h4 { margin: 4px 0 6px; font-size: 15px; }
.platform h4 span { font-weight: 400; color: #5b6f7e; font-size: 13.5px; margin-left: 4px; }
.platform blockquote { margin: 0 0 10px; font-family: var(--serif); font-style: italic; font-weight: 500; font-size: 17px; line-height: 1.35; color: #2b4152; }
.platform blockquote p { margin: 0; }
.board.has-line .platform:not(.is-served) { opacity: .45; }
.board.has-line .platform.is-served { border-color: var(--enamel-ink); box-shadow: inset 4px 0 0 var(--enamel-ink); }
.board .video-cards { gap: 6px; }
.board .video-card {
border: 1.5px solid var(--enamel-rule); background: #fff; border-radius: 6px; padding: 9px 12px; font-size: 14px; font-weight: 500;
display: flex; align-items: center; gap: 10px;
}
.board .video-card::before {
content: ""; width: 0; height: 0; border-style: solid; border-width: 6px 0 6px 10px;
border-color: transparent transparent transparent #c0392b; flex: none;
}
.board .video-card:hover { border-color: var(--enamel-ink); opacity: 1; }
.board .audio-slot { border-radius: 6px; border-color: #9fb0bb; font-size: 14px; color: #4a5f70; }
/* ---------- credits ---------- */
/* Credits stay fully legible in every state: opaque, full opacity, never covered by the board. */
body .fan-credits { position: relative; z-index: 11; opacity: 1; background: var(--ink-deep); border-top: 1px solid var(--rule); color: #b4c4d0; font-size: 12.5px; padding: 10px 24px; }
/* ---------- small screens ---------- */
@media (max-width: 900px) {
body { overflow: auto; height: auto; }
#app { grid-template-columns: 1fr; }
.key { border-right: 0; border-bottom: 1px solid var(--rule); }
.network { height: 70vh; }
.board { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}