feat(app): bottom timeline; drop the Odysseus thread from the album data
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea728176ae
commit
b3385185b5
8 files changed
+728
-6
No files matched your search
@@ -84,3 +84,9 @@ work; committing them would push them to Gitea and bake 1.5 GB into the image. T
|
||||
**Decision:** the Odyssey and Iliad are served from Butcher & Lang (Gutenberg #1728) and Lang, Leaf &
|
||||
Myers (#3059) when present, falling back to Butler (#1727, #2199). **Why:** the user disliked Butler's
|
||||
Roman names (Ulysses, Minerva, Jove); EPIC uses the Greek ones. Both are public domain.
|
||||
|
||||
## 2026-10-30 — No thread for Odysseus himself
|
||||
|
||||
**Decision:** the album data has no Odysseus character theme. **Why:** character themes are derived from
|
||||
singers, and Odysseus sings in 35 of 40 songs, so his thread marked almost every stop and crowded the
|
||||
timeline ("+28 more"). His story is carried by the musical motifs (Warrior of the Mind, Just a Man, …).
|
||||
@@ -112,7 +112,7 @@ export function loadLyrics(ref: { url: string; timed: boolean }): Promise<Lyrics
|
||||
export function loadBook(book: number): Promise<HomerBook | null>;
|
||||
export function passageStart(book: HomerBook, keywords: string[]): { index: number; word: string | null };
|
||||
```
|
||||
- [ ] Steps: failing tests with a mocked `fetch` (library 404 → `false`; match by normalised title incl. curly apostrophe "Wouldn’t You Like"; `.txt` lyrics → `{timed:false}`; `passageStart` finds first keyword paragraph, else 0) → implement → pass → commit `feat(app): content client`.
|
||||
- [x] Steps: failing tests with a mocked `fetch` (library 404 → `false`; match by normalised title incl. curly apostrophe "Wouldn’t You Like"; `.txt` lyrics → `{timed:false}`; `passageStart` finds first keyword paragraph, else 0) → implement → pass → commit `feat(app): content client`.
|
||||
|
||||
### Task 4: Side panel
|
||||
|
||||
@@ -124,7 +124,7 @@ export function passageStart(book: HomerBook, keywords: string[]): { index: numb
|
||||
|
||||
Behaviour pinned by the user: **Listen & lyrics** contains the player, the lyrics and **Watch** (video cards via `createVideoCards`); About contains no Watch and no listen button. **Paths** shows the motif strip at its top **only when `state.timeline === "closed"`**, then per-motif "heard before / here / returns" rows, then the nine-place chart. **Fan works**: empty state "No fan works collected yet. Real ones will be credited and linked to their creators." when `fanWorks` has none for the song. Header play button: starts audio and switches to `listen`. Reading view (`state.read`) shows the Odyssey book (Butcher & Lang, Greek names — no Roman-names note) from the passage start, "Show the N paragraphs before", Back.
|
||||
|
||||
- [ ] Steps: failing tests — About has no `.watch`; Listen has `.player`, `.lyrics`, `.watch`; Paths has `.motif-strip` iff timeline closed; fan empty state text; without content (`loadLibrary` false) Listen shows "Content folder not mounted" and no `<audio>`; long title wraps (no `white-space: nowrap` on `.panel__title`) → implement → pass → commit `feat(app): side panel`.
|
||||
- [x] Steps: failing tests — About has no `.watch`; Listen has `.player`, `.lyrics`, `.watch`; Paths has `.motif-strip` iff timeline closed; fan empty state text; without content (`loadLibrary` false) Listen shows "Content folder not mounted" and no `<audio>`; long title wraps (no `white-space: nowrap` on `.panel__title`) → implement → pass → commit `feat(app): side panel`.
|
||||
|
||||
### Task 5: Timeline
|
||||
|
||||
@@ -139,7 +139,7 @@ export function mountTimeline(root: HTMLElement, store: Store): { refresh(): voi
|
||||
```
|
||||
Layout rule: the current saga is expanded (labels, spacing by measured label width + 16px gap, min 64px); other sagas are compact (no labels, 18px per stop, saga name above); remaining width distributed to gaps between sagas; if it still doesn't fit, the expanded saga scrolls into view (the strip scrolls horizontally). Threads: one row per motif of the current song, knots at songs that use it, across all 40. Hide/show control writes `state.timeline`. "Threads on the sea" switch writes `state.ribbons`.
|
||||
|
||||
- [ ] Steps: failing tests — 40 stops; stops strictly increasing x; labels only in the current saga; in every saga expanded (try each saga's first song, width 980, measure = 7.2px/char) no two labelled stops overlap; collapse sets `timeline=closed` → implement → pass → commit `feat(app): bottom timeline`.
|
||||
- [x] Steps: failing tests — 40 stops; stops strictly increasing x; labels only in the current saga; in every saga expanded (try each saga's first song, width 980, measure = 7.2px/char) no two labelled stops overlap; collapse sets `timeline=closed` → implement → pass → commit `feat(app): bottom timeline`.
|
||||
|
||||
### Task 6: The 3D world, landings and friezes
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Motif colours are tuned to be bright on the sea and the clay frieze. On the timeline's parchment the
|
||||
// same hue is re-inked darker (mixed toward the iron ink) until text in it reads at 4.5:1.
|
||||
const PARCHMENT = [0xec, 0xe2, 0xc6]; // --vellum-hi
|
||||
const IRON = [0x2b, 0x21, 0x18]; // --iron
|
||||
|
||||
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
const lum = (c: number[]) => {
|
||||
const [r, g, b] = c.map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
|
||||
};
|
||||
export const contrast = (a: number[], b: number[]) => {
|
||||
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
|
||||
return (hi! + 0.05) / (lo! + 0.05);
|
||||
};
|
||||
const hex = (c: number[]) => `#${c.map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`;
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
/** The colour darkened toward iron until it has at least 4.5:1 contrast on the timeline's parchment. */
|
||||
export function inkOnParchment(color: string): string {
|
||||
const hit = cache.get(color);
|
||||
if (hit) return hit;
|
||||
const base = rgb(color);
|
||||
let out = base;
|
||||
for (let k = 0; k <= 50 && contrast(out, PARCHMENT) < 4.5; k++) out = base.map((v, i) => Math.round(v + (IRON[i]! - v) * (k / 50)));
|
||||
const res = hex(out);
|
||||
cache.set(color, res);
|
||||
return res;
|
||||
}
|
||||
export const parchment = PARCHMENT;
|
||||
export const rgbOf = rgb;
|
||||
@@ -0,0 +1,117 @@
|
||||
// The bottom timeline's geometry (plan task 5), a pure function of the current song, a width and a text
|
||||
// measure. The current saga is expanded with its song titles; the others are compact (stops only, the
|
||||
// saga's short name above). Coordinates are in px from the left of the strip.
|
||||
import { motifs, sagas, songById, songs, songsInSaga } from "../../data/album";
|
||||
import type { Motif, PlaceId } from "../../data/album";
|
||||
|
||||
export interface TimelineStop { songId: string; x: number; label: string | null; current: boolean }
|
||||
export interface TimelineLayout {
|
||||
stops: TimelineStop[];
|
||||
sagas: { sagaId: PlaceId; x0: number; x1: number; expanded: boolean }[];
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** Room between neighbouring titles in the expanded saga. */
|
||||
export const LABEL_GAP = 16;
|
||||
/** Minimum distance between stops in the expanded saga. */
|
||||
export const MIN_SPACING = 64;
|
||||
/** Distance between stops in a compact saga. */
|
||||
export const COMPACT_STEP = 18;
|
||||
/** Minimum room between two sagas (the divider sits in the middle); slack is added here. */
|
||||
export const SAGA_GAP = 14;
|
||||
/** Room at both ends of the strip. */
|
||||
export const END_PAD = 8;
|
||||
/** Room a saga name needs beyond its text (it starts 4px after the saga's left edge). */
|
||||
const NAME_PAD = 8;
|
||||
|
||||
/** The saga's name as shown above the strip: short ("Cyclops") when compact, "Cyclops Saga" when expanded. */
|
||||
export function sagaLabel(sagaId: PlaceId, expanded: boolean): string {
|
||||
const name = sagas.find((g) => g.id === sagaId)!.name.replace(/^The /, "");
|
||||
return expanded ? name : name.replace(/ Saga$/, "");
|
||||
}
|
||||
|
||||
/** A title on one line, or split into the two lines that make it narrowest ("\n" between them). */
|
||||
export function splitTitle(title: string, measure: (t: string) => number): { text: string; w: number } {
|
||||
const words = title.split(" ");
|
||||
let best = { text: title, w: measure(title) };
|
||||
for (let i = 1; i < words.length; i++) {
|
||||
const a = words.slice(0, i).join(" "), b = words.slice(i).join(" ");
|
||||
const w = Math.max(measure(a), measure(b));
|
||||
if (w < best.w - 0.5) best = { text: `${a}\n${b}`, w };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
interface Slot { sagaId: PlaceId; expanded: boolean; width: number; offsets: number[]; labels: (string | null)[] }
|
||||
|
||||
function slotFor(sagaId: PlaceId, expanded: boolean, measure: (t: string) => number): Slot {
|
||||
const list = songsInSaga(sagaId);
|
||||
const nameW = measure(sagaLabel(sagaId, expanded)) + NAME_PAD;
|
||||
if (!expanded) {
|
||||
const content = list.length * COMPACT_STEP;
|
||||
const width = Math.max(content, nameW);
|
||||
const start = (width - content) / 2 + COMPACT_STEP / 2;
|
||||
return { sagaId, expanded, width, offsets: list.map((_, i) => start + i * COMPACT_STEP), labels: list.map(() => null) };
|
||||
}
|
||||
const split = list.map((s) => splitTitle(s.title, measure));
|
||||
const offsets: number[] = [];
|
||||
let x = split[0]!.w / 2;
|
||||
split.forEach((t, i) => {
|
||||
if (i > 0) x += Math.max(MIN_SPACING, (split[i - 1]!.w + t.w) / 2 + LABEL_GAP);
|
||||
offsets.push(x);
|
||||
});
|
||||
const content = x + split.at(-1)!.w / 2;
|
||||
const width = Math.max(content, nameW);
|
||||
const shift = (width - content) / 2;
|
||||
return { sagaId, expanded, width, offsets: offsets.map((o) => o + shift), labels: split.map((t) => t.text) };
|
||||
}
|
||||
|
||||
export function layoutTimeline(currentSongId: string, width: number, measure: (text: string) => number): TimelineLayout {
|
||||
const current = songById(currentSongId) ?? songs[0]!;
|
||||
const slots = sagas.map((g) => slotFor(g.id, g.id === current.sagaId, measure));
|
||||
const gaps = slots.length - 1;
|
||||
const need = END_PAD * 2 + slots.reduce((a, s) => a + s.width, 0) + SAGA_GAP * gaps;
|
||||
const gap = SAGA_GAP + Math.max(0, width - need) / gaps;
|
||||
|
||||
const stops: TimelineStop[] = [];
|
||||
const spans: TimelineLayout["sagas"] = [];
|
||||
let x0 = END_PAD;
|
||||
for (const slot of slots) {
|
||||
songsInSaga(slot.sagaId).forEach((s, i) => {
|
||||
stops.push({ songId: s.id, x: x0 + slot.offsets[i]!, label: slot.labels[i]!, current: s.id === current.id });
|
||||
});
|
||||
spans.push({ sagaId: slot.sagaId, x0, x1: x0 + slot.width, expanded: slot.expanded });
|
||||
x0 += slot.width + gap;
|
||||
}
|
||||
return { stops, sagas: spans, width: Math.max(width, need) };
|
||||
}
|
||||
|
||||
/** Every motif a song uses, song-level or in a part. */
|
||||
function motifIdsOf(songId: string): Set<string> {
|
||||
const s = songById(songId);
|
||||
return new Set(s ? [...s.motifIds, ...s.parts.flatMap((p) => p.motifIds)] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The motifs that get a thread for this song: musical motifs before character themes (the Odysseus
|
||||
* theme plays in 35 songs, so it would otherwise crowd out the rarer ones), each in the album's motif
|
||||
* order. A picked motif is included even if the song does not use it, so it can be followed.
|
||||
*/
|
||||
export function threadMotifs(songId: string, picked: string | null): Motif[] {
|
||||
const ids = motifIdsOf(songId);
|
||||
if (picked) ids.add(picked);
|
||||
const rank = (m: Motif) => (m.kind === "motif" ? 0 : 1);
|
||||
return motifs.filter((m) => ids.has(m.id)).sort((a, b) => rank(a) - rank(b) || motifs.indexOf(a) - motifs.indexOf(b));
|
||||
}
|
||||
|
||||
/** The most thread rows shown at once; past it, a "+N more" row takes the last place. */
|
||||
export const MAX_ROWS = 4;
|
||||
|
||||
/** Which of `all` are shown: all of them if they fit (or `showAll`), else the first 3 with the picked one kept. */
|
||||
export function visibleMotifs(all: Motif[], picked: string | null, showAll: boolean): { shown: Motif[]; hidden: number } {
|
||||
if (showAll || all.length <= MAX_ROWS) return { shown: all, hidden: 0 };
|
||||
let shown = all.slice(0, MAX_ROWS - 1);
|
||||
const p = all.find((m) => m.id === picked);
|
||||
if (p && !shown.includes(p)) shown = [...shown.slice(0, -1), p];
|
||||
return { shown, hidden: all.length - shown.length };
|
||||
}
|
||||
@@ -1 +1,111 @@
|
||||
/* the bottom timeline (plan task 5) */
|
||||
/* The bottom timeline (plan task 5): a bar with the voyage controls slot, "Threads on the sea" and
|
||||
Hide/Show, then the strip of songs and motif threads (look from mockup 17's ".hs" strip).
|
||||
#timeline itself is positioned in ../style.css; the open timeline stays within --timeline-h. */
|
||||
.tl { display: flex; flex-direction: column; padding-bottom: 2px; font-size: var(--fs-label); }
|
||||
|
||||
/* ---------- the bar ---------- */
|
||||
.tl-bar {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
min-height: 30px;
|
||||
padding: 1px 10px 1px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.timeline__controls { display: flex; align-items: center; gap: 6px; margin-right: auto; min-width: 0; }
|
||||
.tl-switch, .tl-toggle {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 3px 10px 3px 6px;
|
||||
border: 0; border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: var(--fs-small); font-weight: 600; line-height: 1.3;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.tl-switch:hover { background: rgb(43 33 24 / 0.07); }
|
||||
.tl-switch__track {
|
||||
position: relative; flex: none; width: 34px; height: 20px;
|
||||
border: 1.5px solid var(--glaze); border-radius: 999px; background: var(--vellum-lo);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.tl-switch__knob {
|
||||
position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; border-radius: 50%;
|
||||
background: var(--glaze); transition: transform 0.2s var(--ease);
|
||||
}
|
||||
.tl-switch[aria-checked="true"] .tl-switch__track { background: var(--clay); }
|
||||
.tl-switch[aria-checked="true"] .tl-switch__knob { transform: translateX(14px); }
|
||||
.tl-toggle { padding: 3px 10px 3px 12px; border: 1.5px solid var(--line); }
|
||||
.tl-toggle:hover { border-color: var(--glaze); }
|
||||
.tl-toggle__chev {
|
||||
width: 7px; height: 7px; margin: -3px 2px 0;
|
||||
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
|
||||
transform: rotate(45deg); transition: transform 0.2s var(--ease);
|
||||
}
|
||||
.tl[data-state="closed"] .tl-toggle__chev { margin-top: 3px; transform: rotate(-135deg); }
|
||||
|
||||
/* ---------- the strip: a fixed column of names, the songs and threads scroll beside it ---------- */
|
||||
.tl-body { display: grid; grid-template-columns: var(--tl-label-w, 110px) minmax(0, 1fr); padding: 2px 0 0 12px; }
|
||||
.tl-labels { display: flex; flex-direction: column; align-items: flex-start; padding-top: 76px; position: relative; }
|
||||
.tl-labels__course {
|
||||
position: absolute; left: 0; top: 29px;
|
||||
font-size: var(--fs-label); font-weight: 600; line-height: 1.3; color: var(--iron-soft);
|
||||
}
|
||||
.tl[data-state="closed"] .tl-labels { padding-top: 0; }
|
||||
.tl[data-state="closed"] .tl-labels__course { top: 19px; }
|
||||
.tl[data-state="closed"] .tl-body { height: 44px; }
|
||||
.tl-m, .tl-more {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
height: 14px; padding: 0 6px 0 0;
|
||||
border: 0; border-radius: 3px; background: transparent;
|
||||
font-size: var(--fs-label); font-weight: 600; line-height: 1; white-space: nowrap;
|
||||
text-align: left; cursor: pointer;
|
||||
}
|
||||
.tl-m { color: var(--c); transition: opacity 0.2s ease; }
|
||||
.tl-m i { flex: none; width: 10px; height: 10px; border-radius: 50%; background: var(--c-bright); box-shadow: 0 0 0 1.5px var(--glaze); }
|
||||
.tl-m:hover, .tl-more:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.tl-m.is-on { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
|
||||
.tl-m.is-dim { opacity: 0.5; }
|
||||
.tl-more { color: var(--clay-deep); padding-left: 17px; }
|
||||
|
||||
.tl-scroll {
|
||||
overflow-x: auto; overflow-y: hidden;
|
||||
padding-right: 12px;
|
||||
scrollbar-width: thin; scrollbar-color: var(--clay) transparent;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
.tl-scroll { --fade-l: 0px; --fade-r: 0px; mask-image: linear-gradient(to right, transparent, #000 var(--fade-l), #000 calc(100% - var(--fade-r)), transparent); }
|
||||
.tl-scroll.is-more-left { --fade-l: 28px; }
|
||||
.tl-scroll.is-more-right { --fade-r: 28px; }
|
||||
.tl-svg { display: block; overflow: visible; }
|
||||
.tl-band { fill: rgb(196 106 51 / 0.16); }
|
||||
.tl-course { fill: none; stroke: var(--clay-deep); stroke-width: 1.6; stroke-dasharray: 5 3.5; }
|
||||
.tl-divider { stroke: var(--glaze); stroke-width: 1.4; }
|
||||
.tl-saga { font: 600 13px var(--sans); fill: var(--clay-deep); }
|
||||
.tl-saga.is-current { fill: var(--iron); }
|
||||
.tl-stop { cursor: pointer; outline: none; }
|
||||
.tl-stop__hit { fill: transparent; }
|
||||
.tl-stop circle { fill: var(--vellum-hi); stroke: var(--glaze); stroke-width: 1.6; transition: fill 0.15s ease; }
|
||||
.tl-stop:hover circle { fill: var(--added-white); stroke-width: 2.2; }
|
||||
.tl-stop.is-current circle { fill: var(--clay); stroke-width: 2; }
|
||||
.tl-stop:focus-visible .tl-stop__hit { fill: rgb(196 106 51 / 0.12); stroke: var(--clay); stroke-width: 2.5; }
|
||||
.tl-title { font: 500 13px var(--sans); fill: var(--iron-soft); }
|
||||
.tl-stop:hover .tl-title { fill: var(--iron); text-decoration: underline; }
|
||||
.tl-stop.is-current .tl-title { font-weight: 600; fill: var(--iron); }
|
||||
.tl-ship { pointer-events: none; }
|
||||
.tl-ship__hull, .tl-ship__sail { fill: var(--clay); stroke: var(--glaze); stroke-width: 1.4; stroke-linejoin: round; }
|
||||
.tl-ship__mast { stroke: var(--glaze); stroke-width: 1.6; }
|
||||
.tl-here { font: 600 13px var(--sans); fill: var(--clay-deep); paint-order: stroke; stroke: var(--vellum-hi); stroke-width: 4px; stroke-linejoin: round; }
|
||||
|
||||
.tl-thread { transition: opacity 0.2s ease; }
|
||||
.tl-thread__line { fill: none; stroke: var(--c); stroke-width: 2.4; stroke-linecap: round; opacity: 0.85; }
|
||||
.tl-thread__knot { fill: var(--vellum-hi); stroke: var(--c); stroke-width: 2.2; }
|
||||
.tl-thread__knot.is-here { fill: var(--c-bright); stroke: var(--glaze); stroke-width: 1.6; }
|
||||
.tl-thread.is-on .tl-thread__line { stroke-width: 3.6; opacity: 1; }
|
||||
.tl-thread.is-on .tl-thread__knot { fill: var(--c-bright); }
|
||||
.tl-thread.is-dim { opacity: 0.22; }
|
||||
|
||||
/* below 900px: a tighter bar */
|
||||
@media (max-width: 900px) {
|
||||
.tl-bar { padding-inline: 8px; gap: 2px; }
|
||||
.tl-body { padding-left: 8px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tl *, .tl *::before { transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// The bottom timeline (plan task 5, design record ideas 8 and 9; look ported from mockup 17's threads.ts).
|
||||
// A bar on top (the controller's play controls go in .timeline__controls; "Threads on the sea"; Hide /
|
||||
// Show timeline), then the strip: the 40 songs on a course line, the current saga expanded with titles,
|
||||
// "you are here", and one thread per motif of the current song, knotted wherever that motif plays.
|
||||
// Collapsed, the strip shrinks to a slim course with the ship. Reads songId and motif; writes songId,
|
||||
// motif, timeline and ribbons.
|
||||
import { songById, songs, songsWithMotif } from "../../data/album";
|
||||
import type { Store } from "../state";
|
||||
import { inkOnParchment } from "./ink";
|
||||
import { layoutTimeline, MAX_ROWS, sagaLabel, threadMotifs, visibleMotifs, type TimelineLayout } from "./layout";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
/** The course line's y in the open strip; saga names sit above it, titles below. */
|
||||
const COURSE_Y = 38;
|
||||
/** Height of the course part of the open strip (names, ship, stops, two-line titles). */
|
||||
const COURSE_H = 76;
|
||||
const ROW_H = 14;
|
||||
/** The slim (collapsed) strip. */
|
||||
const SLIM_Y = 30;
|
||||
const SLIM_H = 42;
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
||||
const f = (n: number) => n.toFixed(1);
|
||||
|
||||
/* Titles and names are laid out by their measured width (13px Inter, semibold so the current one fits). */
|
||||
let ctx: CanvasRenderingContext2D | null | undefined;
|
||||
const widths = new Map<string, number>();
|
||||
function measure(t: string): number {
|
||||
const hit = widths.get(t);
|
||||
if (hit !== undefined) return hit;
|
||||
if (ctx === undefined) {
|
||||
try { ctx = document.createElement("canvas").getContext("2d"); } catch { ctx = null; }
|
||||
}
|
||||
let w = t.length * 7.2;
|
||||
if (ctx) {
|
||||
ctx.font = '600 13px "Inter", system-ui, sans-serif';
|
||||
const m = ctx.measureText(t).width;
|
||||
if (m > 0) w = m;
|
||||
}
|
||||
widths.set(t, w);
|
||||
return w;
|
||||
}
|
||||
|
||||
const pickCls = (id: string, pick: string | null) => (pick === id ? " is-on" : pick ? " is-dim" : "");
|
||||
|
||||
function ship(x: number, y: number, label: string, flip: boolean): string {
|
||||
return `<g class="tl-ship" transform="translate(${f(x)},${y})">
|
||||
<path class="tl-ship__hull" d="M-8,-8 Q0,-1 8,-8 Z"/><path class="tl-ship__mast" d="M0,-8 V-20"/>
|
||||
<path class="tl-ship__sail" d="M0.5,-19 L7,-11.5 H0.5 Z"/>
|
||||
<text class="tl-here" x="${flip ? -12 : 12}" y="-11"${flip ? ' text-anchor="end"' : ""}>${esc(label)}</text></g>`;
|
||||
}
|
||||
|
||||
const stopAttrs = (id: string, cur: boolean) => {
|
||||
const s = songById(id)!;
|
||||
return `data-song="${id}" role="link" tabindex="${cur ? 0 : -1}" aria-label="${s.track}. ${esc(s.title)}"${cur ? ' aria-current="step"' : ""}`;
|
||||
};
|
||||
|
||||
/** The open strip's SVG: course, sagas, stops with titles in the expanded saga, ship, threads. */
|
||||
function openSvg(l: TimelineLayout, songId: string, shown: ReturnType<typeof threadMotifs>, pick: string | null, rows: number): string {
|
||||
const H = COURSE_H + rows * ROW_H + 2;
|
||||
const xOf = new Map(l.stops.map((s) => [s.songId, s.x]));
|
||||
const ci = l.stops.findIndex((s) => s.current);
|
||||
const cur = l.stops[ci]!;
|
||||
const prevX = l.stops[ci - 1]?.x, nextX = l.stops[ci + 1]?.x;
|
||||
const half = cur.label ? Math.max(...cur.label.split("\n").map(measure)) / 2 + 8 : 9;
|
||||
const bandL = Math.max(cur.x - half, prevX !== undefined ? (prevX + cur.x) / 2 : 0);
|
||||
const bandR = Math.min(cur.x + half, nextX !== undefined ? (nextX + cur.x) / 2 : l.width);
|
||||
let svg = `<rect class="tl-band" x="${f(bandL)}" y="${COURSE_Y - 26}" width="${f(bandR - bandL)}" height="${H - COURSE_Y + 26}" rx="3"/>`;
|
||||
svg += `<path class="tl-course" d="M${f(l.stops[0]!.x - 8)},${COURSE_Y}H${f(l.stops.at(-1)!.x + 8)}"/>`;
|
||||
l.sagas.forEach((g, i) => {
|
||||
if (i > 0) {
|
||||
const x = (l.sagas[i - 1]!.x1 + g.x0) / 2;
|
||||
svg += `<path class="tl-divider" d="M${f(x)},0V${H}"/>`;
|
||||
}
|
||||
svg += `<text class="tl-saga${g.expanded ? " is-current" : ""}" x="${f(g.x0 + 4)}" y="12">${esc(sagaLabel(g.sagaId, g.expanded))}</text>`;
|
||||
});
|
||||
for (const s of l.stops) {
|
||||
const song = songById(s.songId)!;
|
||||
const lines = s.label?.split("\n") ?? [];
|
||||
const w = lines.length ? Math.max(...lines.map(measure)) + 8 : 18;
|
||||
const h = lines.length ? COURSE_H - COURSE_Y + 12 : 26;
|
||||
svg += `<g class="tl-stop${s.current ? " is-current" : ""}" ${stopAttrs(s.songId, s.current)}>
|
||||
<title>${song.track}. ${esc(song.title)}</title>
|
||||
<rect class="tl-stop__hit" x="${f(s.x - w / 2)}" y="${COURSE_Y - 13}" width="${f(w)}" height="${h}"/>
|
||||
<circle cx="${f(s.x)}" cy="${COURSE_Y}" r="${s.current ? 6.5 : 5}"/>
|
||||
${lines.length ? `<text class="tl-title" x="${f(s.x)}" y="${COURSE_Y + 19}" text-anchor="middle">${lines.map((t, j) => `<tspan x="${f(s.x)}" dy="${j ? 14 : 0}">${esc(t)}</tspan>`).join("")}</text>` : ""}
|
||||
</g>`;
|
||||
}
|
||||
// "you are here" reads to the right of the ship unless that would cross into the next saga
|
||||
const label = "you are here";
|
||||
const sagaEnd = l.sagas.find((g) => g.expanded)!.x1;
|
||||
svg += ship(cur.x, COURSE_Y, label, cur.x + 12 + measure(label) > Math.min(sagaEnd + 4, l.width - 4));
|
||||
|
||||
const wav = (x: number, k: number) => Math.sin(x / 26 + k * 1.7) * 1.6;
|
||||
shown.forEach((m, k) => {
|
||||
const y = COURSE_H + k * ROW_H + ROW_H / 2;
|
||||
const hits = songsWithMotif(m.id).map((s) => ({ s, x: xOf.get(s.id)! }));
|
||||
if (!hits.length) return;
|
||||
const x0 = hits[0]!.x, x1 = hits.at(-1)!.x;
|
||||
let d = "";
|
||||
for (let x = x0; x < x1; x += 5) d += `${d ? "L" : "M"}${f(x)},${f(y + wav(x, k))}`;
|
||||
d += `${d ? "L" : "M"}${f(x1)},${f(y + wav(x1, k))}`;
|
||||
svg += `<g class="tl-thread${pickCls(m.id, pick)}" data-m="${m.id}" style="--c:${inkOnParchment(m.color)};--c-bright:${m.color}">
|
||||
<path class="tl-thread__line" d="${d}"/>
|
||||
${hits.map(({ s, x }) => `<circle class="tl-thread__knot${s.id === songId ? " is-here" : ""}" cx="${f(x)}" cy="${f(y + wav(x, k))}" r="${s.id === songId ? 5 : 4}"><title>${esc(m.name)} in ${esc(s.title)}</title></circle>`).join("")}
|
||||
</g>`;
|
||||
});
|
||||
return `<svg class="tl-svg" width="${f(l.width)}" height="${H}" viewBox="0 0 ${f(l.width)} ${H}">${svg}</svg>`;
|
||||
}
|
||||
|
||||
/** The collapsed strip: all 40 stops evenly spaced (a little extra room at saga changes) and the ship. */
|
||||
function slimSvg(width: number, songId: string): string {
|
||||
const breaks = songs.filter((s, i) => i > 0 && s.sagaId !== songs[i - 1]!.sagaId).length;
|
||||
const pad = 12;
|
||||
const step = (width - pad * 2) / (songs.length - 1 + breaks * 0.6);
|
||||
let x = pad, svg = "";
|
||||
const xs: number[] = [];
|
||||
songs.forEach((s, i) => {
|
||||
if (i > 0) {
|
||||
const brk = s.sagaId !== songs[i - 1]!.sagaId;
|
||||
if (brk) svg += `<path class="tl-divider" d="M${f(x + step * 0.8)},${SLIM_Y - 9}V${SLIM_Y + 9}"/>`;
|
||||
x += step * (brk ? 1.6 : 1);
|
||||
}
|
||||
xs.push(x);
|
||||
});
|
||||
svg = `<path class="tl-course" d="M${f(xs[0]! - 6)},${SLIM_Y}H${f(xs.at(-1)! + 6)}"/>` + svg;
|
||||
songs.forEach((s, i) => {
|
||||
const cur = s.id === songId;
|
||||
svg += `<g class="tl-stop tl-stop--slim${cur ? " is-current" : ""}" ${stopAttrs(s.id, cur)}><title>${s.track}. ${esc(s.title)}</title>
|
||||
<rect class="tl-stop__hit" x="${f(xs[i]! - step / 2)}" y="${SLIM_Y - 10}" width="${f(step)}" height="20"/>
|
||||
<circle cx="${f(xs[i]!)}" cy="${SLIM_Y}" r="${cur ? 6 : 4}"/></g>`;
|
||||
});
|
||||
const ci = songs.findIndex((s) => s.id === songId);
|
||||
const label = `you are here · ${songById(songId)!.title}`;
|
||||
svg += ship(xs[ci]!, SLIM_Y - 2, label, xs[ci]! + 12 + measure(label) > width - 4);
|
||||
return `<svg class="tl-svg" width="${f(width)}" height="${SLIM_H}" viewBox="0 0 ${f(width)} ${SLIM_H}">${svg}</svg>`;
|
||||
}
|
||||
|
||||
/** Fade the strip's edges where more of it is scrolled out of view. */
|
||||
function updateEdges(scroll: HTMLElement) {
|
||||
const max = scroll.scrollWidth - scroll.clientWidth;
|
||||
scroll.classList.toggle("is-more-left", max > 1 && scroll.scrollLeft > 1);
|
||||
scroll.classList.toggle("is-more-right", max > 1 && scroll.scrollLeft < max - 1);
|
||||
}
|
||||
|
||||
export function mountTimeline(root: HTMLElement, store: Store): { refresh(): void } {
|
||||
root.classList.add("tl");
|
||||
root.innerHTML = `
|
||||
<div class="tl-bar">
|
||||
<div class="timeline__controls"></div>
|
||||
<button type="button" class="tl-switch" role="switch" aria-checked="false" data-action="ribbons">
|
||||
<span class="tl-switch__track" aria-hidden="true"><span class="tl-switch__knob"></span></span>
|
||||
Threads on the sea
|
||||
</button>
|
||||
<button type="button" class="tl-toggle" data-action="toggle-timeline" aria-expanded="true" aria-controls="tl-body">
|
||||
<span class="tl-toggle__text">Hide timeline</span><span class="tl-toggle__chev" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tl-body" id="tl-body"></div>`;
|
||||
const body = root.querySelector<HTMLElement>(".tl-body")!;
|
||||
const sw = root.querySelector<HTMLButtonElement>("[data-action=ribbons]")!;
|
||||
const toggle = root.querySelector<HTMLButtonElement>("[data-action=toggle-timeline]")!;
|
||||
let showAll = false;
|
||||
|
||||
function render() {
|
||||
const s = store.get();
|
||||
const open = s.timeline === "open";
|
||||
root.dataset.state = open ? "open" : "closed";
|
||||
sw.setAttribute("aria-checked", String(s.ribbons));
|
||||
toggle.setAttribute("aria-expanded", String(open));
|
||||
toggle.querySelector(".tl-toggle__text")!.textContent = open ? "Hide timeline" : "Show timeline";
|
||||
|
||||
// keep keyboard focus on the equivalent control across the re-render
|
||||
const active = document.activeElement;
|
||||
const had = active && body.contains(active)
|
||||
? (active.hasAttribute("data-song") ? "[aria-current=step]" : active.getAttribute("data-pick") ? `[data-pick="${active.getAttribute("data-pick")}"]` : active.getAttribute("data-action") === "more" ? "[data-action=more]" : null)
|
||||
: null;
|
||||
const oldScroll = body.querySelector<HTMLElement>(".tl-scroll");
|
||||
const keepLeft = oldScroll?.scrollLeft ?? 0;
|
||||
|
||||
const all = open ? threadMotifs(s.songId, s.motif) : [];
|
||||
const { shown, hidden } = visibleMotifs(all, s.motif, showAll);
|
||||
const moreRow = hidden > 0 || (showAll && all.length > MAX_ROWS);
|
||||
const rows = shown.length + (moreRow ? 1 : 0);
|
||||
const labelW = Math.ceil(Math.max(measure("The voyage") + 10, ...shown.map((m) => measure(m.name) + 26), moreRow ? measure("Show fewer") + 10 : 0));
|
||||
|
||||
const labels = shown.map((m) => `<button type="button" class="tl-m${pickCls(m.id, s.motif)}" data-m="${m.id}" data-pick="${m.id}" aria-pressed="${s.motif === m.id}" style="--c:${inkOnParchment(m.color)};--c-bright:${m.color}" title="${esc(m.name)}: ${esc(m.description)}"><i aria-hidden="true"></i>${esc(m.name)}</button>`).join("");
|
||||
const more = !moreRow ? "" : hidden > 0
|
||||
? `<button type="button" class="tl-more" data-action="more" aria-expanded="false" title="Also: ${esc(all.filter((m) => !shown.includes(m)).map((m) => m.name).join(", "))}">+${hidden} more</button>`
|
||||
: `<button type="button" class="tl-more" data-action="more" aria-expanded="true">Show fewer</button>`;
|
||||
|
||||
body.style.setProperty("--tl-label-w", `${labelW}px`);
|
||||
body.innerHTML = `<div class="tl-labels"><span class="tl-labels__course">The voyage</span>${labels}${more}</div><div class="tl-scroll"></div>`;
|
||||
const scroll = body.querySelector<HTMLElement>(".tl-scroll")!;
|
||||
const avail = Math.max(0, scroll.clientWidth || (root.clientWidth ? root.clientWidth - labelW - 24 : 980));
|
||||
|
||||
if (open) {
|
||||
const l = layoutTimeline(s.songId, avail, measure);
|
||||
scroll.innerHTML = openSvg(l, s.songId, shown, s.motif, rows);
|
||||
if (l.width > avail) {
|
||||
// bring the expanded saga into view; the current stop must be visible in any case
|
||||
const g = l.sagas.find((x) => x.expanded)!;
|
||||
const cur = l.stops.find((x) => x.current)!.x;
|
||||
let left = (g.x0 + g.x1) / 2 - avail / 2;
|
||||
if (cur < left + 40 || cur > left + avail - 40) left = cur - avail / 2;
|
||||
scroll.scrollLeft = Math.max(0, Math.min(scroll.scrollWidth - scroll.clientWidth, s.songId === lastSong ? keepLeft : left));
|
||||
}
|
||||
} else {
|
||||
scroll.innerHTML = slimSvg(Math.max(avail, 320), s.songId);
|
||||
}
|
||||
updateEdges(scroll);
|
||||
scroll.addEventListener("scroll", () => updateEdges(scroll), { passive: true });
|
||||
lastSong = s.songId;
|
||||
if (had) body.querySelector<HTMLElement | SVGElement>(had)?.focus();
|
||||
}
|
||||
let lastSong = "";
|
||||
|
||||
const selectSong = (id: string) => { if (songById(id)) store.set({ songId: id }); };
|
||||
|
||||
root.addEventListener("click", (e) => {
|
||||
const t = e.target as Element;
|
||||
const stop = t.closest("[data-song]");
|
||||
if (stop) return selectSong(stop.getAttribute("data-song")!);
|
||||
const pick = t.closest("[data-pick]");
|
||||
if (pick) {
|
||||
const id = pick.getAttribute("data-pick")!;
|
||||
return store.set({ motif: store.get().motif === id ? null : id });
|
||||
}
|
||||
const action = t.closest("[data-action]")?.getAttribute("data-action");
|
||||
if (action === "ribbons") store.set({ ribbons: !store.get().ribbons });
|
||||
else if (action === "toggle-timeline") store.set({ timeline: store.get().timeline === "open" ? "closed" : "open" });
|
||||
else if (action === "more") { showAll = !showAll; render(); }
|
||||
});
|
||||
|
||||
// stops: Enter/Space select; arrow keys move along the course (one tab stop for the whole strip)
|
||||
root.addEventListener("keydown", (e) => {
|
||||
const stop = (e.target as Element).closest?.("[data-song]");
|
||||
if (!stop) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectSong(stop.getAttribute("data-song")!);
|
||||
} else if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
const all = [...body.querySelectorAll<SVGElement>("[data-song]")];
|
||||
const i = all.indexOf(stop as SVGElement);
|
||||
const j = e.key === "Home" ? 0 : e.key === "End" ? all.length - 1 : Math.max(0, Math.min(all.length - 1, i + (e.key === "ArrowLeft" ? -1 : 1)));
|
||||
all[i]!.setAttribute("tabindex", "-1");
|
||||
all[j]!.setAttribute("tabindex", "0");
|
||||
all[j]!.focus();
|
||||
}
|
||||
});
|
||||
|
||||
store.subscribe((s, prev) => {
|
||||
if (s.songId !== prev.songId) showAll = false;
|
||||
if (s.songId !== prev.songId || s.motif !== prev.motif || s.timeline !== prev.timeline || s.ribbons !== prev.ribbons) render();
|
||||
});
|
||||
|
||||
let lastW = -1;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
new ResizeObserver(() => {
|
||||
const w = root.clientWidth;
|
||||
if (w !== lastW) { lastW = w; render(); }
|
||||
}).observe(root);
|
||||
}
|
||||
// titles are laid out by measured width: measure again once Inter has loaded
|
||||
document.fonts?.ready.then(() => { widths.clear(); render(); });
|
||||
|
||||
render();
|
||||
return { refresh: render };
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export const motifs: Motif[] = [
|
||||
{ id: "little-wolf", name: "Little Wolf", kind: "motif", color: "#8a8f99", description: "Athena's name and melody for Telemachus as he learns to fight." }, // TODO(verify) recurrences
|
||||
{ id: "fall-in-love", name: "Fall in Love Again", kind: "motif", color: "#d46f9a", description: "The question of love tested by time and temptation, answered at the very end." }, // TODO(verify) recurrences
|
||||
// Character themes
|
||||
{ id: "odysseus", name: "Odysseus", kind: "character", color: "#f2e3b3", description: "Theme of the king of Ithaca." },
|
||||
{ id: "athena", name: "Athena", kind: "character", color: "#b69bd6", description: "Theme of the goddess of wisdom." },
|
||||
{ id: "polyphemus", name: "Polyphemus", kind: "character", color: "#c0504d", description: "Theme of the Cyclops, Poseidon's son." },
|
||||
{ id: "poseidon", name: "Poseidon", kind: "character", color: "#1f7a7a", description: "Theme of the god of the sea." },
|
||||
@@ -36,7 +35,9 @@ export const motifs: Motif[] = [
|
||||
|
||||
/** Singer name → character motif id, used to derive character themes from a song's singers. */
|
||||
export const characterMotifBySinger: Record<string, string> = {
|
||||
Odysseus: "odysseus", Athena: "athena", Polyphemus: "polyphemus", Poseidon: "poseidon", Zeus: "zeus",
|
||||
// Odysseus sings in almost every song, so a thread for him would mark nearly all 40 stops and say
|
||||
// nothing; his story is carried by the musical motifs instead (Warrior of the Mind, Just a Man…).
|
||||
Athena: "athena", Polyphemus: "polyphemus", Poseidon: "poseidon", Zeus: "zeus",
|
||||
Penelope: "penelope", Telemachus: "telemachus", Circe: "circe", Hermes: "hermes", Calypso: "calypso",
|
||||
Eurylochus: "eurylochus", Polites: "polites", Aeolus: "aeolus", Antinous: "antinous",
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
import { sagas, songs, songsInSaga } from "../src/data/album";
|
||||
import { createStore, DEFAULT_STATE, type AppState } from "../src/app/state";
|
||||
import { layoutTimeline, threadMotifs } from "../src/app/timeline/layout";
|
||||
import { contrast, inkOnParchment, parchment, rgbOf } from "../src/app/timeline/ink";
|
||||
import { motifs } from "../src/data/album";
|
||||
|
||||
const measure = (t: string) => t.length * 7.2;
|
||||
/** The widest line of a (possibly two-line) label. */
|
||||
const labelW = (label: string) => Math.max(...label.split("\n").map(measure));
|
||||
|
||||
describe("layoutTimeline", () => {
|
||||
const l = layoutTimeline("the-horse-and-the-infant", 980, measure);
|
||||
|
||||
test("has 40 stops in album order with strictly increasing x", () => {
|
||||
expect(l.stops).toHaveLength(40);
|
||||
expect(l.stops.map((s) => s.songId)).toEqual(songs.map((s) => s.id));
|
||||
for (let i = 1; i < l.stops.length; i++) expect(l.stops[i]!.x).toBeGreaterThan(l.stops[i - 1]!.x);
|
||||
expect(l.stops[0]!.x).toBeGreaterThan(0);
|
||||
expect(l.stops.at(-1)!.x).toBeLessThan(l.width);
|
||||
});
|
||||
|
||||
test("labels only the current saga's stops, and marks the current song", () => {
|
||||
for (const s of l.stops) {
|
||||
const inTroy = songs.find((x) => x.id === s.songId)!.sagaId === "troy";
|
||||
expect(s.label !== null).toBe(inTroy);
|
||||
}
|
||||
expect(l.stops.filter((s) => s.current).map((s) => s.songId)).toEqual(["the-horse-and-the-infant"]);
|
||||
expect(l.sagas.filter((g) => g.expanded).map((g) => g.sagaId)).toEqual(["troy"]);
|
||||
});
|
||||
|
||||
test("sagas are 9 ordered, non-overlapping spans that contain their stops", () => {
|
||||
expect(l.sagas.map((g) => g.sagaId)).toEqual(sagas.map((g) => g.id));
|
||||
for (let i = 1; i < l.sagas.length; i++) expect(l.sagas[i]!.x0).toBeGreaterThanOrEqual(l.sagas[i - 1]!.x1);
|
||||
for (const g of l.sagas) {
|
||||
for (const song of songsInSaga(g.sagaId)) {
|
||||
const x = l.stops.find((s) => s.songId === song.id)!.x;
|
||||
expect(x).toBeGreaterThan(g.x0);
|
||||
expect(x).toBeLessThan(g.x1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("uses at least the given width, and spreads out when there is room", () => {
|
||||
expect(l.width).toBeGreaterThanOrEqual(980);
|
||||
const wide = layoutTimeline("the-horse-and-the-infant", 3000, measure);
|
||||
expect(wide.width).toBe(3000);
|
||||
expect(wide.stops.at(-1)!.x).toBeGreaterThan(2900);
|
||||
});
|
||||
|
||||
test("compact sagas space their stops 18px apart", () => {
|
||||
const cyc = songsInSaga("cyclops").map((s) => l.stops.find((x) => x.songId === s.id)!.x);
|
||||
for (let i = 1; i < cyc.length; i++) expect(cyc[i]! - cyc[i - 1]!).toBeCloseTo(18, 5);
|
||||
});
|
||||
|
||||
// Review focus 5: long titles never overlap in the expanded saga.
|
||||
for (const saga of sagas) {
|
||||
test(`no two labelled stops overlap with ${saga.name} expanded`, () => {
|
||||
const first = songsInSaga(saga.id)[0]!;
|
||||
const lay = layoutTimeline(first.id, 980, measure);
|
||||
const labelled = lay.stops.filter((s) => s.label !== null);
|
||||
expect(labelled.length).toBe(songsInSaga(saga.id).length);
|
||||
for (const s of labelled) expect(s.label!.split("\n").length).toBeLessThanOrEqual(2);
|
||||
for (let i = 1; i < labelled.length; i++) {
|
||||
const a = labelled[i - 1]!, b = labelled[i]!;
|
||||
expect(b.x - a.x).toBeGreaterThanOrEqual(64);
|
||||
expect(b.x - a.x).toBeGreaterThanOrEqual((labelW(a.label!) + labelW(b.label!)) / 2 + 16 - 1e-6);
|
||||
}
|
||||
// labels stay inside their saga's span
|
||||
const g = lay.sagas.find((x) => x.expanded)!;
|
||||
expect(labelled[0]!.x - labelW(labelled[0]!.label!) / 2).toBeGreaterThanOrEqual(g.x0 - 1e-6);
|
||||
expect(labelled.at(-1)!.x + labelW(labelled.at(-1)!.label!) / 2).toBeLessThanOrEqual(g.x1 + 1e-6);
|
||||
});
|
||||
}
|
||||
|
||||
test("the longest title is split onto two lines", () => {
|
||||
const lay = layoutTimeline("would-you-fall-in-love-with-me-again", 980, measure);
|
||||
const s = lay.stops.find((x) => x.songId === "would-you-fall-in-love-with-me-again")!;
|
||||
expect(s.label!.split("\n")).toHaveLength(2);
|
||||
expect(s.label!.replace("\n", " ")).toBe("Would You Fall in Love with Me Again");
|
||||
});
|
||||
});
|
||||
|
||||
describe("threadMotifs", () => {
|
||||
test("lists musical motifs before character themes", () => {
|
||||
const ms = threadMotifs("full-speed-ahead", null);
|
||||
const kinds = ms.map((m) => m.kind);
|
||||
expect(kinds.indexOf("character")).toBeGreaterThan(kinds.lastIndexOf("motif"));
|
||||
expect(ms.map((m) => m.id).sort()).toEqual(["eurylochus", "full-speed", "polites", "six-hundred"]);
|
||||
});
|
||||
test("includes a picked motif the song does not use", () => {
|
||||
expect(threadMotifs("storm", "warrior").map((m) => m.id)).toContain("warrior");
|
||||
});
|
||||
});
|
||||
|
||||
test("every motif's ink reads at 4.5:1 on the timeline's parchment", () => {
|
||||
for (const m of motifs) expect(contrast(rgbOf(inkOnParchment(m.color)), parchment)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
describe("mountTimeline", () => {
|
||||
beforeAll(() => GlobalRegistrator.register());
|
||||
afterAll(() => GlobalRegistrator.unregister());
|
||||
|
||||
const setup = async (patch: Partial<AppState> = {}) => {
|
||||
const { mountTimeline } = await import("../src/app/timeline/timeline");
|
||||
const root = document.createElement("nav");
|
||||
document.body.append(root);
|
||||
const store = createStore({ ...DEFAULT_STATE, ...patch }, { history: false });
|
||||
const tl = mountTimeline(root, store);
|
||||
return { root, store, tl };
|
||||
};
|
||||
|
||||
test("renders 40 stops, a controls slot and the current song's threads", async () => {
|
||||
const { root } = await setup({ songId: "polyphemus" });
|
||||
expect(root.querySelectorAll("[data-song]")).toHaveLength(40);
|
||||
expect(root.querySelector(".timeline__controls")).not.toBeNull();
|
||||
expect(root.querySelectorAll(".tl-thread")).toHaveLength(1);
|
||||
expect(root.querySelector('[data-song="polyphemus"]')!.getAttribute("aria-current")).toBe("step");
|
||||
});
|
||||
|
||||
test("the hide control collapses the timeline, and shows it again", async () => {
|
||||
const { root, store } = await setup();
|
||||
const btn = root.querySelector<HTMLButtonElement>("[data-action=toggle-timeline]")!;
|
||||
expect(btn.getAttribute("aria-expanded")).toBe("true");
|
||||
btn.click();
|
||||
expect(store.get().timeline).toBe("closed");
|
||||
expect(root.dataset.state).toBe("closed");
|
||||
expect(root.querySelectorAll(".tl-thread")).toHaveLength(0);
|
||||
root.querySelector<HTMLButtonElement>("[data-action=toggle-timeline]")!.click();
|
||||
expect(store.get().timeline).toBe("open");
|
||||
});
|
||||
|
||||
test("the threads-on-the-sea switch writes state.ribbons", async () => {
|
||||
const { root, store } = await setup();
|
||||
const sw = root.querySelector<HTMLButtonElement>("[role=switch]")!;
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
sw.click();
|
||||
expect(store.get().ribbons).toBe(true);
|
||||
expect(root.querySelector("[role=switch]")!.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
test("clicking a stop selects that song", async () => {
|
||||
const { root, store } = await setup();
|
||||
root.querySelector<SVGElement>('[data-song="storm"]')!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(store.get().songId).toBe("storm");
|
||||
expect(root.querySelector('[data-song="storm"]')!.getAttribute("aria-current")).toBe("step");
|
||||
});
|
||||
|
||||
test("clicking a motif name toggles state.motif", async () => {
|
||||
const { root, store } = await setup({ songId: "polyphemus" });
|
||||
const pick = () => root.querySelector<HTMLButtonElement>('[data-pick="polyphemus"]')!;
|
||||
pick().click();
|
||||
expect(store.get().motif).toBe("polyphemus");
|
||||
expect(pick().getAttribute("aria-pressed")).toBe("true");
|
||||
pick().click();
|
||||
expect(store.get().motif).toBeNull();
|
||||
});
|
||||
|
||||
test("shows at most 4 thread rows, with a +N more control", async () => {
|
||||
const { root } = await setup({ songId: "god-games" }); // 5 motifs
|
||||
expect(root.querySelectorAll(".tl-thread")).toHaveLength(3);
|
||||
const more = root.querySelector<HTMLButtonElement>("[data-action=more]")!;
|
||||
expect(more.textContent).toContain("+2 more");
|
||||
more.click();
|
||||
expect(root.querySelectorAll(".tl-thread")).toHaveLength(5);
|
||||
const fewer = root.querySelector<HTMLButtonElement>("[data-action=more]")!;
|
||||
expect(fewer.textContent).toContain("Show fewer");
|
||||
fewer.click();
|
||||
expect(root.querySelectorAll(".tl-thread")).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("a picked motif stays visible even past the row limit", async () => {
|
||||
const { root } = await setup({ songId: "god-games", motif: "polyphemus" });
|
||||
expect(root.querySelector('.tl-thread[data-m="polyphemus"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("refresh re-renders without changing state", async () => {
|
||||
const { root, store, tl } = await setup();
|
||||
const before = store.get();
|
||||
tl.refresh();
|
||||
expect(store.get()).toBe(before);
|
||||
expect(root.querySelectorAll("[data-song]")).toHaveLength(40);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user