Files
JamBuddy/src/components/MiniPiano.jsx
T
vadimwit e2110ae21f 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>
2026-07-08 23:58:23 +01:00

327 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Mini piano keyboard.
//
// TWO render modes, chosen by props (additive — legacy path is the default):
//
// 1. LEGACY <MiniPiano rootPc={..} lh={[..]} rh={[..]} />
// Used by ChordDetailModal + ExplorePanel. `lh`/`rh` are semitone intervals
// ABOVE the root (left hand shown blue, right hand shown purple), spanning a
// 2-octave keyboard. Behaviour here is UNCHANGED — byte-for-byte the same
// output the existing consumers have always rendered.
//
// 2. VOICING <MiniPiano voicing={pianoVoicing(...)} size="thumb|full" />
// Renders the output of src/lib/piano.js `pianoVoicing({rootPc,quality},opts)`:
// voicing.notes — ABSOLUTE semitone key positions, 0 = C of the low octave,
// range [0,36]. A note value `n` maps to the key `n`
// semitones above the low C (octave = ⌊n/12⌋, pc = n%12).
// voicing.pcs — pitch classes sounding.
// voicing.bass — lowest absolute note (the LH anchor) — marked distinctly.
// voicing.style / voicing.label — captions (shown in `full` size).
// 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).
const WW = 22 // white key width
const WH = 60 // white key height
const BW = 14 // black key width
const BH = 38 // black key height
// White key pitch classes within an octave, in order
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] // C D E F G A B
const WHITE_NAMES = ['C','D','E','F','G','A','B']
// Black key offsets (x position relative to white key 0) and pitch classes
const BLACK_OFFSETS = [
{ pc: 1, afterWhite: 0 }, // C#
{ pc: 3, afterWhite: 1 }, // D#
{ pc: 6, afterWhite: 3 }, // F#
{ pc: 8, afterWhite: 4 }, // G#
{ pc: 10, afterWhite: 5 }, // A#
]
// ─── Design tokens (literal — SVG fills can't read Tailwind classes) ───────────
const ACCENT = '#a855f7' // text-accent — root pitch class (focal tone)
const ACCENT_SOFT = '#c084fc' // lighter accent — non-root voicing tones
const LH_BLUE = '#3b82f6' // legacy left-hand colour (unchanged)
const WHITE_FILL = '#f5f5f5'
const BLACK_FILL = '#1f2937'
const WHITE_STROKE = '#374151'
const BLACK_STROKE = '#111827'
const BASS_RING = '#fbbf24' // amber ring marking the LH bass anchor (AA on keys)
function legacyNoteColor(hand) {
return hand === 'L' ? LH_BLUE : ACCENT
}
function handLabel(hand) {
return hand === 'L' ? 'LH' : 'RH'
}
// ════════════════════════════════════════════════════════════════════════════
// LEGACY render path — {rootPc, lh, rh}. UNCHANGED from the original component.
// ════════════════════════════════════════════════════════════════════════════
function LegacyPiano({ rootPc, lh = [], rh = [] }) {
const OCTAVES = 2
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES // 14
const SVG_W = WW * TOTAL_WHITES + 2
const SVG_H = WH + 24
// Build a set of highlighted notes: pc → { hand, interval }
// We span 2 octaves (semitones 0…23 above root), mapped to absolute pitch classes
const highlights = new Map() // absIdx → { color, label }
function addNotes(intervals, hand) {
for (const iv of intervals) {
const octave = Math.floor(iv / 12)
const pc = (rootPc + iv) % 12
highlights.set(`${octave}-${pc}`, { color: legacyNoteColor(hand), label: handLabel(hand) })
}
}
addNotes(lh, 'L')
addNotes(rh, 'R')
function isHighlighted(octave, pc) {
return highlights.get(`${octave}-${pc}`)
}
// White keys
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 x = absWi * WW + 1
const hl = isHighlighted(oct, pc)
whites.push({ x, pc, oct, wi, absWi, hl, name: WHITE_NAMES[wi] + (oct + 4) })
}
}
// Black keys
const blacks = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (const { pc, afterWhite } of BLACK_OFFSETS) {
const absWi = oct * WHITE_PCS.length + afterWhite
const x = absWi * WW + WW - BW / 2
const hl = isHighlighted(oct, pc)
blacks.push({ x, pc, oct, hl })
}
}
return (
<svg width={SVG_W} height={SVG_H} viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="overflow-visible">
{/* White keys */}
{whites.map(({ x, hl, name, absWi }) => (
<g key={`w${absWi}`}>
<rect
x={x} y={1} width={WW - 1} height={WH}
rx={2}
fill={hl ? hl.color : WHITE_FILL}
stroke={WHITE_STROKE}
strokeWidth={0.5}
/>
{hl && (
<text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={7} fontWeight="bold">
{hl.label}
</text>
)}
</g>
))}
{/* Black keys */}
{blacks.map(({ x, pc, oct, hl }, i) => (
<g key={`b${oct}-${pc}`}>
<rect
x={x} y={1} width={BW} height={BH}
rx={2}
fill={hl ? hl.color : BLACK_FILL}
stroke={BLACK_STROKE}
strokeWidth={0.5}
/>
{hl && (
<text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
{hl.label}
</text>
)}
</g>
))}
{/* Root label at bottom */}
{whites.map(({ x, pc, oct, name, absWi }) => {
const isRoot = pc === rootPc && oct === 0
if (!isRoot) return null
return (
<text key={`lbl${absWi}`} x={x + (WW - 1) / 2} y={WH + 14}
textAnchor="middle" fill={ACCENT} fontSize={8} fontWeight="bold">
R
</text>
)
})}
</svg>
)
}
// ════════════════════════════════════════════════════════════════════════════
// VOICING render path — renders a pianoVoicing({notes,pcs,bass,style,label}).
// `notes` are absolute semitone positions, 0 = C of the low octave, range [0,36].
// ════════════════════════════════════════════════════════════════════════════
function VoicingPiano({ voicing, size }) {
const isFull = size === 'full'
const notes = Array.isArray(voicing?.notes) ? voicing.notes : []
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)
// ── 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 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
const SVG_W = baseW * scale
const SVG_H = (WH + (isFull ? 26 : 4)) * scale
// A highlighted absolute note → its render style. Keyed by absolute note value.
const noteSet = new Set(notes)
function styleFor(absNote) {
if (!noteSet.has(absNote)) return null
const pc = ((absNote % 12) + 12) % 12
return {
isRoot: pc === rootPc,
isBass: absNote === bass,
color: pc === rootPc ? ACCENT : ACCENT_SOFT,
}
}
// 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 = (octStart + oct) * 12 + pc // absolute semitone of this white key
const x = absWi * WW + 1
whites.push({ x, absWi, absNote, hl: styleFor(absNote) })
}
}
// Trailing high C (top of the renderable window, e.g. note 36 when OCTAVES=3).
{
const absWi = OCTAVES * WHITE_PCS.length
const absNote = (octStart + OCTAVES) * 12
whites.push({ x: absWi * WW + 1, absWi, absNote, hl: styleFor(absNote) })
}
// Black keys across OCTAVES octaves.
const blacks = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (const { pc, afterWhite } of BLACK_OFFSETS) {
const absWi = oct * WHITE_PCS.length + afterWhite
const absNote = (octStart + oct) * 12 + pc
const x = absWi * WW + WW - BW / 2
blacks.push({ x, pc, oct, absNote, hl: styleFor(absNote) })
}
}
const ariaLabel = `Piano voicing${voicing?.label ? `: ${voicing.label}` : ''}`
return (
<svg
width={SVG_W} height={SVG_H} viewBox={`0 0 ${baseW} ${WH + (isFull ? 26 : 4)}`}
className="overflow-visible" role="img" aria-label={ariaLabel}
>
{/* White keys */}
{whites.map(({ x, hl, absWi }) => (
<g key={`w${absWi}`}>
<rect
x={x} y={1} width={WW - 1} height={WH}
rx={2}
fill={hl ? hl.color : WHITE_FILL}
stroke={WHITE_STROKE}
strokeWidth={0.5}
/>
{hl?.isBass && (
<rect
x={x + 1} y={2} width={WW - 3} height={WH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2}
/>
)}
{hl?.isRoot && (
<text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={8} fontWeight="bold">
R
</text>
)}
</g>
))}
{/* Black keys */}
{blacks.map(({ x, pc, oct, hl }) => (
<g key={`b${oct}-${pc}`}>
<rect
x={x} y={1} width={BW} height={BH}
rx={2}
fill={hl ? hl.color : BLACK_FILL}
stroke={BLACK_STROKE}
strokeWidth={0.5}
/>
{hl?.isBass && (
<rect
x={x + 1} y={2} width={BW - 2} height={BH - 2}
rx={2} fill="none" stroke={BASS_RING} strokeWidth={2}
/>
)}
{hl?.isRoot && (
<text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
R
</text>
)}
</g>
))}
{/* Caption (full size only): label + style */}
{isFull && voicing?.label && (
<text x={baseW / 2} y={WH + 20}
textAnchor="middle" fill={ACCENT} fontSize={9} fontWeight="bold">
{voicing.label}
</text>
)}
</svg>
)
}
// ════════════════════════════════════════════════════════════════════════════
// Public component — dispatches on whether `voicing` is supplied (additive).
// ════════════════════════════════════════════════════════════════════════════
export default function MiniPiano({ rootPc, lh = [], rh = [], voicing, size = 'thumb' }) {
if (voicing) {
return <VoicingPiano voicing={voicing} size={size} />
}
return <LegacyPiano rootPc={rootPc} lh={lh} rh={rh} />
}