118 lines
3.3 KiB
TypeScript
118 lines
3.3 KiB
TypeScript
/** 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>
|
|
);
|
|
}
|