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]>
97 lines
6.0 KiB
TypeScript
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>`;
|
|
}
|