'use client'; import { useRef, useCallback, useState } from 'react'; const SIZE = 32; const RADIUS = 12; const START_ANGLE = 225; const END_ANGLE = -45; const RANGE = 270; function polarToCart(cx: number, cy: number, r: number, deg: number) { const rad = (deg - 90) * Math.PI / 180; return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; } function describeArc(cx: number, cy: number, r: number, startDeg: number, endDeg: number) { const start = polarToCart(cx, cy, r, endDeg); const end = polarToCart(cx, cy, r, startDeg); const large = endDeg - startDeg <= 180 ? '0' : '1'; return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`; } interface KnobProps { value: number; min: number; max: number; onChange: (v: number) => void; color?: string; modulated?: boolean; liveValue?: number; } export function Knob({ value, min, max, onChange, color = 'var(--accent, #6366f1)', modulated = false, liveValue }: KnobProps) { const ref = useRef(null); const dragRef = useRef<{ startY: number; startValue: number } | null>(null); const inputRef = useRef(null); const [editing, setEditing] = useState(false); const [editText, setEditText] = useState(''); const displayNum = liveValue !== undefined ? liveValue : value; const clampedDisplay = Math.max(min, Math.min(max, displayNum)); const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min))); const angleDeg = START_ANGLE - norm * RANGE; const cx = SIZE / 2, cy = SIZE / 2; const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE); const fillAngle = START_ANGLE - norm * RANGE; const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : ''; const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg); const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min))); const baseAngle = START_ANGLE - baseNorm * RANGE; const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle); const handlePointerDown = useCallback((e: React.PointerEvent) => { if (editing) return; e.preventDefault(); e.stopPropagation(); dragRef.current = { startY: e.clientY, startValue: value }; const handleMove = (me: PointerEvent) => { if (!dragRef.current) return; const dy = dragRef.current.startY - me.clientY; const sensitivity = (max - min) / 200; let newVal = dragRef.current.startValue + dy * sensitivity; newVal = Math.max(min, Math.min(max, newVal)); if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) { newVal = Math.round(newVal); } onChange(newVal); }; const handleUp = () => { window.removeEventListener('pointermove', handleMove); window.removeEventListener('pointerup', handleUp); dragRef.current = null; }; window.addEventListener('pointermove', handleMove); window.addEventListener('pointerup', handleUp); }, [value, min, max, onChange, editing]); const handleWheel = useCallback((e: React.WheelEvent) => { if (editing) return; e.preventDefault(); e.stopPropagation(); const step = (max - min) / 100; const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step)); onChange(newVal); }, [value, min, max, onChange, editing]); const handleDoubleClick = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setEditText(String(value)); setEditing(true); setTimeout(() => inputRef.current?.focus(), 0); }, [value]); const commitEdit = useCallback(() => { const parsed = parseFloat(editText); if (!isNaN(parsed)) { onChange(Math.max(min, Math.min(max, parsed))); } setEditing(false); }, [editText, min, max, onChange]); if (editing) { return (
e.stopPropagation()} style={{ width: SIZE, height: SIZE, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> setEditText(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditing(false); }} onBlur={commitEdit} onPointerDown={(e) => e.stopPropagation()} style={{ width: 48, height: 22, background: 'oklch(0.145 0 0)', border: '1px solid var(--accent, #6366f1)', borderRadius: 3, color: 'var(--accent, #6366f1)', fontSize: 10, textAlign: 'center', fontFamily: "'JetBrains Mono', monospace", outline: 'none', }} />
); } return (
{/* Modulation glow ring */} {modulated && ( )} {/* Track */} {/* Fill */} {fillPath && } {/* Ghost dot at base value when modulated */} {liveValue !== undefined && ( )} {/* Current value dot */}
); }