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

View File

@@ -1,4 +1,6 @@
import { useState } from 'react';
import type { Tuning, FretPosition } from '../music/Tunings'; import type { Tuning, FretPosition } from '../music/Tunings';
import { getNoteDetails } from '../music/NoteUtils';
interface FretboardProps { interface FretboardProps {
tuning: Tuning; tuning: Tuning;
@@ -6,7 +8,10 @@ interface FretboardProps {
maxFrets?: number; maxFrets?: number;
interactive?: boolean; interactive?: boolean;
showHints?: boolean; showHints?: boolean;
showTooltips?: boolean;
displayTranspose?: number;
onPlayNote?: (midi: number) => void; onPlayNote?: (midi: number) => void;
onHover?: (midi: number | null) => void;
} }
export function Fretboard({ export function Fretboard({
@@ -15,8 +20,13 @@ export function Fretboard({
maxFrets = 15, maxFrets = 15,
interactive = false, interactive = false,
showHints = true, showHints = true,
onPlayNote showTooltips = false,
displayTranspose = 0,
onPlayNote,
onHover
}: FretboardProps) { }: FretboardProps) {
const [hoverPos, setHoverPos] = useState<{ stringIndex: number, fret: number } | null>(null);
// Config // Config
const numStrings = tuning.strings.length; const numStrings = tuning.strings.length;
// Visual params // Visual params
@@ -66,7 +76,8 @@ export function Fretboard({
<div className="fretboard-container" style={{ <div className="fretboard-container" style={{
width: '100%', width: '100%',
height: '100%', height: '100%',
overflow: 'hidden', overflow: 'visible', // Allow tooltips to pop out
position: 'relative',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: '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) */} {/* Interaction Overlay (Invisible hit targets) */}
{interactive && tuning.strings.map((_, stringIndex) => { {interactive && tuning.strings.map((_, stringIndex) => {
const y = getStringY(stringIndex); const y = getStringY(stringIndex);
@@ -206,6 +238,7 @@ export function Fretboard({
} }
const rectWidth = xEnd - xStart; const rectWidth = xEnd - xStart;
const midi = tuning.strings[stringIndex] + fret;
return ( return (
<rect <rect
@@ -217,7 +250,14 @@ export function Fretboard({
fill="transparent" fill="transparent"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => handleFretClick(stringIndex, fret)} 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" className="fret-hit-target"
> >
<title>String {stringIndex + 1}, Fret {fret}</title> <title>String {stringIndex + 1}, Fret {fret}</title>
@@ -226,6 +266,48 @@ export function Fretboard({
}); });
})} })}
</svg> </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> </div>
); );
} }

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { getNoteDetails } from '../music/NoteUtils';
// Constants // Constants
const WHITE_KEY_WIDTH_PX = 40; const WHITE_KEY_WIDTH_PX = 40;
@@ -11,6 +12,9 @@ interface PianoKeysProps {
markedNotes?: number[]; // Notes to highlight (e.g. hints) markedNotes?: number[]; // Notes to highlight (e.g. hints)
interactive?: boolean; interactive?: boolean;
onPlayNote?: (midi: number) => void; onPlayNote?: (midi: number) => void;
onHover?: (midi: number | null) => void;
showTooltips?: boolean;
displayTranspose?: number;
height?: number; height?: number;
} }
@@ -20,9 +24,13 @@ export function PianoKeys({
markedNotes = [], markedNotes = [],
interactive = true, interactive = true,
onPlayNote, onPlayNote,
onHover,
showTooltips = false,
displayTranspose = 0,
height = 160 height = 160
}: PianoKeysProps) { }: PianoKeysProps) {
const [viewMode, setViewMode] = useState<'full' | 'zoomed'>('zoomed'); const [viewMode, setViewMode] = useState<'full' | 'zoomed'>('zoomed');
const [hoverMidi, setHoverMidi] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Helpers to determine key type and position // Helpers to determine key type and position
@@ -203,13 +211,31 @@ export function PianoKeys({
let fill = k.isBlack ? '#222' : '#fff'; let fill = k.isBlack ? '#222' : '#fff';
if (isMarked) { if (isMarked) {
fill = k.isBlack ? '#d32f2f' : '#ffcdd2'; // Red-ish for marked 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 stroke = '#000';
const rectHeight = k.isBlack ? BLACK_KEY_HEIGHT_PERCENT * 100 : 100; const rectHeight = k.isBlack ? BLACK_KEY_HEIGHT_PERCENT * 100 : 100;
return ( 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 <rect
x={k.x} x={k.x}
y={0} y={0}
@@ -255,9 +281,52 @@ export function PianoKeys({
</div> </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> </div>
</div> </div>
</div> </div >
); );
} }

View File

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