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:
1 parent
42b51983c1
commit
e880a69177
6 files changed
+1158
-61
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,2 +1,2 @@
|
||||
/* 11-chart-atlas stub */
|
||||
body { margin: 0; }
|
||||
body { margin: 0; --stub-11: 1; }
|
||||
Reference in new issue
Block a user