feat: PianoKeys component

This commit is contained in:
2026-04-09 00:38:14 +02:00
parent 90df2b36f2
commit 7653933f46

View File

@@ -0,0 +1,117 @@
/** Chroma value (0=C … 11=B) for every note name tonal might return */
const NOTE_CHROMA: Record<string, number> = {
'C': 0, 'C#': 1, 'Db': 1,
'D': 2, 'D#': 3, 'Eb': 3,
'E': 4, 'Fb': 4,
'F': 5, 'E#': 5, 'F#': 6, 'Gb': 6,
'G': 7, 'G#': 8, 'Ab': 8,
'A': 9, 'A#': 10, 'Bb': 10,
'B': 11, 'Cb': 11, 'B#': 0,
};
const WHITE_KEYS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
const WHITE_KEY_W = 14; // px
const WHITE_KEY_H = 56; // px
const BLACK_KEY_W = 9; // px
const BLACK_KEY_H = 34; // px
/** Left offset (px) of each black key from the left edge of the keyboard */
const BLACK_KEY_LEFT: Record<string, number> = {
'C#': 1 * WHITE_KEY_W - BLACK_KEY_W / 2,
'D#': 2 * WHITE_KEY_W - BLACK_KEY_W / 2,
'F#': 4 * WHITE_KEY_W - BLACK_KEY_W / 2,
'G#': 5 * WHITE_KEY_W - BLACK_KEY_W / 2,
'A#': 6 * WHITE_KEY_W - BLACK_KEY_W / 2,
};
const BLACK_KEY_NAMES = Object.keys(BLACK_KEY_LEFT);
interface Props {
/** Note names from tonal, e.g. ["C","E","G"] or ["Ab","C","Eb"] */
notes: string[];
}
export function PianoKeys({ notes }: Props) {
const activeChroma = new Set(
notes.map((n) => NOTE_CHROMA[n]).filter((c) => c !== undefined)
);
const totalWidth = WHITE_KEY_W * 7;
if (notes.length === 0) {
return (
<div
className="flex items-center justify-center text-muted-foreground text-xs"
style={{ width: totalWidth, height: WHITE_KEY_H }}
>
?
</div>
);
}
return (
<div className="relative inline-block" style={{ width: totalWidth, height: WHITE_KEY_H }}>
{/* White keys */}
{WHITE_KEYS.map((note, i) => {
const active = activeChroma.has(NOTE_CHROMA[note]);
return (
<div
key={note}
className="absolute border border-border rounded-b-sm"
style={{
left: i * WHITE_KEY_W,
top: 0,
width: WHITE_KEY_W - 1,
height: WHITE_KEY_H,
background: active ? 'hsl(var(--primary) / 0.15)' : 'white',
}}
>
{active && (
<div
className="absolute rounded-full bg-primary"
style={{
width: 8,
height: 8,
bottom: 6,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
</div>
);
})}
{/* Black keys (rendered on top) */}
{BLACK_KEY_NAMES.map((note) => {
const chroma = NOTE_CHROMA[note];
const active = activeChroma.has(chroma);
return (
<div
key={note}
className="absolute z-10 rounded-b-sm"
style={{
left: BLACK_KEY_LEFT[note],
top: 0,
width: BLACK_KEY_W,
height: BLACK_KEY_H,
background: active ? 'hsl(var(--primary))' : '#1a1a1a',
}}
>
{active && (
<div
className="absolute rounded-full bg-primary-foreground"
style={{
width: 6,
height: 6,
bottom: 4,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
</div>
);
})}
</div>
);
}