'use client'; import * as React from 'react'; import { cn } from '@/lib/utils/cn'; export interface CircularKnobProps { value: number; // -1.0 to 1.0 for pan onChange: (value: number) => void; min?: number; max?: number; step?: number; size?: number; className?: string; label?: string; formatValue?: (value: number) => string; onTouchStart?: () => void; onTouchEnd?: () => void; } export function CircularKnob({ value, onChange, min = -1, max = 1, step = 0.01, size = 48, className, label, formatValue, onTouchStart, onTouchEnd, }: CircularKnobProps) { const knobRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); const dragStartRef = React.useRef({ x: 0, y: 0, value: 0 }); const updateValue = React.useCallback( (clientX: number, clientY: number) => { if (!knobRef.current) return; const rect = knobRef.current.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; // Calculate vertical drag distance from start const deltaY = dragStartRef.current.y - clientY; const sensitivity = 200; // pixels for full range const range = max - min; const delta = (deltaY / sensitivity) * range; let newValue = dragStartRef.current.value + delta; // Snap to step if (step) { newValue = Math.round(newValue / step) * step; } // Clamp to range newValue = Math.max(min, Math.min(max, newValue)); onChange(newValue); }, [min, max, step, onChange] ); const handleMouseDown = React.useCallback( (e: React.MouseEvent) => { e.preventDefault(); setIsDragging(true); dragStartRef.current = { x: e.clientX, y: e.clientY, value, }; onTouchStart?.(); }, [value, onTouchStart] ); const handleMouseMove = React.useCallback( (e: MouseEvent) => { if (isDragging) { updateValue(e.clientX, e.clientY); } }, [isDragging, updateValue] ); const handleMouseUp = React.useCallback(() => { setIsDragging(false); onTouchEnd?.(); }, [onTouchEnd]); const handleTouchStart = React.useCallback( (e: React.TouchEvent) => { e.preventDefault(); const touch = e.touches[0]; setIsDragging(true); dragStartRef.current = { x: touch.clientX, y: touch.clientY, value, }; onTouchStart?.(); }, [value, onTouchStart] ); const handleTouchMove = React.useCallback( (e: TouchEvent) => { if (isDragging && e.touches.length > 0) { const touch = e.touches[0]; updateValue(touch.clientX, touch.clientY); } }, [isDragging, updateValue] ); const handleTouchEnd = React.useCallback(() => { setIsDragging(false); onTouchEnd?.(); }, [onTouchEnd]); React.useEffect(() => { if (isDragging) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); window.addEventListener('touchmove', handleTouchMove); window.addEventListener('touchend', handleTouchEnd); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('touchmove', handleTouchMove); window.removeEventListener('touchend', handleTouchEnd); }; } }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]); // Calculate rotation angle (-135deg to 135deg, 270deg range) const percentage = (value - min) / (max - min); const angle = -135 + percentage * 270; const displayValue = formatValue ? formatValue(value) : value === 0 ? 'C' : value < 0 ? `L${Math.abs(Math.round(value * 100))}` : `R${Math.round(value * 100)}`; // Calculate arc parameters for center-based rendering const isNearCenter = Math.abs(value) < 0.01; const centerPercentage = 0.5; // Center position (50%) // Arc goes from center to current value let arcStartPercentage: number; let arcLength: number; if (value < -0.01) { // Left side: arc from value to center arcStartPercentage = percentage; arcLength = centerPercentage - percentage; } else if (value > 0.01) { // Right side: arc from center to value arcStartPercentage = centerPercentage; arcLength = percentage - centerPercentage; } else { // Center: no arc arcStartPercentage = centerPercentage; arcLength = 0; } return (
{label && (
{label}
)}
{/* Outer ring */} {/* Background arc */} {/* Value arc - only show when not centered */} {!isNearCenter && ( )} {/* Knob body */}
{/* Indicator line */}
{/* Value Display */}
{displayValue}
); }