feat: PianoKeys component
This commit is contained in:
117
app/app/components/chord-diagram/piano-keys.tsx
Normal file
117
app/app/components/chord-diagram/piano-keys.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user