/** * Intercept - Radio Knob Component * Interactive rotary knob control with drag-to-rotate */ class RadioKnob { constructor(element, options = {}) { this.element = element; this.value = parseFloat(element.dataset.value) || 0; this.min = parseFloat(element.dataset.min) || 0; this.max = parseFloat(element.dataset.max) || 100; this.step = parseFloat(element.dataset.step) || 1; this.rotation = this.valueToRotation(this.value); this.isDragging = false; this.startY = 0; this.startRotation = 0; this.sensitivity = options.sensitivity || 1.5; this.onChange = options.onChange || null; this.bindEvents(); this.updateVisual(); } valueToRotation(value) { const range = this.max - this.min; const normalized = (value - this.min) / range; return normalized * 270 - 135; // -135 to +135 degrees } rotationToValue(rotation) { const normalized = (rotation + 135) / 270; let value = this.min + normalized * (this.max - this.min); // Snap to step value = Math.round(value / this.step) * this.step; return Math.max(this.min, Math.min(this.max, value)); } bindEvents() { // Mouse events this.element.addEventListener('mousedown', (e) => this.startDrag(e)); document.addEventListener('mousemove', (e) => this.drag(e)); document.addEventListener('mouseup', () => this.endDrag()); // Touch support this.element.addEventListener('touchstart', (e) => { e.preventDefault(); this.startDrag(e.touches[0]); }, { passive: false }); document.addEventListener('touchmove', (e) => { if (this.isDragging) { e.preventDefault(); this.drag(e.touches[0]); } }, { passive: false }); document.addEventListener('touchend', () => this.endDrag()); // Scroll wheel support this.element.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false }); // Double-click to reset this.element.addEventListener('dblclick', () => this.reset()); } startDrag(e) { this.isDragging = true; this.startY = e.clientY; this.startRotation = this.rotation; this.element.style.cursor = 'grabbing'; this.element.classList.add('active'); // Play click sound if available if (typeof playClickSound === 'function') { playClickSound(); } } drag(e) { if (!this.isDragging) return; const deltaY = this.startY - e.clientY; let newRotation = this.startRotation + deltaY * this.sensitivity; // Clamp rotation newRotation = Math.max(-135, Math.min(135, newRotation)); this.rotation = newRotation; this.value = this.rotationToValue(this.rotation); this.updateVisual(); this.dispatchChange(); } endDrag() { if (!this.isDragging) return; this.isDragging = false; this.element.style.cursor = 'grab'; this.element.classList.remove('active'); } handleWheel(e) { e.preventDefault(); const delta = e.deltaY > 0 ? -this.step : this.step; const multiplier = e.shiftKey ? 5 : 1; // Faster with shift key this.setValue(this.value + delta * multiplier); // Play click sound if available if (typeof playClickSound === 'function') { playClickSound(); } } setValue(value, silent = false) { this.value = Math.max(this.min, Math.min(this.max, value)); this.rotation = this.valueToRotation(this.value); this.updateVisual(); if (!silent) { this.dispatchChange(); } } getValue() { return this.value; } reset() { const defaultValue = parseFloat(this.element.dataset.default) || (this.min + this.max) / 2; this.setValue(defaultValue); } updateVisual() { this.element.style.transform = `rotate(${this.rotation}deg)`; // Update associated value display const valueDisplayId = this.element.id.replace('Knob', 'Value'); const valueDisplay = document.getElementById(valueDisplayId); if (valueDisplay) { valueDisplay.textContent = Math.round(this.value); } // Update data attribute this.element.dataset.value = this.value; } dispatchChange() { // Custom callback if (this.onChange) { this.onChange(this.value, this); } // Custom event this.element.dispatchEvent(new CustomEvent('knobchange', { detail: { value: this.value, knob: this }, bubbles: true })); } } /** * Tuning Dial - Larger rotary control for frequency tuning */ class TuningDial extends RadioKnob { constructor(element, options = {}) { super(element, { sensitivity: options.sensitivity || 0.8, ...options }); this.fineStep = options.fineStep || 0.025; this.coarseStep = options.coarseStep || 0.2; } handleWheel(e) { e.preventDefault(); const step = e.shiftKey ? this.fineStep : this.coarseStep; const delta = e.deltaY > 0 ? -step : step; this.setValue(this.value + delta); } // Override to not round to step for smooth tuning rotationToValue(rotation) { const normalized = (rotation + 135) / 270; let value = this.min + normalized * (this.max - this.min); return Math.max(this.min, Math.min(this.max, value)); } updateVisual() { this.element.style.transform = `rotate(${this.rotation}deg)`; // Update associated value display with decimals const valueDisplayId = this.element.id.replace('Dial', 'Value'); const valueDisplay = document.getElementById(valueDisplayId); if (valueDisplay) { valueDisplay.textContent = this.value.toFixed(3); } this.element.dataset.value = this.value; } } /** * Initialize all radio knobs on the page */ function initRadioKnobs() { // Initialize standard knobs document.querySelectorAll('.radio-knob').forEach(element => { if (!element._knob) { element._knob = new RadioKnob(element); } }); // Initialize tuning dials document.querySelectorAll('.tuning-dial').forEach(element => { if (!element._dial) { element._dial = new TuningDial(element); } }); } // Auto-initialize on DOM ready document.addEventListener('DOMContentLoaded', initRadioKnobs); // Export for use in modules if (typeof module !== 'undefined' && module.exports) { module.exports = { RadioKnob, TuningDial, initRadioKnobs }; }