Add hints on mouseover on virtual instrument

This commit is contained in:
2025-12-28 17:10:37 +01:00
parent 1101c7be9c
commit 6f67769974
4 changed files with 188 additions and 16 deletions

View File

@@ -71,6 +71,7 @@ function App() {
const [virtualNote, setVirtualNote] = useState<number | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentTuning = TUNINGS[settings.tuningId];
@@ -450,6 +451,7 @@ function App() {
width={Math.min(window.innerWidth - 40, 500)}
height={activeClef === 'grand' ? 260 : 180}
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
hoverMidi={settings.showHint ? hoveredMidi : null}
/>
</div>
@@ -491,16 +493,22 @@ function App() {
minMidi={36} // C2
maxMidi={84} // C6
markedNotes={settings.showHint ? [targetMidi] : []}
interactive={settings.showFretboard}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualInstrumentPlay}
onHover={setHoveredMidi}
/>
) : (
currentTuning && (
<Fretboard
tuning={currentTuning}
positions={hintPositions}
interactive={settings.showFretboard}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualInstrumentPlay}
onHover={setHoveredMidi}
showHints={settings.showHint}
maxFrets={15}
/>

View File

@@ -1,4 +1,6 @@
import { useState } from 'react';
import type { Tuning, FretPosition } from '../music/Tunings';
import { getNoteDetails } from '../music/NoteUtils';
interface FretboardProps {
tuning: Tuning;
@@ -6,7 +8,10 @@ interface FretboardProps {
maxFrets?: number;
interactive?: boolean;
showHints?: boolean;
showTooltips?: boolean;
displayTranspose?: number;
onPlayNote?: (midi: number) => void;
onHover?: (midi: number | null) => void;
}
export function Fretboard({
@@ -15,8 +20,13 @@ export function Fretboard({
maxFrets = 15,
interactive = false,
showHints = true,
onPlayNote
showTooltips = false,
displayTranspose = 0,
onPlayNote,
onHover
}: FretboardProps) {
const [hoverPos, setHoverPos] = useState<{ stringIndex: number, fret: number } | null>(null);
// Config
const numStrings = tuning.strings.length;
// Visual params
@@ -66,7 +76,8 @@ export function Fretboard({
<div className="fretboard-container" style={{
width: '100%',
height: '100%',
overflow: 'hidden',
overflow: 'visible', // Allow tooltips to pop out
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
@@ -185,6 +196,27 @@ export function Fretboard({
);
})}
{/* Hover Highlight (Ghost Dot) */}
{interactive && hoverPos && (
(() => {
const cx = getNoteX(hoverPos.fret);
const cy = getStringY(hoverPos.stringIndex);
return (
<g style={{ pointerEvents: 'none' }}>
<circle
cx={cx}
cy={cy}
r={8}
fill="transparent"
stroke="#888"
strokeWidth={2}
/>
</g>
);
})()
)}
{/* Interaction Overlay (Invisible hit targets) */}
{interactive && tuning.strings.map((_, stringIndex) => {
const y = getStringY(stringIndex);
@@ -206,6 +238,7 @@ export function Fretboard({
}
const rectWidth = xEnd - xStart;
const midi = tuning.strings[stringIndex] + fret;
return (
<rect
@@ -217,7 +250,14 @@ export function Fretboard({
fill="transparent"
style={{ cursor: 'pointer' }}
onClick={() => handleFretClick(stringIndex, fret)}
// Hover effect could be added here via CSS class if we want
onMouseEnter={() => {
setHoverPos({ stringIndex, fret });
onHover?.(midi);
}}
onMouseLeave={() => {
setHoverPos(null);
onHover?.(null);
}}
className="fret-hit-target"
>
<title>String {stringIndex + 1}, Fret {fret}</title>
@@ -226,6 +266,48 @@ export function Fretboard({
});
})}
</svg>
{/* HTML Tooltip Overlay */}
{interactive && showTooltips && hoverPos && (() => {
const cx = getNoteX(hoverPos.fret);
const cy = getStringY(hoverPos.stringIndex);
const midi = tuning.strings[hoverPos.stringIndex] + hoverPos.fret;
// Convert to percentages to handle SVG scaling
const left = (cx / width) * 100;
const top = (cy / height) * 100;
return (
<div style={{
position: 'absolute',
left: `${left}%`,
top: `${top}%`,
transform: 'translate(-50%, -100%) translateY(-15px)', // Center X, Move Y up.
background: '#333',
color: 'white',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
zIndex: 10,
opacity: 0.9,
whiteSpace: 'nowrap',
pointerEvents: 'none'
}}>
{getNoteDetails(midi + displayTranspose).scientific}
{/* CSS Arrow */}
<div style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '6px solid #333'
}} />
</div>
);
})()}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { getNoteDetails } from '../music/NoteUtils';
// Constants
const WHITE_KEY_WIDTH_PX = 40;
@@ -11,6 +12,9 @@ interface PianoKeysProps {
markedNotes?: number[]; // Notes to highlight (e.g. hints)
interactive?: boolean;
onPlayNote?: (midi: number) => void;
onHover?: (midi: number | null) => void;
showTooltips?: boolean;
displayTranspose?: number;
height?: number;
}
@@ -20,9 +24,13 @@ export function PianoKeys({
markedNotes = [],
interactive = true,
onPlayNote,
onHover,
showTooltips = false,
displayTranspose = 0,
height = 160
}: PianoKeysProps) {
const [viewMode, setViewMode] = useState<'full' | 'zoomed'>('zoomed');
const [hoverMidi, setHoverMidi] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Helpers to determine key type and position
@@ -203,13 +211,31 @@ export function PianoKeys({
let fill = k.isBlack ? '#222' : '#fff';
if (isMarked) {
fill = k.isBlack ? '#d32f2f' : '#ffcdd2'; // Red-ish for marked
} else if (interactive && hoverMidi === k.midi) {
fill = k.isBlack ? '#444' : '#eee'; // Subtle hover
}
const stroke = '#000';
const rectHeight = k.isBlack ? BLACK_KEY_HEIGHT_PERCENT * 100 : 100;
return (
<g key={k.midi} onClick={() => interactive && onPlayNote?.(k.midi)} style={{ cursor: interactive ? 'pointer' : 'default' }}>
<g
key={k.midi}
onClick={() => interactive && onPlayNote?.(k.midi)}
onMouseEnter={() => {
if (interactive) {
setHoverMidi(k.midi);
onHover?.(k.midi);
}
}}
onMouseLeave={() => {
if (interactive) {
setHoverMidi(null);
onHover?.(null);
}
}}
style={{ cursor: interactive ? 'pointer' : 'default' }}
>
<rect
x={k.x}
y={0}
@@ -255,6 +281,49 @@ export function PianoKeys({
</div>
);
})}
{/* HTML Tooltip Overlay (Prevents distortion) */}
{interactive && showTooltips && hoverMidi && (() => {
const k = keyRects.find(k => k.midi === hoverMidi);
if (!k) return null;
const left = viewMode === 'full'
? `${k.x + (k.width / 2)}%`
: `${k.x + (k.width / 2)}px`;
return (
<div
key={`tooltip-${k.midi}`}
style={{
position: 'absolute',
left: left,
bottom: '40px', // Positioned above labels
transform: 'translateX(-50%)',
background: '#333',
color: 'white',
padding: '4px 8px',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
zIndex: 10,
opacity: 0.9,
whiteSpace: 'nowrap'
}}
>
{getNoteDetails(hoverMidi + displayTranspose).scientific}
{/* Simple CSS Arrow */}
<div style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
borderLeft: '6px solid transparent',
borderRight: '6px solid transparent',
borderTop: '6px solid #333'
}} />
</div>
);
})()}
</div>
</div>
</div>

View File

@@ -12,6 +12,7 @@ interface SheetMusicProps {
transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar)
keySignature?: string;
hideTargetNote?: boolean;
hoverMidi?: number | null;
}
export const SheetMusic: React.FC<SheetMusicProps> = ({
@@ -22,7 +23,8 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
height = 250, // Increased default height for Grand Staff
transpose = 12, // Default to +1 octave (Guitar Notation)
keySignature = 'C',
hideTargetNote = false
hideTargetNote = false,
hoverMidi = null
}) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -46,7 +48,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
};
// --- Measure Calculation ---
const numMeasures = playedMidi ? 2 : 1;
const numMeasures = (playedMidi || hoverMidi) ? 2 : 1;
// If 2 measures, split total width.
// We want a bit of padding. width is total width.
// Let's reserve 10px on Left/Right.
@@ -195,15 +197,26 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
voicesToDraw.push({ stave: m1StaveForKey, voice });
}
// --- Render Played Note (Measure 2) ---
if (playedMidi && numMeasures === 2) {
const playedNoteObj = createStaveNote(playedMidi, "w", 'played');
// --- Render Played Note OR Hover Preview (Measure 2) ---
// If playedMidi exists, it takes precedence.
// If not, we show hoverMidi as a preview (ghost/grey).
const noteToShowMidi = playedMidi ?? (hoverMidi || null);
const isPreview = !playedMidi && hoverMidi;
// Add Played to Measure 2 Stave
const m2StaveForKey = stavesMeasure2[playedNoteObj.clef];
if (noteToShowMidi && numMeasures === 2) {
// We reuse 'played' logic for creation but handle style manually if preview
const noteObj = createStaveNote(noteToShowMidi, "w", 'played');
if (isPreview) {
// Apply grey style for preview
noteObj.note.setStyle({ fillStyle: "#888888", strokeStyle: "#888888" });
}
// Add Played/Preview to Measure 2 Stave
const m2StaveForKey = stavesMeasure2[noteObj.clef];
if (m2StaveForKey) {
const voice = new Voice({ numBeats: 4, beatValue: 4 });
voice.addTickables([playedNoteObj.note]);
voice.addTickables([noteObj.note]);
new Formatter().joinVoices([voice]).format([voice], measureWidth - 50);
voicesToDraw.push({ stave: m2StaveForKey, voice });
}
@@ -216,7 +229,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
});
}, [targetMidi, playedMidi, clef, width, height, transpose, keySignature, hideTargetNote]);
}, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote]);
return <div ref={containerRef} className="sheet-music-container" />;
};