mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
- Rebrand from INTERCEPT to iNTERCEPT - New logo design with 'i' and signal wave brackets - Add animated landing page with "See the Invisible" tagline - Fix tuning dial audio issues with debouncing and restart prevention - Fix Listening Post scanner with proper signal hit logging - Update setup script for apt-based Python package installation - Add Instagram promo video template - Add full-size logo assets for external use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
227 lines
7.0 KiB
JavaScript
227 lines
7.0 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|