Files
reaktor/src/components/Knob.jsx
Jose Luis a1be6df355 feat: UI sounds, live LFO visualization, wire fix, worlds 7-12, bug fixes
- Add procedural UI sound effects (connect/disconnect, engine start/stop,
  level complete/fail, star earned, hint, navigation) via Tone.js
- Live LFO modulation visualization: knobs animate in real-time showing
  modulated value, ghost dot shows base value, number glows cyan
- Fix wire recalculation on zoom/pan/level re-entry (post-layout refresh)
- Fix retry button to keep current patch instead of reloading level
- Fix default param detection: newly added modules now populate all
  default params so level checkers work without manual param changes
- Add worlds 7-12: Secuencias y Ritmos, Texturas de Ruido, Síntesis
  Sustractiva, Espacio y Stereo, Técnicas Avanzadas, Gran Final
  (48 new levels, 144 new possible stars, 288 total stars)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:03:29 +01:00

151 lines
5.5 KiB
JavaScript

import React, { useRef, useCallback, useState } from 'react';
const SIZE = 32;
const RADIUS = 12;
const STROKE = 3;
const START_ANGLE = 225;
const END_ANGLE = -45;
const RANGE = 270; // degrees
function polarToCart(cx, cy, r, deg) {
const rad = (deg - 90) * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function describeArc(cx, cy, r, startDeg, endDeg) {
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}`;
}
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
const ref = useRef(null);
const dragRef = useRef(null);
const inputRef = useRef(null);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState('');
// Use liveValue for visual display when being modulated, base value for interaction
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);
// Also show base value indicator when modulated
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 displayVal = formatValue ? formatValue(displayNum) :
displayNum >= 1000 ? `${(displayNum / 1000).toFixed(1)}k` :
displayNum >= 100 ? Math.round(displayNum) :
displayNum >= 1 ? displayNum.toFixed(1) :
displayNum.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
const handlePointerDown = useCallback((e) => {
if (editing) return;
e.preventDefault(); e.stopPropagation();
dragRef.current = { startY: e.clientY, startValue: value };
const handleMove = (me) => {
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) => {
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]);
// Double-click: open inline text input
const handleDoubleClick = useCallback((e) => {
e.preventDefault(); e.stopPropagation();
setEditText(String(typeof displayVal === 'number' ? displayVal : value));
setEditing(true);
// Focus input after render
setTimeout(() => inputRef.current?.focus(), 0);
}, [value, displayVal]);
const commitEdit = useCallback(() => {
const parsed = parseFloat(editText);
if (!isNaN(parsed)) {
const clamped = Math.max(min, Math.min(max, parsed));
onChange(clamped);
}
setEditing(false);
}, [editText, min, max, onChange]);
const handleInputKeyDown = useCallback((e) => {
e.stopPropagation();
if (e.key === 'Enter') {
commitEdit();
} else if (e.key === 'Escape') {
setEditing(false);
}
}, [commitEdit]);
const handleInputBlur = useCallback(() => {
commitEdit();
}, [commitEdit]);
if (editing) {
return (
<div className="knob-container knob-editing" onWheel={(e) => e.stopPropagation()}>
<input
ref={inputRef}
className="knob-input"
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
onPointerDown={(e) => e.stopPropagation()}
/>
</div>
);
}
return (
<div className={`knob-container ${modulated ? 'knob-modulated' : ''}`} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
onPointerDown={handlePointerDown} ref={ref}>
{/* Modulation glow ring */}
{modulated && (
<circle className="knob-mod-ring" cx={cx} cy={cy} r={RADIUS + 1} style={{ stroke: color }} />
)}
<path className="knob-track" d={trackPath} />
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
{/* Ghost dot at base value position when modulated */}
{liveValue !== undefined && (
<circle className="knob-base-dot" cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} />
)}
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
</svg>
</div>
);
}