Spaces:
Sleeping
Sleeping
| import { useEffect, useRef, useCallback } from 'react'; | |
| import { COLORS } from '../utils/colorScheme'; | |
| const LOOK_AHEAD = 4; // seconds ahead to show | |
| // Lane display order (top to bottom) | |
| const LANE_CONFIG = [ | |
| { id: 'crash', label: 'Crash', color: COLORS.drumCrash, shape: 'diamond' }, | |
| { id: 'ride', label: 'Ride', color: COLORS.drumRide, shape: 'diamond' }, | |
| { id: 'hihat', label: 'HH', color: COLORS.drumHihat, shape: 'x' }, | |
| { id: 'tom_high', label: 'Tom H', color: COLORS.drumTomHigh, shape: 'circle' }, | |
| { id: 'snare', label: 'Snare', color: COLORS.drumSnare, shape: 'circle' }, | |
| { id: 'tom_low', label: 'Tom L', color: COLORS.drumTomLow, shape: 'circle' }, | |
| { id: 'kick', label: 'Kick', color: COLORS.drumKick, shape: 'circle' }, | |
| ]; | |
| function drawDiamond(ctx, x, y, size) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y - size); | |
| ctx.lineTo(x + size, y); | |
| ctx.lineTo(x, y + size); | |
| ctx.lineTo(x - size, y); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| } | |
| function drawX(ctx, x, y, size) { | |
| ctx.lineWidth = 2.5; | |
| ctx.beginPath(); | |
| ctx.moveTo(x - size, y - size); | |
| ctx.lineTo(x + size, y + size); | |
| ctx.moveTo(x + size, y - size); | |
| ctx.lineTo(x - size, y + size); | |
| ctx.stroke(); | |
| } | |
| export default function DrumSheet({ | |
| tabData, | |
| currentTimeRef, | |
| width, | |
| height, | |
| }) { | |
| const canvasRef = useRef(null); | |
| const animRef = useRef(null); | |
| const draw = useCallback(() => { | |
| const canvas = canvasRef.current; | |
| if (!canvas || !tabData) return; | |
| const ctx = canvas.getContext('2d'); | |
| const dpr = window.devicePixelRatio || 1; | |
| if (canvas.width !== width * dpr || canvas.height !== height * dpr) { | |
| canvas.width = width * dpr; | |
| canvas.height = height * dpr; | |
| canvas.style.width = `${width}px`; | |
| canvas.style.height = `${height}px`; | |
| ctx.scale(dpr, dpr); | |
| } | |
| const currentTime = currentTimeRef?.current ?? 0; | |
| const numLanes = LANE_CONFIG.length; | |
| // Layout | |
| const topMargin = 50; | |
| const bottomMargin = 30; | |
| const leftMargin = 55; | |
| const rightMargin = 20; | |
| const laneAreaHeight = height - topMargin - bottomMargin; | |
| const laneSpacing = laneAreaHeight / (numLanes - 1); | |
| const playableWidth = width - leftMargin - rightMargin; | |
| // Progress line at 20% from left | |
| const progressX = leftMargin + playableWidth * 0.2; | |
| const pixelsPerSecond = (playableWidth * 0.8) / LOOK_AHEAD; | |
| // Clear | |
| ctx.fillStyle = COLORS.tabBg; | |
| ctx.fillRect(0, 0, width, height); | |
| // Draw lane labels | |
| ctx.font = '12px Inter, monospace'; | |
| ctx.textAlign = 'right'; | |
| ctx.textBaseline = 'middle'; | |
| for (let i = 0; i < numLanes; i++) { | |
| const y = topMargin + i * laneSpacing; | |
| ctx.fillStyle = LANE_CONFIG[i].color; | |
| ctx.fillText(LANE_CONFIG[i].label, leftMargin - 10, y); | |
| } | |
| // Draw lane lines (subtle) | |
| ctx.strokeStyle = COLORS.tabString; | |
| ctx.lineWidth = 0.5; | |
| for (let i = 0; i < numLanes; i++) { | |
| const y = topMargin + i * laneSpacing; | |
| ctx.beginPath(); | |
| ctx.moveTo(leftMargin, y); | |
| ctx.lineTo(width - rightMargin, y); | |
| ctx.stroke(); | |
| } | |
| // Build lane index lookup | |
| const laneIndex = {}; | |
| LANE_CONFIG.forEach((lane, i) => { laneIndex[lane.id] = i; }); | |
| // Draw drum hits | |
| const events = tabData.events || []; | |
| const hitSize = 7; | |
| for (const event of events) { | |
| const x = progressX + (event.time - currentTime) * pixelsPerSecond; | |
| if (x < leftMargin - 20 || x > width + 20) continue; | |
| const laneIdx = laneIndex[event.lane]; | |
| if (laneIdx === undefined) continue; | |
| const lane = LANE_CONFIG[laneIdx]; | |
| const y = topMargin + laneIdx * laneSpacing; | |
| const isPast = event.time <= currentTime; | |
| const isActive = isPast && event.time + 0.1 > currentTime; | |
| // Glow for active hits | |
| if (isActive) { | |
| ctx.shadowColor = lane.color; | |
| ctx.shadowBlur = 12; | |
| } | |
| // Set color: brighter for future, dimmer for past | |
| const alpha = isPast ? 0.4 : 1.0; | |
| ctx.fillStyle = lane.color; | |
| ctx.strokeStyle = lane.color; | |
| ctx.globalAlpha = alpha; | |
| // Draw shape based on instrument type | |
| if (lane.shape === 'diamond') { | |
| drawDiamond(ctx, x, y, hitSize); | |
| } else if (lane.shape === 'x') { | |
| drawX(ctx, x, y, hitSize * 0.7); | |
| } else { | |
| // Circle (default for kick, snare, toms) | |
| ctx.beginPath(); | |
| ctx.arc(x, y, hitSize * 0.8, 0, Math.PI * 2); | |
| ctx.fill(); | |
| } | |
| ctx.globalAlpha = 1.0; | |
| ctx.shadowBlur = 0; | |
| } | |
| // Progress line | |
| ctx.strokeStyle = COLORS.tabProgressLine; | |
| ctx.lineWidth = 2; | |
| ctx.shadowColor = 'rgba(139, 92, 246, 0.5)'; | |
| ctx.shadowBlur = 8; | |
| ctx.beginPath(); | |
| ctx.moveTo(progressX, topMargin - 10); | |
| ctx.lineTo(progressX, topMargin + (numLanes - 1) * laneSpacing + 10); | |
| ctx.stroke(); | |
| ctx.shadowBlur = 0; | |
| // "DRUMS" clef label | |
| ctx.fillStyle = COLORS.textMuted; | |
| ctx.font = 'bold 11px Inter, sans-serif'; | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| const clefY = topMargin + laneAreaHeight / 2; | |
| ctx.fillText('D', 15, clefY - 24); | |
| ctx.fillText('R', 15, clefY - 12); | |
| ctx.fillText('U', 15, clefY); | |
| ctx.fillText('M', 15, clefY + 12); | |
| ctx.fillText('S', 15, clefY + 24); | |
| animRef.current = requestAnimationFrame(draw); | |
| }, [tabData, currentTimeRef, width, height]); | |
| useEffect(() => { | |
| animRef.current = requestAnimationFrame(draw); | |
| return () => { | |
| if (animRef.current) cancelAnimationFrame(animRef.current); | |
| }; | |
| }, [draw]); | |
| if (!tabData) { | |
| return ( | |
| <div className="tab-empty"> | |
| <p>No drum data available.</p> | |
| </div> | |
| ); | |
| } | |
| return <canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />; | |
| } | |