Files
Epic-The-Musical-Timeline/src/app/panel/paths.ts
T
Lucas WintherandClaude Opus 5.5 2fbf3a6980 feat(app): content client and side panel
About, Listen & lyrics (real player, synced lyrics, Watch), Paths (strip
when the timeline is hidden, motif rows, nine-place chart), Fan works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:09:22 +01:00

97 lines
6.0 KiB
TypeScript

// Paths: the motif strip (only while the bottom timeline is hidden), then one row per motif of the song with
// where it was heard before, where it plays here and where it returns, the followed motif's whole thread,
// and the chart of the nine places.
import { motifs, sagaOf, songs, type AlbumSong, type Motif } from "../../data/album";
import { chartSvg } from "./chart";
import { dot, esc, hitsOf, motifsOf } from "./util";
export function pathsView(song: AlbumSong, pick: string | null, showStrip: boolean): HTMLElement {
const el = document.createElement("div");
el.className = "paths";
const ms = motifsOf(song);
const followed = pick ? motifs.find((m) => m.id === pick) ?? null : null;
el.innerHTML = `
${showStrip ? motifStrip(song, ms) : ""}
<h3 class="sec">Motif threads</h3>
${ms.length
? `<p class="hint">Each thread is a motif or a character's theme. Pick one to follow it through the album.</p>
<ul class="mrows">${ms.map((m) => motifRow(song, m)).join("")}</ul>`
: `<p class="note">No motifs are recorded for this song yet.</p>`}
${followed ? threadDetail(song, followed) : ""}
<h3 class="sec">The route</h3>
<figure class="route">${chartSvg(song)}<figcaption>${esc(sagaOf(song).place)} · stop ${song.track} of ${songs.length} on the voyage.</figcaption></figure>`;
return el;
}
const songBtn = (s: AlbumSong) =>
`<button type="button" class="mrow__song" data-song="${s.id}"><span class="mrow__n">${s.track}</span>${esc(s.title)}</button>`;
function motifRow(song: AlbumSong, m: Motif): string {
const hits = hitsOf(m.id);
const before = hits.filter((s) => s.track < song.track);
const after = hits.filter((s) => s.track > song.track);
const prev = before.at(-1);
const next = after[0];
const inParts = song.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => p.name);
const here = inParts.length && inParts.length < song.parts.length ? `in ${inParts.map(esc).join(", ")}` : "throughout";
return `<li class="mrow" data-m="${m.id}">
<button type="button" class="mrow__name" data-pick="${m.id}" aria-pressed="false">${dot(m)}<span>${esc(m.name)}</span>
<span class="mrow__kind">${m.kind === "motif" ? "motif" : "character theme"}</span></button>
<dl class="mrow__route">
<div><dt>Heard before</dt><dd>${prev ? songBtn(prev) : `<span class="mrow__none">first heard here</span>`}</dd></div>
<div><dt>Here</dt><dd>${here}</dd></div>
<div><dt>Returns</dt><dd>${next ? songBtn(next) + (after.length > 1 ? ` <span class="mrow__more">+${after.length - 1} more</span>` : "") : `<span class="mrow__none">not heard again</span>`}</dd></div>
</dl>
</li>`;
}
/** The followed motif: what it is and every song it plays in. */
function threadDetail(song: AlbumSong, m: Motif): string {
const hits = hitsOf(m.id);
return `<section class="mthread" data-m="${m.id}" aria-label="${esc(m.name)} through the album">
<p class="mthread__head">${dot(m)}<span>Following <b>${esc(m.name)}</b> · ${hits.length} song${hits.length === 1 ? "" : "s"}</span></p>
<p class="mthread__desc">${esc(m.description)}</p>
<ol class="mthread__songs">${hits.map((s) => `<li><button type="button" class="mthread__song${s.id === song.id ? " is-current" : ""}" data-song="${s.id}"${s.id === song.id ? ' aria-current="true"' : ""}>
<span class="mrow__n">${s.track}</span>${esc(s.title)}</button></li>`).join("")}</ol>
</section>`;
}
/* The motif strip: the bottom timeline in miniature, for when the timeline is hidden. The album's 40 songs
as stops on a course (saga dividers, the ship on the current song), then one thread per motif of this
song, knotted where it plays. */
const W = 356, PAD = 8, COURSE_Y = 26, ROW = 34;
const X = (i: number) => PAD + (i * (W - PAD * 2)) / (songs.length - 1);
function motifStrip(song: AlbumSong, ms: Motif[]): string {
const cur = songs.indexOf(song);
const H = COURSE_Y + 14 + ms.length * ROW;
let svg = `<rect class="ms__band" x="${(X(cur) - 5).toFixed(1)}" y="0" width="10" height="${H}" rx="2"/>`;
svg += `<path class="ms__course" d="M${PAD - 4},${COURSE_Y}H${W - PAD + 4}"/>`;
songs.forEach((s, i) => {
if (i > 0 && s.sagaId !== songs[i - 1]!.sagaId) {
const x = ((X(i) + X(i - 1)) / 2).toFixed(1);
svg += `<path class="ms__saga" d="M${x},${COURSE_Y - 9}V${H}"/>`;
}
svg += `<circle class="ms__stop${i === cur ? " is-current" : ""}" data-song="${s.id}" cx="${X(i).toFixed(1)}" cy="${COURSE_Y}" r="${i === cur ? 4.5 : 2}"><title>${s.track}. ${esc(s.title)}</title></circle>`;
});
svg += `<g class="ms__ship" transform="translate(${X(cur).toFixed(1)},${COURSE_Y - 6})"><path class="ms__hull" d="M-7,-6 Q0,0 7,-6 Z"/><path class="ms__mast" d="M0,-6 V-17"/><path class="ms__hull" d="M0.5,-16 L6,-10 H0.5 Z"/></g>`;
ms.forEach((m, k) => {
const y = COURSE_Y + 14 + k * ROW + ROW - 9;
const idx = hitsOf(m.id).map((s) => songs.indexOf(s));
const x0 = X(idx[0]!), x1 = X(idx.at(-1)!);
svg += `<g class="ms__thread" data-m="${m.id}" style="--c:${m.color}">
<path class="ms__edge" d="M${x0.toFixed(1)},${y}H${x1.toFixed(1)}"/><path class="ms__line" d="M${x0.toFixed(1)},${y}H${x1.toFixed(1)}"/>
${idx.map((i) => `<circle class="ms__knot${i === cur ? " is-here" : ""}" cx="${X(i).toFixed(1)}" cy="${y}" r="${i === cur ? 4.6 : 3.2}"><title>${esc(m.name)} in ${esc(songs[i]!.title)}</title></circle>`).join("")}
</g>`;
});
const names = ms.map((m, k) => `<button type="button" class="ms__name" data-m="${m.id}" data-pick="${m.id}" aria-pressed="false"
style="top:${COURSE_Y + 14 + k * ROW}px">${dot(m)}${esc(m.name)}</button>`).join("");
return `<section class="motif-strip" aria-label="Motif paths across the album">
<p class="ms__head"><span class="ms__title">Motif paths</span><span class="ms__sub">Track ${song.track} of ${songs.length} · heard before · here · returns</span></p>
<div class="ms__body" style="--w:${W};--h:${H}">
<svg class="ms__svg" viewBox="0 0 ${W} ${H}" aria-hidden="true">${svg}</svg>
${names}
</div>
</section>`;
}