feat(mockups): 10 unrolling chart spike; unique stub css for 11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-29 02:31:50 +01:00
1 parent 42b51983c1
commit e880a69177
6 files changed
+1158 -61

No files matched your search

+119
View File
@@ -0,0 +1,119 @@
import { songs } from "../../data/sample";
import { gapYears } from "../../data/query";
/* Chart geometry in chart units (1 unit = 1px at scale 1). Mockup 01's reading
model: every song is one STEP after the last, and every in-story year adds
YEAR units, so elapsed time reads as distance sailed. */
export const STEP = 220;
export const YEAR = 600;
export const START = 300;
export const W = 4600;
export const H = 626;
export const SEA_TOP = 44;
export const SEA_BOTTOM = 412;
export const SCALE_Y = 396;
export type Pt = readonly [number, number];
export const xs: number[] = [];
songs.forEach((s, i) => {
xs.push(i === 0 ? START : xs[i - 1]! + STEP + Math.max(0, gapYears(songs[i - 1]!, s)) * YEAR);
});
/* Port heights: gentle, deterministic undulation; the Cyclops ports sit a
little lower so the island has room above them. */
const PORT_Y = [262, 282, 266, 288, 280, 296, 304, 292, 300, 276];
export const ports: Pt[] = xs.map((x, i) => [x, PORT_Y[i] ?? 280] as const);
/* Extra control points keep the long open-water gaps from being straight. */
const EXTRA: Pt[] = [
[130, 246],
[1370, 322],
[3040, 250],
[3460, 318],
[3800, 258],
[4470, 262],
];
const CONTROL: Pt[] = [...EXTRA, ...ports].sort((a, b) => a[0] - b[0]);
/* The route is y = f(x): a cubic Hermite spline over x, so it can never loop
back on itself and stays left-to-right. */
const tangents = CONTROL.map((p, i) => {
const a = CONTROL[Math.max(0, i - 1)]!;
const b = CONTROL[Math.min(CONTROL.length - 1, i + 1)]!;
return (b[1] - a[1]) / (b[0] - a[0]);
});
export function routeY(x: number): number {
const c = CONTROL;
if (x <= c[0]![0]) return c[0]![1];
if (x >= c[c.length - 1]![0]) return c[c.length - 1]![1];
let i = 0;
while (c[i + 1]![0] < x) i++;
const [x0, y0] = c[i]!;
const [x1, y1] = c[i + 1]!;
const h = x1 - x0;
const t = (x - x0) / h;
const t2 = t * t, t3 = t2 * t;
return (2 * t3 - 3 * t2 + 1) * y0 + (t3 - 2 * t2 + t) * h * tangents[i]! + (-2 * t3 + 3 * t2) * y1 + (t3 - t2) * h * tangents[i + 1]!;
}
export const routeAngle = (x: number) => (Math.atan2(routeY(x + 4) - routeY(x - 4), 8) * 180) / Math.PI;
export const ROUTE_START = CONTROL[0]![0];
export const ROUTE_END = CONTROL[CONTROL.length - 1]![0];
/** Path along the route between two x positions, optionally offset vertically. */
export function routePath(a: number, b: number, dy = 0): string {
let d = "";
for (let x = a; x < b; x += 6) d += `${d ? "L" : "M"}${x.toFixed(1)},${(routeY(x) + dy).toFixed(1)}`;
return d + `L${b.toFixed(1)},${(routeY(b) + dy).toFixed(1)}`;
}
/* ---------------------------------------------------------------- coastlines */
function rand(seed: number) {
let s = seed >>> 0;
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
}
function seg(p0: Pt, p1: Pt, p2: Pt, p3: Pt): string {
const t = 1 / 6;
return ` C${(p1[0] + (p2[0] - p0[0]) * t).toFixed(1)},${(p1[1] + (p2[1] - p0[1]) * t).toFixed(1)} ${(p2[0] - (p3[0] - p1[0]) * t).toFixed(1)},${(p2[1] - (p3[1] - p1[1]) * t).toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
}
export function closedSmooth(pts: Pt[]): string {
const n = pts.length;
let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`;
for (let i = 0; i < n; i++) d += seg(pts[(i - 1 + n) % n]!, pts[i]!, pts[(i + 1) % n]!, pts[(i + 2) % n]!);
return d + "Z";
}
export function openSmooth(pts: Pt[]): string {
let d = `M${pts[0]![0].toFixed(1)},${pts[0]![1].toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) d += seg(pts[Math.max(0, i - 1)]!, pts[i]!, pts[i + 1]!, pts[Math.min(pts.length - 1, i + 2)]!);
return d;
}
/** A seeded, irregular island outline; `grow` inflates it for offset water lines. */
export function island(cx: number, cy: number, rx: number, ry: number, seed: number, grow = 0, n = 30): string {
const r = rand(seed);
const jitter = Array.from({ length: n }, () => r());
const pts: Pt[] = [];
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2;
const k = 1 + 0.13 * Math.sin(3 * a + seed) + 0.08 * Math.sin(7 * a + seed * 2) + (jitter[i]! - 0.5) * 0.12;
pts.push([cx + Math.cos(a) * (rx * k + grow), cy + Math.sin(a) * (ry * k + grow)]);
}
return closedSmooth(pts);
}
/** Troy's mainland: a coast hanging from the top edge of the sea. */
export function troyCoast(dy = 0): Pt[] {
const r = rand(7);
const pts: Pt[] = [];
for (let x = -20; x <= 1180; x += 40) {
const fall = x > 900 ? ((x - 900) / 280) ** 2 * 120 : 0;
pts.push([x, 112 + 24 * Math.sin(x / 150) + 15 * Math.sin(x / 61 + 1) + 8 * Math.sin(x / 23 + 2) + (r() - 0.5) * 18 - fall + dy]);
}
pts.push([1240, SEA_TOP - 6 + dy]);
return pts;
}
+605 -1
View File
@@ -1,4 +1,608 @@
import { motifs, sagas, songs } from "../../data/sample";
import { gapYears, motifsInSong, sagaOf, songsWithMotif } 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 {
H, ROUTE_END, ROUTE_START, SCALE_Y, SEA_BOTTOM, SEA_TOP, STEP, W,
island, openSmooth, ports, routeAngle, routePath, routeY, troyCoast, xs,
} from "./chart";
mountCredits();
document.getElementById("app")!.textContent = "Unrolling chart (stub)";
const params = new URLSearchParams(location.search);
const OVERLAY = params.get("overlay") === "1";
const S = OVERLAY ? 0.62 : 1.1;
const SVG_NS = "http://www.w3.org/2000/svg";
/* Shared motif colours are tuned for dark pages; on vellum they are re-inked
to darker iron-gall versions of the same hue. */
const INK: Record<string, string> = {
warrior: "#93650b",
"open-arms": "#b2472c",
"just-a-man": "#2e5d8b",
remember: "#3b7347",
odysseus: "#5b3c20",
athena: "#6a4a9d",
polyphemus: "#8e1e24",
};
const ink = (m: Motif) => INK[m.id] ?? m.color;
/* Thread rows beneath the sea: motifs, a small gap, then characters. */
const threadY = new Map<string, number>();
motifs.filter((m) => m.kind === "motif").forEach((m, i) => threadY.set(m.id, 448 + i * 24));
motifs.filter((m) => m.kind === "character").forEach((m, i) => threadY.set(m.id, 554 + i * 24));
/* ------------------------------------------------------------------ helpers */
type Attrs = Record<string, string | number | undefined>;
function h<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Attrs = {}, ...kids: (Node | string)[]) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
el.append(...kids);
return el;
}
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, text?: string) {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
if (text !== undefined) el.textContent = text;
return el;
}
/** Break a label into at most two lines near the middle. */
function twoLines(text: string, max: number): string[] {
if (text.length <= max) return [text];
const words = text.split(" ");
let best = 1, bestDiff = Infinity;
for (let i = 1; i < words.length; i++) {
const d = Math.abs(words.slice(0, i).join(" ").length - words.slice(i).join(" ").length);
if (d < bestDiff) { bestDiff = d; best = i; }
}
return [words.slice(0, best).join(" "), words.slice(best).join(" ")];
}
const WORDS = ["no time", "a year", "two years", "three years", "four years"];
const yearsPass = (n: number) => (n === 1 ? "a year passes" : `${WORDS[n] ?? `${n} years`} pass at sea`);
const yearName = (y: number) => (y === 0 ? "The year Troy falls" : `Year ${y} after Troy`);
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ------------------------------------------------------------------ skeleton */
const app = document.getElementById("app")!;
document.body.classList.toggle("is-overlay", OVERLAY);
const minimap = h("div", { class: "minimap", role: "slider", tabindex: 0, "aria-label": "Whole chart. Click to jump.", "aria-valuemin": 0, "aria-valuemax": 100 });
const masthead = h(
"header",
{ class: "masthead" },
h(
"div",
{ class: "masthead__words" },
h("h1", { class: "masthead__title" }, OVERLAY ? "Sea chart" : "The voyage, unrolled"),
h("p", { class: "masthead__dek" }, OVERLAY ? "Pick a port to set sail." : "A chart of EPIC: The Musical. Pan east to sail; the longer the water, the more years pass."),
),
minimap,
);
const scroller = h("section", { class: "roll", tabindex: 0, "aria-label": "Sea chart of the songs. Drag, scroll or use the arrow keys to sail." });
const track = h("div", { class: "roll__track", style: `width:${W * S}px;height:${H * S}px` });
const svg = s("svg", { class: "chart", width: W * S, height: H * S, viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Sea chart" });
track.append(svg);
scroller.append(track);
const rollers = h("div", { class: "rollers", "aria-hidden": "true" }, h("span", { class: "roller roller--west" }), h("span", { class: "roller roller--east" }));
const sheet = h("div", { class: "sheet" }, scroller, rollers);
const legend = h("nav", { class: "legend", "aria-label": "Motifs", style: `height:${H * S}px` });
const stage = h("div", { class: "stage" }, legend, sheet);
const caption = h("p", { class: "caption", "aria-live": "polite" });
const panel = h("aside", { class: "drawer", "aria-label": "Song", "aria-hidden": "true" });
if (OVERLAY) {
const frame = h("div", { class: "frame" }, masthead, stage, caption);
app.append(h("div", { class: "backdrop", "aria-hidden": "true" }, h("span", { class: "backdrop__note" }, "3D sea voyage, paused")), frame);
} else {
app.append(masthead, stage, caption, panel);
}
/* ------------------------------------------------------------------ svg layers */
const defs = s("defs");
defs.append(s("clipPath", { id: "sea-clip" }));
defs.lastElementChild!.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: SEA_BOTTOM - SEA_TOP }));
svg.append(defs);
// Vellum ground with a faint fold every saga.
svg.append(s("rect", { x: 0, y: 0, width: W, height: H, class: "vellum" }));
svg.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: SEA_BOTTOM - SEA_TOP, class: "sea" }));
// Rhumb lines radiating from two compass roses, beneath everything.
const gRhumb = s("g", { class: "rhumbs", "clip-path": "url(#sea-clip)" });
const roses: [number, number][] = [[1370, 168], [3060, 150]];
for (const [cx, cy] of roses) {
for (let i = 0; i < 16; i++) {
const a = (i / 16) * Math.PI * 2;
const cls = i % 4 === 0 ? "rhumb rhumb--ink" : i % 2 === 0 ? "rhumb rhumb--green" : "rhumb rhumb--red";
gRhumb.append(s("line", { x1: cx, y1: cy, x2: cx + Math.cos(a) * 1400, y2: cy + Math.sin(a) * 1400, class: cls }));
}
}
svg.append(gRhumb);
// Land.
const gLand = s("g", { class: "land", "clip-path": "url(#sea-clip)" });
const coast = troyCoast();
const coastPath = `${openSmooth(coast)} L1260,0 L-40,0 Z`;
for (const g of [26, 14]) gLand.append(s("path", { d: openSmooth(troyCoast(g)), class: "waterline" }));
gLand.append(s("path", { d: coastPath, class: "landmass" }));
gLand.append(s("path", { d: openSmooth(coast), class: "coastwash" }));
const cyclops = { cx: 2330, cy: 132, rx: 380, ry: 64, seed: 11 };
for (const g of [24, 12]) gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed, g), class: "waterline" }));
gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "landmass" }));
gLand.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "coastwash" }));
// A few rocks in the open water.
for (const [x, y, r, seed] of [[1660, 360, 16, 3], [2860, 356, 12, 5], [3600, 140, 22, 9], [3720, 176, 10, 13]] as const) {
gLand.append(s("path", { d: island(x, y, r, r * 0.7, seed, 7, 14), class: "waterline" }));
gLand.append(s("path", { d: island(x, y, r, r * 0.7, seed, 0, 14), class: "landmass" }));
}
svg.append(gLand);
// Place names, written the way a portolan writes them: italic, along the coast.
const gNames = s("g", { class: "places" });
gNames.append(s("text", { x: 300, y: 82, class: "place place--big" }, "Troy"));
// Towers of Troy.
const towers = s("g", { class: "towers", transform: "translate(420 64)" });
for (const [dx, hgt] of [[0, 18], [12, 26], [24, 20], [36, 14]] as const) towers.append(s("rect", { x: dx, y: 26 - hgt, width: 9, height: hgt }));
gNames.append(towers);
gNames.append(s("text", { x: cyclops.cx, y: cyclops.cy + 8, class: "place place--big", "text-anchor": "middle" }, "Island of the Cyclopes"));
gNames.append(s("text", { x: 3300, y: 214, class: "place place--sea", "text-anchor": "middle" }, "The open sea"));
svg.append(gNames);
// Compass roses.
for (const [cx, cy] of roses) {
const g = s("g", { class: "rose", transform: `translate(${cx} ${cy})` });
g.append(s("circle", { r: 44, class: "rose__ring" }));
g.append(s("circle", { r: 36, class: "rose__ring rose__ring--thin" }));
for (let i = 0; i < 8; i++) {
const len = i % 2 === 0 ? 52 : 30;
const cls = i % 2 === 0 ? "rose__point" : "rose__point rose__point--minor";
g.append(s("path", { d: `M0,${-len} L6,0 L0,0 Z`, class: cls, transform: `rotate(${i * 45})` }));
g.append(s("path", { d: `M0,${-len} L-6,0 L0,0 Z`, class: `${cls} rose__point--shade`, transform: `rotate(${i * 45})` }));
}
g.append(s("text", { y: -58, class: "rose__n", "text-anchor": "middle" }, "N"));
svg.append(g);
}
// The storm: a swirl of ink above Storm's port.
const storm = s("g", { class: "storm", transform: `translate(${xs[9]! + 150} 150)` });
for (let k = 0; k < 3; k++) {
let d = "";
for (let t = 0; t < 4.2 * Math.PI; t += 0.2) {
const r = 6 + t * 5.2;
const x = Math.cos(t + k * 2.1) * r * 1.5;
const y = Math.sin(t + k * 2.1) * r * 0.75;
d += `${d ? "L" : "M"}${x.toFixed(1)},${y.toFixed(1)}`;
}
storm.append(s("path", { d, class: "storm__arm" }));
}
for (let i = 0; i < 9; i++) storm.append(s("line", { x1: -110 + i * 26, y1: 70, x2: -126 + i * 26, y2: 108, class: "storm__rain" }));
svg.append(storm);
// Saga band.
const gSaga = s("g", { class: "sagas" });
for (const saga of sagas) {
const idx = songs.map((so, i) => (so.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (!idx.length) continue;
const a = xs[idx[0]!]! - 90;
const b = xs[idx[idx.length - 1]!]! + 90;
gSaga.append(s("path", { d: `M${a} 38 v-7 H${b} v7`, class: "saga__bracket" }));
gSaga.append(s("text", { x: a + 8, y: 24, class: "saga__name" }, saga.name));
}
svg.append(gSaga);
// Nautical scale bar doubling as the time axis.
const gScale = s("g", { class: "scale" });
for (let x = ROUTE_START, k = 0; x < ROUTE_END; x += 110, k++) {
gScale.append(s("rect", { x, y: SCALE_Y, width: Math.min(110, ROUTE_END - x), height: 6, class: k % 2 ? "scale__cell" : "scale__cell scale__cell--ink" }));
}
songs.forEach((so, i) => {
const gy = i === 0 ? 1 : gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
const x = i === 0 ? ROUTE_START : xs[i]! - STEP / 2;
gScale.append(s("line", { x1: x, x2: x, y1: SCALE_Y - 8, y2: SCALE_Y + 14, class: "scale__tick" }));
gScale.append(s("text", { x: x + 6, y: SCALE_Y - 6, class: "scale__label" }, yearName(so.storyTime.yearsAfterTroy)));
});
svg.append(gScale);
// Meridians from each port down through the motif threads.
const gMer = s("g", { class: "meridians" });
xs.forEach((x) => gMer.append(s("line", { x1: x, x2: x, y1: SCALE_Y + 16, y2: H - 8 })));
svg.append(gMer);
// Motif threads.
const gThreads = s("g", { class: "threads" });
const threadEls = new Map<string, SVGGElement>();
for (const m of motifs) {
const y = threadY.get(m.id)!;
const g = s("g", { class: "thread", "data-motif": m.id, style: `--ink:${ink(m)}` });
g.append(s("line", { x1: ROUTE_START, x2: ROUTE_END, y1: y, y2: y, class: "thread__ghost" }));
const hits = songs.map((so, i) => [so, i] as const).filter(([so]) => so.parts.some((p) => p.motifIds.includes(m.id)));
if (hits.length) {
g.append(s("line", { x1: xs[hits[0]![1]]!, x2: xs[hits[hits.length - 1]![1]]!, y1: y, y2: y, class: "thread__line" }));
for (const [so, i] of hits) {
const parts = so.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name);
g.append(s("circle", { cx: xs[i]!, cy: y, r: 5, class: "thread__knot" }));
g.append(s("text", { x: xs[i]! + 9, y: y - 7, class: "thread__part" }, parts.join(", ")));
}
}
threadEls.set(m.id, g);
gThreads.append(g);
}
svg.append(gThreads);
// The route itself.
const gRoute = s("g", { class: "route" });
gRoute.append(s("path", { d: routePath(ROUTE_START, ROUTE_END), class: "route__line" }));
const gMotifRoute = s("g", { class: "route__motif" });
gRoute.append(gMotifRoute);
// Elapsed time written along the water.
songs.forEach((so, i) => {
if (i === 0) return;
const gy = gapYears(songs[i - 1]!, so);
if (gy <= 0) return;
const id = `gap-${i}`;
defs.append(s("path", { id, d: routePath(xs[i - 1]! + 120, xs[i]! - 120, -12) }));
const t = s("text", { class: "route__elapsed" });
const tp = s("textPath", { href: `#${id}`, startOffset: "50%", "text-anchor": "middle" }, yearsPass(gy));
t.append(tp);
gRoute.append(t);
});
svg.append(gRoute);
// Ports.
const gPorts = s("g", { class: "ports" });
const portEls: SVGGElement[] = [];
songs.forEach((so, i) => {
const [x, y] = ports[i]!;
const g = s("g", { class: "port", transform: `translate(${x} ${y})`, tabindex: -1, role: "button", "aria-label": `${so.title}, ${so.storyTime.label}`, "data-song": so.id });
g.append(s("circle", { r: 18, class: "port__hit" }));
g.append(s("circle", { r: 13, class: "port__ring" }));
g.append(s("circle", { r: 7, class: "port__dot" }));
g.append(s("circle", { r: 2.2, class: "port__pin" }));
const title = twoLines(so.title, 15);
title.forEach((line, k) => g.append(s("text", { y: -38 - (title.length - 1 - k) * 20, class: "port__title", "text-anchor": "middle" }, line)));
const when = twoLines(so.storyTime.label, 22);
when.forEach((line, k) => g.append(s("text", { y: 32 + k * 16, class: "port__when", "text-anchor": "middle" }, line)));
portEls.push(g);
gPorts.append(g);
});
svg.append(gPorts);
// The ship.
const ship = s("g", { class: "ship" });
const shipBody = s("g", { class: "ship__body", transform: "scale(0.72)" });
shipBody.append(s("path", { d: "M-20,-2 L20,-2 L13,8 L-14,8 Z", class: "ship__hull" }));
shipBody.append(s("path", { d: "M0,-2 L0,-34", class: "ship__mast" }));
shipBody.append(s("path", { d: "M-13,-31 Q0,-24 13,-31 L12,-9 Q0,-4 -12,-9 Z", class: "ship__sail" }));
shipBody.append(s("path", { d: "M0,-34 L10,-38 L0,-41 Z", class: "ship__pennant" }));
ship.append(s("ellipse", { cx: 0, cy: 9, rx: 26, ry: 4, class: "ship__wake" }), shipBody);
svg.append(ship);
const youAreHere = h("div", { class: "here", "aria-live": "polite" });
if (OVERLAY) track.append(youAreHere);
/* ------------------------------------------------------------------ legend */
const legendKey = h(
"div",
{ class: "legend__key", style: `height:${(SEA_BOTTOM - 4) * S}px` },
h("p", {}, "Each port is a song, in story order."),
h("p", {}, "Open water between ports is time passing: the wider the gap, the more years lost."),
h("p", {}, "Threads below show where each motif plays. Pick one to follow it."),
);
legend.append(legendKey);
const legendBtns = new Map<string, HTMLButtonElement>();
for (const m of motifs) {
const b = h("button", { type: "button", class: `legend__item legend__item--${m.kind}`, style: `top:${(threadY.get(m.id)! - 11) * S}px;--ink:${ink(m)}`, "aria-pressed": "false", title: m.description }, h("span", { class: "legend__swatch" }), m.name);
b.addEventListener("click", () => setMotif(state.motif === m.id ? null : m.id));
legendBtns.set(m.id, b);
legend.append(b);
}
/* ------------------------------------------------------------------ minimap */
const MINI_H = SEA_BOTTOM;
const mini = s("svg", { class: "minimap__svg", viewBox: `0 ${SEA_TOP} ${W} ${MINI_H - SEA_TOP}`, preserveAspectRatio: "none", "aria-hidden": "true" });
mini.append(s("rect", { x: 0, y: SEA_TOP, width: W, height: MINI_H - SEA_TOP, class: "mini__sea" }));
mini.append(s("path", { d: coastPath, class: "mini__land" }));
mini.append(s("path", { d: island(cyclops.cx, cyclops.cy, cyclops.rx, cyclops.ry, cyclops.seed), class: "mini__land" }));
for (const saga of sagas.slice(1)) {
const i = songs.findIndex((so) => so.sagaId === saga.id);
if (i > 0) mini.append(s("line", { x1: xs[i]! - 110, x2: xs[i]! - 110, y1: SEA_TOP, y2: MINI_H, class: "mini__fold" }));
}
mini.append(s("path", { d: routePath(ROUTE_START, ROUTE_END), class: "mini__route" }));
const miniDots = ports.map(([x, y]) => {
const r = s("rect", { x: x - 14, y: y - 30, width: 28, height: 60, class: "mini__port" });
mini.append(r);
return r;
});
const miniShip = h("span", { class: "minimap__ship" });
const miniView = h("span", { class: "minimap__view" });
minimap.append(mini, miniView, miniShip);
/* ------------------------------------------------------------------ state */
const state = { motif: null as string | null, song: null as string | null, shipX: xs[0]!, here: 6 };
const FIRST = xs[0]!, LAST = xs[xs.length - 1]!;
const maxScroll = () => Math.max(1, scroller.scrollWidth - scroller.clientWidth);
function placeShip(x: number) {
state.shipX = x;
ship.setAttribute("transform", `translate(${x.toFixed(1)} ${routeY(x).toFixed(1)}) rotate(${routeAngle(x).toFixed(1)})`);
miniShip.style.left = `${(x / W) * 100}%`;
if (OVERLAY) {
youAreHere.style.left = `${x * S}px`;
youAreHere.style.top = `${(routeY(x) - 64) * S}px`;
}
}
function nearestPort(x: number) {
let best = 0;
xs.forEach((px, i) => { if (Math.abs(px - x) < Math.abs(xs[best]! - x)) best = i; });
return best;
}
/** Scroll position at which the ship sits exactly on port i. */
const scrollForPort = (i: number) => ((xs[i]! - FIRST) / (LAST - FIRST)) * maxScroll();
function onScroll() {
const f = scroller.scrollLeft / maxScroll();
const vw = scroller.clientWidth / S;
miniView.style.left = `${((scroller.scrollLeft / S) / W) * 100}%`;
miniView.style.width = `${(vw / W) * 100}%`;
minimap.setAttribute("aria-valuenow", String(Math.round(f * 100)));
if (OVERLAY) return;
placeShip(FIRST + (LAST - FIRST) * f);
const i = nearestPort(state.shipX);
const so = songs[i]!;
const atPort = Math.abs(xs[i]! - state.shipX) < 40;
caption.replaceChildren(
h("span", { class: "caption__lead" }, atPort ? "Moored at " : "Sailing past "),
h("strong", {}, so.title),
`. ${so.storyTime.label}, in ${sagaOf(so).name}.`,
);
portEls.forEach((p, k) => p.classList.toggle("is-near", k === i));
}
scroller.addEventListener("scroll", onScroll, { passive: true });
/* ------------------------------------------------------------------ motif */
function setMotif(id: string | null) {
state.motif = id;
svg.classList.toggle("has-motif", !!id);
for (const [mid, g] of threadEls) g.classList.toggle("is-on", mid === id);
for (const [mid, b] of legendBtns) b.setAttribute("aria-pressed", String(mid === id));
legend.classList.toggle("has-motif", !!id);
gMotifRoute.replaceChildren();
const hitIds = new Set(id ? songsWithMotif(id).map((so) => so.id) : []);
portEls.forEach((p, i) => {
p.classList.toggle("is-motif", hitIds.has(songs[i]!.id));
p.style.setProperty("--motif", id ? ink(motifs.find((m) => m.id === id)!) : "");
});
miniDots.forEach((r, i) => r.classList.toggle("is-on", hitIds.has(songs[i]!.id)));
if (!id) return;
const m = motifs.find((mm) => mm.id === id)!;
const idx = songs.map((so, i) => (hitIds.has(so.id) ? i : -1)).filter((i) => i >= 0);
gMotifRoute.style.setProperty("--ink", ink(m));
if (idx.length > 1) gMotifRoute.append(s("path", { d: routePath(xs[idx[0]!]!, xs[idx[idx.length - 1]!]!), class: "route__motif-span" }));
for (const i of idx) gMotifRoute.append(s("path", { d: routePath(xs[i]! - 80, xs[i]! + 80), class: "route__motif-run" }));
mini.style.setProperty("--ink", ink(m));
}
/* ------------------------------------------------------------------ song drawer */
function openSong(id: string | null) {
state.song = id;
portEls.forEach((p) => p.classList.toggle("is-open", p.dataset.song === id));
const so = songs.find((x) => x.id === id);
if (!so) {
panel.setAttribute("aria-hidden", "true");
panel.classList.remove("is-open");
return;
}
panel.replaceChildren(...songDetail(so));
panel.setAttribute("aria-hidden", "false");
panel.classList.add("is-open");
}
function songDetail(so: Song): Node[] {
const close = h("button", { type: "button", class: "drawer__close", "aria-label": "Close song" }, "Close");
close.addEventListener("click", () => openSong(null));
const odyssey = so.odyssey
? h("a", { href: so.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, `Odyssey, Book ${so.odyssey.book}, lines ${so.odyssey.lines}`)
: h("span", {}, "No direct counterpart in the Odyssey");
const parts = h(
"ol",
{ class: "drawer__parts" },
...so.parts.map((p) =>
h(
"li",
{},
h("span", { class: "drawer__part" }, p.name),
h("span", { class: "drawer__singers" }, p.singers.join(", ")),
h(
"span",
{ class: "drawer__chips" },
...p.motifIds.map((mid) => {
const m = motifs.find((x) => x.id === mid)!;
const chip = h("button", { type: "button", class: "chip", style: `--ink:${ink(m)}` }, m.name);
chip.addEventListener("click", () => setMotif(mid));
return chip;
}),
),
),
),
);
return [
close,
h("p", { class: "drawer__meta" }, `Track ${so.track}, ${sagaOf(so).name}`),
h("h2", { class: "drawer__title" }, so.title),
h("p", { class: "drawer__when" }, `${so.storyTime.label}. ${yearName(so.storyTime.yearsAfterTroy)}.`),
h("p", { class: "drawer__odyssey" }, odyssey),
parts,
h("p", { class: "drawer__motifs" }, `Motifs here: ${motifsInSong(so).map((m) => m.name).join(", ")}.`),
createAudioSlot(so),
createVideoCards(so),
];
}
/* ------------------------------------------------------------------ overlay: sail here */
const pop = h("div", { class: "pop", hidden: "" });
if (OVERLAY) track.append(pop);
function setHere(i: number) {
state.here = i;
youAreHere.textContent = `You are here: ${songs[i]!.title}`;
portEls.forEach((p, k) => p.classList.toggle("is-here", k === i));
caption.textContent = `The ship is at ${songs[i]!.title}. ${songs[i]!.storyTime.label}. Pick another port to sail there.`;
}
function sailTo(i: number) {
const from = state.shipX, to = xs[i]!;
pop.hidden = true;
youAreHere.textContent = `Sailing to ${songs[i]!.title}`;
const dur = reduceMotion ? 0 : Math.min(2600, 500 + Math.abs(to - from) * 0.9);
const t0 = performance.now();
const step = (now: number) => {
const t = dur ? Math.min(1, (now - t0) / dur) : 1;
const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
placeShip(from + (to - from) * e);
if (t < 1) requestAnimationFrame(step);
else setHere(i);
};
requestAnimationFrame(step);
}
function showPop(i: number) {
const so = songs[i]!;
const [x, y] = ports[i]!;
const sail = h("button", { type: "button", class: "pop__sail" }, "Sail here");
sail.addEventListener("click", () => sailTo(i));
const here = i === state.here;
pop.replaceChildren(
h("strong", { class: "pop__title" }, so.title),
h("span", { class: "pop__when" }, so.storyTime.label),
here ? h("span", { class: "pop__now" }, "The ship is here") : sail,
);
pop.style.left = `${x * S}px`;
pop.style.top = `${(y + 22) * S}px`;
pop.hidden = false;
if (!here) sail.focus({ preventScroll: true });
}
/* ------------------------------------------------------------------ input */
function activatePort(i: number) {
if (OVERLAY) showPop(i);
else {
openSong(songs[i]!.id);
goToPort(i);
}
}
function goToPort(i: number, smooth = true) {
if (OVERLAY) {
scroller.scrollTo({ left: xs[i]! * S - scroller.clientWidth / 2, behavior: smooth && !reduceMotion ? "smooth" : "auto" });
} else {
scroller.scrollTo({ left: scrollForPort(i), behavior: smooth && !reduceMotion ? "smooth" : "auto" });
}
}
// Drag to pan; a press that barely moves is a click on whatever is under it.
let drag: { x: number; left: number; moved: boolean } | null = null;
scroller.addEventListener("pointerdown", (e) => {
if ((e.target as Element).closest(".pop")) return;
drag = { x: e.clientX, left: scroller.scrollLeft, moved: false };
});
window.addEventListener("pointermove", (e) => {
if (!drag) return;
const dx = e.clientX - drag.x;
if (Math.abs(dx) > 4) {
drag.moved = true;
scroller.classList.add("is-dragging");
}
if (drag.moved) scroller.scrollLeft = drag.left - dx;
});
window.addEventListener("pointerup", (e) => {
const d = drag;
drag = null;
scroller.classList.remove("is-dragging");
if (!d || d.moved) return;
const port = (e.target as Element).closest?.(".port") as SVGGElement | null;
if (port) activatePort(portEls.indexOf(port));
else if (OVERLAY && !(e.target as Element).closest?.(".pop")) pop.hidden = true;
});
// A vertical wheel sails sideways.
scroller.addEventListener(
"wheel",
(e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
e.preventDefault();
scroller.scrollLeft += e.deltaY;
},
{ passive: false },
);
// Arrow keys step from port to port.
scroller.addEventListener("keydown", (e) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const cur = OVERLAY ? nearestPort((scroller.scrollLeft + scroller.clientWidth / 2) / S) : nearestPort(state.shipX);
const next = Math.max(0, Math.min(songs.length - 1, cur + (e.key === "ArrowRight" ? 1 : -1)));
goToPort(next);
});
scroller.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
activatePort(OVERLAY ? nearestPort((scroller.scrollLeft + scroller.clientWidth / 2) / S) : nearestPort(state.shipX));
});
// Minimap: click or drag to jump; the clicked spot becomes the middle of the view.
function miniJump(e: PointerEvent) {
const r = minimap.getBoundingClientRect();
const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
scroller.scrollLeft = f * W * S - scroller.clientWidth / 2;
}
let miniDrag = false;
minimap.addEventListener("pointerdown", (e) => {
miniDrag = true;
minimap.setPointerCapture(e.pointerId);
miniJump(e);
});
minimap.addEventListener("pointermove", (e) => { if (miniDrag) miniJump(e); });
minimap.addEventListener("pointerup", () => { miniDrag = false; });
minimap.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight") scroller.scrollLeft += scroller.clientWidth * 0.8;
if (e.key === "ArrowLeft") scroller.scrollLeft -= scroller.clientWidth * 0.8;
});
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (OVERLAY) pop.hidden = true;
else openSong(null);
});
/* ------------------------------------------------------------------ boot */
const motifParam = params.get("motif");
if (motifParam && threadEls.has(motifParam)) setMotif(motifParam);
const songParam = songs.findIndex((so) => so.id === params.get("song"));
if (OVERLAY) {
const here = songs.findIndex((so) => so.id === "survive");
placeShip(xs[here]!);
setHere(here);
goToPort(here, false);
if (songParam >= 0) showPop(songParam);
} else if (songParam >= 0) {
openSong(songs[songParam]!.id);
goToPort(songParam, false);
} else if (state.motif) {
const first = songs.findIndex((so) => so.parts.some((p) => p.motifIds.includes(state.motif!)));
if (first > 0) goToPort(first, false);
}
onScroll();
// One orchestrated moment: the chart unrolls from the west on load.
if (!reduceMotion && !OVERLAY) {
sheet.classList.add("is-unrolling");
sheet.addEventListener("animationend", () => sheet.classList.remove("is-unrolling"), { once: true });
}
+381 -2
View File
@@ -1,2 +1,381 @@
/* 10-chart-scroll stub */
body { margin: 0; }
/* 10-chart-scroll: a portolan chart unrolled on vellum */
@import "@fontsource/cormorant-garamond/latin-500.css";
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
@import "@fontsource/cormorant-garamond/latin-600.css";
@import "@fontsource/inter/latin-400.css";
@import "@fontsource/inter/latin-500.css";
:root {
--vellum: #e3d6b4;
--vellum-hi: #ece2c6;
--land: #c9b07a;
--iron: #2b2118;
--iron-soft: #5e4d3a;
--rhumb-red: #a23a2a;
--rhumb-green: #3f6b5c;
--desk: #3a2f24;
--serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
--sans: "Inter", system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--desk) radial-gradient(ellipse at 50% 40%, #4a3c2e 0%, var(--desk) 70%);
color: var(--iron);
font-family: var(--sans);
}
#app { flex: 1; display: flex; flex-direction: column; padding: 18px 28px 0; gap: 12px; }
/* ---------------------------------------------------------------- masthead */
.masthead { display: flex; align-items: flex-end; gap: 36px; color: var(--vellum-hi); }
.masthead__words { flex: 0 0 360px; }
.masthead__title { margin: 0; font: 600 32px/1 var(--serif); letter-spacing: 0.01em; }
.masthead__dek { margin: 6px 0 0; font: 400 12.5px/1.45 var(--sans); color: #cbbd9b; max-width: 46ch; }
.minimap {
position: relative;
flex: 1;
height: 56px;
border: 1px solid #8c7752;
background: var(--vellum);
cursor: pointer;
touch-action: none;
}
.minimap:focus-visible { outline: 2px solid var(--vellum-hi); outline-offset: 3px; }
.minimap__svg { display: block; width: 100%; height: 100%; }
.mini__sea { fill: var(--vellum); }
.mini__land { fill: var(--land); stroke: var(--iron-soft); stroke-width: 3; vector-effect: non-scaling-stroke; }
.mini__route { fill: none; stroke: var(--rhumb-red); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.mini__fold { stroke: var(--iron-soft); stroke-dasharray: 2 3; stroke-width: 1; vector-effect: non-scaling-stroke; opacity: 0.6; }
.mini__port { fill: var(--iron); transform-box: fill-box; transform-origin: center; transform: scale(0.35, 0.12); }
.mini__port.is-on { fill: var(--ink, var(--rhumb-red)); transform: scale(0.7, 0.25); }
.minimap__view {
position: absolute;
top: -4px;
bottom: -4px;
border: 2px solid var(--iron);
background: rgb(43 33 24 / 0.08);
pointer-events: none;
}
.minimap__ship {
position: absolute;
top: -9px;
width: 0;
height: 0;
margin-left: -6px;
border: 6px solid transparent;
border-top: 8px solid var(--rhumb-red);
pointer-events: none;
}
/* ---------------------------------------------------------------- stage */
.stage { display: flex; align-items: flex-start; gap: 0; }
.legend { position: relative; flex: 0 0 210px; background: var(--vellum-hi); border-right: 1px solid #b9a47a; }
.legend__key { padding: 58px 18px 0 20px; font: 400 12px/1.5 var(--sans); color: var(--iron-soft); }
.legend__key p { margin: 0 0 10px; }
.legend__item {
position: absolute;
left: 10px;
right: 8px;
display: flex;
align-items: center;
gap: 9px;
height: 22px;
padding: 0 8px;
border: 0;
border-radius: 3px;
background: none;
color: var(--iron);
font: 500 13px/1 var(--sans);
text-align: left;
cursor: pointer;
}
.legend__item--character { font: 600 15px/1 var(--serif); }
.legend__swatch { flex: 0 0 22px; height: 3px; background: var(--ink); border-radius: 2px; }
.legend__item:hover { background: rgb(43 33 24 / 0.07); }
.legend__item:focus-visible { outline: 2px solid var(--iron); }
.legend__item[aria-pressed="true"] { background: var(--ink); color: var(--vellum-hi); }
.legend__item[aria-pressed="true"] .legend__swatch { background: var(--vellum-hi); }
.legend.has-motif .legend__item[aria-pressed="false"] { opacity: 0.5; }
.sheet { position: relative; flex: 1; min-width: 0; }
.roll {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
cursor: grab;
outline: none;
background: var(--vellum);
}
.roll::-webkit-scrollbar { display: none; }
.roll.is-dragging { cursor: grabbing; user-select: none; }
.roll:focus-visible { box-shadow: inset 0 0 0 2px var(--rhumb-red); }
.roll__track { position: relative; }
.chart { display: block; }
/* Paper rollers: the chart is wound on to a rod at each edge. */
.rollers { position: absolute; inset: 0; pointer-events: none; }
.roller { position: absolute; top: -8px; bottom: -8px; width: 22px; border-radius: 11px; }
.roller--west {
left: -11px;
background: linear-gradient(90deg, #5d4a30, #b49d71 35%, #efe4c8 55%, #a68e62 80%, #5d4a30);
box-shadow: 10px 0 18px -6px rgb(43 33 24 / 0.45);
}
.roller--east {
right: -11px;
background: linear-gradient(90deg, #5d4a30, #a68e62 20%, #efe4c8 45%, #b49d71 65%, #5d4a30);
box-shadow: -10px 0 18px -6px rgb(43 33 24 / 0.45);
}
.sheet::before,
.sheet::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 44px;
z-index: 1;
pointer-events: none;
}
.sheet::before { left: 0; background: linear-gradient(90deg, rgb(93 74 48 / 0.35), transparent); }
.sheet::after { right: 0; background: linear-gradient(270deg, rgb(93 74 48 / 0.35), transparent); }
.sheet.is-unrolling .roll { animation: unroll 1000ms cubic-bezier(0.3, 0.7, 0.2, 1) both; }
.sheet.is-unrolling .roller--east { animation: roll-east 1000ms cubic-bezier(0.3, 0.7, 0.2, 1) both; }
@keyframes unroll { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
@keyframes roll-east { from { right: calc(100% - 11px); } to { right: -11px; } }
/* ---------------------------------------------------------------- chart ink */
.vellum { fill: var(--vellum); }
.sea { fill: var(--vellum); }
.rhumb { stroke-width: 0.8; opacity: 0.35; }
.rhumb--ink { stroke: var(--iron); opacity: 0.22; }
.rhumb--green { stroke: var(--rhumb-green); }
.rhumb--red { stroke: var(--rhumb-red); }
.landmass { fill: var(--land); stroke: var(--iron); stroke-width: 1.6; }
.coastwash { fill: none; stroke: var(--rhumb-green); stroke-width: 9; opacity: 0.28; }
.waterline { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 3 5; opacity: 0.7; }
.place { font-family: var(--serif); font-style: italic; fill: var(--iron); }
.place--big { font-size: 26px; font-weight: 500; letter-spacing: 0.04em; }
.place--sea { font-size: 30px; letter-spacing: 0.32em; fill: var(--rhumb-green); opacity: 0.8; }
.towers rect { fill: var(--iron); }
.rose__ring { fill: none; stroke: var(--iron); stroke-width: 1; }
.rose__ring--thin { stroke-width: 0.6; stroke-dasharray: 2 2; }
.rose__point { fill: var(--rhumb-red); stroke: var(--iron); stroke-width: 0.6; }
.rose__point--minor { fill: var(--rhumb-green); }
.rose__point--shade { fill: var(--vellum-hi); }
.rose__n { font: 600 15px var(--serif); fill: var(--iron); }
.storm__arm { fill: none; stroke: var(--iron); stroke-width: 1.4; opacity: 0.75; }
.storm__rain { stroke: var(--iron-soft); stroke-width: 1; opacity: 0.6; }
.saga__bracket { fill: none; stroke: var(--iron); stroke-width: 1; }
.saga__name { font: italic 500 21px var(--serif); fill: var(--iron); letter-spacing: 0.02em; }
.scale__cell { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 0.8; }
.scale__cell--ink { fill: var(--iron); }
.scale__tick { stroke: var(--iron); stroke-width: 1.4; }
.scale__label { font: italic 500 16px var(--serif); fill: var(--iron); }
.meridians line { stroke: var(--iron-soft); stroke-width: 0.8; stroke-dasharray: 1 4; opacity: 0.6; }
.thread__ghost { stroke: var(--iron-soft); stroke-width: 0.6; stroke-dasharray: 1 6; opacity: 0.5; }
.thread__line { stroke: var(--ink); stroke-width: 2.4; stroke-linecap: round; }
.thread__knot { fill: var(--vellum); stroke: var(--ink); stroke-width: 2.4; }
.thread__part { font: 500 10.5px var(--sans); fill: var(--ink); opacity: 0; paint-order: stroke; stroke: var(--vellum); stroke-width: 4px; }
.chart.has-motif .thread { opacity: 0.18; }
.chart.has-motif .thread.is-on { opacity: 1; }
.thread.is-on .thread__line { stroke-width: 4; }
.thread.is-on .thread__knot { fill: var(--ink); r: 6.5; }
.thread.is-on .thread__part { opacity: 1; }
.route__line { fill: none; stroke: var(--rhumb-red); stroke-width: 2.2; stroke-dasharray: 9 5; }
.route__motif-span { fill: none; stroke: var(--ink); stroke-width: 2; opacity: 0.55; }
.route__motif-run { fill: none; stroke: var(--ink); stroke-width: 7; stroke-linecap: round; opacity: 0.85; }
.route__elapsed { font: italic 500 19px var(--serif); fill: var(--rhumb-red); letter-spacing: 0.08em; }
.port { cursor: pointer; outline: none; }
.port__hit { fill: transparent; }
.port__ring { fill: none; stroke: transparent; stroke-width: 3; }
.port__dot { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 2; transition: r 160ms; }
.port__pin { fill: var(--iron); }
.port__title, .port__when { paint-order: stroke; stroke: var(--vellum); stroke-width: 5px; stroke-linejoin: round; }
.port__title { font: 600 19px var(--serif); fill: var(--iron); }
.port__when { font: italic 500 14px var(--serif); fill: var(--iron-soft); }
.port:hover .port__dot, .port:focus-visible .port__dot, .port.is-near .port__dot { r: 9; }
.port.is-open .port__dot { fill: var(--rhumb-red); }
.chart.has-motif .port:not(.is-motif) .port__title { fill: #8c7b62; }
.port.is-motif .port__ring { stroke: var(--motif); }
.port.is-motif .port__dot { stroke: var(--motif); }
.ship { pointer-events: none; transition: none; }
.ship__hull { fill: var(--iron); }
.ship__mast { stroke: var(--iron); stroke-width: 1.6; }
.ship__sail { fill: var(--vellum-hi); stroke: var(--iron); stroke-width: 1.3; }
.ship__pennant { fill: var(--rhumb-red); }
.ship__wake { fill: none; stroke: var(--iron-soft); stroke-width: 0.9; stroke-dasharray: 4 3; }
/* ---------------------------------------------------------------- caption */
.caption { margin: 0; padding-left: 210px; font: 400 20px/1.3 var(--serif); color: var(--vellum-hi); min-height: 26px; }
.caption strong { font-weight: 600; }
.caption__lead { font-style: italic; color: #cbbd9b; }
/* ---------------------------------------------------------------- drawer */
.drawer {
position: fixed;
top: 100px;
right: 0;
bottom: 56px;
width: 400px;
z-index: 5;
padding: 22px 26px 24px;
overflow-y: auto;
background: var(--vellum-hi);
border-left: 3px double var(--iron);
box-shadow: -18px 0 30px -12px rgb(20 14 8 / 0.55);
transform: translateX(105%);
transition: transform 260ms ease-out;
}
.drawer.is-open { transform: none; }
.drawer__close {
float: right;
border: 1px solid var(--iron);
background: none;
font: 500 12px var(--sans);
color: var(--iron);
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
}
.drawer__meta { margin: 0; font: 400 12px var(--sans); color: var(--iron-soft); }
.drawer__title { margin: 6px 0 4px; font: 600 34px/1.05 var(--serif); }
.drawer__when { margin: 0 0 10px; font: italic 500 17px/1.35 var(--serif); color: var(--iron-soft); }
.drawer__odyssey { margin: 0 0 14px; font: 400 13px var(--sans); }
.drawer__odyssey a { color: var(--rhumb-red); }
.drawer__parts { margin: 0 0 12px; padding: 0; list-style: none; border-top: 1px solid #b9a47a; }
.drawer__parts li { display: grid; grid-template-columns: 70px 1fr; gap: 2px 10px; padding: 9px 0; border-bottom: 1px solid #b9a47a; }
.drawer__part { font: 600 16px var(--serif); }
.drawer__singers { font: 400 13px/1.4 var(--sans); }
.drawer__chips { grid-column: 2; display: flex; flex-wrap: wrap; gap: 5px; }
.chip {
border: 1.5px solid var(--ink);
border-radius: 999px;
background: none;
color: var(--ink);
font: 500 11px var(--sans);
padding: 2px 8px;
cursor: pointer;
}
.chip:hover { background: var(--ink); color: var(--vellum-hi); }
.drawer__motifs { margin: 0 0 14px; font: italic 500 15px var(--serif); color: var(--iron-soft); }
.drawer .audio-slot { margin-bottom: 12px; font-size: 13px; }
.drawer .video-card { font-size: 13px; }
/* ---------------------------------------------------------------- credits */
.fan-credits { color: #d8ccae; opacity: 0.9; position: relative; z-index: 6; }
/* ---------------------------------------------------------------- overlay (in-3D map) */
body.is-overlay { background: #0d2230; }
.is-overlay #app { align-items: center; justify-content: center; padding: 24px; }
.backdrop {
position: fixed;
inset: 0;
background:
linear-gradient(rgb(6 16 24 / 0.55), rgb(6 16 24 / 0.55)),
repeating-linear-gradient(172deg, transparent 0 22px, rgb(160 205 220 / 0.07) 22px 24px),
radial-gradient(ellipse at 50% 120%, #2c6b7a 0%, #133447 45%, #071521 100%);
}
.backdrop__note { position: absolute; left: 28px; top: 22px; font: 400 12px var(--sans); color: #8fb3c0; }
.frame {
position: relative;
width: 1200px;
padding: 16px 22px 14px;
display: flex;
flex-direction: column;
gap: 10px;
background: #4a3c2e;
border: 1px solid #8c7752;
border-radius: 6px;
box-shadow: 0 30px 80px rgb(0 0 0 / 0.6);
}
.is-overlay .masthead { align-items: center; }
.is-overlay .masthead__words { flex-basis: 220px; }
.is-overlay .masthead__title { font-size: 26px; }
.is-overlay .masthead__dek { font-size: 12px; }
.is-overlay .minimap { height: 40px; }
.is-overlay .legend { flex-basis: 150px; }
.is-overlay .legend__key { padding: 30px 10px 0 12px; font-size: 10.5px; line-height: 1.4; }
.is-overlay .legend__key p:last-child { display: none; }
.is-overlay .legend__item { height: 15px; font-size: 10.5px; left: 4px; padding: 0 5px; gap: 6px; }
.is-overlay .legend__item--character { font-size: 12.5px; }
.is-overlay .legend__swatch { flex-basis: 14px; }
.is-overlay .caption { padding-left: 150px; font-size: 17px; }
.is-overlay .fan-credits { color: #b9cfd8; }
.here {
position: absolute;
transform: translate(-50%, -100%);
padding: 4px 9px;
background: var(--iron);
color: var(--vellum-hi);
font: 500 12px var(--sans);
white-space: nowrap;
border-radius: 3px;
pointer-events: none;
}
.here::after {
content: "";
position: absolute;
left: 50%;
bottom: -5px;
margin-left: -5px;
border: 5px solid transparent;
border-bottom: 0;
border-top-color: var(--iron);
}
.port.is-here .port__dot { fill: var(--iron); }
.pop {
position: absolute;
transform: translate(-50%, 8px);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
min-width: 170px;
padding: 10px 12px;
background: var(--vellum-hi);
border: 1.5px solid var(--iron);
border-radius: 4px;
box-shadow: 0 8px 18px rgb(20 14 8 / 0.35);
z-index: 3;
}
.pop[hidden] { display: none; }
.pop__title { font: 600 19px/1.1 var(--serif); }
.pop__when { font: italic 500 14px var(--serif); color: var(--iron-soft); }
.pop__sail {
margin-top: 6px;
border: 0;
border-radius: 3px;
padding: 6px 12px;
background: var(--rhumb-red);
color: var(--vellum-hi);
font: 500 13px var(--sans);
cursor: pointer;
}
.pop__sail:focus-visible { outline: 2px solid var(--iron); outline-offset: 2px; }
.pop__now { margin-top: 4px; font: 500 12px var(--sans); color: var(--rhumb-green); }
@media (prefers-reduced-motion: reduce) {
.drawer { transition: none; }
.port__dot { transition: none; }
}
+1 -1
View File
@@ -1,2 +1,2 @@
/* 11-chart-atlas stub */
body { margin: 0; }
body { margin: 0; --stub-11: 1; }