From 23251b6961105599409769a76240d1c0be65fa8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:23:54 +0000 Subject: [PATCH] Size the gap between staves to the chords in each system The sheet reserved room for the note-name pills by widening every staff gap by the biggest chord in the whole piece, so one 4-note chord spread every system apart. Now OSMD's bottom line is lowered only under each chord by its own stack of pills, so the gap follows what each system actually holds. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014oVTjqwWe51bFsyUnDqaeJ --- src/ui/practice/SheetView.tsx | 98 ++++++++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 20 deletions(-) diff --git a/src/ui/practice/SheetView.tsx b/src/ui/practice/SheetView.tsx index c527e25..977d0c5 100644 --- a/src/ui/practice/SheetView.tsx +++ b/src/ui/practice/SheetView.tsx @@ -27,14 +27,74 @@ interface StepInfo extends OsmdStep { const EPS = 1e-4; -/** Most notes struck together by one hand (how many name pills can stack under a staff). */ -function maxChordSize(score: Score): number { - const counts = new Map(); - for (const n of score.notes) { - const k = `${n.hand === 'L' ? 'L' : 'R'}${n.startBeat.toFixed(3)}`; - counts.set(k, (counts.get(k) ?? 0) + 1); +/** Note-name pills under each staff: height, gap between stacked pills and offset below the notes (px). */ +const PILL_H = 15; +const PILL_GAP = 2; +const PILL_TOP = 6; +/** Half the widest pill (px), so the reserved space covers the pills on either side of a notehead. */ +const PILL_HALF_W = 15; + +// The parts of OSMD's (untyped, internal) layout calculator that the pill spacing touches. +type SkyBottom = { BottomLine: number[]; SamplingUnit: number }; +type CalcNote = { sourceNote: { isRest: () => boolean; NoteTie?: { StartNote: unknown } } }; +type CalcEntry = { + PositionAndShape: { RelativePosition: { x: number }; BorderLeft: number; BorderRight: number }; + graphicalVoiceEntries: { notes: CalcNote[] }[]; +}; +type CalcStaffLine = { Measures: { PositionAndShape: { RelativePosition: { x: number } }; staffEntries: CalcEntry[] }[]; SkyBottomLineCalculator: SkyBottom }; +type Calc = { musicSystems: { StaffLines: CalcStaffLine[] }[]; calculateSkyBottomLines: () => void; calculateLyricsPosition: () => void }; + +/** Notes that get a name pill in a staff entry (rests and tied continuations get none). */ +function pillCount(e: CalcEntry): number { + let n = 0; + for (const ve of e.graphicalVoiceEntries) { + for (const g of ve.notes) { + const src = g.sourceNote; + if (!src.isRest() && (!src.NoteTie || src.NoteTie.StartNote === src)) n++; + } } - return Math.max(1, ...counts.values()); + return n; +} + +/** + * Makes OSMD leave room for the note-name pills only where they are drawn. Once the notes are + * engraved, each staff line remembers how low its notes reach (where the pills start); after the + * fingerings, dynamics and pedal marks are placed, its bottom line is lowered under every chord by + * that chord's stack of pills. The gap below a staff then grows with the chords in that system, + * not with the biggest chord anywhere in the piece. + */ +function reservePillSpace(osmd: OSMDType, pillBase: WeakMap) { + const calc = osmd.GraphicSheet.GetCalculator as unknown as Calc; + const skyLines = calc.calculateSkyBottomLines.bind(calc); + calc.calculateSkyBottomLines = () => { + skyLines(); + for (const sys of calc.musicSystems) { + for (const line of sys.StaffLines) pillBase.set(line, line.SkyBottomLineCalculator.BottomLine.reduce((a, b) => Math.max(a, b), 4)); + } + }; + const lyrics = calc.calculateLyricsPosition.bind(calc); + calc.calculateLyricsPosition = () => { + lyrics(); + const unit = 10 * osmd.zoom; + for (const sys of calc.musicSystems) { + for (const line of sys.StaffLines) { + const top = (pillBase.get(line) ?? 4) + PILL_TOP / unit; + const { BottomLine: bottom, SamplingUnit: per } = line.SkyBottomLineCalculator; + for (const m of line.Measures) { + for (const e of m.staffEntries) { + const rows = pillCount(e); + if (!rows) continue; + const x = m.PositionAndShape.RelativePosition.x + e.PositionAndShape.RelativePosition.x; + const half = PILL_HALF_W / unit; + const from = Math.max(0, Math.floor((x + e.PositionAndShape.BorderLeft - half) * per)); + const to = Math.min(bottom.length - 1, Math.ceil((x + e.PositionAndShape.BorderRight + half) * per)); + const reach = top + (rows * (PILL_H + PILL_GAP)) / unit; + for (let i = from; i <= to; i++) bottom[i] = Math.max(bottom[i], reach); + } + } + } + } + }; } /** @@ -56,6 +116,8 @@ export function SheetView({ score, showFingering, noteNameOpacity, dark, zoom = const [error, setError] = useState(''); const [renderGen, setRenderGen] = useState(0); const dragStart = useRef(null); + /** How low each OSMD staff line's notes reach (staff units); the name pills hang below it. */ + const pillBase = useRef(new WeakMap()); const namesShown = noteNameOpacity > 0.02; // Load + render. @@ -81,15 +143,9 @@ export function SheetView({ score, showFingering, noteNameOpacity, dark, zoom = cursorsOptions: [{ type: 0, color: OK.blue, alpha: 0.3, follow: false }], }); osmdRef.current = osmd; - if (namesShown) { - // Leave room under each staff for the note-name pills (one row per note in the biggest chord). - const rows = maxChordSize(score); - const extra = rows * 1.7 + 0.8; - osmd.EngravingRules.MinSkyBottomDistBetweenStaves += extra; - osmd.EngravingRules.MinSkyBottomDistBetweenSystems += extra; - } await osmd.load(score.musicxml ?? exportMusicXml(score)); if (cancelled) return; + if (namesShown) reservePillSpace(osmd, pillBase.current); renderFit(osmd); buildSteps(osmd); setStatus('ready'); @@ -318,24 +374,26 @@ export function SheetView({ score, showFingering, noteNameOpacity, dark, zoom = const entry = g.parentVoiceEntry?.parentStaffEntry; const measure = entry?.parentMeasure; const row = measure?.ParentStaffLine ?? head; - // Below the staff, or below the note and its stem when they reach further down. - const staffBottom = measure ? svgTop + (measure.PositionAndShape.AbsolutePosition.y + 4) * unit : 0; + // Below the staff line's lowest note or stem (the depth OSMD left room under, see + // reservePillSpace), or below this note when that is unknown. + const base = measure && pillBase.current.get(measure.ParentStaffLine); + const staffBottom = measure ? svgTop + (measure.PositionAndShape.AbsolutePosition.y + (base ?? 4)) * unit : 0; const body = (g as unknown as { getSVGGElement?: () => SVGGElement | null }).getSVGGElement?.()?.getBoundingClientRect(); - const below = Math.max(staffBottom, (body && body.height ? body.bottom : r.bottom) - hostRect.top); + const below = base ? staffBottom : Math.max(staffBottom, (body && body.height ? body.bottom : r.bottom) - hostRect.top); rows.set(row, Math.max(rows.get(row) ?? 0, below)); const key = entry ?? head; const chord = chords.get(key) ?? { x: r.left - hostRect.left + r.width / 2, row, labels: [] }; chord.labels.push({ midi: n.midi, left: n.hand === 'L', name: midiToName(n.midi, flats, false) }); chords.set(key, chord); } - const H = 15; + const H = PILL_H; const names: string[] = []; for (const chord of chords.values()) { - const top = (rows.get(chord.row) ?? 0) + 6; + const top = (rows.get(chord.row) ?? 0) + PILL_TOP; chord.labels.sort((a, b) => b.midi - a.midi); chord.labels.forEach((l, i) => { const w = Math.max(H, 7 * l.name.length + 8); - const y = top + i * (H + 2); + const y = top + i * (H + PILL_GAP); names.push( `` + `${l.name}`,