From 4884a39f0d6c2c0d7acde5dad23427b0325f81eb Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 23 Oct 2026 18:48:42 +0200 Subject: [PATCH] feat(mockups): 05 score / waveform Engraved full score: songs as measures, parts as waveform segments, motifs as notehead staves with ties across recurrences, part cue buttons. Co-Authored-By: Claude Opus 5.5 --- src/mockups/05-score/layout.ts | 162 +++++++++ src/mockups/05-score/main.ts | 601 ++++++++++++++++++++++++++++++++- src/mockups/05-score/style.css | 462 ++++++++++++++++++++++++- 3 files changed, 1222 insertions(+), 3 deletions(-) create mode 100644 src/mockups/05-score/layout.ts diff --git a/src/mockups/05-score/layout.ts b/src/mockups/05-score/layout.ts new file mode 100644 index 0000000..8802bbd --- /dev/null +++ b/src/mockups/05-score/layout.ts @@ -0,0 +1,162 @@ +// Geometry for the score: every song is a measure, every part a sub-segment, gaps in story time are rests. +import { motifs, sagas, songs } from "../../data/sample"; +import { gapYears } from "../../data/query"; +import type { Motif, Part, Song } from "../../data/types"; + +export const PART_W = 128; +export const MEASURE_PAD = 14; +export const REST_W = 104; +export const LEAD = 28; + +export const Y = { + sagaText: 16, + sagaLine: 24, + markTop: 36, + markBottom: 58, + tempo: 80, + waveTop: 96, + waveH: 168, + partText: 288, + motifTop: 312, + rowH: 36, + groupGap: 14, +} as const; + +export const WAVE_MID = Y.waveTop + Y.waveH / 2; + +export interface PartBox { song: Song; part: Part; index: number; x: number; w: number } +export interface Measure { song: Song; index: number; x: number; w: number; parts: PartBox[]; sagaEnd: boolean } +export interface Rest { x: number; w: number; years: number } +export interface SagaSpan { name: string; x: number; w: number } +export interface MotifRow { motif: Motif; y: number } + +export interface Layout { + width: number; + height: number; + measures: Measure[]; + rests: Rest[]; + sagaSpans: SagaSpan[]; + rows: MotifRow[]; + groupBreakY: number; +} + +// Themes first, then character themes, like woodwinds above strings. +export const orderedMotifs: Motif[] = [ + ...motifs.filter((m) => m.kind === "motif"), + ...motifs.filter((m) => m.kind === "character"), +]; + +export function buildLayout(): Layout { + const measures: Measure[] = []; + const rests: Rest[] = []; + let x = LEAD; + songs.forEach((song, i) => { + const prev = songs[i - 1]; + if (prev) { + const years = gapYears(prev, song); + if (years > 0) { + rests.push({ x, w: REST_W, years }); + x += REST_W; + } + } + const w = song.parts.length * PART_W + MEASURE_PAD * 2; + const parts = song.parts.map((part, index) => ({ + song, part, index, x: x + MEASURE_PAD + index * PART_W, w: PART_W, + })); + const next = songs[i + 1]; + measures.push({ song, index: i, x, w, parts, sagaEnd: !next || next.sagaId !== song.sagaId }); + x += w; + }); + + const sagaSpans: SagaSpan[] = [...sagas].sort((a, b) => a.order - b.order).flatMap((saga) => { + const ms = measures.filter((m) => m.song.sagaId === saga.id); + const first = ms[0]; + const last = ms[ms.length - 1]; + if (!first || !last) return []; + return [{ name: saga.name, x: first.x, w: last.x + last.w - first.x }]; + }); + + let y: number = Y.motifTop; + let groupBreakY: number = y; + const rows: MotifRow[] = orderedMotifs.map((motif, i) => { + const prev = orderedMotifs[i - 1]; + if (prev && prev.kind !== motif.kind) { + y += Y.groupGap; + groupBreakY = y; + } + const row = { motif, y: y + Y.rowH / 2 }; + y += Y.rowH; + return row; + }); + + return { width: x + LEAD, height: y + 10, measures, rests, sagaSpans, rows, groupBreakY }; +} + +// ---- Procedural waveform ------------------------------------------------- + +function hash(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619); + return h >>> 0; +} + +function rng(seed: number): () => number { + let a = seed; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// How hard each song hits, so the waveform reads like the soundtrack's dynamics. Invented for the mockup. +const INTENSITY: Record = { + "the-horse-and-the-infant": 0.78, "just-a-man": 0.5, "full-speed-ahead": 0.82, "open-arms": 0.56, + "warrior-of-the-mind": 0.86, polyphemus: 0.9, survive: 0.96, "remember-them": 0.6, + "my-goodbye": 0.72, storm: 1, +}; + +export const BAR_STEP = 4; +export const BAR_W = 2.4; + +/** Bar amplitudes (0..1) for one part segment. */ +export function partAmplitudes(p: PartBox): number[] { + const r = rng(hash(`${p.song.id}:${p.index}`)); + const n = Math.floor((p.w - 8) / BAR_STEP); + const peak = INTENSITY[p.song.id] ?? 0.75; + const chorus = /chorus/i.test(p.part.name); + const last = p.index === p.song.parts.length - 1; + const phase = r() * Math.PI * 2; + const out: number[] = []; + for (let i = 0; i < n; i++) { + const t = i / Math.max(1, n - 1); + // Verses swell, choruses sit high, the final part of a song decays at the end. + let env = chorus ? 0.82 + 0.1 * Math.sin(t * Math.PI) : 0.32 + 0.6 * Math.pow(t, 0.8); + if (last && t > 0.8) env *= 1 - (t - 0.8) * 3.2; + if (p.index === 0 && t < 0.06) env *= t / 0.06; + const pulse = 0.78 + 0.22 * Math.sin(t * 38 + phase); + const grain = 0.55 + 0.45 * r(); + out.push(Math.max(0.04, Math.min(1, env * pulse * grain * peak))); + } + return out; +} + +/** SVG path of mirrored bars around `mid`, `half` = max half-height. */ +export function wavePath(p: PartBox, mid: number, half: number, scaleX = 1, offsetX = 0): string { + const amps = partAmplitudes(p); + let d = ""; + amps.forEach((a, i) => { + const x = offsetX + (p.x + 4 + i * BAR_STEP) * scaleX; + const h = Math.max(0.6, a * half); + const w = Math.max(0.6, BAR_W * scaleX); + d += `M${x.toFixed(1)} ${(mid - h).toFixed(1)}h${w.toFixed(2)}v${(h * 2).toFixed(1)}h${(-w).toFixed(2)}z`; + }); + return d; +} + +export function fmtTime(sec: number): string { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${String(s).padStart(2, "0")}`; +} diff --git a/src/mockups/05-score/main.ts b/src/mockups/05-score/main.ts index 8c3a351..86968d5 100644 --- a/src/mockups/05-score/main.ts +++ b/src/mockups/05-score/main.ts @@ -1,5 +1,604 @@ +import { motifs, songs } from "../../data/sample"; +import { motifsInSong, sagaOf, songsWithMotif } from "../../data/query"; +import type { Motif, Part, Song } from "../../data/types"; import { mountCredits } from "../../shared/credits"; +import { createAudioSlot } from "../../shared/audio-slot"; +import { createVideoCards } from "../../shared/video-cards"; +import { + WAVE_MID, Y, buildLayout, fmtTime, wavePath, type Layout, type PartBox, +} from "./layout"; mountCredits(); + +const esc = (s: string) => + s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +const layout: Layout = buildLayout(); +const motifById = new Map(motifs.map((m) => [m.id, m])); + +// Each theme gets its own notehead, the way percussion parts give each instrument a shape. +function glyph(id: string, cx: number, cy: number, s = 7, cls = "glyph"): string { + const m = motifById.get(id); + const fill = m?.color ?? "#999"; + const a = `class="${cls}" data-glyph="${id}" style="--c:${fill}"`; + switch (id) { + case "warrior": + return ``; + case "open-arms": + return ``; + case "just-a-man": + return ``; + case "remember": + return ``; + case "odysseus": + return ``; + case "athena": + return ``; + case "polyphemus": + return ``; + default: + return ``; + } +} + +function glyphSvg(id: string, size = 18): string { + const h = size / 2; + return ``; +} + +// ---- Page shell ---------------------------------------------------------- + const app = document.getElementById("app")!; -app.innerHTML = `

Score / waveform

stub

`; +app.innerHTML = ` +
+
+

EPIC the voyage in full score

+

Ten songs as measures. Each part is a segment of the waveform; each theme is a row of noteheads below it.

+
+

Drag or scroll sideways to pan. Click a measure to open the song. Click a theme name to follow it.

+
+
+
+
+
+
+
+
+ +`; + +const scroller = document.getElementById("scroller")!; +const scoreHost = document.getElementById("score")!; +const instruments = document.getElementById("instruments")!; +const overviewHost = document.getElementById("overview")!; +const partbook = document.getElementById("partbook")!; + +// ---- Instruments column (sticky motif names) ------------------------------ + +function renderInstruments(): void { + const H = layout.height; + const rowsSvg = layout.rows.map(({ motif, y }) => ` + + + ${esc(motif.name)} + ${glyph(motif.id, 170, y, 6.5)} + `).join(""); + const themes = layout.rows.filter((r) => r.motif.kind === "motif"); + const chars = layout.rows.filter((r) => r.motif.kind === "character"); + const bracket = (rows: typeof themes, label: string) => { + const a = rows[0]; + const b = rows[rows.length - 1]; + if (!a || !b) return ""; + const top = a.y - Y.rowH / 2 + 4; + const bot = b.y + Y.rowH / 2 - 4; + return ` + ${label}`; + }; + instruments.innerHTML = ` + + Story time + Song + Part + ${bracket(themes, "Themes")} + ${bracket(chars, "Characters")} + ${rowsSvg} + `; +} + +// ---- The score ------------------------------------------------------------ + +function partMotifs(p: Part): string { + return p.motifIds.join(" "); +} + +function renderScore(): void { + const { width: W, height: H } = layout; + const parts: string[] = []; + + // Staff lines behind the waveform, and the single-line staves for every theme. + for (let k = -2; k <= 2; k++) { + parts.push(``); + } + for (const row of layout.rows) { + parts.push(``); + } + + // Saga brackets, like the act headings in a vocal score. + for (const s of layout.sagaSpans) { + parts.push(` + ${esc(s.name)}`); + } + + // Rests: years that pass in the story between two songs, drawn as multi-measure rests. + for (const r of layout.rests) { + const cx = r.x + r.w / 2; + parts.push(` + + ${r.years} + + + ${r.years === 1 ? "a year passes" : `${r.years} years pass`} + `); + } + + for (const m of layout.measures) { + const { song } = m; + const songMotifs = motifsInSong(song).map((x) => x.id).join(" "); + const markW = String(song.track).length * 9 + 14; + const label = `${song.track}. ${song.title}. ${song.storyTime.label}. ${song.parts.length} part${song.parts.length > 1 ? "s" : ""}.`; + parts.push(` + + + + ${song.track} + ${esc(song.title)} + ${esc(song.storyTime.label)} + `); + for (const p of m.parts) { + const pm = partMotifs(p.part); + parts.push(` + + + ${esc(p.part.name)} + ${cueMark(p)} + `); + if (p.index > 0) { + parts.push(``); + } + } + // Barline at the end of the song; a double bar where a saga closes. + const bx = m.x + m.w; + parts.push(m.sagaEnd + ? ` + ` + : ``); + parts.push(``); + } + + // Theme marks, plus the thin tie that joins each recurrence to the next. + for (const row of layout.rows) { + const hits: { p: PartBox; cx: number }[] = []; + for (const m of layout.measures) { + for (const p of m.parts) { + if (p.part.motifIds.includes(row.motif.id)) hits.push({ p, cx: p.x + p.w / 2 }); + } + } + let ties = ""; + hits.forEach((h, i) => { + const next = hits[i + 1]; + if (!next) return; + const x1 = h.cx + 10; + const x2 = next.cx - 10; + const lift = Math.min(14, 4 + (x2 - x1) / 60); + ties += `M${x1} ${row.y - 4}Q${(x1 + x2) / 2} ${row.y - 4 - lift * 2} ${x2} ${row.y - 4}`; + }); + parts.push(``); + for (const h of hits) { + parts.push(` + + ${glyph(row.motif.id, h.cx, row.y, 7)} + `); + } + } + + scoreHost.innerHTML = ` + ${parts.join("")} + `; +} + +function cueMark(p: PartBox): string { + if (p.part.startSec === undefined) return ""; + const x = p.x + p.w - 8; + const t = fmtTime(p.part.startSec); + return ` + + + ${t} + `; +} + +// ---- Overview strip --------------------------------------------------------- + +const OV_H = 46; +function renderOverview(): void { + const W = overviewHost.clientWidth || 1000; + const s = W / layout.width; + const mid = OV_H / 2 + 4; + let d = ""; + for (const m of layout.measures) for (const p of m.parts) d += wavePath(p, mid, OV_H / 2 - 8, s); + const ticks = layout.measures.map((m) => { + const parts = m.parts.map((p) => + ``).join(""); + return `${parts} + ${m.song.track}`; + }).join(""); + overviewHost.innerHTML = ` + ${ticks}`; + syncOverview(); +} + +function syncOverview(): void { + const win = document.getElementById("ov-window"); + if (!win) return; + const W = overviewHost.clientWidth || 1000; + const s = W / layout.width; + win.setAttribute("x", String(scroller.scrollLeft * s)); + win.setAttribute("width", String(Math.min(W, scroller.clientWidth * s))); +} + +function overviewJump(clientX: number): void { + const rect = overviewHost.getBoundingClientRect(); + const s = rect.width / layout.width; + scroller.scrollLeft = (clientX - rect.left) / s - scroller.clientWidth / 2; +} + +// ---- State ------------------------------------------------------------------ + +let selectedSong: string | null = null; +let selectedMotif: string | null = null; + +function applyHighlights(): void { + const root = document.body; + root.classList.toggle("has-motif", !!selectedMotif); + root.classList.toggle("has-song", !!selectedSong); + const m = selectedMotif ? motifById.get(selectedMotif) : undefined; + if (m) root.style.setProperty("--lit", m.color); + document.querySelectorAll(".is-lit").forEach((el) => el.classList.remove("is-lit")); + if (selectedMotif) { + document.querySelectorAll(`[data-motifs~="${selectedMotif}"]`).forEach((el) => el.classList.add("is-lit")); + document.querySelectorAll(`[data-row="${selectedMotif}"]`).forEach((el) => el.classList.add("is-lit")); + } + document.querySelectorAll(".instr").forEach((el) => + el.setAttribute("aria-pressed", String(el.getAttribute("data-motif") === selectedMotif))); + document.querySelectorAll(".measure").forEach((el) => + el.classList.toggle("is-open", el.getAttribute("data-song") === selectedSong)); + document.querySelectorAll(".part").forEach((el) => + el.classList.toggle("is-open", el.getAttribute("data-song") === selectedSong)); +} + +function selectMotif(id: string | null): void { + selectedMotif = selectedMotif === id ? null : id; + applyHighlights(); + renderPartbook(); +} + +function openSong(id: string | null, opts: { scroll?: boolean; instant?: boolean; cuePart?: number } = {}): void { + selectedSong = id; + applyHighlights(); + renderPartbook(opts.cuePart); + if (id && opts.scroll) { + const m = layout.measures.find((x) => x.song.id === id); + if (m) scroller.scrollTo({ left: m.x - 60, behavior: opts.instant || matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }); + } +} + +function cuePart(songId: string, partIndex: number): void { + document.querySelectorAll(".part.is-cued").forEach((el) => el.classList.remove("is-cued")); + document.querySelector(`.part[data-song="${songId}"][data-part="${partIndex}"]`)?.classList.add("is-cued"); +} + +// ---- Part book (detail) ----------------------------------------------------- + +function chip(m: Motif): string { + const on = m.id === selectedMotif; + return ``; +} + +function renderPartbook(cue?: number): void { + const song = selectedSong ? songs.find((s) => s.id === selectedSong) : undefined; + if (song) { + renderSong(song, cue); + return; + } + const motif = selectedMotif ? motifById.get(selectedMotif) : undefined; + if (motif) { + renderMotif(motif); + return; + } + renderKey(); +} + +function renderKey(): void { + partbook.innerHTML = ` +

How to read the score

+
+
+
Each measure is a song. Dashed bars split it into its parts, and the height of the wave follows how loud the part is.
+
+
A rest stands for years passing in the story. The number is how many.
+
+
Each row below the waveform is one theme. A notehead marks every part where that theme plays, so a theme's returns line up along its row.
+
+
A cue marks where a part starts in the recording. With an audio file attached, clicking it plays the song from there.
+
+
+

Try following from Troy to the storm, or open .

+
`; +} + +function renderMotif(m: Motif): void { + const list = songsWithMotif(m.id); + const partCount = list.reduce((n, s) => n + s.parts.filter((p) => p.motifIds.includes(m.id)).length, 0); + partbook.innerHTML = ` +
+
${glyphSvg(m.id, 34)}
+
+

${esc(m.name)}

+

${m.kind === "character" ? "Character theme" : "Theme"}, heard in ${partCount} parts across ${list.length} songs

+
+ +
+

${esc(m.description)}

+
    + ${list.map((s) => `
  1. `).join("")} +
`; +} + +function renderSong(song: Song, cue?: number): void { + const saga = sagaOf(song); + const ody = song.odyssey + ? `Book ${song.odyssey.book}, lines ${esc(song.odyssey.lines)}` + : `No counterpart in Homer`; + const years = song.storyTime.yearsAfterTroy; + partbook.innerHTML = ` +
+ ${song.track} +
+

${esc(song.title)}

+

${esc(saga.name)}

+
+ +
+
+
Story time
${esc(song.storyTime.label)} (${years === 0 ? "the year Troy falls" : `${years} year${years > 1 ? "s" : ""} after Troy`})
+
The Odyssey
${ody}
+
+
    + ${song.parts.map((p, i) => ` +
  1. +
    +

    ${esc(p.name)}

    +

    ${p.singers.map(esc).join(" and ")}

    + ${p.startSec !== undefined + ? `` + : `no cue yet`} +
    +
    ${p.lines.map((l) => `

    ${esc(l)}

    `).join("")}
    +
    ${p.motifIds.flatMap((id) => motifById.get(id) ?? []).map(chip).join("")}
    +
  2. `).join("")} +
+ ${song.facts.length ? `

Did you know

${song.facts.map((f) => `

${esc(f)}

`).join("")}
` : ""} +
+

Listen and watch

+
+ +
+
`; + document.getElementById("audio-host")!.append(createAudioSlot(song)); + document.getElementById("video-host")!.append(createVideoCards(song)); + partbook.scrollTop = 0; + if (cue !== undefined) playPart(song, cue); +} + +function playPart(song: Song, index: number): void { + const part = song.parts[index]; + if (!part || part.startSec === undefined) return; + cuePart(song.id, index); + partbook.querySelectorAll(".pb-part.is-cued").forEach((el) => el.classList.remove("is-cued")); + const li = partbook.querySelector(`[data-pb-part="${index}"]`); + li?.classList.add("is-cued"); + const audio = partbook.querySelector(".audio-slot audio"); + const note = document.getElementById("cue-note"); + if (audio) { + audio.currentTime = part.startSec; + void audio.play?.(); + if (note) note.hidden = true; + return; + } + if (note) { + note.hidden = false; + note.textContent = `${part.name} starts at ${fmtTime(part.startSec)}. Once an audio file is added for this song, this cue plays from there.`; + } + li?.scrollIntoView({ block: "nearest" }); +} + +// ---- Events ------------------------------------------------------------------- + +function songFromTarget(t: Element): string | null { + return t.closest(".measure")?.dataset.song ?? null; +} + +let dragMoved = false; +scoreHost.addEventListener("click", (e) => { + if (dragMoved) return; + const t = e.target as Element; + const cue = t.closest(".cue"); + if (cue?.dataset.cue) { + const [sid, idx] = cue.dataset.cue.split(":"); + openSong(sid ?? null, { cuePart: Number(idx) }); + return; + } + const mark = t.closest(".mark"); + if (mark?.dataset.motifs) { + selectMotif(mark.dataset.motifs); + return; + } + const sid = songFromTarget(t); + if (sid) openSong(selectedSong === sid ? null : sid); +}); + +scoreHost.addEventListener("keydown", (e) => { + const t = e.target as Element; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + const cue = t.closest(".cue"); + if (cue?.dataset.cue) { + const [sid, idx] = cue.dataset.cue.split(":"); + openSong(sid ?? null, { cuePart: Number(idx) }); + return; + } + const sid = songFromTarget(t); + if (sid) openSong(sid); + } + if (e.key === "ArrowRight" || e.key === "ArrowLeft") { + const all = [...scoreHost.querySelectorAll(".measure")]; + const i = all.indexOf(t.closest(".measure") as SVGGElement); + const next = all[i + (e.key === "ArrowRight" ? 1 : -1)]; + if (i >= 0 && next) { + e.preventDefault(); + next.focus(); + } + } +}); + +// Hover: light the whole measure column, the way a conductor's eye drops down a page. +scoreHost.addEventListener("pointerover", (e) => { + const sid = songFromTarget(e.target as Element); + document.querySelectorAll(".measure.is-hover").forEach((el) => el.classList.remove("is-hover")); + if (sid) document.querySelector(`.measure[data-song="${sid}"]`)?.classList.add("is-hover"); +}); +scoreHost.addEventListener("pointerleave", () => + document.querySelectorAll(".measure.is-hover").forEach((el) => el.classList.remove("is-hover"))); + +instruments.addEventListener("click", (e) => { + const g = (e.target as Element).closest(".instr"); + if (g?.dataset.motif) selectMotif(g.dataset.motif); +}); +instruments.addEventListener("keydown", (e) => { + const g = (e.target as Element).closest(".instr"); + if (g?.dataset.motif && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + selectMotif(g.dataset.motif); + } +}); + +partbook.addEventListener("click", (e) => { + const t = e.target as Element; + const chipEl = t.closest("[data-chip]"); + if (chipEl?.dataset.chip) { + selectMotif(chipEl.dataset.chip); + return; + } + const open = t.closest("[data-open]"); + if (open?.dataset.open) { + openSong(open.dataset.open, { scroll: true }); + return; + } + if (t.closest("[data-close]")) { + openSong(null); + return; + } + if (t.closest("[data-clear-motif]")) { + selectMotif(selectedMotif); + return; + } + const play = t.closest("[data-play]"); + if (play?.dataset.play && selectedSong) { + const song = songs.find((s) => s.id === selectedSong); + if (song) playPart(song, Number(play.dataset.play)); + } +}); + +// Hovering a part in the part book points at its segment in the score. +partbook.addEventListener("pointerover", (e) => { + const li = (e.target as Element).closest("[data-pb-part]"); + document.querySelectorAll(".part.is-pointed").forEach((el) => el.classList.remove("is-pointed")); + if (li && selectedSong) { + document.querySelector(`.part[data-song="${selectedSong}"][data-part="${li.dataset.pbPart}"]`)?.classList.add("is-pointed"); + } +}); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + if (selectedSong) openSong(null); + else if (selectedMotif) selectMotif(selectedMotif); + } +}); + +// Panning: vertical wheel moves the score sideways; drag with the pointer like sliding a page. +scroller.addEventListener("wheel", (e) => { + if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { + scroller.scrollLeft += e.deltaY; + e.preventDefault(); + } +}, { passive: false }); + +let dragStart: { x: number; left: number } | null = null; +scroller.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; + dragStart = { x: e.clientX, left: scroller.scrollLeft }; + dragMoved = false; +}); +window.addEventListener("pointermove", (e) => { + if (!dragStart) return; + const dx = e.clientX - dragStart.x; + if (Math.abs(dx) > 4) { + dragMoved = true; + scroller.classList.add("is-dragging"); + } + if (dragMoved) scroller.scrollLeft = dragStart.left - dx; +}); +window.addEventListener("pointerup", () => { + dragStart = null; + scroller.classList.remove("is-dragging"); + setTimeout(() => (dragMoved = false), 0); +}); +scroller.addEventListener("scroll", syncOverview, { passive: true }); + +let ovDrag = false; +overviewHost.addEventListener("pointerdown", (e) => { + ovDrag = true; + overviewJump(e.clientX); +}); +window.addEventListener("pointermove", (e) => { if (ovDrag) overviewJump(e.clientX); }); +window.addEventListener("pointerup", () => (ovDrag = false)); +window.addEventListener("resize", () => { + renderOverview(); + applyHighlights(); +}); + +// ---- Boot ------------------------------------------------------------------------- + +renderInstruments(); +renderScore(); +renderOverview(); + +const params = new URLSearchParams(location.search); +const qMotif = params.get("motif"); +const qSong = params.get("song"); +if (qMotif && motifById.has(qMotif)) selectedMotif = qMotif; +applyHighlights(); +if (qSong && songs.some((s) => s.id === qSong)) { + const cue = params.get("cue"); + openSong(qSong, { scroll: true, instant: true, cuePart: cue !== null ? Number(cue) : undefined }); +} else { + renderPartbook(); +} + +// Orchestrate one entrance: the waveform draws in left to right, once. +document.body.classList.add("is-ready"); diff --git a/src/mockups/05-score/style.css b/src/mockups/05-score/style.css index b3817b7..04cfc82 100644 --- a/src/mockups/05-score/style.css +++ b/src/mockups/05-score/style.css @@ -1,2 +1,460 @@ -main { padding: 2rem; font-family: system-ui, sans-serif; } -h1::before { content: "05 · "; } +/* 05 · Score / waveform — an engraved full score on cool manuscript paper. */ +@import "@fontsource/inter/latin-400.css"; +@import "@fontsource/inter/latin-400-italic.css"; +@import "@fontsource/inter/latin-500.css"; +@import "@fontsource/inter/latin-500-italic.css"; +@import "@fontsource/inter/latin-600.css"; +@import "@fontsource/inter/latin-800.css"; + +:root { + color-scheme: light; + --paper: #e4e8e5; /* manuscript paper, cool and slightly green */ + --sheet: #f3f5f2; /* the part book, a fresher leaf on top */ + --ink: #1a1e27; /* engraving ink, blue-black */ + --ink-2: #4b525d; + --ink-3: #7d858d; + --staff: #a9b1b5; + --hair: #c8cfcd; + --cobalt: #2c43a6; /* conductor's pencil: selection and cues */ + --lit: #d9a441; + --sans: "Inter", system-ui, sans-serif; + --instr-w: 200px; + --book-w: 420px; +} + +* { box-sizing: border-box; } + +html { background: var(--paper); } + +body { + margin: 0; + height: 100vh; + display: grid; + grid-template-columns: minmax(0, 1fr) var(--book-w); + grid-template-rows: auto minmax(0, 1fr) auto; + grid-template-areas: "head head" "score book" "foot foot"; + background: var(--paper); + color: var(--ink); + font: 400 15px/1.5 var(--sans); + font-feature-settings: "tnum" 1, "cv11" 1; + overflow: hidden; +} + +main#app { display: contents; } + +button { font: inherit; color: inherit; } +:focus-visible { outline: 2px solid var(--cobalt); outline-offset: 2px; } + +/* ---- Masthead ------------------------------------------------------------ */ + +.masthead { + grid-area: head; + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 2rem; + padding: 1.1rem 1.75rem 0.9rem calc(var(--instr-w) - 52px); + border-bottom: 1px solid var(--hair); +} + +.masthead h1 { + margin: 0; + display: flex; + align-items: baseline; + gap: 0.9rem; + font-weight: 400; +} + +.title-epic { + font-weight: 800; + font-size: 2.6rem; + line-height: 0.9; + letter-spacing: -0.045em; +} + +.title-sub { + font-style: italic; + font-weight: 500; + font-size: 1.15rem; + color: var(--ink-2); +} + +.title-note { + margin: 0.4rem 0 0; + max-width: 62ch; + color: var(--ink-2); + font-size: 0.86rem; +} + +.how { + margin: 0; + max-width: 30ch; + text-align: right; + font-size: 0.8rem; + color: var(--ink-3); +} + +/* ---- Score ------------------------------------------------------------------ */ + +.score-wrap { + grid-area: score; + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + padding-top: 0.6rem; +} + +.system { display: flex; flex: none; } + +.instruments { + flex: none; + width: var(--instr-w); + border-right: 1.5px solid var(--ink); + overflow: hidden; +} + +.scroller { + flex: 1; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + cursor: grab; + scrollbar-width: thin; + scrollbar-color: var(--staff) transparent; +} +.scroller.is-dragging { cursor: grabbing; user-select: none; } +.scroller:focus-visible { outline-offset: -2px; } + +#score svg { display: block; } + +svg text { font-family: var(--sans); fill: var(--ink); } + +.staff { stroke: var(--staff); stroke-width: 0.8; opacity: 0.55; } +.row-line { stroke: var(--staff); stroke-width: 0.8; } +.row-line.is-lit { stroke: var(--lit); stroke-width: 1.6; } + +.saga-bracket { fill: none; stroke: var(--ink); stroke-width: 1.2; } +.saga-name { font-size: 12px; font-weight: 600; fill: var(--ink-2); } + +.barline { stroke: var(--ink); stroke-width: 1.3; } +.barline-thick { fill: var(--ink); } +.part-bar { stroke: var(--ink-3); stroke-width: 1; stroke-dasharray: 3 4; } + +.rest-bar { fill: var(--ink); } +.rest-serif { stroke: var(--ink); stroke-width: 1.4; } +.rest-num { font-size: 22px; font-weight: 800; } +.rest-label { font-size: 11px; font-style: italic; fill: var(--ink-2); } + +.measure { cursor: pointer; outline: none; } +.measure-hit { fill: transparent; transition: fill 160ms ease; } +.measure.is-hover .measure-hit { fill: rgb(44 67 166 / 0.045); } +.measure.is-open .measure-hit { fill: rgb(44 67 166 / 0.065); } +.measure:focus-visible .measure-hit { fill: rgb(44 67 166 / 0.06); stroke: var(--cobalt); stroke-width: 1.5; } +.measure-sel { fill: transparent; } +.measure.is-open .measure-sel { fill: var(--cobalt); } + +.mark-box { fill: none; stroke: var(--ink); stroke-width: 1.4; } +.mark-num { font-size: 14px; font-weight: 800; } +.song-title { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; } +.measure.is-open .song-title, .measure.is-open .mark-num { fill: var(--cobalt); } +.measure.is-open .mark-box { stroke: var(--cobalt); } +.tempo { font-size: 12px; font-style: italic; font-weight: 500; fill: var(--ink-2); } + +.part-wash { fill: transparent; } +.wave { fill: var(--ink); opacity: 0.88; transition: fill 220ms ease, opacity 220ms ease; } +.part-name { font-size: 11.5px; font-weight: 500; fill: var(--ink-2); } + +.part.is-pointed .part-wash, .part.is-cued .part-wash { fill: rgb(44 67 166 / 0.08); } +.part.is-cued .wave { fill: var(--cobalt); } + +.cue { cursor: pointer; outline: none; } +.cue-hit { fill: var(--sheet); stroke: var(--ink-3); stroke-width: 0.8; } +.cue-tri { fill: var(--cobalt); } +.cue-time { font-size: 11px; font-weight: 600; fill: var(--cobalt); } +.cue:hover .cue-hit, .cue:focus-visible .cue-hit { fill: var(--cobalt); stroke: var(--cobalt); } +.cue:hover .cue-tri, .cue:focus-visible .cue-tri, +.cue:hover .cue-time, .cue:focus-visible .cue-time { fill: var(--sheet); } + +/* Noteheads. Each theme has a shape and its colour; ink outlines keep pale colours legible on paper. */ +.glyph { fill: var(--c); stroke: var(--ink); stroke-width: 1.2; stroke-linejoin: round; } +.glyph[data-hollow] { fill: var(--sheet); stroke: color-mix(in oklab, var(--c) 70%, var(--ink)); stroke-width: 2.8; } +.glyph[data-cross] { fill: none; stroke: color-mix(in oklab, var(--c) 80%, var(--ink)); stroke-width: 3; stroke-linecap: round; } +g.glyph[data-glyph="athena"] path { stroke-width: 1; fill: none; } + +.stem { stroke: var(--ink); stroke-width: 0.9; opacity: 0; } +.mark { cursor: pointer; } +.mark .glyph { transition: transform 200ms ease; transform-box: fill-box; transform-origin: center; } +.mark:hover .glyph { transform: scale(1.25); } + +.tie { fill: none; stroke: var(--ink-3); stroke-width: 0.9; stroke-dasharray: 2 3; } +.tie.is-lit { stroke: color-mix(in oklab, var(--c) 75%, var(--ink)); stroke-width: 1.8; stroke-dasharray: none; } + +/* A theme is selected: everything else drops back, its returns light up across the whole score. */ +.has-motif .wave { fill: var(--ink-3); opacity: 0.32; } +.has-motif .part.is-lit .wave { fill: color-mix(in oklab, var(--lit) 78%, var(--ink)); opacity: 1; } +.has-motif .part.is-lit .part-wash { fill: color-mix(in oklab, var(--lit) 14%, transparent); } +.has-motif .part.is-lit .part-name { fill: var(--ink); font-weight: 600; } +.has-motif .mark { opacity: 0.25; } +.has-motif .mark.is-lit { opacity: 1; } +.has-motif .mark.is-lit .stem { opacity: 0.5; stroke: color-mix(in oklab, var(--lit) 70%, var(--ink)); } +.has-motif .mark.is-lit .glyph { transform: scale(1.3); } +.has-motif .measure:not(.is-lit) .song-title, +.has-motif .measure:not(.is-lit) .mark-num { fill: var(--ink-3); } +.has-motif .measure:not(.is-lit) .mark-box { stroke: var(--ink-3); } + +/* Instruments column */ +.instr { cursor: pointer; outline: none; } +.instr-hit { fill: transparent; } +.instr:hover .instr-hit { fill: rgb(26 30 39 / 0.04); } +.instr:focus-visible .instr-hit { stroke: var(--cobalt); stroke-width: 1.5; } +.instr-name { font-size: 12.5px; font-weight: 500; } +.instr.is-lit .instr-hit { fill: color-mix(in oklab, var(--lit) 22%, transparent); } +.instr.is-lit .instr-name { font-weight: 600; } +.has-motif .instr:not(.is-lit) .instr-name { fill: var(--ink-3); } +.instr-head { font-size: 11px; font-style: italic; fill: var(--ink-3); } +.instr-head--wave { font-size: 13px; font-weight: 600; font-style: normal; fill: var(--ink); } +.brace { fill: none; stroke: var(--ink); stroke-width: 1.4; } +.group-label { font-size: 10.5px; font-style: italic; fill: var(--ink-3); } + +/* Overview */ +.overview { + flex: none; + margin: 0.7rem 1.25rem 0.8rem calc(var(--instr-w) + 0px); + height: 50px; + cursor: pointer; + touch-action: none; +} +.overview svg { display: block; } +.ov-wave { fill: var(--ink); opacity: 0.6; } +.ov-part { fill: transparent; } +.has-motif .ov-wave { opacity: 0.25; } +.has-motif .ov-part.is-lit { fill: var(--lit); fill-opacity: 0.45; } +.ov-bar { stroke: var(--staff); stroke-width: 1; } +.ov-num { font-size: 9px; font-weight: 600; fill: var(--ink-3); } +.ov-window { fill: rgb(44 67 166 / 0.06); stroke: var(--cobalt); stroke-width: 1.2; } + +/* Entrance: the waveform rises from the centre line once. */ +@media (prefers-reduced-motion: no-preference) { + .is-ready #score .wave { animation: rise 700ms cubic-bezier(0.2, 0.8, 0.2, 1) both; transform-box: view-box; transform-origin: 0 180px; } + @keyframes rise { from { transform: scaleY(0.02); } to { transform: scaleY(1); } } +} + +/* ---- Part book (detail) ---------------------------------------------------- */ + +.partbook { + grid-area: book; + min-height: 0; + overflow-y: auto; + background: var(--sheet); + border-left: 1px solid var(--hair); + padding: 1.4rem 1.6rem 2rem; + box-shadow: -1px 0 0 rgb(255 255 255 / 0.6) inset; +} + +.partbook h3 { margin: 0; font-size: 0.95rem; font-weight: 600; } + +.pb-head { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: start; + gap: 0.85rem; + margin-bottom: 1rem; +} +.pb-head:has(> :only-child) { grid-template-columns: 1fr; } + +.pb-title { margin: 0; font-size: 1.45rem; line-height: 1.15; font-weight: 800; letter-spacing: -0.02em; } +.pb-sub { margin: 0.2rem 0 0; font-style: italic; color: var(--ink-2); font-size: 0.9rem; } +.pb-mark { + display: grid; + place-items: center; + min-width: 2.2rem; + height: 2.2rem; + border: 2px solid var(--cobalt); + color: var(--cobalt); + font-weight: 800; + font-size: 1.05rem; +} +.pb-glyph { padding-top: 0.15rem; } + +.close { + border: 1px solid var(--ink-3); + background: transparent; + padding: 0.25rem 0.7rem; + border-radius: 999px; + font-size: 0.8rem; + cursor: pointer; +} +.close:hover { border-color: var(--ink); } + +.facts { margin: 0 0 1.2rem; display: grid; gap: 0.35rem; } +.facts div { display: grid; grid-template-columns: 6.5rem 1fr; gap: 0.75rem; } +.facts dt { color: var(--ink-3); font-size: 0.82rem; padding-top: 0.1rem; } +.facts dd { margin: 0; } +.facts a { color: var(--cobalt); text-underline-offset: 3px; } +.muted { color: var(--ink-3); } + +.parts { list-style: none; margin: 0 0 1.3rem; padding: 0; border-top: 1.5px solid var(--ink); } + +.pb-part { + padding: 0.85rem 0 0.9rem 0.9rem; + border-bottom: 1px solid var(--hair); + border-left: 3px solid transparent; + transition: background-color 200ms ease, border-color 200ms ease; +} +.pb-part.is-lit { border-left-color: var(--lit); background: color-mix(in oklab, var(--lit) 10%, transparent); } +.pb-part.is-cued { border-left-color: var(--cobalt); background: rgb(44 67 166 / 0.06); } + +.pb-part-head { display: flex; align-items: baseline; gap: 0.7rem; flex-wrap: wrap; } +.singers { margin: 0; font-style: italic; color: var(--ink-2); font-size: 0.88rem; flex: 1; } + +.play { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 0; + background: var(--cobalt); + color: var(--sheet); + font-size: 0.78rem; + font-weight: 600; + padding: 0.2rem 0.65rem 0.2rem 0.55rem; + border-radius: 999px; + cursor: pointer; +} +.play svg { fill: currentColor; } +.play:hover { background: var(--ink); } +.no-cue { font-size: 0.75rem; color: var(--ink-3); } + +.lines { margin: 0.45rem 0 0.55rem; color: var(--ink-2); font-size: 0.86rem; } +.lines p { margin: 0; } + +.chips { display: flex; flex-wrap: wrap; gap: 0.35rem; } +.chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.6rem 0.15rem 0.3rem; + border: 1px solid var(--hair); + background: var(--sheet); + border-radius: 999px; + font-size: 0.78rem; + cursor: pointer; +} +.chip:hover { border-color: var(--ink-2); } +.chip.is-on { border-color: var(--ink); background: color-mix(in oklab, var(--lit) 24%, var(--sheet)); font-weight: 600; } +.glyph-icon { display: block; overflow: visible; } + +.fact { margin-bottom: 1.3rem; padding-left: 0.9rem; border-left: 3px solid var(--ink); } +.fact p { margin: 0.25rem 0 0; font-size: 0.9rem; } + +.listen h3 { margin-bottom: 0.5rem; } +.listen .audio-slot { + border: 1.5px dashed var(--ink-3); + border-radius: 4px; + padding: 0.7rem 0.85rem; + color: var(--ink-2); + font-size: 0.86rem; + font-style: italic; + opacity: 1; +} +.listen .audio-slot__seek { font-size: 0.78rem; } +.cue-note { + margin: 0.5rem 0 0; + font-size: 0.82rem; + color: var(--cobalt); +} +.listen .video-cards { margin-top: 0.75rem; } +.listen .video-card { + border: 1px solid var(--ink-3); + border-radius: 4px; + padding: 0.5rem 0.75rem 0.5rem 2.1rem; + font-size: 0.85rem; + position: relative; + background: var(--paper); +} +.listen .video-card::before { + content: ""; + position: absolute; + left: 0.75rem; + top: 50%; + translate: 0 -50%; + border-left: 8px solid var(--ink); + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; +} +.listen .video-card:hover { opacity: 1; border-color: var(--ink); } + +/* Key and motif states */ +.key { margin: 0 0 1.4rem; display: grid; gap: 1rem; } +.key div { display: grid; grid-template-columns: 58px 1fr; gap: 0.9rem; align-items: start; } +.key dd { margin: 0; font-size: 0.88rem; color: var(--ink-2); } +.key svg { display: block; overflow: visible; } +.key-wave { fill: var(--ink); } +.key-bar { stroke: var(--ink-3); stroke-dasharray: 3 3; } +.key-rest { fill: var(--ink); } +.key-num { font-size: 11px; font-weight: 800; } +.key-cue { fill: var(--sheet); stroke: var(--ink-3); } +.key-tri { fill: var(--cobalt); } +.key-time { font-size: 10px; font-weight: 600; fill: var(--cobalt); } + +.pb-start p { margin: 0; font-size: 0.9rem; } +.link { + border: 0; + background: none; + padding: 0; + color: var(--cobalt); + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.pb-desc { margin: 0 0 1rem; } +.recur { list-style: none; margin: 0; padding: 0; border-top: 1.5px solid var(--ink); } +.recur-song { + width: 100%; + display: grid; + grid-template-columns: 2rem 1fr; + column-gap: 0.6rem; + text-align: left; + border: 0; + border-bottom: 1px solid var(--hair); + background: transparent; + padding: 0.6rem 0; + cursor: pointer; +} +.recur-song:hover { background: rgb(44 67 166 / 0.05); } +.recur-num { grid-row: span 2; font-weight: 800; text-align: center; border: 1.4px solid var(--ink); height: 1.6rem; line-height: 1.45rem; font-size: 0.85rem; } +.recur-title { font-weight: 600; } +.recur-parts { font-size: 0.8rem; font-style: italic; color: var(--ink-2); } + +/* ---- Credits ------------------------------------------------------------------ */ + +.fan-credits { + grid-area: foot; + border-top: 1.5px solid var(--ink); + background: var(--paper); + color: var(--ink-2); + opacity: 1; + padding: 0.65rem 1.5rem; + font-size: 0.78rem; +} + +/* ---- Narrow screens: stack and let the page scroll ------------------------------ */ + +@media (max-width: 900px) { + :root { --instr-w: 160px; } + body { + height: auto; + grid-template-rows: auto; + min-height: 100vh; + overflow: auto; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: "head" "score" "book" "foot"; + } + .masthead { flex-direction: column; align-items: flex-start; padding: 1rem; gap: 0.5rem; } + .how { text-align: left; } + .instruments svg { margin-left: -40px; } + .instr-name { font-size: 11px; } + .partbook { border-left: 0; border-top: 1px solid var(--hair); overflow: visible; } + .overview { margin-left: 1rem; } +}