aae69e8a7b
Dashboard voicings rail: guitar galleries capped to 4 shapes
(recommended-first, then open, then movable by base fret; prop-gated so
the Knowledge Center and chord modal keep all shapes); piano voicings
render as a 2x2 grid of size="mini" (scale 0.60) keyboards under the
rail's dense path only. Play buttons removed globally from
VoicingBrowser per the user ("leave them off, better not") — the
Knowledge Center + modal render byte-identical minus the buttons.
A scoped .dark-scroll utility (thin, #2a2a2a on transparent) dresses
the dashboard scrollers. Critic PASS (combined gate: byte-identity
proven, zero play buttons everywhere, contract intact).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
333 lines
14 KiB
React
333 lines
14 KiB
React
// 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 2–3 (~266–390px)
|
||
// and Jam Guide station thumbs sit level with guitar ChordDiagram thumbs.
|
||
// `size="mini"` (L-70, dashboard-polish.md §2) is the SAME cropped window at
|
||
// scale 0.60 (vs thumb 0.8) so two cells fit one grid column of the rail's
|
||
// ~451px interior (2-octave cell 213.2px < 222.5px); it bumps the in-SVG "R"
|
||
// glyph (8→10 vu) and the bass-ring stroke (2→2.5) so both stay legible at
|
||
// the smaller render. `thumb`/`full` are byte-untouched — `mini` is additive.
|
||
// `size="full"` keeps the wide C-anchored 2–3-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 isMini = size === 'mini' // L-70: cropped-thumb window at 0.60 for the dashboard 2×2 grid
|
||
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, 2–3 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 : isMini ? 0.6 : 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={isMini ? 2.5 : 2}
|
||
/>
|
||
)}
|
||
{hl?.isRoot && (
|
||
<text x={x + (WW - 1) / 2} y={WH - 8}
|
||
textAnchor="middle" fill="white" fontSize={isMini ? 10 : 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={isMini ? 2.5 : 2}
|
||
/>
|
||
)}
|
||
{hl?.isRoot && (
|
||
<text x={x + BW / 2} y={BH - 5}
|
||
textAnchor="middle" fill="white" fontSize={isMini ? 8 : 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} />
|
||
}
|