feat(jamguide): ChordDiagram — KB guitar-shape SVG, key-aware fret placement (task D-01b)

Renders movable {rootStr,offsets} and open {frets,onlyRoot} KB shapes; computes movable
base fret from chord rootPc in standard tuning (E-shape A7 -> fret 5); root in accent
purple; thumb/full sizes; graceful '-' fallback for unplaceable shapes. Critic PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
vadimwit
2026-06-15 00:40:44 +01:00
parent 5bd48da5ac
commit 66ccc90fdd
+312
View File
@@ -0,0 +1,312 @@
// Compact SVG guitar chord-diagram. Renders the KB guitar shape format (D-01b).
// Secondary per-station voicing thumbnail in the Roadmap panel.
//
// Two shape forms (see src/data/kb/SCHEMA.md):
// movable: { rootStr, offsets, fingers }
// - offsets: 6 entries low-E→high-E, integers = fret offset from the barre
// (base) fret, 'x' = muted. The base fret is derived from where the chord
// root (rootPc) sits on rootStr in standard tuning.
// open: { frets, onlyRoot, fingers }
// - frets: 6 entries low-E→high-E, 0 = open, 'x' = muted, integers = absolute.
//
// Props:
// shape — movable or open form above
// keyRoot — tonic pitch class 011 (accepted; see note below)
// rootPc — chord root pitch class 011 (drives movable placement)
// size — 'thumb' (compact ~64px grid) | 'full' (enlarged + finger #s + label)
// label — optional chord label shown under the grid
//
// Note on key-awareness: the SCHEMA derives the movable base fret from where the
// root note sits "for the current key". In practice the absolute fret depends only
// on the chord root pitch class on rootStr, which is supplied directly as `rootPc`.
// keyRoot is accepted for contract compatibility and used as a fallback for rootPc.
// Standard tuning open-string pitch classes, indexed low-E (0) → high-E (5).
const OPEN_PCS = [4, 9, 2, 7, 11, 4] // E A D G B E
// We render strings top→bottom as high-E first (matches Fretboard.jsx idiom),
// so display index 0 = high E, 5 = low E. Data arrays are low-E first, so the
// data index for display row `di` is `5 - di`.
const ACCENT = '#a855f7' // chord-tone tier (root highlight)
const DOT = '#e5e7eb' // non-root finger dots (light gray, AA on dark board)
const DOT_TEXT_DARK = '#1a1a1a'
const BOARD = '#1a120b' // matches Fretboard board fill
const FRET_LINE = '#4a3a2a'
const NUT_COL = '#c0b090'
const STRING_COL = '#9ca3af'
const MUTE_OPEN = '#9ca3af'
const FRET_LABEL = '#9ca3af'
const NUM_STRINGS = 6
const NUM_FRETS = 5 // visible fret rows in the grid
// ── Fret resolution ──────────────────────────────────────────────────────────
// Returns { frets: number|'x' per display row (high-E first), baseFret, rootRow }
// where baseFret is the absolute fret of the top visible grid line (1 = nut shown).
function resolveShape(shape, rootPc, keyRoot) {
if (!shape) return null
// Open shape: absolute frets, low-E first.
if (Array.isArray(shape.frets)) {
const abs = shape.frets // low-E first
const fretted = abs.filter(f => typeof f === 'number' && f > 0)
const minFret = fretted.length ? Math.min(...fretted) : 0
const maxFret = fretted.length ? Math.max(...fretted) : 0
// Show the nut (baseFret 1) when the shape reaches up to fret ~4 from the nut.
const baseFret = maxFret <= NUM_FRETS ? 1 : minFret
return {
open: true,
absLowE: abs,
baseFret,
rootPc: typeof shape.onlyRoot === 'number' ? shape.onlyRoot : rootPc,
}
}
// Movable shape: offsets relative to a base (barre) fret on rootStr.
if (Array.isArray(shape.offsets)) {
const rootStr = shape.rootStr // 6 = low E … 1 = high E
const rootStrIdx = 6 - rootStr // → low-E-first array index
const targetPc = typeof rootPc === 'number' ? rootPc
: typeof keyRoot === 'number' ? keyRoot : 0
const openPc = OPEN_PCS[rootStrIdx] ?? 4
// Smallest fret >= 1 where the root pc lands on rootStr.
let baseFret = ((targetPc - openPc) % 12 + 12) % 12
if (baseFret === 0) baseFret = 12 // root at open string → use the octave barre
return {
open: false,
offsets: shape.offsets, // low-E first
baseFret,
rootStrIdx,
rootPc: targetPc,
}
}
return null
}
// Build per-display-row absolute fret + root flag from a resolved shape.
// Returns null if the shape cannot be placed gracefully (root above ~fret 12).
function buildRows(resolved) {
if (!resolved) return null
// Absolute fret per low-E-first data index.
let absLowE
if (resolved.open) {
absLowE = resolved.absLowE
} else {
absLowE = resolved.offsets.map(o =>
o === 'x' || o == null ? 'x' : resolved.baseFret + o
)
}
// Highest fretted note — degrade if unplayably high.
const fretted = absLowE.filter(f => typeof f === 'number' && f > 0)
const maxFret = fretted.length ? Math.max(...fretted) : 0
if (maxFret > 15) return null
// Window: lowest visible fret of the grid.
// Show the nut if everything fits within NUM_FRETS of it; else start at the
// lowest fretted note so the grip sits at the top of the window.
const minFret = fretted.length ? Math.min(...fretted) : 0
const startFret = maxFret <= NUM_FRETS ? 1 : minFret
// Root pitch class for colouring.
const rootPc = resolved.rootPc
// Convert to display rows (high-E first → reverse of low-E-first).
const rows = []
for (let di = 0; di < NUM_STRINGS; di++) {
const dataIdx = NUM_STRINGS - 1 - di
const f = absLowE[dataIdx]
const stringPc = (OPEN_PCS[dataIdx] + (typeof f === 'number' ? f : 0)) % 12
const isRoot = typeof f === 'number' && f >= 0 && stringPc === rootPc
rows.push({ fret: f, isRoot, stringPc })
}
return { rows, startFret, showNut: startFret === 1 }
}
export default function ChordDiagram({
shape,
keyRoot,
rootPc,
size = 'thumb',
label,
}) {
const resolved = resolveShape(shape, rootPc, keyRoot)
const built = buildRows(resolved)
const full = size === 'full'
// Geometry. thumb grid ~64px wide; full ~2x.
const scale = full ? 2 : 1
const cell = 11 * scale // px per fret row (vertical)
const sw = 11 * scale // px per string gap (horizontal)
const padL = 14 * scale // left pad (mute/open markers + start-fret label)
const padR = 6 * scale
const padT = 11 * scale // top pad (mute/open marker row)
const padB = (full ? 16 : 6) * scale // bottom pad (finger numbers / breathing room)
const gridW = (NUM_STRINGS - 1) * sw
const gridH = NUM_FRETS * cell
const svgW = padL + gridW + padR
const svgH = padT + gridH + padB
const stringX = si => padL + si * sw // si: 0 = high E (left) … 5 = low E
const fretY = fi => padT + fi * cell // fi: 0 = top line … NUM_FRETS
if (!built) {
// Graceful degradation: shape can't be placed.
return (
<div
className="inline-flex flex-col items-center justify-center bg-panel border border-border rounded-lg text-gray-500"
style={{ width: svgW, minHeight: svgH }}
role="img"
aria-label={label ? `${label}: voicing unavailable` : 'voicing unavailable'}
>
<span style={{ fontSize: 9 * scale }}></span>
{label && full && <span style={{ fontSize: 8 * scale }} className="mt-1">{label}</span>}
</div>
)
}
const { rows, startFret, showNut } = built
const fingers = shape?.fingers // low-E first, optional
const ariaLabel = label
? `${label} guitar chord diagram`
: 'guitar chord diagram'
return (
<div className="inline-flex flex-col items-center" role="img" aria-label={ariaLabel}>
<svg
width={svgW}
height={svgH}
viewBox={`0 0 ${svgW} ${svgH}`}
style={{ display: 'block' }}
>
{/* Board background */}
<rect
x={padL - 1}
y={padT - 1}
width={gridW + 2}
height={gridH + 2}
fill={BOARD}
rx={2 * scale}
/>
{/* Start-fret indicator ("5fr") when the grid begins above the nut */}
{!showNut && (
<text
x={padL - 4 * scale}
y={fretY(0) + cell * 0.62}
textAnchor="end"
fontSize={7 * scale}
fill={FRET_LABEL}
>
{startFret}fr
</text>
)}
{/* Frets (horizontal lines) */}
{Array.from({ length: NUM_FRETS + 1 }, (_, fi) => fi).map(fi => {
const topNut = showNut && fi === 0
return (
<line
key={fi}
x1={stringX(0)}
y1={fretY(fi)}
x2={stringX(NUM_STRINGS - 1)}
y2={fretY(fi)}
stroke={topNut ? NUT_COL : FRET_LINE}
strokeWidth={topNut ? 3 * scale : 1 * scale}
/>
)
})}
{/* Strings (vertical lines) */}
{rows.map((_, si) => (
<line
key={si}
x1={stringX(si)}
y1={fretY(0)}
x2={stringX(si)}
y2={fretY(NUM_FRETS)}
stroke={STRING_COL}
strokeWidth={(si >= 4 ? 1.4 : si >= 2 ? 1.1 : 0.8) * scale}
/>
))}
{/* Per-string markers: mute ✕ / open ○ above the nut, dots on the grid */}
{rows.map((row, si) => {
const x = stringX(si)
const dataIdx = NUM_STRINGS - 1 - si
const finger = fingers ? fingers[dataIdx] : 0
// Muted string → ✕ above the board.
if (row.fret === 'x' || row.fret == null) {
const my = padT - 4 * scale
const r = 3 * scale
return (
<g key={si} stroke={MUTE_OPEN} strokeWidth={1 * scale} strokeLinecap="round">
<line x1={x - r} y1={my - r} x2={x + r} y2={my + r} />
<line x1={x - r} y1={my + r} x2={x + r} y2={my - r} />
</g>
)
}
// Open string (absolute fret 0, only meaningful when nut is shown) → ○.
if (row.fret === 0) {
return (
<circle
key={si}
cx={x}
cy={padT - 4 * scale}
r={3 * scale}
fill="none"
stroke={MUTE_OPEN}
strokeWidth={1 * scale}
/>
)
}
// Fretted note → dot, positioned in its fret row within the window.
const rowInWindow = row.fret - startFret // 0-based row from top
if (rowInWindow < 0 || rowInWindow >= NUM_FRETS) return null
const cy = fretY(rowInWindow) + cell / 2
const r = (full ? 4 : 3.5) * scale
const fill = row.isRoot ? ACCENT : DOT
const showFinger = full && finger > 0
return (
<g key={si}>
<circle cx={x} cy={cy} r={r} fill={fill} />
{showFinger && (
<text
x={x}
y={cy + 3 * scale}
textAnchor="middle"
fontSize={7 * scale}
fontWeight="700"
fill={row.isRoot ? '#fff' : DOT_TEXT_DARK}
>
{finger}
</text>
)}
</g>
)
})}
</svg>
{label && (
<span
className="text-gray-300 leading-none mt-1"
style={{ fontSize: full ? 12 : 9 }}
>
{label}
</span>
)}
</div>
)
}