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 <noreply@anthropic.com>
This commit is contained in:
1 parent
7da49cca31
commit
4884a39f0d
3 files changed
+1222
-3
No files matched your search
@@ -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<string, number> = {
|
||||
"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")}`;
|
||||
}
|
||||
@@ -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 `<path ${a} d="M${cx} ${cy - s * 1.15}L${cx + s} ${cy}L${cx} ${cy + s * 1.15}L${cx - s} ${cy}Z"/>`;
|
||||
case "open-arms":
|
||||
return `<g ${a} data-hollow><ellipse cx="${cx}" cy="${cy}" rx="${s * 1.05}" ry="${s * 0.68}" transform="rotate(-20 ${cx} ${cy})"/></g>`;
|
||||
case "just-a-man":
|
||||
return `<g ${a}><ellipse cx="${cx}" cy="${cy}" rx="${s * 1.05}" ry="${s * 0.7}" transform="rotate(-20 ${cx} ${cy})"/></g>`;
|
||||
case "remember":
|
||||
return `<path ${a} d="M${cx} ${cy - s}L${cx + s * 1.05} ${cy + s * 0.8}L${cx - s * 1.05} ${cy + s * 0.8}Z"/>`;
|
||||
case "odysseus":
|
||||
return `<rect ${a} x="${cx - s * 0.8}" y="${cy - s * 0.8}" width="${s * 1.6}" height="${s * 1.6}"/>`;
|
||||
case "athena":
|
||||
return `<g ${a}><circle cx="${cx}" cy="${cy}" r="${s * 0.9}"/><path d="M${cx - s * 0.9} ${cy}h${s * 1.8}M${cx} ${cy - s * 0.9}v${s * 1.8}"/></g>`;
|
||||
case "polyphemus":
|
||||
return `<path ${a} data-cross d="M${cx - s * 0.85} ${cy - s * 0.85}L${cx + s * 0.85} ${cy + s * 0.85}M${cx + s * 0.85} ${cy - s * 0.85}L${cx - s * 0.85} ${cy + s * 0.85}"/>`;
|
||||
default:
|
||||
return `<circle ${a} cx="${cx}" cy="${cy}" r="${s * 0.8}"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
function glyphSvg(id: string, size = 18): string {
|
||||
const h = size / 2;
|
||||
return `<svg class="glyph-icon" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" aria-hidden="true">${glyph(id, h, h, size * 0.36)}</svg>`;
|
||||
}
|
||||
|
||||
// ---- Page shell ----------------------------------------------------------
|
||||
|
||||
const app = document.getElementById("app")!;
|
||||
app.innerHTML = `<h1>Score / waveform</h1><p>stub</p>`;
|
||||
app.innerHTML = `
|
||||
<header class="masthead">
|
||||
<div class="title-block">
|
||||
<h1><span class="title-epic">EPIC</span> <span class="title-sub">the voyage in full score</span></h1>
|
||||
<p class="title-note">Ten songs as measures. Each part is a segment of the waveform; each theme is a row of noteheads below it.</p>
|
||||
</div>
|
||||
<p class="how">Drag or scroll sideways to pan. Click a measure to open the song. Click a theme name to follow it.</p>
|
||||
</header>
|
||||
<section class="score-wrap" aria-label="Score">
|
||||
<div class="system">
|
||||
<div class="instruments" id="instruments"></div>
|
||||
<div class="scroller" id="scroller" tabindex="0" aria-label="Score, scroll sideways"><div id="score"></div></div>
|
||||
</div>
|
||||
<div class="overview" id="overview" aria-label="Whole score overview"></div>
|
||||
</section>
|
||||
<aside class="partbook" id="partbook" aria-live="polite"></aside>
|
||||
`;
|
||||
|
||||
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 }) => `
|
||||
<g class="instr" data-motif="${motif.id}" data-motifs="${motif.id}" tabindex="0" role="button" aria-pressed="false"
|
||||
aria-label="Follow the theme ${esc(motif.name)}">
|
||||
<rect class="instr-hit" x="0" y="${y - Y.rowH / 2}" width="200" height="${Y.rowH}"/>
|
||||
<text class="instr-name" x="148" y="${y + 4}" text-anchor="end">${esc(motif.name)}</text>
|
||||
${glyph(motif.id, 170, y, 6.5)}
|
||||
</g>`).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 `<path class="brace" d="M196 ${top}h-6v${bot - top}h6"/>
|
||||
<text class="group-label" transform="translate(16 ${(top + bot) / 2}) rotate(-90)" text-anchor="middle">${label}</text>`;
|
||||
};
|
||||
instruments.innerHTML = `
|
||||
<svg width="200" height="${H}" viewBox="0 0 200 ${H}">
|
||||
<text class="instr-head" x="186" y="${Y.tempo}" text-anchor="end">Story time</text>
|
||||
<text class="instr-head instr-head--wave" x="186" y="${WAVE_MID + 4}" text-anchor="end">Song</text>
|
||||
<text class="instr-head" x="186" y="${Y.partText}" text-anchor="end">Part</text>
|
||||
${bracket(themes, "Themes")}
|
||||
${bracket(chars, "Characters")}
|
||||
${rowsSvg}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// ---- 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(`<line class="staff" x1="0" x2="${W}" y1="${WAVE_MID + k * 30}" y2="${WAVE_MID + k * 30}"/>`);
|
||||
}
|
||||
for (const row of layout.rows) {
|
||||
parts.push(`<line class="row-line" data-row="${row.motif.id}" x1="0" x2="${W}" y1="${row.y}" y2="${row.y}"/>`);
|
||||
}
|
||||
|
||||
// Saga brackets, like the act headings in a vocal score.
|
||||
for (const s of layout.sagaSpans) {
|
||||
parts.push(`<path class="saga-bracket" d="M${s.x + 2} ${Y.sagaLine + 6}v-6h${s.w - 4}v6"/>
|
||||
<text class="saga-name" x="${s.x + 6}" y="${Y.sagaText}">${esc(s.name)}</text>`);
|
||||
}
|
||||
|
||||
// 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(`<g class="rest">
|
||||
<line class="barline" x1="${r.x + r.w}" x2="${r.x + r.w}" y1="${Y.waveTop}" y2="${Y.waveTop + Y.waveH}"/>
|
||||
<text class="rest-num" x="${cx}" y="${WAVE_MID - 26}" text-anchor="middle">${r.years}</text>
|
||||
<rect class="rest-bar" x="${r.x + 20}" y="${WAVE_MID - 6}" width="${r.w - 40}" height="12"/>
|
||||
<path class="rest-serif" d="M${r.x + 20} ${WAVE_MID - 14}v28M${r.x + r.w - 20} ${WAVE_MID - 14}v28"/>
|
||||
<text class="rest-label" x="${cx}" y="${WAVE_MID + 36}" text-anchor="middle">${r.years === 1 ? "a year passes" : `${r.years} years pass`}</text>
|
||||
</g>`);
|
||||
}
|
||||
|
||||
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(`<g class="measure" data-song="${song.id}" data-motifs="${songMotifs}" tabindex="0" role="button" aria-label="${esc(label)}">
|
||||
<rect class="measure-hit" x="${m.x}" y="0" width="${m.w}" height="${H}"/>
|
||||
<rect class="measure-sel" x="${m.x}" y="${Y.waveTop - 4}" width="${m.w}" height="3"/>
|
||||
<rect class="mark-box" x="${m.x + 6}" y="${Y.markTop}" width="${markW}" height="${Y.markBottom - Y.markTop}"/>
|
||||
<text class="mark-num" x="${m.x + 6 + markW / 2}" y="${Y.markBottom - 6}" text-anchor="middle">${song.track}</text>
|
||||
<text class="song-title" x="${m.x + markW + 14}" y="${Y.markBottom - 6}">${esc(song.title)}</text>
|
||||
<text class="tempo" x="${m.x + 6}" y="${Y.tempo}">${esc(song.storyTime.label)}</text>
|
||||
`);
|
||||
for (const p of m.parts) {
|
||||
const pm = partMotifs(p.part);
|
||||
parts.push(`<g class="part" data-song="${song.id}" data-part="${p.index}" data-motifs="${pm}">
|
||||
<rect class="part-wash" x="${p.x}" y="${Y.waveTop}" width="${p.w}" height="${Y.waveH}"/>
|
||||
<path class="wave" d="${wavePath(p, WAVE_MID, Y.waveH / 2 - 8)}"/>
|
||||
<text class="part-name" x="${p.x + 6}" y="${Y.partText}">${esc(p.part.name)}</text>
|
||||
${cueMark(p)}
|
||||
</g>`);
|
||||
if (p.index > 0) {
|
||||
parts.push(`<line class="part-bar" x1="${p.x}" x2="${p.x}" y1="${Y.waveTop}" y2="${Y.partText + 6}"/>`);
|
||||
}
|
||||
}
|
||||
// Barline at the end of the song; a double bar where a saga closes.
|
||||
const bx = m.x + m.w;
|
||||
parts.push(m.sagaEnd
|
||||
? `<line class="barline" x1="${bx - 5}" x2="${bx - 5}" y1="${Y.waveTop}" y2="${Y.waveTop + Y.waveH}"/>
|
||||
<rect class="barline-thick" x="${bx - 2}" y="${Y.waveTop}" width="4" height="${Y.waveH}"/>`
|
||||
: `<line class="barline" x1="${bx}" x2="${bx}" y1="${Y.waveTop}" y2="${Y.waveTop + Y.waveH}"/>`);
|
||||
parts.push(`</g>`);
|
||||
}
|
||||
|
||||
// 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(`<path class="tie" data-row="${row.motif.id}" style="--c:${row.motif.color}" d="${ties}"/>`);
|
||||
for (const h of hits) {
|
||||
parts.push(`<g class="mark" data-motifs="${row.motif.id}" data-song="${h.p.song.id}" data-part="${h.p.index}">
|
||||
<line class="stem" x1="${h.cx}" x2="${h.cx}" y1="${Y.partText + 10}" y2="${row.y - 9}"/>
|
||||
${glyph(row.motif.id, h.cx, row.y, 7)}
|
||||
</g>`);
|
||||
}
|
||||
}
|
||||
|
||||
scoreHost.innerHTML = `<svg id="score-svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" role="group" aria-label="Score of the voyage">
|
||||
${parts.join("")}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
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 `<g class="cue" data-cue="${p.song.id}:${p.index}" tabindex="0" role="button"
|
||||
aria-label="Play ${esc(p.part.name)} of ${esc(p.song.title)} from ${t}">
|
||||
<rect class="cue-hit" x="${x - 46}" y="${Y.partText - 14}" width="50" height="20" rx="10"/>
|
||||
<path class="cue-tri" d="M${x - 38} ${Y.partText - 9}l7 4.5l-7 4.5z"/>
|
||||
<text class="cue-time" x="${x - 4}" y="${Y.partText}" text-anchor="end">${t}</text>
|
||||
</g>`;
|
||||
}
|
||||
|
||||
// ---- 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) =>
|
||||
`<rect class="ov-part" data-motifs="${partMotifs(p.part)}" x="${p.x * s}" y="2" width="${p.w * s}" height="${OV_H - 4}"/>`).join("");
|
||||
return `${parts}<line class="ov-bar" x1="${(m.x + m.w) * s}" x2="${(m.x + m.w) * s}" y1="6" y2="${OV_H}"/>
|
||||
<text class="ov-num" x="${m.x * s + 3}" y="11">${m.song.track}</text>`;
|
||||
}).join("");
|
||||
overviewHost.innerHTML = `<svg width="${W}" height="${OV_H + 4}" viewBox="0 0 ${W} ${OV_H + 4}">
|
||||
${ticks}<path class="ov-wave" d="${d}"/><rect class="ov-window" id="ov-window" y="1" height="${OV_H + 2}" rx="3"/></svg>`;
|
||||
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 `<button type="button" class="chip${on ? " is-on" : ""}" data-chip="${m.id}" aria-pressed="${on}">${glyphSvg(m.id, 16)}<span>${esc(m.name)}</span></button>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="pb-head"><h2 class="pb-title">How to read the score</h2></div>
|
||||
<dl class="key">
|
||||
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true"><path class="key-wave" d="M2 8h2v8h-2zM7 4h2v16h-2zM12 6h2v12h-2zM17 2h2v20h-2zM22 7h2v10h-2zM27 5h2v14h-2z"/><line class="key-bar" x1="34" x2="34" y1="0" y2="24"/><path class="key-wave" d="M38 9h2v6h-2zM43 3h2v18h-2zM48 6h2v12h-2z"/></svg></dt>
|
||||
<dd>Each measure is a song. Dashed bars split it into its parts, and the height of the wave follows how loud the part is.</dd></div>
|
||||
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true"><text class="key-num" x="27" y="8" text-anchor="middle">2</text><rect class="key-rest" x="10" y="12" width="34" height="7"/><path class="key-bar" d="M10 9v13M44 9v13"/></svg></dt>
|
||||
<dd>A rest stands for years passing in the story. The number is how many.</dd></div>
|
||||
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true">${glyph("warrior", 12, 12, 6)}${glyph("remember", 27, 12, 6)}${glyph("polyphemus", 42, 12, 6)}</svg></dt>
|
||||
<dd>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.</dd></div>
|
||||
<div><dt><svg viewBox="0 0 54 24" width="54" height="24" aria-hidden="true"><rect class="key-cue" x="4" y="2" width="46" height="20" rx="10"/><path class="key-tri" d="M12 7l7 5l-7 5z"/><text class="key-time" x="44" y="16" text-anchor="end">0:45</text></svg></dt>
|
||||
<dd>A cue marks where a part starts in the recording. With an audio file attached, clicking it plays the song from there.</dd></div>
|
||||
</dl>
|
||||
<div class="pb-start">
|
||||
<p>Try following <button type="button" class="link" data-chip="warrior">Warrior of the Mind</button> from Troy to the storm, or open <button type="button" class="link" data-open="the-horse-and-the-infant">the first song</button>.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="pb-head">
|
||||
<div class="pb-glyph">${glyphSvg(m.id, 34)}</div>
|
||||
<div>
|
||||
<h2 class="pb-title">${esc(m.name)}</h2>
|
||||
<p class="pb-sub">${m.kind === "character" ? "Character theme" : "Theme"}, heard in ${partCount} parts across ${list.length} songs</p>
|
||||
</div>
|
||||
<button type="button" class="close" data-clear-motif aria-label="Stop following this theme">Clear</button>
|
||||
</div>
|
||||
<p class="pb-desc">${esc(m.description)}</p>
|
||||
<ol class="recur">
|
||||
${list.map((s) => `<li><button type="button" class="recur-song" data-open="${s.id}">
|
||||
<span class="recur-num">${s.track}</span><span class="recur-title">${esc(s.title)}</span>
|
||||
<span class="recur-parts">${s.parts.filter((p) => p.motifIds.includes(m.id)).map((p) => esc(p.name)).join(", ")}</span>
|
||||
</button></li>`).join("")}
|
||||
</ol>`;
|
||||
}
|
||||
|
||||
function renderSong(song: Song, cue?: number): void {
|
||||
const saga = sagaOf(song);
|
||||
const ody = song.odyssey
|
||||
? `<a href="${esc(song.odyssey.url)}" target="_blank" rel="noopener noreferrer">Book ${song.odyssey.book}, lines ${esc(song.odyssey.lines)}</a>`
|
||||
: `<span class="muted">No counterpart in Homer</span>`;
|
||||
const years = song.storyTime.yearsAfterTroy;
|
||||
partbook.innerHTML = `
|
||||
<div class="pb-head">
|
||||
<span class="pb-mark">${song.track}</span>
|
||||
<div>
|
||||
<h2 class="pb-title">${esc(song.title)}</h2>
|
||||
<p class="pb-sub">${esc(saga.name)}</p>
|
||||
</div>
|
||||
<button type="button" class="close" data-close aria-label="Close song">Close</button>
|
||||
</div>
|
||||
<dl class="facts">
|
||||
<div><dt>Story time</dt><dd>${esc(song.storyTime.label)} <span class="muted">(${years === 0 ? "the year Troy falls" : `${years} year${years > 1 ? "s" : ""} after Troy`})</span></dd></div>
|
||||
<div><dt>The Odyssey</dt><dd>${ody}</dd></div>
|
||||
</dl>
|
||||
<ol class="parts">
|
||||
${song.parts.map((p, i) => `
|
||||
<li class="pb-part${p.motifIds.includes(selectedMotif ?? "") ? " is-lit" : ""}" data-pb-part="${i}">
|
||||
<div class="pb-part-head">
|
||||
<h3>${esc(p.name)}</h3>
|
||||
<p class="singers">${p.singers.map(esc).join(" and ")}</p>
|
||||
${p.startSec !== undefined
|
||||
? `<button type="button" class="play" data-play="${i}" aria-label="Play ${esc(p.name)} from ${fmtTime(p.startSec)}"><svg viewBox="0 0 10 10" width="10" height="10" aria-hidden="true"><path d="M1 0l9 5l-9 5z"/></svg>${fmtTime(p.startSec)}</button>`
|
||||
: `<span class="no-cue">no cue yet</span>`}
|
||||
</div>
|
||||
<div class="lines">${p.lines.map((l) => `<p>${esc(l)}</p>`).join("")}</div>
|
||||
<div class="chips">${p.motifIds.flatMap((id) => motifById.get(id) ?? []).map(chip).join("")}</div>
|
||||
</li>`).join("")}
|
||||
</ol>
|
||||
${song.facts.length ? `<div class="fact"><h3>Did you know</h3>${song.facts.map((f) => `<p>${esc(f)}</p>`).join("")}</div>` : ""}
|
||||
<div class="listen">
|
||||
<h3>Listen and watch</h3>
|
||||
<div id="audio-host"></div>
|
||||
<p class="cue-note" id="cue-note" hidden></p>
|
||||
<div id="video-host"></div>
|
||||
</div>`;
|
||||
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<HTMLAudioElement>(".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<SVGGElement>(".measure")?.dataset.song ?? null;
|
||||
}
|
||||
|
||||
let dragMoved = false;
|
||||
scoreHost.addEventListener("click", (e) => {
|
||||
if (dragMoved) return;
|
||||
const t = e.target as Element;
|
||||
const cue = t.closest<SVGGElement>(".cue");
|
||||
if (cue?.dataset.cue) {
|
||||
const [sid, idx] = cue.dataset.cue.split(":");
|
||||
openSong(sid ?? null, { cuePart: Number(idx) });
|
||||
return;
|
||||
}
|
||||
const mark = t.closest<SVGGElement>(".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<SVGGElement>(".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<SVGGElement>(".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<SVGGElement>(".instr");
|
||||
if (g?.dataset.motif) selectMotif(g.dataset.motif);
|
||||
});
|
||||
instruments.addEventListener("keydown", (e) => {
|
||||
const g = (e.target as Element).closest<SVGGElement>(".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<HTMLElement>("[data-chip]");
|
||||
if (chipEl?.dataset.chip) {
|
||||
selectMotif(chipEl.dataset.chip);
|
||||
return;
|
||||
}
|
||||
const open = t.closest<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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");
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user