feat(design): MiniPiano voicing thumbs crop to the voicing's octave span (task D-24)

Thumb window = whole octaves from the octave-C at-or-below the lowest
note (+ closing high C): shell thumbs 266px -> 142px, level with guitar
diagram thumbs. size=full and the legacy lh/rh path byte-identical.
Critic PASS (SVG geometry decoded back to absolute notes incl. the
octave-2 offset case; 793/793 smoke).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-07-08 23:58:23 +01:00
parent db397f6ceb
commit e2110ae21f
+31 -8
View File
@@ -19,6 +19,15 @@
// The root pitch class lights in accent purple; the bass key is ringed as the
// LH anchor; the other voicing tones light in a lighter purple.
//
// Keyboard window (D-24): `size="thumb"` crops to the voicing's whole-octave
// span — from the octave-start at-or-below the lowest sounded key to the
// octave-end at-or-above the highest (plus the closing high C) — so a
// ≤1-octave shell renders ONE octave (~140px) instead of 23 (~266390px)
// and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs.
// `size="full"` keeps the wide C-anchored 23-octave window: the enlarged
// view benefits from register context, and its output stays byte-identical
// to the D-10 behaviour.
//
// Design tokens (tailwind.config.js): accent #a855f7. The SVG also uses the
// established Piano/Fretboard note language (accent purple for the focal tone).
@@ -172,11 +181,23 @@ function VoicingPiano({ voicing, size }) {
const rootPc = ((voicing?.rootPc ?? (notes.length ? notes[0] : 0)) % 12 + 12) % 12
const bass = typeof voicing?.bass === 'number' ? voicing.bass : (notes.length ? Math.min(...notes) : null)
// Span enough octaves to contain the highest note (notes ≤ 36 → 3 octaves + the
// closing high C, so a value of 36 lands on the last white key).
// ── Keyboard window (D-24) ──────────────────────────────────────────────────
// `full`: the original wide window — C-anchored, 23 octaves, enough to contain
// the highest note (byte-identical to the pre-D-24 output).
// `thumb`: crop to the voicing's whole-octave span. The window starts at the
// octave C at-or-below the lowest sounded key (octStart) and runs whole
// octaves until the closing C at-or-above the highest key, so white-key
// geometry stays simple and a ≤1-octave voicing renders ONE octave.
// The trailing high C is always drawn (window note octStart*12 +
// OCTAVES*12), so a top note landing exactly on the octave boundary
// still shows without adding a whole extra octave.
const maxNote = notes.length ? Math.max(...notes) : 0
const OCTAVES = Math.min(3, Math.max(2, Math.ceil((maxNote + 1) / 12)))
// White keys: OCTAVES full octaves + 1 trailing C so the top octave's C (e.g. 36) shows.
const minNote = notes.length ? Math.min(...notes) : 0
const octStart = isFull ? 0 : Math.floor(minNote / 12)
const OCTAVES = isFull
? Math.min(3, Math.max(2, Math.ceil((maxNote + 1) / 12)))
: Math.max(1, Math.ceil((maxNote - octStart * 12) / 12))
// White keys: OCTAVES full octaves + 1 trailing C closing the top octave.
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES + 1
const scale = isFull ? 1 : 0.8
const baseW = WW * TOTAL_WHITES + 2
@@ -195,13 +216,15 @@ function VoicingPiano({ voicing, size }) {
}
}
// White keys across OCTAVES octaves + trailing C.
// White keys across OCTAVES octaves + trailing C. `oct` is window-relative
// (drives x geometry); absNote adds octStart so highlights land on the right
// keys inside a cropped window.
const whites = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (let wi = 0; wi < WHITE_PCS.length; wi++) {
const pc = WHITE_PCS[wi]
const absWi = oct * WHITE_PCS.length + wi
const absNote = oct * 12 + pc // absolute semitone of this white key
const absNote = (octStart + oct) * 12 + pc // absolute semitone of this white key
const x = absWi * WW + 1
whites.push({ x, absWi, absNote, hl: styleFor(absNote) })
}
@@ -209,7 +232,7 @@ function VoicingPiano({ voicing, size }) {
// Trailing high C (top of the renderable window, e.g. note 36 when OCTAVES=3).
{
const absWi = OCTAVES * WHITE_PCS.length
const absNote = OCTAVES * 12
const absNote = (octStart + OCTAVES) * 12
whites.push({ x: absWi * WW + 1, absWi, absNote, hl: styleFor(absNote) })
}
@@ -218,7 +241,7 @@ function VoicingPiano({ voicing, size }) {
for (let oct = 0; oct < OCTAVES; oct++) {
for (const { pc, afterWhite } of BLACK_OFFSETS) {
const absWi = oct * WHITE_PCS.length + afterWhite
const absNote = oct * 12 + pc
const absNote = (octStart + oct) * 12 + pc
const x = absWi * WW + WW - BW / 2
blacks.push({ x, pc, oct, absNote, hl: styleFor(absNote) })
}