From 96cb4b536af3244c17d049c40d867d2cb24013a8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 23 Oct 2026 18:38:47 +0200 Subject: [PATCH] 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 --- src/mockups/03-transit-map/main.ts | 367 ++++++++++++++++++++++++++- src/mockups/03-transit-map/map.ts | 118 +++++++++ src/mockups/03-transit-map/style.css | 298 +++++++++++++++++++++- 3 files changed, 779 insertions(+), 4 deletions(-) create mode 100644 src/mockups/03-transit-map/map.ts diff --git a/src/mockups/03-transit-map/main.ts b/src/mockups/03-transit-map/main.ts index cbd9e18..bf6a8d3 100644 --- a/src/mockups/03-transit-map/main.ts +++ b/src/mockups/03-transit-map/main.ts @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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 = `

Transit map

stub

`; +app.innerHTML = ` + +
+ +
+ +
+ +`; +const scroller = app.querySelector(".network__scroll")!; +const sign = app.querySelector(".sign")!; +const board = app.querySelector(".board")!; +const route = app.querySelector(".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 = ``; + ul.append(li); +} + +// ---------- the map ---------- +function renderMap(): string { + const { width, height, stations, lines, zones, gaps, laneTop, rulerY } = layout; + const zoneSvg = zones.map((z) => ` + + + + ${z.index} + Zone ${z.index} + ${esc(z.saga.name)} + + `).join(""); + + const lineSvg = lines.filter((l) => l.d).map((l) => ` + + + + ${esc(l.motif.name)} line + `).join(""); + + const stationSvg = stations.map((st) => { + const n = st.stack.length; + const h = st.bottom - st.top + GAP; + const shape = n > 1 + ? `` + : ``; + const ids = st.stack.map((s) => s.motif.id).join(" "); + const story = yearLabel(st.song.storyTime.yearsAfterTroy); + return ` + + + + ${shape} + + ${pad(st.song.track)}${esc(st.song.title)} + + `; + }).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 + ? `${esc(yearLabel(y))}` + : `Year ${y}`; + prevYear = y; + return `${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 ` + + + ${txt}`; + }).join(""); + + return ` + + + + + ${zoneSvg} + + ${ticks}${gapSvg} + In-story time + ${lineSvg} + + ${stationSvg} + `; +} +scroller.innerHTML = renderMap(); +const svg = scroller.querySelector("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 ``; +} + +function applyLine() { + svg.classList.toggle("has-line", !!activeLine); + svg.querySelectorAll(".line").forEach((g) => g.classList.toggle("is-on", g.dataset.line === activeLine)); + svg.querySelectorAll(".station").forEach((g) => + g.classList.toggle("is-served", !!activeLine && (g.dataset.lines ?? "").split(" ").includes(activeLine))); + app.querySelectorAll(".line-btn").forEach((b) => { + const on = b.dataset.line === activeLine; + b.classList.toggle("is-on", on); + b.setAttribute("aria-pressed", String(on)); + }); + board.querySelectorAll(".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(".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(`#track-${activeLine}`); + const len = track?.getTotalLength?.() ?? 1200; + const dur = Math.max(6, len / 230).toFixed(1); + trainLayer.innerHTML = ` + + `; + } + + 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 = ` +
+

${esc(m.name)} line

+
+

${esc(m.description)}

+

Calls at ${geom.stations.length} stations

+
    ${geom.stations.map((s) => + `
  1. `).join("")}
`; +} + +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 + ? ` + Homer's Odyssey, book ${song.odyssey.book}, lines ${esc(song.odyssey.lines.replace("-", "–"))}` + : `

No counterpart in Homer's Odyssey

`; + board.innerHTML = ` +
${lines.map((m) => ``).join("")}
+
+
+ + Station ${pad(song.track)} + + +
+

${esc(song.title)}

+

${zone.index}${esc(saga.name)}

+
+
+
Story time
${esc(yearLabel(song.storyTime.yearsAfterTroy))}${esc(song.storyTime.label)}
+
Source
${odyssey}
+
+

Lines calling here

${lines.map(chip).join("")}
+

Platforms

+
    ${song.parts.map((p, i) => ` +
  1. + +
    +

    ${esc(p.name)} sung by ${esc(p.singers.join(" and "))}

    +
    ${p.lines.map((l) => `

    ${esc(l)}

    `).join("")}
    +
    ${p.motifIds.map((id) => motifById.get(id)).filter((m): m is Motif => !!m).map(chip).join("")}
    +
    +
  2. `).join("")}
+

Worth knowing

${song.facts.map((f) => `

${esc(f)}

`).join("")}
+

Watch

+

Listen

`; + 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(".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(".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("[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("[data-song]"); + if (songBtn?.dataset.song && !dragged) return setSong(songBtn.dataset.song); +}); +svg.addEventListener("keydown", (e) => { + const st = (e.target as Element).closest(".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 = `${esc(st.song.title)}${esc(st.song.storyTime.label)} + ${st.stack.map((s) => ``).join("")} + ${st.stack.length > 1 ? `Interchange, ${st.stack.length} lines` : "1 line"}`; + 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(".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)); +} diff --git a/src/mockups/03-transit-map/map.ts b/src/mockups/03-transit-map/map.ts new file mode 100644 index 0000000..861663a --- /dev/null +++ b/src/mockups/03-transit-map/map.ts @@ -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(); + 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(); + [...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]}`; +} diff --git a/src/mockups/03-transit-map/style.css b/src/mockups/03-transit-map/style.css index 5c9057d..be0345d 100644 --- a/src/mockups/03-transit-map/style.css +++ b/src/mockups/03-transit-map/style.css @@ -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; } +}