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:
co-authored by
Claude Opus 5.5
parent
24f9ecaaad
commit
96cb4b536a
@@ -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 = `<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));
|
||||
}
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user