156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
import type { GuitarVoicing } from '~/lib/chord-voicing';
|
|
|
|
const STRING_COUNT = 6;
|
|
const FRET_COUNT = 4;
|
|
const STRING_SPACING = 13; // px between adjacent string lines
|
|
const FRET_HEIGHT = 18; // px per fret
|
|
const DOT_R = 5; // finger dot radius
|
|
|
|
const GRID_W = STRING_SPACING * (STRING_COUNT - 1); // 65px
|
|
const GRID_H = FRET_HEIGHT * FRET_COUNT; // 72px
|
|
|
|
interface Props {
|
|
voicing: GuitarVoicing | null;
|
|
}
|
|
|
|
export function GuitarFretboard({ voicing }: Props) {
|
|
if (!voicing) {
|
|
return (
|
|
<div
|
|
className="flex items-center justify-center text-muted-foreground text-xs"
|
|
style={{ height: GRID_H + 20, width: GRID_W + 20 }}
|
|
>
|
|
no voicing
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { frets, baseFret, barre } = voicing;
|
|
const showFretLabel = baseFret > 0;
|
|
|
|
return (
|
|
<div className="flex flex-col items-center">
|
|
{/* O/X string indicators above nut */}
|
|
<div className="relative" style={{ width: GRID_W, height: 16 }}>
|
|
{frets.map((f, i) =>
|
|
f === null ? (
|
|
<span
|
|
key={i}
|
|
className="absolute font-mono text-destructive"
|
|
style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 4 }}
|
|
>
|
|
✕
|
|
</span>
|
|
) : f === 0 ? (
|
|
<span
|
|
key={i}
|
|
className="absolute font-mono text-muted-foreground"
|
|
style={{ fontSize: 10, top: 2, left: i * STRING_SPACING - 5 }}
|
|
>
|
|
○
|
|
</span>
|
|
) : null
|
|
)}
|
|
</div>
|
|
|
|
{/* Fret number + grid */}
|
|
<div className="flex items-start" style={{ gap: 4 }}>
|
|
{/* Fret number label (barre position) */}
|
|
<div style={{ width: 12, textAlign: 'right' }}>
|
|
{showFretLabel && (
|
|
<span
|
|
className="font-mono text-muted-foreground"
|
|
style={{ fontSize: 9, lineHeight: `${FRET_HEIGHT}px` }}
|
|
>
|
|
{baseFret}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Fretboard grid */}
|
|
<div
|
|
className="relative"
|
|
style={{
|
|
width: GRID_W,
|
|
height: GRID_H,
|
|
// Nut: thick when open position, thin when barre
|
|
borderTop: showFretLabel
|
|
? `1px solid var(--border)`
|
|
: `4px solid var(--foreground)`,
|
|
borderBottom: `1px solid var(--border)`,
|
|
}}
|
|
>
|
|
{/* String lines (6 vertical lines) */}
|
|
{Array.from({ length: STRING_COUNT }, (_, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
bottom: 0,
|
|
left: i * STRING_SPACING,
|
|
width: 1,
|
|
background: 'var(--foreground)',
|
|
opacity: 0.35,
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Fret lines (horizontal, between frets) */}
|
|
{Array.from({ length: FRET_COUNT - 1 }, (_, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: (i + 1) * FRET_HEIGHT,
|
|
height: 1,
|
|
background: 'var(--border)',
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{/* Barre bar — only for transposed barre chords */}
|
|
{barre !== null && barre > 0 && (
|
|
<div
|
|
className="absolute rounded-full"
|
|
style={{
|
|
left: 0,
|
|
right: 0,
|
|
height: DOT_R * 2,
|
|
top: (barre - baseFret) * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
|
|
background: 'var(--primary)',
|
|
zIndex: 5,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Finger dots */}
|
|
{frets.map((fret, stringIdx) => {
|
|
if (fret === null || fret === 0) return null;
|
|
// Skip strings covered by the barre bar
|
|
if (barre !== null && fret === barre) return null;
|
|
const fretIdx = baseFret === 0 ? fret - 1 : fret - baseFret;
|
|
if (fretIdx < 0 || fretIdx >= FRET_COUNT) return null;
|
|
return (
|
|
<div
|
|
key={stringIdx}
|
|
className="absolute rounded-full"
|
|
style={{
|
|
width: DOT_R * 2,
|
|
height: DOT_R * 2,
|
|
left: stringIdx * STRING_SPACING - DOT_R,
|
|
top: fretIdx * FRET_HEIGHT + FRET_HEIGHT / 2 - DOT_R,
|
|
background: 'var(--primary)',
|
|
zIndex: 10,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|