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; function s(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(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(); 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(); 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));