Files
Epic-The-Musical-Timeline/src/mockups/08-chart-logline/main.ts
T

528 lines
24 KiB
TypeScript

import { motifs, sagas, songs } from "../../data/sample";
import { sagaOf } from "../../data/query";
import type { Motif, Song } from "../../data/types";
import { mountCredits } from "../../shared/credits";
import { createAudioSlot } from "../../shared/audio-slot";
import { createVideoCards } from "../../shared/video-cards";
import { H, LOG, LOG_W, W, buildRoute, land, logX, ports, rng, xToIndex, yearsLabel } from "./geo";
mountCredits();
/* ------------------------------------------------------------------ state */
const params = new URLSearchParams(location.search);
const overlay = params.get("overlay") === "1";
const HERE = songs.findIndex((s) => s.id === "survive");
let selected: number = songs.findIndex((s) => s.id === params.get("song"));
let motifId: string | null = motifs.some((m) => m.id === params.get("motif")) ? params.get("motif") : null;
let shipAt = overlay ? HERE : songs.length - 1; // fractional song index
let probeAt: number | null = null;
if (overlay) document.body.classList.add("is-overlay");
/* ------------------------------------------------------------------ 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, attrs: Attrs = {}, ...kids: (Node | string)[]): HTMLElementTagNameMap[K] {
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 text(parent: Element, x: number, y: number, str: string, cls: string, anchor = "middle") {
const t = s("text", { x, y, class: cls, "text-anchor": anchor }, parent);
t.textContent = str;
return t;
}
/** Shared motif colours are tuned for dark pages; on the chart they are mixed toward the navy ink. */
function inkOf(m: Motif): string {
const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
const a = hex(m.color), b = hex("#1e2c3b");
const k = m.id === "odysseus" ? 0.55 : 0.28;
return `rgb(${a.map((v, i) => Math.round(v * (1 - k) + b[i]! * k)).join(",")})`;
}
const motifById = new Map(motifs.map((m) => [m.id, m]));
const has = (song: Song, id: string) => song.parts.some((p) => p.motifIds.includes(id));
/* ------------------------------------------------------------------ shell */
const app = document.getElementById("app")!;
const titleBar = h("header", { class: "bar" });
if (overlay) {
titleBar.append(
h("p", { class: "bar__here" }, h("span", { class: "bar__pin", "aria-hidden": "true" }), "You are here: ", h("strong", {}, songs[HERE]!.title)),
h("p", { class: "bar__hint" }, "Pick a port to set a course. Scrub the log to look ahead without sailing."),
);
} else {
titleBar.append(
h("h1", { class: "bar__title" }, "EPIC, charted"),
h("p", { class: "bar__hint" }, "The chart shows where. The log below shows when. Run a finger along the log and the ship follows."),
);
}
const chartWrap = h("section", { class: "chart", "aria-label": "Sea chart of the voyage" });
const logWrap = h("section", { class: "log", "aria-label": "Log line in story time" });
const panel = h("aside", { class: "panel", "aria-live": "polite", hidden: "" });
const pop = h("div", { class: "pop", hidden: "" });
chartWrap.append(panel, pop);
app.append(titleBar, chartWrap, logWrap);
/* ================================================================== CHART */
const svg = s("svg", { class: "chart__svg", role: "img", "aria-label": "Stylised chart: Troy, the land of the Cicones, the island of the Cyclopes and the open sea" }, chartWrap);
const defs = s("defs", {}, svg);
const hatch = s("pattern", { id: "hatch", width: 7, height: 7, patternUnits: "userSpaceOnUse", patternTransform: "rotate(35)" }, defs);
s("line", { x1: 0, y1: 0, x2: 0, y2: 7, class: "hatch" }, hatch);
// Sea, graticule, soundings
s("rect", { x: -400, y: -300, width: W + 800, height: H + 600, class: "sea" }, svg);
const grat = s("g", { class: "grat" }, svg);
for (let x = 0; x <= W; x += 200) s("line", { x1: x, y1: -300, x2: x, y2: H + 300 }, grat);
for (let y = 0; y <= H; y += 200) s("line", { x1: -400, y1: y, x2: W + 400, y2: y }, grat);
const sounds = s("g", { class: "sounds" }, svg);
// Land: shallow-water bands, then the land fill and hachure
const landG = s("g", {}, svg);
const landPaths: SVGPathElement[] = [];
for (const [w, cls] of [[30, "shoal-1"], [16, "shoal-2"], [6, "shoal-3"]] as const) {
for (const d of Object.values(land)) s("path", { d, class: cls, "stroke-width": w }, landG);
}
for (const d of Object.values(land)) {
landPaths.push(s("path", { d, class: "land" }, landG));
s("path", { d, class: "land-hatch" }, landG);
}
// Names: land upright, sea areas in italic (chart convention)
const names = s("g", { class: "names" }, svg);
text(names, 70, 250, "Troy", "place place--big");
text(names, 70, 274, "the burning city", "place-sub");
text(names, 486, 34, "Land of the Cicones", "place");
text(names, 918, 336, "Island of the", "place");
text(names, 918, 358, "Cyclopes", "place");
text(names, 1100, 150, "Goat Island", "place place--small");
text(names, 420, 548, "The Troy Saga", "sea-area");
text(names, 690, 470, "The Cyclops Saga", "sea-area");
text(names, 1290, 200, "The Ocean Saga", "sea-area");
text(names, 1290, 226, "open water, no landfall", "place-sub");
// Compass rose
const rose = s("g", { class: "rose", transform: "translate(1500 92)" }, svg);
s("circle", { r: 46, class: "rose__ring" }, rose);
s("circle", { r: 38, class: "rose__ring rose__ring--in" }, rose);
for (let a = 0; a < 360; a += 10) {
const r1 = a % 90 === 0 ? 30 : 38, rad = (a * Math.PI) / 180;
s("line", { x1: Math.sin(rad) * r1, y1: -Math.cos(rad) * r1, x2: Math.sin(rad) * 46, y2: -Math.cos(rad) * 46, class: "rose__tick" }, rose);
}
s("path", { d: "M0,-44 L7,0 L0,44 L-7,0Z M-44,0 L0,7 L44,0 L0,-7Z", class: "rose__star" }, rose);
s("path", { d: "M0,-44 L7,0 L-7,0Z", class: "rose__north" }, rose);
text(rose, 0, -54, "N", "rose__n");
// Route: one leg per pair of consecutive songs
const route = buildRoute();
const routeG = s("g", { class: "route" }, svg);
const motifG = s("g", { class: "motif-route" }, svg);
const legs = route.map((seg) => {
const base = s("path", { d: seg.d, class: "leg leg--plan" }, routeG);
const sailed = s("path", { d: seg.d, class: "leg leg--sailed" }, routeG);
return { seg, base, sailed, len: 0 };
});
for (const l of legs) {
l.len = l.base.getTotalLength();
if (l.seg.years > 0) {
const p = l.base.getPointAtLength(l.len * 0.5), q = l.base.getPointAtLength(l.len * 0.5 + 4);
let a = Math.atan2(q.y - p.y, q.x - p.x);
if (Math.cos(a) < 0) a += Math.PI; // keep text upright
const nx = Math.sin(a) * 16, ny = -Math.cos(a) * 16; // normal, on the upper side of the text
const lab = text(routeG, p.x + nx, p.y + ny, yearsLabel(l.seg.years), "crossing");
lab.setAttribute("transform", `rotate(${((a * 180) / Math.PI).toFixed(1)} ${p.x + nx} ${p.y + ny})`);
}
}
// Soundings: deterministic, kept off land and off the course
{
const r = rng(8);
let placed = 0;
for (let k = 0; k < 600 && placed < 70; k++) {
const x = r() * W, y = r() * H;
const pt = new DOMPoint(x, y);
if (landPaths.some((p) => p.isPointInFill(pt) || p.isPointInStroke(pt))) continue;
const near = legs.some((l) => { for (let t = 0; t <= 1; t += 0.05) { const q = l.base.getPointAtLength(l.len * t); if (Math.hypot(q.x - x, q.y - y) < 34) return true; } return false; });
if (near) continue;
const ns = Object.values(ports).some((g) => Math.hypot(g.at[0] - x, g.at[1] - y) < 90);
if (ns) continue;
text(sounds, x, y, String(Math.round(8 + r() * 60 * (x / W + 0.3))), "sound");
placed++;
}
}
// Ports
const portG = s("g", { class: "ports" }, svg);
const portEls = songs.map((song, i) => {
const g = ports[song.id]!;
const el = s("g", { class: "port", transform: `translate(${g.at[0]} ${g.at[1]})`, tabindex: 0, role: "button", "aria-label": `${song.track}. ${song.title}` }, portG);
s("circle", { r: 22, class: "port__ring" }, el);
s("circle", { r: 12, class: "port__dot" }, el);
text(el, 0, 4, String(song.track), "port__num");
const [lx, ly, anchor] = g.label;
text(el, lx, ly, song.title, "port__label", anchor);
el.addEventListener("click", (e) => { e.stopPropagation(); onPort(i); });
el.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPort(i); } });
return el;
});
// Ship and probe
const probe = s("circle", { r: 26, class: "probe", visibility: "hidden" }, svg);
const ship = s("g", { class: "ship" }, svg);
s("path", { d: "M-16,0 L-10,-7 L12,-7 L18,0 L12,7 L-10,7Z", class: "ship__hull" }, ship);
s("path", { d: "M-6,-7 L2,-24 L6,-7Z", class: "ship__sail" }, ship);
function pointAt(f: number): { x: number; y: number; a: number } {
const i = Math.min(Math.floor(f), legs.length - 1);
if (f >= songs.length - 1) { const g = ports[songs[songs.length - 1]!.id]!; const l = legs[legs.length - 1]!; const q = l.base.getPointAtLength(l.len - 1); return { x: g.at[0], y: g.at[1], a: Math.atan2(g.at[1] - q.y, g.at[0] - q.x) }; }
const l = legs[i]!, t = f - i;
const p = l.base.getPointAtLength(l.len * t), q = l.base.getPointAtLength(Math.min(l.len, l.len * t + 2));
return { x: p.x, y: p.y, a: Math.atan2(q.y - p.y, q.x - p.x) };
}
/** The ship rides beside the course, on the side away from each port's label. */
const shipOff = songs.map((x) => {
const [lx, ly] = ports[x.id]!.label;
const n = Math.hypot(lx, ly) || 1;
return [(-lx / n) * 30, (-ly / n) * 30 - 6] as const;
});
function drawShip() {
const p = pointAt(shipAt);
const i = Math.min(Math.floor(shipAt), songs.length - 1), t = shipAt - i;
const a = shipOff[i]!, b = shipOff[Math.min(i + 1, songs.length - 1)]!;
const ox = a[0] + (b[0] - a[0]) * t, oy = a[1] + (b[1] - a[1]) * t;
ship.setAttribute("transform", `translate(${(p.x + ox).toFixed(1)} ${(p.y + oy).toFixed(1)})${Math.cos(p.a) < 0 ? " scale(-1 1)" : ""}`);
legs.forEach((l, i) => {
const done = shipAt >= i + 1 ? 1 : shipAt <= i ? 0 : shipAt - i;
l.sailed.setAttribute("stroke-dasharray", `${l.len * done} ${l.len + 10}`);
});
if (probeAt === null) probe.setAttribute("visibility", "hidden");
else { const q = pointAt(probeAt); probe.setAttribute("cx", String(q.x)); probe.setAttribute("cy", String(q.y)); probe.setAttribute("visibility", "visible"); }
}
/* ------------------------------------------------------------------ view (animated viewBox) */
const view = { cx: W / 2, cy: H / 2, z: 1 };
let target = { ...view };
let raf = 0;
function applyView() {
const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1;
const scale = Math.min(cw / W, ch / H) * view.z;
const vw = cw / scale, vh = ch / scale;
svg.setAttribute("viewBox", `${(view.cx - vw / 2).toFixed(1)} ${(view.cy - vh / 2).toFixed(1)} ${vw.toFixed(1)} ${vh.toFixed(1)}`);
svg.style.setProperty("--z", String(view.z));
}
function tick() {
const k = 0.16;
view.cx += (target.cx - view.cx) * k;
view.cy += (target.cy - view.cy) * k;
view.z += (target.z - view.z) * k;
applyView();
if (Math.abs(target.cx - view.cx) + Math.abs(target.cy - view.cy) + Math.abs(target.z - view.z) * 100 > 0.3) raf = requestAnimationFrame(tick);
else { Object.assign(view, target); applyView(); raf = 0; }
}
function goTo(t: { cx: number; cy: number; z: number }, instant = false) {
target = t;
if (instant || matchMedia("(prefers-reduced-motion: reduce)").matches) { Object.assign(view, t); applyView(); return; }
if (!raf) raf = requestAnimationFrame(tick);
}
const fullView = () => ({ cx: W / 2, cy: H / 2, z: 1 });
/** Centre on a world point, shifted so it lands at `frac` of the chart width. */
function focusOn(x: number, y: number, z: number, frac = 0.5) {
const cw = svg.clientWidth || 1, ch = svg.clientHeight || 1;
const vw = cw / (Math.min(cw / W, ch / H) * z);
return { cx: x + (0.5 - frac) * vw, cy: y, z };
}
function restView() {
if (selected >= 0 && !overlay) { const g = ports[songs[selected]!.id]!; return focusOn(g.at[0], g.at[1], 1.55, 0.36); }
return fullView();
}
new ResizeObserver(() => applyView()).observe(svg);
/* ================================================================== LOG LINE */
const AXIS_Y = 92, LANE_Y = 140, LANE = 16;
const logTrack = h("div", { class: "log__track" });
const legend = h("div", { class: "log__legend" });
const logBody = h("div", { class: "log__body", style: `width:${LOG_W}px` });
logTrack.append(legend, logBody);
logWrap.append(logTrack);
const logSvg = s("svg", { class: "log__svg", width: LOG_W, height: LANE_Y + motifs.length * LANE }, logBody);
// Saga brackets
for (const saga of sagas) {
const idx = songs.map((x, i) => (x.sagaId === saga.id ? i : -1)).filter((i) => i >= 0);
if (!idx.length) continue;
const x0 = logX[idx[0]!]! - 62, x1 = logX[idx[idx.length - 1]!]! + 62;
s("path", { d: `M${x0},24 V18 H${x1} V24`, class: "bracket" }, logSvg);
const lab = text(logSvg, x0 + 4, 12, saga.name, "bracket__name", "start");
lab.setAttribute("dy", "0");
}
// Axis: straight within a year, a long swell across the years at sea
for (let i = 0; i < songs.length - 1; i++) {
const a = logX[i]!, b = logX[i + 1]!, gap = route[i]!.years;
if (gap <= 0) { s("line", { x1: a, y1: AXIS_Y, x2: b, y2: AXIS_Y, class: "axis" }, logSvg); continue; }
const g0 = a + LOG.hop / 2, g1 = b - LOG.hop / 2;
let d = `M${a},${AXIS_Y} H${g0}`;
const waves = Math.round((g1 - g0) / 30);
for (let k = 0; k < waves; k++) { const x = g0 + ((g1 - g0) * (k + 0.5)) / waves, x2 = g0 + ((g1 - g0) * (k + 1)) / waves; d += ` Q${x},${AXIS_Y + (k % 2 ? 5 : -5)} ${x2},${AXIS_Y}`; }
d += ` H${b}`;
s("path", { d, class: "axis axis--sea" }, logSvg);
text(logSvg, (g0 + g1) / 2, AXIS_Y - 14, yearsLabel(gap), "gap-label");
for (let k = 1; k <= gap; k++) {
const x = g0 + ((g1 - g0) * k) / gap;
s("line", { x1: x, y1: 30, x2: x, y2: AXIS_Y + 6, class: "year-tick" }, logSvg);
text(logSvg, x - 4, 38, `year ${songs[i]!.storyTime.yearsAfterTroy + k}`, "year-label", "end");
}
}
s("line", { x1: logX[0]! - 50, y1: AXIS_Y, x2: logX[0]!, y2: AXIS_Y, class: "axis" }, logSvg);
s("line", { x1: logX[logX.length - 1]!, y1: AXIS_Y, x2: LOG_W - 20, y2: AXIS_Y, class: "axis axis--fade" }, logSvg);
text(logSvg, logX[0]! - 56, 38, "year 0", "year-label", "start");
// Motif lanes with part boxes
const laneEls = new Map<string, SVGGElement>();
motifs.forEach((m, mi) => {
const y = LANE_Y + mi * LANE;
const g = s("g", { class: "lane", "data-motif": m.id, style: `--c:${inkOf(m)}` }, logSvg);
laneEls.set(m.id, g);
const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0);
if (occ.length) {
s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__case" }, g);
s("line", { x1: logX[occ[0]!]!, y1: y, x2: logX[occ[occ.length - 1]!]!, y2: y, class: "lane__line" }, g);
}
for (const i of occ) {
const parts = songs[i]!.parts, bw = 8, gap = 2, tot = parts.length * bw + (parts.length - 1) * gap;
parts.forEach((p, k) => {
s("rect", { x: logX[i]! - tot / 2 + k * (bw + gap), y: y - 4, width: bw, height: 8, class: p.motifIds.includes(m.id) ? "box box--on" : "box" }, g);
});
}
});
// Song entries (HTML, so titles can wrap)
const entryEls = songs.map((song, i) => {
const b = h("button", { type: "button", class: "entry", style: `left:${logX[i]}px` },
h("span", { class: "entry__num" }, String(song.track)),
h("span", { class: "entry__title" }, song.title),
);
b.addEventListener("click", () => select(i, "log"));
logBody.append(b, h("span", { class: "entry__when", style: `left:${logX[i]}px` }, song.storyTime.label));
s("circle", { cx: logX[i]!, cy: AXIS_Y, r: 4.5, class: "stop", "data-i": i }, logSvg);
return b;
});
// Scrub cursor
const cursor = h("div", { class: "cursor", hidden: "" }, h("span", { class: "cursor__read" }));
logBody.append(cursor);
// Legend
legend.append(h("p", { class: "legend__head" }, "Motifs, sung where filled"));
const legendBtns = new Map<string, HTMLButtonElement>();
for (const m of motifs) {
const b = h("button", { type: "button", class: "legend__btn", style: `--c:${inkOf(m)}`, "aria-pressed": "false" },
h("span", { class: "legend__sw", "aria-hidden": "true" }), m.name);
b.addEventListener("click", () => setMotif(motifId === m.id ? null : m.id));
legend.append(b);
legendBtns.set(m.id, b);
}
/* ------------------------------------------------------------------ scrubbing */
let leaveTimer = 0;
let lastClientX: number | null = null;
function scrubAt(clientX: number) {
const r = logBody.getBoundingClientRect();
const x = Math.max(logX[0]!, Math.min(logX[logX.length - 1]!, clientX - r.left));
const f = xToIndex(x);
cursor.hidden = false;
cursor.style.left = `${x}px`;
const i = Math.round(f);
const atPort = Math.abs(f - i) < 0.12;
const yrs = atPort ? songs[i]!.storyTime.yearsAfterTroy : Math.floor(interpYears(f));
(cursor.firstChild as HTMLElement).textContent = atPort ? `year ${yrs}, ${songs[i]!.title}` : `year ${yrs}, at sea`;
if (overlay) probeAt = f; else shipAt = f;
drawShip();
const p = pointAt(f);
goTo(focusOn(p.x, p.y, 1.5, selected >= 0 && !overlay ? 0.36 : 0.5));
}
function interpYears(f: number) {
const i = Math.floor(f), a = songs[i]!, b = songs[Math.min(i + 1, songs.length - 1)]!;
return a.storyTime.yearsAfterTroy + (b.storyTime.yearsAfterTroy - a.storyTime.yearsAfterTroy) * (f - i);
}
logBody.addEventListener("pointermove", (e) => { clearTimeout(leaveTimer); lastClientX = e.clientX; scrubAt(e.clientX); });
logWrap.addEventListener("scroll", () => { if (lastClientX !== null) scrubAt(lastClientX); }, { passive: true });
logBody.addEventListener("pointerleave", () => {
lastClientX = null;
leaveTimer = window.setTimeout(() => {
cursor.hidden = true;
probeAt = null;
drawShip();
goTo(restView());
}, 600);
});
/* ================================================================== SELECTION */
function scrollLogTo(i: number, instant = false) {
const visible = logWrap.clientWidth - legend.offsetWidth;
const still = instant || matchMedia("(prefers-reduced-motion: reduce)").matches;
logWrap.scrollTo({ left: Math.max(0, logX[i]! - visible / 2), behavior: still ? "auto" : "smooth" });
}
function onPort(i: number) {
if (overlay) { showPop(i); scrollLogTo(i); return; }
select(i, "chart");
}
function select(i: number, from: "chart" | "log" | "init") {
selected = i;
if (overlay) { showPop(i); syncUrl(); return; }
shipAt = i;
drawShip();
renderPanel();
if (from !== "log") scrollLogTo(i, from === "init");
goTo(restView(), from === "init");
syncUrl();
paint();
}
function close() {
selected = -1;
panel.hidden = true;
pop.hidden = true;
goTo(restView());
syncUrl();
paint();
}
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
chartWrap.addEventListener("click", (e) => { if (e.target === svg || (e.target as Element).classList?.contains("sea")) { if (overlay) { pop.hidden = true; selected = -1; syncUrl(); paint(); } } });
function renderPanel() {
const song = songs[selected];
if (!song) { panel.hidden = true; return; }
const closeBtn = h("button", { type: "button", class: "panel__close", "aria-label": "Close notes" }, "Close");
closeBtn.addEventListener("click", close);
const odyssey = song.odyssey
? h("a", { href: song.odyssey.url, target: "_blank", rel: "noopener noreferrer" }, `Odyssey, book ${song.odyssey.book}, lines ${song.odyssey.lines}`)
: h("span", { class: "panel__none" }, "No counterpart in Homer");
const parts = h("ol", { class: "parts" });
for (const p of song.parts) {
const chips = h("span", { class: "chips" });
for (const id of p.motifIds) {
const m = motifById.get(id)!;
const c = h("button", { type: "button", class: `chip${id === motifId ? " is-on" : ""}`, style: `--c:${inkOf(m)}` }, m.name);
c.addEventListener("click", () => setMotif(motifId === id ? null : id));
chips.append(c);
}
parts.append(h("li", {}, h("span", { class: "parts__name" }, p.name), h("span", { class: "parts__who" }, p.singers.join(" and ")), chips));
}
panel.replaceChildren(
closeBtn,
h("p", { class: "panel__kicker" }, `${sagaOf(song).name}, track ${song.track}`),
h("h2", { class: "panel__title" }, song.title),
h("p", { class: "panel__when" }, `${song.storyTime.label}, year ${song.storyTime.yearsAfterTroy} after Troy`),
h("p", { class: "panel__ody" }, odyssey),
parts,
createAudioSlot(song),
createVideoCards(song),
);
panel.hidden = false;
}
/* ------------------------------------------------------------------ overlay popover */
function showPop(i: number) {
selected = i;
const song = songs[i]!;
const g = ports[song.id]!;
const isHere = Math.round(shipAt) === i && Math.abs(shipAt - i) < 0.01;
const sail = h("button", { type: "button", class: "pop__sail" }, isHere ? "You are moored here" : "Sail here");
if (isHere) sail.setAttribute("disabled", "");
sail.addEventListener("click", () => sailTo(i));
const yrs = song.storyTime.yearsAfterTroy - songs[Math.round(shipAt)]!.storyTime.yearsAfterTroy;
pop.replaceChildren(
h("p", { class: "pop__num" }, `Port ${song.track}`),
h("p", { class: "pop__title" }, song.title),
h("p", { class: "pop__when" }, song.storyTime.label + (yrs ? `, ${Math.abs(yrs)} ${Math.abs(yrs) === 1 ? "year" : "years"} ${yrs > 0 ? "ahead" : "back"}` : "")),
sail,
);
pop.hidden = false;
placePop(g.at[0], g.at[1]);
syncUrl();
paint();
}
function placePop(x: number, y: number) {
const m = svg.getScreenCTM();
if (!m) return;
const p = new DOMPoint(x, y).matrixTransform(m);
const r = chartWrap.getBoundingClientRect();
pop.style.left = `${p.x - r.left}px`;
pop.style.top = `${p.y - r.top}px`;
}
function sailTo(i: number) {
const from = shipAt, t0 = performance.now(), dur = 900 + Math.abs(i - from) * 260;
pop.hidden = true;
const step = (now: number) => {
const t = Math.min(1, (now - t0) / dur), e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
shipAt = from + (i - from) * e;
drawShip();
if (t < 1) requestAnimationFrame(step);
else { const here = titleBar.querySelector("strong"); if (here) here.textContent = songs[i]!.title; selected = -1; paint(); syncUrl(); }
};
requestAnimationFrame(step);
}
/* ================================================================== MOTIF */
function setMotif(id: string | null) {
motifId = id;
syncUrl();
paint();
if (selected >= 0 && !overlay) renderPanel();
}
function paint() {
document.body.classList.toggle("has-motif", !!motifId);
// Chart: course coloured where the motif carries on, dotted where it skips a port
motifG.replaceChildren();
const m = motifId ? motifById.get(motifId) : undefined;
if (m) {
const occ = songs.map((x, i) => (has(x, m.id) ? i : -1)).filter((i) => i >= 0);
const c = inkOf(m);
for (let i = occ[0]!; i < occ[occ.length - 1]!; i++) {
const both = has(songs[i]!, m.id) && has(songs[i + 1]!, m.id);
s("path", { d: legs[i]!.seg.d, class: both ? "mleg" : "mleg mleg--skip", style: `--c:${c}` }, motifG);
}
}
portEls.forEach((el, i) => {
el.classList.toggle("is-motif", !!m && has(songs[i]!, m.id));
el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id));
el.classList.toggle("is-selected", i === selected);
if (m) el.style.setProperty("--c", inkOf(m)); else el.style.removeProperty("--c");
});
entryEls.forEach((el, i) => {
el.classList.toggle("is-selected", i === selected);
el.classList.toggle("is-dim", !!m && !has(songs[i]!, m.id));
el.setAttribute("aria-pressed", String(i === selected));
});
for (const [id, g] of laneEls) g.classList.toggle("is-faded", !!m && id !== m.id);
for (const [id, b] of legendBtns) { b.setAttribute("aria-pressed", String(id === motifId)); b.classList.toggle("is-faded", !!m && id !== m.id); }
}
function syncUrl() {
const p = new URLSearchParams();
if (overlay) p.set("overlay", "1");
if (selected >= 0) p.set("song", songs[selected]!.id);
if (motifId) p.set("motif", motifId);
const q = p.toString();
history.replaceState(null, "", q ? `?${q}` : location.pathname);
}
/* ------------------------------------------------------------------ boot */
applyView();
drawShip();
paint();
if (selected >= 0) requestAnimationFrame(() => select(selected, "init"));
if (overlay) requestAnimationFrame(() => scrollLogTo(selected >= 0 ? selected : HERE, true));