feat(mockups): 11 atlas with insets spike; round notes updated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a422234970
commit
65b5aa0d00
@@ -0,0 +1,291 @@
|
||||
// 11 · Atlas with insets: geometry for the general chart (overview) and each saga's inset plate.
|
||||
import { motifs, sagas, songs } from "../../data/sample";
|
||||
import type { Motif, Saga, Song } from "../../data/types";
|
||||
|
||||
export const W = 1100;
|
||||
export const H = 700;
|
||||
|
||||
export type Pt = { x: number; y: number };
|
||||
|
||||
/* ------------------------------------------------------------- utilities */
|
||||
export function rng(seed: number) {
|
||||
let t = seed >>> 0;
|
||||
return () => {
|
||||
t += 0x6d2b79f5;
|
||||
let r = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
|
||||
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Closed smooth path through points (Catmull-Rom → cubic Bézier). */
|
||||
export function smoothClosed(pts: Pt[]): string {
|
||||
const n = pts.length;
|
||||
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p0 = pts[(i - 1 + n) % n]!, p1 = pts[i]!, p2 = pts[(i + 1) % n]!, p3 = pts[(i + 2) % n]!;
|
||||
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
|
||||
}
|
||||
return d + "Z";
|
||||
}
|
||||
|
||||
/** Open smooth path through points. */
|
||||
export function smoothOpen(pts: Pt[]): string {
|
||||
if (pts.length < 2) return "";
|
||||
let d = `M${pts[0]!.x.toFixed(1)},${pts[0]!.y.toFixed(1)}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(0, i - 1)]!, p1 = pts[i]!, p2 = pts[i + 1]!, p3 = pts[Math.min(pts.length - 1, i + 2)]!;
|
||||
d += ` C${(p1.x + (p2.x - p0.x) / 6).toFixed(1)},${(p1.y + (p2.y - p0.y) / 6).toFixed(1)} ${(p2.x - (p3.x - p1.x) / 6).toFixed(1)},${(p2.y - (p3.y - p1.y) / 6).toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Irregular island outline around an ellipse. */
|
||||
export function blob(cx: number, cy: number, rx: number, ry: number, seed: number, wobble = 0.16, n = 22): Pt[] {
|
||||
const r = rng(seed);
|
||||
const pts: Pt[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = (i / n) * Math.PI * 2;
|
||||
const k = 1 + (r() - 0.5) * wobble * 2;
|
||||
pts.push({ x: cx + Math.cos(a) * rx * k, y: cy + Math.sin(a) * ry * k });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Hand-tinted colours on paper: the data colours are made for dark grounds, so deepen them. */
|
||||
export function inkTone(hex: string, t = 0.32): string {
|
||||
const ink = [0x24, 0x2b, 0x3a];
|
||||
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
// Odysseus' pale gold needs extra weight to read on paper.
|
||||
const lum = (0.299 * c[0]! + 0.587 * c[1]! + 0.114 * c[2]!) / 255;
|
||||
const k = lum > 0.8 ? 0.55 : t;
|
||||
return "#" + c.map((v, i) => Math.round(v * (1 - k) + ink[i]! * k).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- places */
|
||||
export interface Place {
|
||||
sagaId: string;
|
||||
place: string; // place name on the chart
|
||||
roman: string; // plate number
|
||||
tint: string; // watercolour wash
|
||||
label: Pt; // top-left of label block on the general chart
|
||||
focus: { x: number; y: number; w: number; h: number }; // zoom box on the general chart
|
||||
}
|
||||
|
||||
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
|
||||
|
||||
const PLACE_INFO: Record<string, Omit<Place, "sagaId" | "roman">> = {
|
||||
troy: { place: "Troy", tint: "#e7b9a9", label: { x: 30, y: 70 }, focus: { x: 0, y: 150, w: 470, h: 430 } },
|
||||
cyclops: { place: "Cyclops' Island", tint: "#bfd3a0", label: { x: 455, y: 92 }, focus: { x: 380, y: 200, w: 420, h: 380 } },
|
||||
ocean: { place: "Open sea and storm", tint: "#a9c0dc", label: { x: 818, y: 500 }, focus: { x: 790, y: 190, w: 310, h: 300 } },
|
||||
};
|
||||
|
||||
export const places: Place[] = [...sagas]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s, i) => ({
|
||||
sagaId: s.id,
|
||||
roman: ROMAN[i] ?? String(i + 1),
|
||||
...(PLACE_INFO[s.id] ?? {
|
||||
place: s.name,
|
||||
tint: "#d8d0c0",
|
||||
label: { x: 40 + i * 120, y: 560 },
|
||||
focus: { x: 0, y: 0, w: W, h: H },
|
||||
}),
|
||||
}));
|
||||
|
||||
export const placeOf = (sagaId: string) => places.find((p) => p.sagaId === sagaId)!;
|
||||
export const sagaById = (id: string): Saga => sagas.find((s) => s.id === id)!;
|
||||
export const songsIn = (sagaId: string): Song[] => songs.filter((s) => s.sagaId === sagaId);
|
||||
|
||||
export function yearSpan(list: Song[]): string {
|
||||
const ys = list.map((s) => s.storyTime.yearsAfterTroy);
|
||||
const a = Math.min(...ys), b = Math.max(...ys);
|
||||
return a === b ? `Year ${a}` : `Years ${a} to ${b}`;
|
||||
}
|
||||
|
||||
export function motifsPlaying(song: Song): Set<string> {
|
||||
return new Set(song.parts.flatMap((p) => p.motifIds));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- general chart */
|
||||
// Troy: mainland on the west edge with a headland. Cyclops' island mid-chart. Storm out east.
|
||||
export const TROY_CENTER = { x: 230, y: 360 };
|
||||
export const CYCLOPS = { cx: 585, cy: 405, rx: 135, ry: 92 };
|
||||
export const STORM = { cx: 945, cy: 335, r: 118 };
|
||||
|
||||
export function troyMainland(width = W, height = H, scale = 1): string {
|
||||
const r = rng(7);
|
||||
const pts: Pt[] = [];
|
||||
for (let y = -10; y <= height + 10; y += 26) {
|
||||
const yy = y / scale;
|
||||
let x = 248 + (r() - 0.5) * 18;
|
||||
const dy = yy - TROY_CENTER.y;
|
||||
if (Math.abs(dy) < 125) x = Math.max(x, TROY_CENTER.x + Math.sqrt(125 * 125 - dy * dy) * 0.9 + (r() - 0.5) * 12);
|
||||
pts.push({ x: x * scale, y });
|
||||
}
|
||||
void width;
|
||||
const path = smoothOpen(pts);
|
||||
return `${path} L-20,${height + 20} L-20,-20 Z`;
|
||||
}
|
||||
|
||||
/** Port positions on the general chart, one per song in story order. */
|
||||
export function overviewPorts(): Pt[] {
|
||||
const out: Pt[] = [];
|
||||
for (const place of places) {
|
||||
const list = songsIn(place.sagaId);
|
||||
const n = list.length;
|
||||
list.forEach((_, i) => {
|
||||
const t = n === 1 ? 0.5 : i / (n - 1);
|
||||
if (place.sagaId === "troy") {
|
||||
const a = (-62 + t * 112) * (Math.PI / 180);
|
||||
out.push({ x: TROY_CENTER.x + Math.cos(a) * 172, y: TROY_CENTER.y + Math.sin(a) * 172 });
|
||||
} else if (place.sagaId === "cyclops") {
|
||||
const a = (165 - t * 150) * (Math.PI / 180);
|
||||
out.push({ x: CYCLOPS.cx + Math.cos(a) * (CYCLOPS.rx + 30), y: CYCLOPS.cy + Math.sin(a) * (CYCLOPS.ry + 30) });
|
||||
} else if (place.sagaId === "ocean") {
|
||||
const a = (200 + t * 140) * (Math.PI / 180);
|
||||
const rr = n === 1 ? 0 : 50;
|
||||
out.push({ x: STORM.cx + Math.cos(a) * rr, y: STORM.cy + Math.sin(a) * rr });
|
||||
} else {
|
||||
out.push({ x: place.label.x + 40 + i * 30, y: place.label.y - 30 });
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Route through the overview ports with a few sea waypoints between sagas. */
|
||||
export function overviewRoute(ports: Pt[]): Pt[] {
|
||||
const pts: Pt[] = [];
|
||||
songs.forEach((s, i) => {
|
||||
const prev = songs[i - 1];
|
||||
if (prev && prev.sagaId !== s.sagaId) {
|
||||
const a = ports[i - 1]!, b = ports[i]!;
|
||||
if (prev.sagaId === "troy") pts.push({ x: (a.x + b.x) / 2, y: Math.max(a.y, b.y) + 40 });
|
||||
else if (s.sagaId === "ocean") pts.push({ x: (a.x + b.x) / 2 + 20, y: (a.y + b.y) / 2 + 60 });
|
||||
}
|
||||
pts.push(ports[i]!);
|
||||
});
|
||||
return pts;
|
||||
}
|
||||
|
||||
/** Storm: a spiral of squall lines. */
|
||||
export function stormSpiral(cx: number, cy: number, r: number, turns = 2.6): string {
|
||||
const pts: Pt[] = [];
|
||||
const steps = 90;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const a = t * turns * Math.PI * 2;
|
||||
const rr = 14 + t * (r - 14);
|
||||
pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr * 0.78 });
|
||||
}
|
||||
return smoothOpen(pts);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- inset plates */
|
||||
export interface InsetPort { song: Song; x: number; y: number }
|
||||
export interface InsetLayout {
|
||||
ports: InsetPort[];
|
||||
/** Route polyline per segment i → i+1, starting and ending horizontally. */
|
||||
segments: Pt[][];
|
||||
/** Year gaps between consecutive ports (for "a year passes" notes). */
|
||||
gaps: number[];
|
||||
motifs: Motif[]; // motifs that play in this saga, in legend order
|
||||
lead: Pt[]; // route entering from the west edge (empty if first plate)
|
||||
tail: Pt[]; // route leaving to the east edge (empty if last plate)
|
||||
}
|
||||
|
||||
export const ROUTE_Y = 268;
|
||||
export const BAND_TOP = 18;
|
||||
export const BAND_STEP = 9;
|
||||
|
||||
export function insetLayout(sagaId: string): InsetLayout {
|
||||
const list = songsIn(sagaId);
|
||||
const n = list.length;
|
||||
const y0 = list[0]?.storyTime.yearsAfterTroy ?? 0;
|
||||
const gaps = list.slice(1).map((s, i) => s.storyTime.yearsAfterTroy - list[i]!.storyTime.yearsAfterTroy);
|
||||
const units = Math.max(1, n - 1 + gaps.reduce((a, g) => a + Math.min(g, 2) * 0.9, 0));
|
||||
const sagaIdx = places.findIndex((p) => p.sagaId === sagaId);
|
||||
const hasLead = sagaIdx > 0, hasTail = sagaIdx < places.length - 1;
|
||||
const left = hasLead ? 250 : 130, right = hasTail ? 840 : 960;
|
||||
const step = Math.min(230, (right - left) / units);
|
||||
const used = step * units;
|
||||
let x = n === 1 ? (left + right) / 2 : left + (right - left - used) / 2;
|
||||
const ports: InsetPort[] = list.map((song, i) => {
|
||||
if (i > 0) x += step * (1 + Math.min(gaps[i - 1]!, 2) * 0.9);
|
||||
const dyYears = Math.min(song.storyTime.yearsAfterTroy - y0, 2);
|
||||
return { song, x, y: ROUTE_Y + dyYears * 46 };
|
||||
});
|
||||
const segments: Pt[][] = [];
|
||||
for (let i = 0; i < ports.length - 1; i++) {
|
||||
const a = ports[i]!, b = ports[i + 1]!;
|
||||
const dy = b.y - a.y;
|
||||
if (Math.abs(dy) < 1) segments.push([{ x: a.x, y: a.y }, { x: b.x, y: b.y }]);
|
||||
else {
|
||||
const mid = (a.x + b.x) / 2;
|
||||
segments.push([
|
||||
{ x: a.x, y: a.y },
|
||||
{ x: mid - Math.abs(dy) / 2, y: a.y },
|
||||
{ x: mid + Math.abs(dy) / 2, y: b.y },
|
||||
{ x: b.x, y: b.y },
|
||||
]);
|
||||
}
|
||||
}
|
||||
const first = ports[0], last = ports[ports.length - 1];
|
||||
const lead = hasLead && first ? [{ x: 34, y: first.y }, { x: first.x, y: first.y }] : [];
|
||||
const tail = hasTail && last ? [{ x: last.x, y: last.y }, { x: W - 34, y: last.y }] : [];
|
||||
const ids = new Set(list.flatMap((s) => [...motifsPlaying(s)]));
|
||||
return { ports, segments, gaps, motifs: motifs.filter((m) => ids.has(m.id)), lead, tail };
|
||||
}
|
||||
|
||||
/** Offset an open polyline sideways by d (positive = to the right of travel, i.e. south when heading east). */
|
||||
export function offsetPolyline(pts: Pt[], d: number): Pt[] {
|
||||
const normals = pts.slice(1).map((p, i) => {
|
||||
const q = pts[i]!;
|
||||
const dx = p.x - q.x, dy = p.y - q.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
return { x: -dy / len, y: dx / len };
|
||||
});
|
||||
return pts.map((p, i) => {
|
||||
const n1 = normals[Math.max(0, i - 1)]!, n2 = normals[Math.min(normals.length - 1, i)]!;
|
||||
const dot = n1.x * n2.x + n1.y * n2.y;
|
||||
const k = d / (1 + dot);
|
||||
return { x: p.x + (n1.x + n2.x) * k, y: p.y + (n1.y + n2.y) * k };
|
||||
});
|
||||
}
|
||||
|
||||
export const polyD = (pts: Pt[]) => pts.map((p, i) => `${i ? "L" : "M"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
|
||||
|
||||
/** Wrap a title into at most two lines of ~maxChars. */
|
||||
export function wrap(text: string, maxChars: number): string[] {
|
||||
if (text.length <= maxChars) return [text];
|
||||
const words = text.split(" ");
|
||||
let best = [text];
|
||||
let bestScore = Infinity;
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
|
||||
const score = Math.max(a.length, b.length);
|
||||
if (score < bestScore) { bestScore = score; best = [a, b]; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The part of a polyline between fractions t0..t1 of its length. */
|
||||
export function subPolyline(pts: Pt[], t0: number, t1: number): Pt[] {
|
||||
const lens = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i]!.x, p.y - pts[i]!.y));
|
||||
const total = lens.reduce((a, b) => a + b, 0);
|
||||
const at = (d: number): { p: Pt; i: number } => {
|
||||
let acc = 0;
|
||||
for (let i = 0; i < lens.length; i++) {
|
||||
if (acc + lens[i]! >= d || i === lens.length - 1) {
|
||||
const k = lens[i]! ? Math.min(1, (d - acc) / lens[i]!) : 0;
|
||||
const a = pts[i]!, b = pts[i + 1]!;
|
||||
return { p: { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k }, i };
|
||||
}
|
||||
acc += lens[i]!;
|
||||
}
|
||||
return { p: pts[pts.length - 1]!, i: lens.length - 1 };
|
||||
};
|
||||
const A = at(t0 * total), B = at(t1 * total);
|
||||
return [A.p, ...pts.slice(A.i + 1, B.i + 1), B.p];
|
||||
}
|
||||
@@ -1,4 +1,679 @@
|
||||
// 11 · Atlas with insets: a general chart of the whole voyage, and one inset plate per saga.
|
||||
import { motifs, songs } from "../../data/sample";
|
||||
import { motifsInSong, songsWithMotif } from "../../data/query";
|
||||
import type { Song } from "../../data/types";
|
||||
import { mountCredits } from "../../shared/credits";
|
||||
import { createAudioSlot } from "../../shared/audio-slot";
|
||||
import { createVideoCards } from "../../shared/video-cards";
|
||||
import {
|
||||
BAND_STEP, BAND_TOP, CYCLOPS, H, STORM, TROY_CENTER, W, blob, inkTone, insetLayout, motifsPlaying,
|
||||
offsetPolyline, overviewPorts, subPolyline, overviewRoute, placeOf, places, polyD, sagaById, smoothClosed, smoothOpen,
|
||||
songsIn, stormSpiral, troyMainland, wrap, yearSpan, type Pt,
|
||||
} from "./geo";
|
||||
|
||||
mountCredits();
|
||||
document.getElementById("app")!.textContent = "Atlas with insets (stub)";
|
||||
|
||||
/* ------------------------------------------------------------------ state */
|
||||
const params = new URLSearchParams(location.search);
|
||||
const overlay = params.get("overlay") === "1";
|
||||
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const initialSong = songs.find((s) => s.id === params.get("song")) ?? null;
|
||||
|
||||
const state = {
|
||||
sagaId: (initialSong?.sagaId ?? places.find((p) => p.sagaId === params.get("saga"))?.sagaId ?? null) as string | null,
|
||||
songId: (overlay ? null : initialSong?.id ?? null) as string | null,
|
||||
motifId: motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null,
|
||||
shipAt: "survive",
|
||||
sailPick: (overlay ? initialSong?.id ?? null : null) as string | null,
|
||||
note: "" as string,
|
||||
};
|
||||
|
||||
if (overlay) document.body.classList.add("is-overlay");
|
||||
|
||||
const tone = (id: string) => inkTone(motifs.find((m) => m.id === id)!.color);
|
||||
|
||||
/* ------------------------------------------------------------------ dom helpers */
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
type Attrs = Record<string, string | number | undefined>;
|
||||
function s<K extends keyof SVGElementTagNameMap>(tag: K, attrs: Attrs = {}, parent?: Element): SVGElementTagNameMap[K] {
|
||||
const el = document.createElementNS(SVG, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) if (v !== undefined) el.setAttribute(k, String(v));
|
||||
parent?.append(el);
|
||||
return el;
|
||||
}
|
||||
function h<K extends keyof HTMLElementTagNameMap>(tag: K, cls = "", text?: string, parent?: Element): HTMLElementTagNameMap[K] {
|
||||
const el = document.createElement(tag);
|
||||
if (cls) el.className = cls;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
parent?.append(el);
|
||||
return el;
|
||||
}
|
||||
function txt(parent: Element, x: number, y: number, text: string, cls: string, attrs: Attrs = {}) {
|
||||
const t = s("text", { x, y, class: cls, ...attrs }, parent);
|
||||
t.textContent = text;
|
||||
return t;
|
||||
}
|
||||
function activate(el: Element, label: string, fn: () => void) {
|
||||
el.setAttribute("tabindex", "0");
|
||||
el.setAttribute("role", "button");
|
||||
el.setAttribute("aria-label", label);
|
||||
el.addEventListener("click", (e) => { e.stopPropagation(); fn(); });
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "Enter" || k === " ") { e.preventDefault(); e.stopPropagation(); fn(); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ skeleton */
|
||||
const app = document.getElementById("app")!;
|
||||
app.className = "atlas";
|
||||
const head = h("header", "atlas-head", undefined, app);
|
||||
const body = h("div", "atlas-body", undefined, app);
|
||||
const plateWrap = h("section", "plate-wrap", undefined, body);
|
||||
const plate = h("div", "plate", undefined, plateWrap);
|
||||
const index = h("aside", "themes", undefined, body);
|
||||
index.setAttribute("aria-label", "Themes");
|
||||
|
||||
let svg: SVGSVGElement = s("svg", {});
|
||||
|
||||
/* ------------------------------------------------------------------ shared chart pieces */
|
||||
function neatline(root: SVGElement) {
|
||||
const g = s("g", { class: "neatline", "pointer-events": "none" }, root);
|
||||
s("rect", { x: 3, y: 3, width: W - 6, height: H - 6, class: "nl-outer" }, g);
|
||||
s("rect", { x: 9, y: 9, width: W - 18, height: H - 18, class: "nl-band" }, g);
|
||||
s("rect", { x: 13, y: 13, width: W - 26, height: H - 26, class: "nl-inner" }, g);
|
||||
}
|
||||
|
||||
/** Water-lining: concentric strokes outside a coast, the old engraver's way of showing shallows. */
|
||||
function waterLines(root: SVGElement, d: string) {
|
||||
const g = s("g", { class: "waterlines", "pointer-events": "none" }, root);
|
||||
for (const w of [34, 24, 15, 8]) {
|
||||
s("path", { d, class: "wl-ink", "stroke-width": w }, g);
|
||||
s("path", { d, class: "wl-paper", "stroke-width": w - 1.6 }, g);
|
||||
}
|
||||
}
|
||||
|
||||
let clipN = 0;
|
||||
function land(root: SVGElement, d: string, tint: string, extraCls = "") {
|
||||
const id = `clip${++clipN}`;
|
||||
const cp = s("clipPath", { id }, root);
|
||||
s("path", { d }, cp);
|
||||
s("path", { d, fill: tint, class: `land ${extraCls}` }, root);
|
||||
// Watercolour edge: a darker wash pooled just inside the coast.
|
||||
s("path", { d, stroke: tint, class: "land-edge", "clip-path": `url(#${id})` }, root);
|
||||
s("path", { d, class: "coast" }, root);
|
||||
}
|
||||
|
||||
function troyGlyph(root: SVGElement, x: number, y: number, k = 1) {
|
||||
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})` }, root);
|
||||
s("path", { d: "M-26,0 L-26,-14 L-20,-14 L-20,-22 L-14,-22 L-14,-14 L-4,-14 L-4,-30 L4,-30 L4,-14 L14,-14 L14,-22 L20,-22 L20,-14 L26,-14 L26,0 Z" }, g);
|
||||
s("path", { d: "M-2,-30 C-6,-40 4,-44 0,-54 C-4,-62 6,-66 2,-74", class: "smoke" }, g);
|
||||
}
|
||||
function caveGlyph(root: SVGElement, x: number, y: number, k = 1) {
|
||||
const g = s("g", { class: "glyph", transform: `translate(${x} ${y}) scale(${k})` }, root);
|
||||
s("path", { d: "M-30,4 C-24,-22 -8,-30 0,-30 C10,-30 24,-20 30,4", class: "hill" }, g);
|
||||
s("path", { d: "M-9,4 C-9,-10 9,-10 9,4 Z" }, g);
|
||||
}
|
||||
function hachures(root: SVGElement, cx: number, cy: number, n: number, spread: number, seed: number) {
|
||||
let r = seed;
|
||||
const g = s("g", { class: "hachure", "pointer-events": "none" }, root);
|
||||
for (let i = 0; i < n; i++) {
|
||||
r = (r * 9301 + 49297) % 233280;
|
||||
const x = cx + ((r / 233280) - 0.5) * spread;
|
||||
r = (r * 9301 + 49297) % 233280;
|
||||
const y = cy + ((r / 233280) - 0.5) * spread * 0.35;
|
||||
s("path", { d: `M${x - 7},${y + 3} Q${x},${y - 6} ${x + 7},${y + 3}` }, g);
|
||||
}
|
||||
}
|
||||
function ship(root: SVGElement, x: number, y: number, label: string) {
|
||||
const g = s("g", { class: "ship", transform: `translate(${x} ${y})`, "pointer-events": "none" }, root);
|
||||
s("circle", { r: 15, class: "ship-ring" }, g);
|
||||
s("path", { d: "M-9,4 L9,4 L6,9 L-6,9 Z M0,4 L0,-11 L8,1 Z", class: "ship-hull" }, g);
|
||||
const t = txt(g, 0, -24, label, "ship-label", { "text-anchor": "middle" });
|
||||
const bb = { w: label.length * 6.6 + 16 };
|
||||
const bg = s("rect", { x: -bb.w / 2, y: -38, width: bb.w, height: 20, rx: 2, class: "ship-tag" });
|
||||
g.insertBefore(bg, t);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ general chart */
|
||||
const OV_PORTS = overviewPorts();
|
||||
const OV_ROUTE = overviewRoute(OV_PORTS);
|
||||
const CYC_SHAPE = smoothClosed(blob(CYCLOPS.cx, CYCLOPS.cy, CYCLOPS.rx, CYCLOPS.ry, 11, 0.13));
|
||||
const TROY_SHAPE = troyMainland();
|
||||
|
||||
function drawGeneral(root: SVGElement, mini = false) {
|
||||
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
|
||||
const motifSongs = state.motifId ? new Set(songsWithMotif(state.motifId).map((x) => x.id)) : null;
|
||||
|
||||
// Halo under islands that carry the selected theme.
|
||||
const lit = (sagaId: string) => !!motifSongs && songsIn(sagaId).some((x) => motifSongs.has(x.id));
|
||||
if (state.motifId && !mini) {
|
||||
const c = tone(state.motifId);
|
||||
if (lit("troy")) s("path", { d: TROY_SHAPE, class: "halo", stroke: c }, root);
|
||||
if (lit("cyclops")) s("path", { d: CYC_SHAPE, class: "halo", stroke: c }, root);
|
||||
if (lit("ocean")) s("circle", { cx: STORM.cx, cy: STORM.cy, r: STORM.r - 10, class: "halo", stroke: c }, root);
|
||||
}
|
||||
|
||||
if (!mini) { waterLines(root, TROY_SHAPE); waterLines(root, CYC_SHAPE); }
|
||||
// decorative islets
|
||||
for (const [x, y, rx, seed] of [[420, 120, 26, 3], [745, 625, 30, 5], [700, 150, 14, 13]] as const) {
|
||||
const d = smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14));
|
||||
if (!mini) waterLines(root, d);
|
||||
s("path", { d, class: "islet" }, root);
|
||||
}
|
||||
|
||||
const groups: Record<string, SVGGElement> = {};
|
||||
for (const p of places) {
|
||||
const g = s("g", { class: "isle", "data-saga": p.sagaId }, root);
|
||||
if (state.motifId && !lit(p.sagaId) && !mini) g.classList.add("is-dim");
|
||||
groups[p.sagaId] = g;
|
||||
}
|
||||
land(groups.troy!, TROY_SHAPE, placeOf("troy").tint);
|
||||
troyGlyph(groups.troy!, TROY_CENTER.x - 50, TROY_CENTER.y + 16, mini ? 1.4 : 1);
|
||||
land(groups.cyclops!, CYC_SHAPE, placeOf("cyclops").tint);
|
||||
if (!mini) { hachures(groups.cyclops!, CYCLOPS.cx - 10, CYCLOPS.cy - 10, 9, 150, 4); caveGlyph(groups.cyclops!, CYCLOPS.cx + 40, CYCLOPS.cy + 34); }
|
||||
s("circle", { cx: STORM.cx, cy: STORM.cy, r: STORM.r, class: "storm-wash" }, groups.ocean!);
|
||||
s("path", { d: stormSpiral(STORM.cx, STORM.cy, STORM.r - 6), class: "storm-spiral" }, groups.ocean!);
|
||||
|
||||
// Route
|
||||
s("path", { d: smoothOpen(OV_ROUTE), class: "course" }, root);
|
||||
if (mini) return;
|
||||
|
||||
// Year notes on long crossings
|
||||
songs.forEach((song, i) => {
|
||||
const prev = songs[i - 1];
|
||||
if (!prev) return;
|
||||
const g = song.storyTime.yearsAfterTroy - prev.storyTime.yearsAfterTroy;
|
||||
if (g <= 0) return;
|
||||
const a = OV_PORTS[i - 1]!, b = OV_PORTS[i]!;
|
||||
const note = g === 1 ? "a year passes" : `${g === 2 ? "two" : g} years at sea`;
|
||||
const east = song.sagaId !== "ocean";
|
||||
txt(root, (a.x + b.x) / 2 + (east ? 18 : -16), (a.y + b.y) / 2 + (east ? 10 : -4), note, "time-note", { "text-anchor": east ? "start" : "end" });
|
||||
});
|
||||
|
||||
// Ports
|
||||
songs.forEach((song, i) => {
|
||||
const p = OV_PORTS[i]!;
|
||||
const on = motifSongs?.has(song.id);
|
||||
const c = s("circle", { cx: p.x, cy: p.y, r: on ? 7 : 5, class: "ov-port" + (motifSongs && !on ? " is-dim" : "") }, root);
|
||||
if (on) c.setAttribute("fill", tone(state.motifId!));
|
||||
txt(root, p.x + (song.sagaId === "troy" ? 12 : 0), p.y + (song.sagaId === "troy" ? 4 : song.sagaId === "ocean" ? 22 : -11), String(song.track), "ov-num", { "text-anchor": song.sagaId === "troy" ? "start" : "middle" });
|
||||
});
|
||||
|
||||
// Label blocks, with the track list so song titles are legible from the general chart
|
||||
for (const p of places) {
|
||||
const g = groups[p.sagaId]!;
|
||||
const list = songsIn(p.sagaId);
|
||||
const saga = sagaById(p.sagaId);
|
||||
const lb = s("g", { class: "isle-label", transform: `translate(${p.label.x} ${p.label.y})` }, g);
|
||||
txt(lb, 0, 0, p.place, "place-name");
|
||||
txt(lb, 0, 22, `${saga.name}, plate ${p.roman}`, "place-sub");
|
||||
txt(lb, 0, 40, `${list.length} ${list.length === 1 ? "song" : "songs"}, ${yearSpan(list).toLowerCase()}`, "place-meta");
|
||||
list.forEach((song, j) => {
|
||||
const on = motifSongs?.has(song.id);
|
||||
const row = txt(lb, 0, 64 + j * 19, "", "track-row" + (motifSongs && !on ? " is-dim" : ""));
|
||||
const n = s("tspan", { class: "track-n" }, row); n.textContent = String(song.track).padStart(2, " ") + " ";
|
||||
const t = s("tspan", {}, row); t.textContent = song.title;
|
||||
if (on) row.setAttribute("fill", tone(state.motifId!));
|
||||
});
|
||||
if (state.motifId && motifSongs) {
|
||||
const count = list.filter((x) => motifSongs.has(x.id)).length;
|
||||
const m = motifs.find((x) => x.id === state.motifId)!;
|
||||
const y = 64 + list.length * 19 + 8;
|
||||
const label = count ? `${m.name} plays in ${count} of ${list.length}` : `${m.name} is silent here`;
|
||||
const badge = s("g", { class: "motif-badge" + (count ? "" : " is-silent"), transform: `translate(0 ${y})` }, lb);
|
||||
s("rect", { x: 0, y: 0, width: label.length * 6.4 + 20, height: 22, rx: 11, fill: count ? tone(m.id) : "none" }, badge);
|
||||
txt(badge, 10, 15, label, "badge-text");
|
||||
}
|
||||
activate(g, `Open plate ${p.roman}: ${p.place}, ${list.length} songs`, () => openInset(p.sagaId));
|
||||
}
|
||||
|
||||
// Overlay: where the ship is now
|
||||
if (overlay) {
|
||||
const i = songs.findIndex((x) => x.id === state.shipAt);
|
||||
const p = OV_PORTS[i]!;
|
||||
ship(root, p.x, p.y, `You are here: ${songs[i]!.title}`);
|
||||
}
|
||||
|
||||
// North arrow
|
||||
const n = s("g", { class: "north", transform: "translate(330 652)", "pointer-events": "none" }, root);
|
||||
s("path", { d: "M0,-30 L8,8 L0,2 L-8,8 Z" }, n);
|
||||
txt(n, 0, -36, "N", "north-n", { "text-anchor": "middle" });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ inset plate */
|
||||
function insetLand(root: SVGElement, sagaId: string, tint: string) {
|
||||
if (sagaId === "troy") {
|
||||
const pts: Pt[] = [];
|
||||
const rnd = (() => { let t = 21; return () => ((t = (t * 9301 + 49297) % 233280) / 233280); })();
|
||||
for (let x = -20; x <= W + 20; x += 55) pts.push({ x, y: 478 + Math.sin(x / 140) * 22 + (rnd() - 0.5) * 16 - (x < 260 ? (260 - x) * 0.25 : 0) });
|
||||
const d = `${smoothOpen(pts)} L${W + 20},${H + 20} L-20,${H + 20} Z`;
|
||||
waterLines(root, d);
|
||||
land(root, d, tint);
|
||||
troyGlyph(root, 210, 588, 1.6);
|
||||
txt(root, 330, 590, "Troy", "inset-land-name");
|
||||
hachures(root, 720, 600, 10, 300, 8);
|
||||
} else if (sagaId === "cyclops") {
|
||||
const d = smoothClosed(blob(560, 600, 430, 120, 17, 0.08, 26));
|
||||
waterLines(root, d);
|
||||
land(root, d, tint);
|
||||
hachures(root, 720, 590, 12, 260, 12);
|
||||
caveGlyph(root, 380, 600, 1.6);
|
||||
txt(root, 470, 616, "Cyclops' Island", "inset-land-name");
|
||||
} else {
|
||||
s("circle", { cx: 545, cy: 330, r: 290, class: "storm-wash" }, root);
|
||||
s("path", { d: stormSpiral(545, 330, 280, 3.2), class: "storm-spiral" }, root);
|
||||
for (const [x, y, rx, seed] of [[180, 560, 30, 4], [880, 600, 22, 6]] as const) {
|
||||
const d = smoothClosed(blob(x, y, rx, rx * 0.7, seed, 0.2, 14));
|
||||
waterLines(root, d);
|
||||
s("path", { d, class: "islet" }, root);
|
||||
}
|
||||
txt(root, 545, 610, "Open sea", "inset-land-name", { "text-anchor": "middle" });
|
||||
}
|
||||
}
|
||||
|
||||
function drawInset(root: SVGElement, sagaId: string) {
|
||||
const place = placeOf(sagaId);
|
||||
const L = insetLayout(sagaId);
|
||||
const pIdx = places.indexOf(place);
|
||||
s("rect", { x: 0, y: 0, width: W, height: H, class: "sea" }, root);
|
||||
insetLand(root, sagaId, place.tint);
|
||||
|
||||
const sel = state.motifId;
|
||||
const sagaSongs = L.ports.map((p) => p.song);
|
||||
const offsetOf = new Map(L.motifs.map((m, k) => [m.id, BAND_TOP + k * BAND_STEP]));
|
||||
const earlier = new Set(songs.filter((x) => placeOf(x.sagaId) && places.indexOf(placeOf(x.sagaId)) < pIdx).flatMap((x) => [...motifsPlaying(x)]));
|
||||
const later = new Set(songs.filter((x) => places.indexOf(placeOf(x.sagaId)) > pIdx).flatMap((x) => [...motifsPlaying(x)]));
|
||||
|
||||
// Route (ship's course): dashed track
|
||||
const courseG = s("g", { class: "inset-course" }, root);
|
||||
if (L.lead.length) s("path", { d: polyD(L.lead), class: "course course--edge" }, courseG);
|
||||
if (L.tail.length) s("path", { d: polyD(L.tail), class: "course course--edge" }, courseG);
|
||||
for (const seg of L.segments) s("path", { d: polyD(seg), class: "course" }, courseG);
|
||||
|
||||
// Motif lines, transit-style, running alongside the course on its seaward side
|
||||
const lines = s("g", { class: "motif-lines" }, root);
|
||||
for (const m of L.motifs) {
|
||||
const d = offsetOf.get(m.id)!;
|
||||
const c = tone(m.id);
|
||||
const g = s("g", { class: "mline" + (sel && sel !== m.id ? " is-dim" : "") + (sel === m.id ? " is-on" : ""), stroke: c }, lines);
|
||||
const idx = sagaSongs.map((x, i) => (motifsPlaying(x).has(m.id) ? i : -1)).filter((i) => i >= 0);
|
||||
const first = idx[0]!, last = idx[idx.length - 1]!;
|
||||
for (let i = first; i < last; i++) {
|
||||
// A theme line is solid for the half-segment next to each port where it plays, quiet in between.
|
||||
const off = offsetPolyline(L.segments[i]!, d);
|
||||
const a = idx.includes(i), b = idx.includes(i + 1);
|
||||
if (a && b) { s("path", { d: polyD(off), class: "ml-solid" }, g); continue; }
|
||||
s("path", { d: polyD(subPolyline(off, a ? 0.42 : 0, b ? 0.58 : 1)), class: "ml-quiet" }, g);
|
||||
if (a) s("path", { d: polyD(subPolyline(off, 0, 0.42)), class: "ml-solid" }, g);
|
||||
if (b) s("path", { d: polyD(subPolyline(off, 0.58, 1)), class: "ml-solid" }, g);
|
||||
}
|
||||
// Quiet stubs follow the course out to the plate edge when the theme plays on another plate.
|
||||
const along = (segs: Pt[][]) => segs.flatMap((sg, k) => (k ? sg.slice(1) : sg));
|
||||
if (earlier.has(m.id) && L.lead.length) s("path", { d: polyD(offsetPolyline(along([L.lead, ...L.segments.slice(0, first)]), d)), class: "ml-quiet" }, g);
|
||||
if (later.has(m.id) && L.tail.length) s("path", { d: polyD(offsetPolyline(along([...L.segments.slice(last), L.tail]), d)), class: "ml-quiet" }, g);
|
||||
}
|
||||
|
||||
|
||||
// Ports (stations)
|
||||
for (const p of L.ports) {
|
||||
const playing = motifsPlaying(p.song);
|
||||
const offs = [...playing].map((id) => offsetOf.get(id)!).filter((v) => v !== undefined);
|
||||
const deepest = Math.max(0, ...offs);
|
||||
const g = s("g", { class: "port" + (sel && !playing.has(sel) ? " is-dim" : "") + (state.songId === p.song.id || state.sailPick === p.song.id ? " is-open" : ""), "data-song": p.song.id }, root);
|
||||
s("rect", { x: p.x - 70, y: p.y - 92, width: 140, height: 190 + deepest, class: "port-hit" }, g);
|
||||
if (deepest) s("line", { x1: p.x, y1: p.y, x2: p.x, y2: p.y + deepest, class: "port-stem" }, g);
|
||||
for (const id of playing) {
|
||||
const off = offsetOf.get(id);
|
||||
if (off === undefined) continue;
|
||||
s("circle", { cx: p.x, cy: p.y + off, r: sel === id ? 5 : 3.6, fill: tone(id), class: "port-dot" + (sel && sel !== id ? " is-dim" : "") }, g);
|
||||
}
|
||||
s("circle", { cx: p.x, cy: p.y, r: 9, class: "port-ring" }, g);
|
||||
s("circle", { cx: p.x, cy: p.y, r: 3.2, class: "port-core" }, g);
|
||||
const lines2 = wrap(p.song.title, 15);
|
||||
txt(g, p.x, p.y - 22 - (lines2.length - 1) * 23 - 24, `Track ${p.song.track}`, "port-track", { "text-anchor": "middle" });
|
||||
lines2.forEach((ln, k) => txt(g, p.x, p.y - 22 - (lines2.length - 1 - k) * 23, ln, "port-title", { "text-anchor": "middle" }));
|
||||
wrap(p.song.storyTime.label, 20).forEach((ln, k) => txt(g, p.x, p.y + BAND_TOP + Math.max(0, L.motifs.length - 1) * BAND_STEP + 30 + k * 18, ln, "port-time", { "text-anchor": "middle" }));
|
||||
activate(g, `${p.song.title}, track ${p.song.track}. ${p.song.storyTime.label}`, () => pickSong(p.song.id));
|
||||
}
|
||||
|
||||
// Edge notes: "continued on plate…"
|
||||
if (L.lead.length) {
|
||||
const prev = places[pIdx - 1]!;
|
||||
const g = s("g", { class: "plate-link" }, root);
|
||||
const y = L.lead[0]!.y + BAND_TOP + L.motifs.length * BAND_STEP + 22;
|
||||
txt(g, 36, y, `From plate ${prev.roman}`, "edge-note");
|
||||
txt(g, 36, y + 19, prev.place, "edge-note edge-place");
|
||||
s("path", { d: `M28,${L.lead[0]!.y} l10,-6 v12 z`, class: "edge-arrow" }, g);
|
||||
activate(g, `Back to plate ${prev.roman}: ${prev.place}`, () => stepInset(-1));
|
||||
}
|
||||
if (L.tail.length) {
|
||||
const next = places[pIdx + 1]!;
|
||||
const g = s("g", { class: "plate-link" }, root);
|
||||
const y = L.tail[1]!.y + BAND_TOP + L.motifs.length * BAND_STEP + 22;
|
||||
txt(g, W - 36, y, `Continued on plate ${next.roman}`, "edge-note", { "text-anchor": "end" });
|
||||
txt(g, W - 36, y + 19, next.place, "edge-note edge-place", { "text-anchor": "end" });
|
||||
s("path", { d: `M${W - 28},${L.tail[1]!.y} l-10,-6 v12 z`, class: "edge-arrow" }, g);
|
||||
activate(g, `Continue to plate ${next.roman}: ${next.place}`, () => stepInset(1));
|
||||
}
|
||||
|
||||
// Scale of story time, aligned under the ports
|
||||
const sc = s("g", { class: "scale", transform: "translate(0 664)" }, root);
|
||||
const xs = L.ports.map((p) => p.x);
|
||||
const x0 = xs.length > 1 ? xs[0]! : xs[0]! - 60, x1 = xs.length > 1 ? xs[xs.length - 1]! : xs[0]! + 60;
|
||||
txt(sc, x0 - 14, 4, "Scale of story time", "scale-cap", { "text-anchor": "end" });
|
||||
const stops = xs.length > 1 ? xs : [x0, x1];
|
||||
for (let i = 0; i < stops.length - 1; i++) s("rect", { x: stops[i]!, y: -4, width: stops[i + 1]! - stops[i]!, height: 6, class: i % 2 ? "scale-w" : "scale-b" }, sc);
|
||||
let lastYear = -1;
|
||||
L.ports.forEach((p) => {
|
||||
const y = p.song.storyTime.yearsAfterTroy;
|
||||
s("line", { x1: p.x, x2: p.x, y1: -8, y2: 4, class: "scale-tick" }, sc);
|
||||
if (y !== lastYear) txt(sc, p.x, -14, `Year ${y}`, "scale-year", { "text-anchor": "middle" });
|
||||
lastYear = y;
|
||||
});
|
||||
L.gaps.forEach((gap, i) => {
|
||||
if (gap <= 0) return;
|
||||
const a = L.ports[i]!, b = L.ports[i + 1]!;
|
||||
txt(sc, (a.x + b.x) / 2, -12, gap === 1 ? "a year passes" : `${gap} years pass`, "time-note", { "text-anchor": "middle" });
|
||||
});
|
||||
|
||||
// Locator: where this plate sits on the general chart
|
||||
const loc = s("g", { class: "locator", transform: "translate(866 34)" }, root);
|
||||
s("rect", { x: -4, y: -4, width: 218, height: 147, class: "loc-frame" }, loc);
|
||||
const inner = s("svg", { x: 0, y: 0, width: 210, height: 134, viewBox: `0 0 ${W} ${H}` }, loc);
|
||||
drawGeneral(inner, true);
|
||||
const f = place.focus;
|
||||
s("rect", { x: f.x, y: f.y, width: f.w, height: f.h, class: "loc-focus" }, inner);
|
||||
txt(loc, 0, 156, "Key to the general chart", "loc-cap");
|
||||
activate(loc, "Back to the general chart of the Aegean", () => closeInset());
|
||||
|
||||
if (overlay) {
|
||||
const at = L.ports.find((p) => p.song.id === state.shipAt);
|
||||
if (at) ship(root, at.x, at.y, `You are here: ${at.song.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ html chrome */
|
||||
let sheet: HTMLElement | null = null;
|
||||
let pop: HTMLElement | null = null;
|
||||
|
||||
function cartouche() {
|
||||
const c = h("div", "cartouche", undefined, plate);
|
||||
if (!state.sagaId) {
|
||||
h("p", "c-kicker", "General chart", c);
|
||||
h("h2", "c-title", "The voyage from Troy", c);
|
||||
h("p", "c-line", `${songs.length} songs across ${places.length} sagas, Year 0 to Year ${Math.max(...songs.map((x) => x.storyTime.yearsAfterTroy))}.`, c);
|
||||
h("p", "c-line c-hint", "Choose an island to open its plate.", c);
|
||||
} else {
|
||||
const p = placeOf(state.sagaId);
|
||||
const list = songsIn(state.sagaId);
|
||||
h("p", "c-kicker", `Plate ${p.roman}`, c);
|
||||
h("h2", "c-title", p.place, c);
|
||||
h("p", "c-line", `${sagaById(p.sagaId).name}, tracks ${list[0]!.track} to ${list[list.length - 1]!.track}`.replace(/tracks (\d+) to \1$/, "track $1"), c);
|
||||
h("p", "c-line", yearSpan(list), c);
|
||||
}
|
||||
}
|
||||
|
||||
function songSheet(song: Song) {
|
||||
sheet = h("section", "slip", undefined, plate);
|
||||
sheet.setAttribute("aria-label", `Song: ${song.title}`);
|
||||
const close = h("button", "slip-close", "Close song", sheet);
|
||||
close.type = "button";
|
||||
close.addEventListener("click", () => { state.songId = null; render(); });
|
||||
const a = h("div", "slip-col slip-id", undefined, sheet);
|
||||
h("p", "slip-track", `Track ${song.track}, ${sagaById(song.sagaId).name}`, a);
|
||||
h("h3", "slip-title", song.title, a);
|
||||
h("p", "slip-time", `${song.storyTime.label}. Year ${song.storyTime.yearsAfterTroy} after Troy.`, a);
|
||||
if (song.odyssey) {
|
||||
const l = h("a", "slip-odyssey", `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`, a) as HTMLAnchorElement;
|
||||
l.href = song.odyssey.url; l.target = "_blank"; l.rel = "noopener noreferrer";
|
||||
} else h("p", "slip-odyssey is-none", "No counterpart in Homer's Odyssey", a);
|
||||
|
||||
const b = h("div", "slip-col slip-parts", undefined, sheet);
|
||||
h("h4", "slip-h", "Parts", b);
|
||||
const ol = h("ol", "parts", undefined, b);
|
||||
for (const part of song.parts) {
|
||||
const li = h("li", "", undefined, ol);
|
||||
h("span", "part-name", part.name, li);
|
||||
h("span", "part-singers", part.singers.join(", "), li);
|
||||
const ticks = h("span", "part-motifs", undefined, li);
|
||||
for (const id of part.motifIds) {
|
||||
const t = h("i", "tick", undefined, ticks);
|
||||
t.style.background = tone(id);
|
||||
t.title = motifs.find((m) => m.id === id)!.name;
|
||||
}
|
||||
}
|
||||
|
||||
const c = h("div", "slip-col slip-media", undefined, sheet);
|
||||
h("h4", "slip-h", "Themes in this song", c);
|
||||
const chips = h("div", "chips", undefined, c);
|
||||
for (const m of motifsInSong(song)) {
|
||||
const chip = h("button", "chip" + (state.motifId === m.id ? " is-on" : ""), m.name, chips);
|
||||
chip.type = "button";
|
||||
chip.style.setProperty("--c", tone(m.id));
|
||||
chip.addEventListener("click", () => toggleMotif(m.id));
|
||||
}
|
||||
c.append(createAudioSlot(song), createVideoCards(song));
|
||||
}
|
||||
|
||||
function sailPopover(song: Song) {
|
||||
const g = svg.querySelector(`.port[data-song="${song.id}"] .port-ring`) as SVGGraphicsElement | null;
|
||||
if (!g) return;
|
||||
const box = g.getBoundingClientRect();
|
||||
const host = plate.getBoundingClientRect();
|
||||
pop = h("div", "sail-pop", undefined, plate);
|
||||
pop.style.left = `${box.left + box.width / 2 - host.left}px`;
|
||||
pop.style.top = `${box.top - host.top + 40}px`;
|
||||
h("p", "sp-title", song.title, pop);
|
||||
if (song.id === state.shipAt) {
|
||||
h("p", "sp-line", "The ship is anchored here.", pop);
|
||||
} else {
|
||||
const btn = h("button", "sp-sail", `Sail here`, pop);
|
||||
btn.type = "button";
|
||||
btn.addEventListener("click", () => {
|
||||
state.shipAt = song.id;
|
||||
state.sailPick = null;
|
||||
state.note = `Course set for ${song.title}. In the 3D voyage the ship sails there now.`;
|
||||
render();
|
||||
});
|
||||
}
|
||||
const open = h("button", "sp-open", "Open song", pop);
|
||||
open.type = "button";
|
||||
open.addEventListener("click", () => { state.sailPick = null; state.songId = song.id; render(); });
|
||||
}
|
||||
|
||||
function header() {
|
||||
head.replaceChildren();
|
||||
const brand = h("div", "brand", undefined, head);
|
||||
h("h1", "brand-title", overlay ? "Captain's table" : "EPIC, an atlas", brand);
|
||||
h("p", "brand-sub", overlay ? "The chart, laid out over the 3D voyage." : "A fan's chart of Odysseus's voyage in EPIC: The Musical.", brand);
|
||||
|
||||
const crumbs = h("nav", "crumbs", undefined, head);
|
||||
crumbs.setAttribute("aria-label", "Breadcrumb");
|
||||
const root = h(state.sagaId ? "button" : "span", "crumb", "Aegean", crumbs);
|
||||
if (state.sagaId) { (root as HTMLButtonElement).type = "button"; root.addEventListener("click", () => closeInset()); }
|
||||
else root.setAttribute("aria-current", "page");
|
||||
if (state.sagaId) {
|
||||
h("span", "crumb-sep", "›", crumbs).setAttribute("aria-hidden", "true");
|
||||
const here = h("span", "crumb", placeOf(state.sagaId).place, crumbs);
|
||||
here.setAttribute("aria-current", "page");
|
||||
}
|
||||
|
||||
const nav = h("div", "plate-nav", undefined, head);
|
||||
if (state.sagaId) {
|
||||
const i = places.findIndex((p) => p.sagaId === state.sagaId);
|
||||
const prev = places[i - 1], next = places[i + 1];
|
||||
const pb = h("button", "pn", prev ? `‹ ${prev.roman}. ${prev.place}` : "‹", nav);
|
||||
pb.type = "button"; pb.disabled = !prev; pb.addEventListener("click", () => stepInset(-1));
|
||||
pb.setAttribute("aria-label", prev ? `Previous plate: ${prev.place}` : "No previous plate");
|
||||
const nb = h("button", "pn", next ? `${next.roman}. ${next.place} ›` : "›", nav);
|
||||
nb.type = "button"; nb.disabled = !next; nb.addEventListener("click", () => stepInset(1));
|
||||
nb.setAttribute("aria-label", next ? `Next plate: ${next.place}` : "No next plate");
|
||||
h("p", "keys", "Arrow keys turn plates. Esc returns to the Aegean.", nav);
|
||||
} else {
|
||||
h("p", "keys", "Choose an island, or press → to open plate I.", nav);
|
||||
}
|
||||
if (overlay) {
|
||||
const here = songs.find((x) => x.id === state.shipAt)!;
|
||||
const tag = h("p", "here-tag", undefined, head);
|
||||
h("span", "here-k", "You are here", tag);
|
||||
h("span", "here-v", here.title, tag);
|
||||
}
|
||||
}
|
||||
|
||||
function themes() {
|
||||
index.replaceChildren();
|
||||
const inSaga = state.sagaId ? songsIn(state.sagaId) : null;
|
||||
h("h2", "th-title", "Themes", index);
|
||||
h("p", "th-sub", state.sagaId ? "Songs on this plate, and in the whole voyage." : "Pick one to light the islands where it plays.", index);
|
||||
for (const kind of ["motif", "character"] as const) {
|
||||
h("h3", "th-group", kind === "motif" ? "Musical motifs" : "Character themes", index);
|
||||
const ul = h("ul", "th-list", undefined, index);
|
||||
for (const m of motifs.filter((x) => x.kind === kind)) {
|
||||
const all = songsWithMotif(m.id);
|
||||
const here = inSaga ? all.filter((x) => x.sagaId === state.sagaId).length : null;
|
||||
const li = h("li", "", undefined, ul);
|
||||
const b = h("button", "th-row" + (state.motifId === m.id ? " is-on" : "") + (here === 0 ? " is-absent" : ""), undefined, li);
|
||||
b.type = "button";
|
||||
b.setAttribute("aria-pressed", String(state.motifId === m.id));
|
||||
b.style.setProperty("--c", tone(m.id));
|
||||
h("span", "th-swatch", undefined, b);
|
||||
h("span", "th-name", m.name, b);
|
||||
h("span", "th-count", here === null ? String(all.length) : `${here} / ${all.length}`, b);
|
||||
b.addEventListener("click", () => toggleMotif(m.id));
|
||||
}
|
||||
}
|
||||
const key = h("div", "th-key", undefined, index);
|
||||
h("h3", "th-group", "Key", key);
|
||||
const rows: [string, string][] = [
|
||||
["k-course", "The ship's course"],
|
||||
["k-port", "Port: one song"],
|
||||
["k-line", "Theme plays at both ports"],
|
||||
["k-quiet", "Theme is silent, returns later"],
|
||||
];
|
||||
for (const [cls, label] of rows) {
|
||||
const r = h("p", "key-row", undefined, key);
|
||||
h("i", `key-sym ${cls}`, undefined, r);
|
||||
h("span", "", label, r);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ render */
|
||||
function render(anim?: "in" | "out" | "next" | "prev") {
|
||||
header();
|
||||
themes();
|
||||
plate.replaceChildren();
|
||||
sheet = null; pop = null;
|
||||
svg = s("svg", { viewBox: `0 0 ${W} ${H}`, class: "chart", role: "img" }, plate);
|
||||
svg.setAttribute("aria-label", state.sagaId ? `Plate ${placeOf(state.sagaId).roman}: ${placeOf(state.sagaId).place}` : "General chart of the voyage");
|
||||
if (state.sagaId) drawInset(svg, state.sagaId);
|
||||
else drawGeneral(svg);
|
||||
neatline(svg);
|
||||
cartouche();
|
||||
plate.classList.toggle("is-inset", !!state.sagaId);
|
||||
plate.classList.toggle("has-slip", !!state.songId);
|
||||
const song = songs.find((x) => x.id === state.songId);
|
||||
if (song && state.sagaId) songSheet(song);
|
||||
const pick = songs.find((x) => x.id === state.sailPick);
|
||||
if (overlay && pick && state.sagaId === pick.sagaId) requestAnimationFrame(() => sailPopover(pick));
|
||||
if (state.note) {
|
||||
const n = h("p", "sail-note", state.note, plate);
|
||||
n.setAttribute("role", "status");
|
||||
}
|
||||
syncUrl();
|
||||
if (anim && !reduceMotion) {
|
||||
plate.dataset.anim = anim;
|
||||
plate.addEventListener("animationend", () => delete plate.dataset.anim, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
function syncUrl() {
|
||||
const q = new URLSearchParams();
|
||||
if (overlay) q.set("overlay", "1");
|
||||
if (state.songId) q.set("song", state.songId);
|
||||
else if (state.sagaId) q.set("saga", state.sagaId);
|
||||
if (state.motifId) q.set("motif", state.motifId);
|
||||
const str = q.toString();
|
||||
history.replaceState(null, "", str ? `?${str}` : location.pathname);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ navigation */
|
||||
let busy = false;
|
||||
function zoomViewBox(to: { x: number; y: number; w: number; h: number }, from: { x: number; y: number; w: number; h: number }, ms: number, done: () => void) {
|
||||
if (reduceMotion) return done();
|
||||
busy = true;
|
||||
const t0 = performance.now();
|
||||
const step = (t: number) => {
|
||||
const k = Math.min(1, (t - t0) / ms);
|
||||
const e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
|
||||
const lerp = (a: number, b: number) => a + (b - a) * e;
|
||||
svg.setAttribute("viewBox", `${lerp(from.x, to.x)} ${lerp(from.y, to.y)} ${lerp(from.w, to.w)} ${lerp(from.h, to.h)}`);
|
||||
if (k < 1) requestAnimationFrame(step);
|
||||
else { busy = false; done(); }
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
const FULL = { x: 0, y: 0, w: W, h: H };
|
||||
function fitFocus(f: { x: number; y: number; w: number; h: number }) {
|
||||
// keep the plate's aspect so the zoom does not distort
|
||||
const a = W / H;
|
||||
let w = f.w, hh = f.h;
|
||||
if (w / hh > a) hh = w / a; else w = hh * a;
|
||||
return { x: f.x + f.w / 2 - w / 2, y: f.y + f.h / 2 - hh / 2, w, h: hh };
|
||||
}
|
||||
|
||||
function openInset(sagaId: string) {
|
||||
if (busy) return;
|
||||
state.note = "";
|
||||
if (state.sagaId) { state.sagaId = sagaId; render("next"); return; }
|
||||
plate.classList.add("is-zooming");
|
||||
zoomViewBox(fitFocus(placeOf(sagaId).focus), FULL, 520, () => {
|
||||
plate.classList.remove("is-zooming");
|
||||
state.sagaId = sagaId;
|
||||
render("in");
|
||||
});
|
||||
}
|
||||
function closeInset() {
|
||||
if (busy || !state.sagaId) return;
|
||||
const from = fitFocus(placeOf(state.sagaId).focus);
|
||||
state.sagaId = null; state.songId = null; state.sailPick = null; state.note = "";
|
||||
render("out");
|
||||
svg.setAttribute("viewBox", `${from.x} ${from.y} ${from.w} ${from.h}`);
|
||||
zoomViewBox(FULL, from, 480, () => svg.setAttribute("viewBox", `0 0 ${W} ${H}`));
|
||||
}
|
||||
function stepInset(dir: 1 | -1) {
|
||||
if (busy) return;
|
||||
if (!state.sagaId) { if (dir === 1) openInset(places[0]!.sagaId); return; }
|
||||
const i = places.findIndex((p) => p.sagaId === state.sagaId);
|
||||
const next = places[i + dir];
|
||||
if (!next) return;
|
||||
state.sagaId = next.sagaId; state.songId = null; state.sailPick = null; state.note = "";
|
||||
render(dir === 1 ? "next" : "prev");
|
||||
}
|
||||
function pickSong(id: string) {
|
||||
if (overlay) { state.sailPick = state.sailPick === id ? null : id; state.songId = null; state.note = ""; render(); return; }
|
||||
state.songId = state.songId === id ? null : id;
|
||||
render();
|
||||
}
|
||||
function toggleMotif(id: string) {
|
||||
state.motifId = state.motifId === id ? null : id;
|
||||
render();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "AUDIO") return;
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); stepInset(1); }
|
||||
else if (e.key === "ArrowLeft") { e.preventDefault(); stepInset(-1); }
|
||||
else if (e.key === "Escape") {
|
||||
if (state.songId || state.sailPick) { state.songId = null; state.sailPick = null; render(); }
|
||||
else closeInset();
|
||||
}
|
||||
});
|
||||
plate.addEventListener("click", (e) => {
|
||||
if (!state.sailPick) return;
|
||||
if ((e.target as Element).closest(".sail-pop")) return;
|
||||
state.sailPick = null; render();
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
@@ -1,2 +1,346 @@
|
||||
/* 11-chart-atlas stub */
|
||||
body { margin: 0; --stub-11: 1; }
|
||||
/* 11 · Atlas with insets: a bound atlas. A general chart of the voyage, then one inset plate per saga. */
|
||||
@import "@fontsource/cormorant-garamond/latin-400.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-500.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-600.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-400-italic.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-500-italic.css";
|
||||
@import "@fontsource/cormorant-garamond/latin-600-italic.css";
|
||||
@import "@fontsource/inter/400.css";
|
||||
@import "@fontsource/inter/500.css";
|
||||
@import "@fontsource/inter/600.css";
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--cloth: #2f3d4b;
|
||||
--cloth-deep: #26323e;
|
||||
--paper: #eff1eb;
|
||||
--paper-warm: #f7f7f1;
|
||||
--sea: #e3eae8;
|
||||
--ink: #242b3a;
|
||||
--ink-soft: #5a6372;
|
||||
--carmine: #a3303d;
|
||||
--wood: #5d3f2a;
|
||||
--atlas-serif: "Cormorant Garamond", "Iowan Old Style", Georgia, serif;
|
||||
--atlas-sans: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--paper);
|
||||
font-family: var(--atlas-sans);
|
||||
background:
|
||||
repeating-linear-gradient(45deg, rgb(255 255 255 / 0.018) 0 2px, transparent 2px 5px),
|
||||
repeating-linear-gradient(-45deg, rgb(0 0 0 / 0.05) 0 2px, transparent 2px 5px),
|
||||
var(--cloth);
|
||||
}
|
||||
button { font: inherit; color: inherit; cursor: pointer; }
|
||||
:focus-visible { outline: 2px solid #f0c36b; outline-offset: 2px; }
|
||||
|
||||
/* ------------------------------------------------------------- frame */
|
||||
.atlas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
padding: 0 28px;
|
||||
}
|
||||
.atlas-head {
|
||||
display: grid;
|
||||
grid-template-columns: 330px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 0 10px;
|
||||
}
|
||||
.brand-title { margin: 0; font: 600 30px/1 var(--atlas-serif); letter-spacing: 0.01em; }
|
||||
.brand-sub { margin: 5px 0 0; font-size: 12.5px; opacity: 0.72; }
|
||||
|
||||
.crumbs { white-space: nowrap; display: flex; align-items: baseline; gap: 10px; font: italic 500 24px/1.1 var(--atlas-serif); }
|
||||
.crumb { background: none; border: 0; padding: 0; }
|
||||
button.crumb { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 5px; opacity: 0.8; }
|
||||
button.crumb:hover { opacity: 1; color: #f0c36b; }
|
||||
.crumb-sep { opacity: 0.55; font-style: normal; }
|
||||
|
||||
.plate-nav { display: flex; align-items: center; gap: 10px; justify-content: flex-end; flex-wrap: wrap; max-width: 560px; }
|
||||
.pn {
|
||||
background: rgb(239 241 235 / 0.06);
|
||||
border: 1px solid rgb(239 241 235 / 0.38);
|
||||
border-radius: 2px;
|
||||
padding: 5px 12px 6px;
|
||||
font: 500 17px/1.1 var(--atlas-serif);
|
||||
}
|
||||
.pn:hover:not(:disabled) { background: rgb(239 241 235 / 0.16); }
|
||||
.pn:disabled { opacity: 0.3; cursor: default; }
|
||||
.keys { margin: 0; flex-basis: 100%; text-align: right; font-size: 11.5px; opacity: 0.62; }
|
||||
|
||||
.atlas-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 252px;
|
||||
gap: 20px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.plate-wrap { min-height: 0; min-width: 0; display: flex; align-items: flex-start; justify-content: center; }
|
||||
.plate {
|
||||
position: relative;
|
||||
width: min(100%, calc((100vh - 136px) * 1100 / 700));
|
||||
aspect-ratio: 1100 / 700;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
box-shadow: 0 1px 0 rgb(255 255 255 / 0.25) inset, 0 18px 40px rgb(10 16 24 / 0.45), 0 2px 4px rgb(10 16 24 / 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chart { display: block; width: 100%; height: 100%; }
|
||||
|
||||
/* ------------------------------------------------------------- chart ink */
|
||||
.sea { fill: var(--sea); }
|
||||
.wl-ink { fill: none; stroke: rgb(36 43 58 / 0.24); stroke-linejoin: round; }
|
||||
.wl-paper { fill: none; stroke: var(--sea); stroke-linejoin: round; }
|
||||
.land-edge { fill: none; stroke-width: 16; opacity: 0.75; mix-blend-mode: multiply; }
|
||||
.coast { fill: none; stroke: var(--ink); stroke-width: 1.3; stroke-linejoin: round; }
|
||||
.islet { fill: #dcd6c4; stroke: var(--ink); stroke-width: 1; }
|
||||
.halo { fill: none; stroke-width: 18; opacity: 0.6; stroke-linejoin: round; }
|
||||
.storm-wash { fill: rgb(96 128 176 / 0.16); }
|
||||
.storm-spiral { fill: none; stroke: #4f6587; stroke-width: 1.3; stroke-dasharray: 6 4; }
|
||||
.course { fill: none; stroke: var(--ink); stroke-width: 2; stroke-dasharray: 8 5; stroke-linecap: round; }
|
||||
.course--edge { opacity: 0.55; }
|
||||
.glyph path { fill: var(--ink); }
|
||||
.glyph .smoke { fill: none; stroke: var(--ink-soft); stroke-width: 1.4; }
|
||||
.glyph .hill { fill: none; stroke: var(--ink); stroke-width: 1.6; }
|
||||
.hachure path { fill: none; stroke: rgb(36 43 58 / 0.55); stroke-width: 1.1; }
|
||||
.north path { fill: var(--ink); }
|
||||
.north-n { font: 600 16px var(--atlas-serif); fill: var(--ink); }
|
||||
|
||||
.nl-outer { fill: none; stroke: var(--ink); stroke-width: 1.5; }
|
||||
.nl-band { fill: none; stroke: var(--ink); stroke-width: 6; stroke-dasharray: 44 44; }
|
||||
.nl-inner { fill: none; stroke: var(--ink); stroke-width: 0.8; }
|
||||
|
||||
.time-note { font: italic 500 18px var(--atlas-serif); fill: var(--carmine); }
|
||||
.is-dim { opacity: 0.3; }
|
||||
|
||||
/* general chart */
|
||||
.isle { cursor: pointer; outline: none; }
|
||||
.isle:hover .coast, .isle:focus-visible .coast { stroke: var(--carmine); stroke-width: 2.6; }
|
||||
.isle:hover .place-name, .isle:focus-visible .place-name { fill: var(--carmine); }
|
||||
.isle:focus-visible .storm-spiral { stroke: var(--carmine); }
|
||||
.ov-port { fill: var(--paper); stroke: var(--ink); stroke-width: 1.6; }
|
||||
.ov-num { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
.place-name { font: italic 600 34px var(--atlas-serif); fill: var(--ink); }
|
||||
.place-sub { font: 500 12.5px var(--atlas-sans); fill: var(--ink); }
|
||||
.place-meta { font: 400 12.5px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
.track-row { font: 500 17px var(--atlas-serif); fill: var(--ink); }
|
||||
.track-n { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
.motif-badge rect { stroke: none; }
|
||||
.motif-badge.is-silent rect { stroke: var(--ink-soft); stroke-dasharray: 3 3; }
|
||||
.badge-text { font: 600 12px var(--atlas-sans); fill: #fff; }
|
||||
.motif-badge.is-silent .badge-text { fill: var(--ink-soft); font-weight: 500; }
|
||||
|
||||
.ship-ring { fill: var(--paper); stroke: var(--carmine); stroke-width: 2.2; }
|
||||
.ship-hull { fill: var(--carmine); }
|
||||
.ship-tag { fill: var(--carmine); }
|
||||
.ship-label { font: 600 12px var(--atlas-sans); fill: #fff; }
|
||||
|
||||
/* inset plates */
|
||||
.inset-land-name { font: italic 500 46px var(--atlas-serif); letter-spacing: 0.14em; fill: rgb(36 43 58 / 0.6); }
|
||||
.mline .ml-solid { fill: none; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.mline .ml-quiet { fill: none; stroke-width: 2.2; stroke-dasharray: 0.5 6; stroke-linecap: round; }
|
||||
.mline.is-on .ml-solid { stroke-width: 7.5; }
|
||||
.mline.is-on .ml-quiet { stroke-width: 3.4; }
|
||||
.mline.is-dim { opacity: 0.16; }
|
||||
.port { cursor: pointer; outline: none; }
|
||||
.port-hit { fill: transparent; }
|
||||
.port-stem { stroke: var(--ink); stroke-width: 1.4; }
|
||||
.port-dot { stroke: var(--paper); stroke-width: 1.5; }
|
||||
.port-dot.is-dim { opacity: 0.25; }
|
||||
.port-ring { fill: var(--paper); stroke: var(--ink); stroke-width: 2.4; }
|
||||
.port-core { fill: var(--ink); }
|
||||
.port-title { font: 600 23px var(--atlas-serif); fill: var(--ink); }
|
||||
.port-track { font: 500 11px var(--atlas-sans); fill: var(--ink-soft); letter-spacing: 0.02em; }
|
||||
.port-time { font: italic 500 16.5px var(--atlas-serif); fill: var(--ink-soft); }
|
||||
.port:hover .port-title, .port:focus-visible .port-title { fill: var(--carmine); }
|
||||
.port:focus-visible .port-ring, .port.is-open .port-ring { stroke: var(--carmine); stroke-width: 3.6; }
|
||||
.port.is-open .port-core { fill: var(--carmine); }
|
||||
.port.is-dim { opacity: 0.32; }
|
||||
|
||||
.plate-link { cursor: pointer; outline: none; }
|
||||
.edge-note { font: italic 500 16px var(--atlas-serif); fill: var(--ink); }
|
||||
.edge-arrow { fill: var(--ink); }
|
||||
.edge-place { font-weight: 600; }
|
||||
.plate-link:hover .edge-note, .plate-link:focus-visible .edge-note { fill: var(--carmine); text-decoration: underline; }
|
||||
|
||||
.scale-b { fill: var(--ink); }
|
||||
.scale-w { fill: var(--paper); stroke: var(--ink); stroke-width: 0.8; }
|
||||
.scale-tick { stroke: var(--ink); stroke-width: 1; }
|
||||
.scale-year { font: 600 11.5px var(--atlas-sans); fill: var(--ink); }
|
||||
.scale-cap { font: italic 500 16px var(--atlas-serif); fill: var(--ink); }
|
||||
|
||||
.locator { cursor: pointer; outline: none; }
|
||||
.loc-frame { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
|
||||
.locator:hover .loc-frame, .locator:focus-visible .loc-frame { stroke: var(--carmine); stroke-width: 2; }
|
||||
.loc-focus { fill: rgb(163 48 61 / 0.08); stroke: var(--carmine); stroke-width: 12; }
|
||||
.loc-cap { font: 500 10.5px var(--atlas-sans); fill: var(--ink-soft); }
|
||||
|
||||
/* ------------------------------------------------------------- cartouche */
|
||||
.cartouche {
|
||||
position: absolute;
|
||||
left: 3.1%;
|
||||
top: 4.4%;
|
||||
max-width: 290px;
|
||||
padding: 10px 16px 11px;
|
||||
background: var(--paper-warm);
|
||||
border: 4px double var(--ink);
|
||||
pointer-events: none;
|
||||
}
|
||||
.plate:not(.is-inset) .cartouche { left: auto; right: 3.1%; max-width: 330px; }
|
||||
.c-kicker { margin: 0; font: italic 500 15px var(--atlas-serif); color: var(--ink-soft); }
|
||||
.c-title { margin: 0 0 4px; font: 600 28px/1.05 var(--atlas-serif); }
|
||||
.c-line { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--ink); }
|
||||
.c-hint { margin-top: 4px; color: var(--carmine); font-weight: 500; }
|
||||
|
||||
/* ------------------------------------------------------------- song slip */
|
||||
.slip {
|
||||
position: absolute;
|
||||
left: 14px; right: 14px; bottom: 14px;
|
||||
height: 36%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1.15fr;
|
||||
gap: 28px;
|
||||
padding: 18px 26px 16px;
|
||||
background: var(--paper-warm);
|
||||
border-top: 5px double var(--ink);
|
||||
box-shadow: 0 -14px 26px rgb(36 43 58 / 0.14);
|
||||
animation: slip-up 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.slip-close {
|
||||
position: absolute; right: 18px; top: 12px;
|
||||
background: none; border: 1px solid var(--ink-soft); border-radius: 2px;
|
||||
padding: 3px 10px; font-size: 12px; color: var(--ink);
|
||||
}
|
||||
.slip-close:hover { border-color: var(--carmine); color: var(--carmine); }
|
||||
.slip-col { min-width: 0; overflow: auto; }
|
||||
.slip-track { margin: 0; font-size: 12px; color: var(--ink-soft); }
|
||||
.slip-title { margin: 2px 0 6px; font: 600 36px/1.02 var(--atlas-serif); }
|
||||
.slip-time { margin: 0 0 10px; font: italic 500 18px/1.3 var(--atlas-serif); }
|
||||
.slip-odyssey { font-size: 13px; color: var(--carmine); }
|
||||
.slip-odyssey.is-none { margin: 0; color: var(--ink-soft); }
|
||||
.slip-h { margin: 4px 0 8px; font: italic 600 18px var(--atlas-serif); }
|
||||
.parts { list-style: none; margin: 0; padding: 0; }
|
||||
.parts li {
|
||||
display: grid; grid-template-columns: 78px 1fr auto; gap: 10px; align-items: baseline;
|
||||
padding: 6px 0; border-bottom: 1px solid rgb(36 43 58 / 0.14); font-size: 13px;
|
||||
}
|
||||
.part-name { font: 600 16px var(--atlas-serif); }
|
||||
.part-motifs { display: flex; gap: 3px; }
|
||||
.tick { display: inline-block; width: 14px; height: 5px; border-radius: 1px; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
|
||||
.chip {
|
||||
border: 1.5px solid var(--c); background: transparent; border-radius: 999px;
|
||||
padding: 3px 10px; font-size: 12px; color: var(--ink);
|
||||
}
|
||||
.chip.is-on { background: var(--c); color: #fff; }
|
||||
.slip .audio-slot { font-size: 12.5px; margin-bottom: 8px; padding: 8px 10px; border-radius: 2px; }
|
||||
.slip .video-card { font-size: 12.5px; border-radius: 2px; padding: 5px 10px; color: var(--ink); }
|
||||
|
||||
/* ------------------------------------------------------------- themes slip */
|
||||
.themes {
|
||||
align-self: start;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
padding: 18px 16px 16px;
|
||||
box-shadow: 0 12px 30px rgb(10 16 24 / 0.38);
|
||||
border-top: 4px double var(--ink);
|
||||
}
|
||||
.th-title { margin: 0; font: 600 26px var(--atlas-serif); }
|
||||
.th-sub { margin: 2px 0 10px; font-size: 12px; line-height: 1.4; color: var(--ink-soft); }
|
||||
.th-group { margin: 12px 0 4px; font: italic 600 16px var(--atlas-serif); }
|
||||
.th-list { list-style: none; margin: 0; padding: 0; }
|
||||
.th-row {
|
||||
width: 100%; display: grid; grid-template-columns: 24px 1fr auto; gap: 9px; align-items: center;
|
||||
background: none; border: 0; border-left: 3px solid transparent; padding: 5px 6px 5px 5px; text-align: left; font-size: 13.5px;
|
||||
}
|
||||
.th-row:hover { background: rgb(36 43 58 / 0.06); }
|
||||
.th-row.is-on { background: color-mix(in srgb, var(--c) 16%, transparent); border-left-color: var(--c); font-weight: 600; }
|
||||
.th-row.is-absent { opacity: 0.42; }
|
||||
.th-swatch { height: 5px; border-radius: 1px; background: var(--c); }
|
||||
.th-count { font-size: 12px; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
|
||||
.th-key { margin-top: 10px; border-top: 1px solid rgb(36 43 58 / 0.18); padding-top: 2px; }
|
||||
.key-row { display: flex; align-items: center; gap: 10px; margin: 5px 0; font-size: 12px; color: var(--ink-soft); }
|
||||
.key-sym { width: 24px; flex: none; display: inline-block; }
|
||||
.k-course { border-top: 2px dashed var(--ink); }
|
||||
.k-port { width: 14px; height: 14px; margin: 0 5px; border: 2.4px solid var(--ink); border-radius: 50%; background: var(--paper); }
|
||||
.k-line { height: 5px; background: #8d6b2e; border-radius: 3px; }
|
||||
.k-quiet { border-top: 3px dotted #8d6b2e; }
|
||||
|
||||
/* ------------------------------------------------------------- transitions */
|
||||
.plate.is-zooming .cartouche { opacity: 0; transition: opacity 0.2s; }
|
||||
.plate[data-anim="in"] .chart { animation: plate-in 0.36s ease-out; }
|
||||
.plate[data-anim="next"] .chart { animation: turn-next 0.34s ease-out; }
|
||||
.plate[data-anim="prev"] .chart { animation: turn-prev 0.34s ease-out; }
|
||||
@keyframes plate-in { from { opacity: 0.2; transform: scale(1.04); } to { opacity: 1; transform: none; } }
|
||||
@keyframes turn-next { from { opacity: 0; transform: translateX(36px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes turn-prev { from { opacity: 0; transform: translateX(-36px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes slip-up { from { transform: translateY(40%); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .slip, .chart { animation: none !important; } }
|
||||
|
||||
/* ------------------------------------------------------------- overlay: the captain's table */
|
||||
.sail-pop {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
padding: 10px 12px 12px;
|
||||
background: var(--paper-warm);
|
||||
border: 1px solid var(--ink);
|
||||
box-shadow: 0 10px 22px rgb(36 43 58 / 0.25);
|
||||
text-align: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.sp-title { margin: 0 0 8px; font: 600 19px var(--atlas-serif); }
|
||||
.sp-line { margin: 0 0 8px; font-size: 12px; color: var(--ink-soft); }
|
||||
.sp-sail { display: block; width: 100%; margin-bottom: 6px; padding: 7px 0; border: 0; border-radius: 2px; background: var(--carmine); color: #fff; font-weight: 600; font-size: 13px; }
|
||||
.sp-open { background: none; border: 0; font-size: 12px; text-decoration: underline; color: var(--ink); }
|
||||
.sail-note {
|
||||
position: absolute; left: 50%; top: 22px; transform: translateX(-50%);
|
||||
margin: 0; padding: 6px 14px; background: var(--carmine); color: #fff; font-size: 12.5px; border-radius: 2px;
|
||||
}
|
||||
|
||||
body.is-overlay {
|
||||
justify-content: center;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 120%, rgb(240 190 120 / 0.25), transparent 55%),
|
||||
repeating-linear-gradient(172deg, rgb(255 255 255 / 0.035) 0 1px, transparent 1px 22px),
|
||||
linear-gradient(180deg, #1c3742 0%, #12262f 60%, #0b171d 100%);
|
||||
}
|
||||
body.is-overlay::before {
|
||||
content: "3D voyage continues behind the table";
|
||||
position: fixed; left: 24px; top: 18px;
|
||||
font: italic 500 15px var(--atlas-serif); color: rgb(239 241 235 / 0.45);
|
||||
}
|
||||
body.is-overlay .atlas {
|
||||
flex: none;
|
||||
width: 1160px;
|
||||
margin: auto;
|
||||
padding: 4px 22px 18px;
|
||||
border-radius: 10px;
|
||||
background:
|
||||
repeating-linear-gradient(90deg, rgb(0 0 0 / 0.07) 0 3px, transparent 3px 11px),
|
||||
linear-gradient(180deg, #6c4a32, var(--wood));
|
||||
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.4), 0 40px 90px rgb(0 0 0 / 0.65);
|
||||
}
|
||||
body.is-overlay .atlas-head { grid-template-columns: 220px 1fr auto auto; padding: 10px 0 8px; }
|
||||
body.is-overlay .brand-title { font-size: 25px; }
|
||||
body.is-overlay .atlas-body { grid-template-columns: 1fr 220px; }
|
||||
body.is-overlay .plate { width: 100%; transform: rotate(-0.4deg); }
|
||||
body.is-overlay .themes { font-size: 12px; padding: 12px; }
|
||||
body.is-overlay .th-key { display: none; }
|
||||
.here-tag { margin: 0; display: flex; border: 1px solid var(--carmine); border-radius: 2px; overflow: hidden; font-size: 12.5px; }
|
||||
.here-k { padding: 5px 9px; background: var(--carmine); color: #fff; }
|
||||
.here-v { padding: 5px 10px; font: 600 16px/1.1 var(--atlas-serif); background: var(--paper); color: var(--ink); }
|
||||
|
||||
/* ------------------------------------------------------------- credits */
|
||||
.fan-credits { color: rgb(239 241 235 / 0.82); opacity: 1; padding: 0.7rem 1.5rem 0.85rem; font-size: 12.5px; }
|
||||
body.is-overlay .fan-credits { margin-top: auto; }
|
||||
|
||||
Reference in New Issue
Block a user