891 lines
24 KiB
JavaScript
891 lines
24 KiB
JavaScript
|
|
// Control Surface UI — DOM for compound axes + settings drawer
|
||
|
|
// Renders 3 compact sliders on the floating bar and a gear-icon settings drawer.
|
||
|
|
//
|
||
|
|
// Usage:
|
||
|
|
// import { initControlSurfaceUI } from './ui/control-surface-ui.js';
|
||
|
|
// const { surface, destroy } = initControlSurfaceUI();
|
||
|
|
// // surface is the ControlSurface instance
|
||
|
|
|
||
|
|
import { ControlSurface, CONTROL_PRESETS, PARAM_DEFAULTS, PARAM_RANGES, LOG_SCALE_PARAMS } from './control-surface.js';
|
||
|
|
|
||
|
|
// ---- Parameter metadata for the drawer ----
|
||
|
|
|
||
|
|
const SECTIONS = [
|
||
|
|
{
|
||
|
|
id: 'input',
|
||
|
|
label: 'Input',
|
||
|
|
params: [
|
||
|
|
{ name: 'zoom', label: 'Zoom', log: true },
|
||
|
|
{ name: 'deadzone', label: 'Deadzone' },
|
||
|
|
{ name: 'inputCurve', label: 'Curve' },
|
||
|
|
{ name: 'smoothing', label: 'Smoothing' },
|
||
|
|
{ name: 'momentumZoom', label: 'Momentum', type: 'select', options: ['off', 'gentle', 'strong'] },
|
||
|
|
{ name: 'invertX', label: 'Invert X', type: 'toggle' },
|
||
|
|
{ name: 'invertY', label: 'Invert Y', type: 'toggle' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'training',
|
||
|
|
label: 'Training',
|
||
|
|
params: [
|
||
|
|
{ name: 'learningRate', label: 'Learning Rate', log: true },
|
||
|
|
{ name: 'maxIterations', label: 'Max Iterations' },
|
||
|
|
{ name: 'convergenceThreshold', label: 'Convergence', log: true },
|
||
|
|
{ name: 'rlTrainIntensity', label: 'RL Intensity' },
|
||
|
|
{ name: 'maxExamples', label: 'Max Examples' },
|
||
|
|
{ name: 'exampleDecay', label: 'Example Decay' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'exploration',
|
||
|
|
label: 'Exploration',
|
||
|
|
params: [
|
||
|
|
{ name: 'spread', label: 'Spread' },
|
||
|
|
{ name: 'noiseFloor', label: 'Noise Floor' },
|
||
|
|
{ name: 'noiseCap', label: 'Noise Cap' },
|
||
|
|
{ name: 'noiseGrowth', label: 'Noise Growth' },
|
||
|
|
{ name: 'noiseDecay', label: 'Noise Decay' },
|
||
|
|
{ name: 'weightDecay', label: 'Weight Decay' },
|
||
|
|
{ name: 'noiseDistribution', label: 'Distribution', type: 'select', options: ['gaussian', 'cauchy'] },
|
||
|
|
{ name: 'layerAwareNoise', label: 'Layer-Aware', type: 'toggle' },
|
||
|
|
{ name: 'zoomAwareFeedback', label: 'Zoom-Aware FB', type: 'toggle' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: 'output',
|
||
|
|
label: 'Output',
|
||
|
|
params: [
|
||
|
|
{ name: 'outputSmoothing', label: 'Smoothing' },
|
||
|
|
{ name: 'outputSlewRate', label: 'Slew Rate' },
|
||
|
|
{ name: 'tame', label: 'Tame' },
|
||
|
|
{ name: 'globalCurve', label: 'Global Curve' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
// ---- Utility: log-scale slider helpers ----
|
||
|
|
|
||
|
|
function toLogSlider(value, min, max) {
|
||
|
|
// Map a log-scale value to 0-1 slider position
|
||
|
|
const logMin = Math.log(min);
|
||
|
|
const logMax = Math.log(max);
|
||
|
|
return (Math.log(Math.max(value, min)) - logMin) / (logMax - logMin);
|
||
|
|
}
|
||
|
|
|
||
|
|
function fromLogSlider(sliderPos, min, max) {
|
||
|
|
const logMin = Math.log(min);
|
||
|
|
const logMax = Math.log(max);
|
||
|
|
return Math.exp(logMin + sliderPos * (logMax - logMin));
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatValue(name, value) {
|
||
|
|
if (typeof value === 'boolean') return value ? 'On' : 'Off';
|
||
|
|
if (typeof value === 'string') return value;
|
||
|
|
if (typeof value !== 'number') return String(value);
|
||
|
|
// Convergence threshold: scientific notation
|
||
|
|
if (name === 'convergenceThreshold') return value.toExponential(1);
|
||
|
|
// Integers
|
||
|
|
if (name === 'maxExamples' || name === 'maxIterations') return Math.round(value).toString();
|
||
|
|
// Small decimals
|
||
|
|
if (Math.abs(value) < 0.01 && value !== 0) return value.toExponential(1);
|
||
|
|
if (Math.abs(value) < 1) return value.toFixed(3);
|
||
|
|
return value.toFixed(2);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Build DOM ----
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initialize the control surface UI.
|
||
|
|
* Inserts compound axis sliders into the floating bar and creates the settings drawer.
|
||
|
|
* Returns { surface, destroy }.
|
||
|
|
*/
|
||
|
|
export function initControlSurfaceUI() {
|
||
|
|
const surface = new ControlSurface();
|
||
|
|
|
||
|
|
// Apply default preset
|
||
|
|
surface.applyPreset('default');
|
||
|
|
|
||
|
|
// --- Floating bar: compound axis sliders ---
|
||
|
|
const $floatingBar = document.getElementById('floating-bar');
|
||
|
|
const axisContainer = document.createElement('div');
|
||
|
|
axisContainer.className = 'cs-axes';
|
||
|
|
axisContainer.innerHTML = `
|
||
|
|
<div class="cs-axis" data-axis="boldness">
|
||
|
|
<label class="cs-axis-label">Bold</label>
|
||
|
|
<input type="range" class="cs-axis-slider" min="0" max="1" step="0.01" value="0.5">
|
||
|
|
</div>
|
||
|
|
<div class="cs-axis" data-axis="memory">
|
||
|
|
<label class="cs-axis-label">Mem</label>
|
||
|
|
<input type="range" class="cs-axis-slider" min="0" max="1" step="0.01" value="0.5">
|
||
|
|
</div>
|
||
|
|
<div class="cs-axis" data-axis="precision">
|
||
|
|
<label class="cs-axis-label">Prec</label>
|
||
|
|
<input type="range" class="cs-axis-slider" min="0" max="1" step="0.01" value="0.3">
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
// Insert before the chevron button
|
||
|
|
const $chevron = document.getElementById('chevron-btn');
|
||
|
|
if ($chevron) {
|
||
|
|
$floatingBar.insertBefore(axisContainer, $chevron);
|
||
|
|
} else {
|
||
|
|
$floatingBar.appendChild(axisContainer);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wire axis sliders
|
||
|
|
const axisSliders = {};
|
||
|
|
for (const axisEl of axisContainer.querySelectorAll('.cs-axis')) {
|
||
|
|
const axisName = axisEl.dataset.axis;
|
||
|
|
const slider = axisEl.querySelector('.cs-axis-slider');
|
||
|
|
axisSliders[axisName] = slider;
|
||
|
|
|
||
|
|
slider.addEventListener('input', () => {
|
||
|
|
const val = parseFloat(slider.value);
|
||
|
|
if (axisName === 'boldness') surface.setBoldness(val);
|
||
|
|
else if (axisName === 'memory') surface.setMemory(val);
|
||
|
|
else if (axisName === 'precision') surface.setPrecision(val);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Double-tap to clear all overrides for this axis
|
||
|
|
let lastTap = 0;
|
||
|
|
slider.addEventListener('pointerdown', () => {
|
||
|
|
const now = Date.now();
|
||
|
|
if (now - lastTap < 350) {
|
||
|
|
surface.clearAllOverrides();
|
||
|
|
syncDrawerValues();
|
||
|
|
}
|
||
|
|
lastTap = now;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Gear button (settings drawer toggle) ---
|
||
|
|
const gearBtn = document.createElement('button');
|
||
|
|
gearBtn.className = 'cs-gear-btn';
|
||
|
|
gearBtn.title = 'Control surface settings';
|
||
|
|
gearBtn.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||
|
|
<circle cx="8" cy="8" r="2.5"/>
|
||
|
|
<path d="M13.3 9.5a1.2 1.2 0 00.24 1.32l.04.04a1.45 1.45 0 11-2.05 2.05l-.04-.04a1.2 1.2 0 00-1.32-.24 1.2 1.2 0 00-.73 1.1v.12a1.45 1.45 0 11-2.9 0v-.06a1.2 1.2 0 00-.78-1.1 1.2 1.2 0 00-1.32.24l-.04.04a1.45 1.45 0 11-2.05-2.05l.04-.04a1.2 1.2 0 00.24-1.32 1.2 1.2 0 00-1.1-.73h-.12a1.45 1.45 0 110-2.9h.06a1.2 1.2 0 001.1-.78 1.2 1.2 0 00-.24-1.32l-.04-.04A1.45 1.45 0 114.38 1.68l.04.04a1.2 1.2 0 001.32.24h.06a1.2 1.2 0 00.73-1.1V.74a1.45 1.45 0 112.9 0v.06a1.2 1.2 0 00.73 1.1 1.2 1.2 0 001.32-.24l.04-.04a1.45 1.45 0 112.05 2.05l-.04.04a1.2 1.2 0 00-.24 1.32v.06a1.2 1.2 0 001.1.73h.12a1.45 1.45 0 110 2.9h-.06a1.2 1.2 0 00-1.1.73z"/>
|
||
|
|
</svg>`;
|
||
|
|
|
||
|
|
// Place gear button near the help button (top-right area)
|
||
|
|
const $helpBtn = document.getElementById('help-btn');
|
||
|
|
if ($helpBtn && $helpBtn.parentNode) {
|
||
|
|
$helpBtn.parentNode.insertBefore(gearBtn, $helpBtn);
|
||
|
|
} else {
|
||
|
|
document.body.appendChild(gearBtn);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Settings drawer ---
|
||
|
|
const overlay = document.createElement('div');
|
||
|
|
overlay.className = 'cs-drawer-overlay hidden';
|
||
|
|
|
||
|
|
const drawer = document.createElement('div');
|
||
|
|
drawer.className = 'cs-drawer';
|
||
|
|
|
||
|
|
// Build drawer content
|
||
|
|
drawer.innerHTML = buildDrawerHTML();
|
||
|
|
overlay.appendChild(drawer);
|
||
|
|
document.body.appendChild(overlay);
|
||
|
|
|
||
|
|
// --- Wire drawer interactions ---
|
||
|
|
|
||
|
|
let drawerOpen = false;
|
||
|
|
|
||
|
|
function openDrawer() {
|
||
|
|
drawerOpen = true;
|
||
|
|
overlay.classList.remove('hidden');
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
overlay.classList.add('open');
|
||
|
|
drawer.classList.add('open');
|
||
|
|
});
|
||
|
|
syncDrawerValues();
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeDrawer() {
|
||
|
|
drawerOpen = false;
|
||
|
|
overlay.classList.remove('open');
|
||
|
|
drawer.classList.remove('open');
|
||
|
|
setTimeout(() => {
|
||
|
|
if (!drawerOpen) overlay.classList.add('hidden');
|
||
|
|
}, 220);
|
||
|
|
}
|
||
|
|
|
||
|
|
gearBtn.addEventListener('click', () => {
|
||
|
|
if (drawerOpen) closeDrawer();
|
||
|
|
else openDrawer();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Close on overlay click (outside drawer)
|
||
|
|
overlay.addEventListener('click', (e) => {
|
||
|
|
if (e.target === overlay) closeDrawer();
|
||
|
|
});
|
||
|
|
|
||
|
|
// Close button inside drawer
|
||
|
|
const closeBtn = drawer.querySelector('.cs-drawer-close');
|
||
|
|
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
|
||
|
|
|
||
|
|
// Preset dropdown
|
||
|
|
const presetSelect = drawer.querySelector('#cs-preset-select');
|
||
|
|
if (presetSelect) {
|
||
|
|
presetSelect.addEventListener('change', () => {
|
||
|
|
const id = presetSelect.value;
|
||
|
|
if (id && CONTROL_PRESETS[id]) {
|
||
|
|
surface.applyPreset(id);
|
||
|
|
// Sync axis sliders
|
||
|
|
axisSliders.boldness.value = surface.getBoldness();
|
||
|
|
axisSliders.memory.value = surface.getMemory();
|
||
|
|
axisSliders.precision.value = surface.getPrecision();
|
||
|
|
syncDrawerValues();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Reset button
|
||
|
|
const resetBtn = drawer.querySelector('#cs-reset-preset');
|
||
|
|
if (resetBtn) {
|
||
|
|
resetBtn.addEventListener('click', () => {
|
||
|
|
const id = presetSelect?.value || 'default';
|
||
|
|
surface.applyPreset(id);
|
||
|
|
axisSliders.boldness.value = surface.getBoldness();
|
||
|
|
axisSliders.memory.value = surface.getMemory();
|
||
|
|
axisSliders.precision.value = surface.getPrecision();
|
||
|
|
syncDrawerValues();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Section expand/collapse
|
||
|
|
for (const header of drawer.querySelectorAll('.cs-section-header')) {
|
||
|
|
header.addEventListener('click', () => {
|
||
|
|
const section = header.parentElement;
|
||
|
|
section.classList.toggle('collapsed');
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Wire individual parameter controls
|
||
|
|
wireParamControls(drawer, surface, () => syncDrawerValues());
|
||
|
|
|
||
|
|
// --- Sync drawer values from surface state ---
|
||
|
|
|
||
|
|
function syncDrawerValues() {
|
||
|
|
const params = surface.getParams();
|
||
|
|
for (const section of SECTIONS) {
|
||
|
|
for (const p of section.params) {
|
||
|
|
const val = params[p.name];
|
||
|
|
const el = drawer.querySelector(`[data-param="${p.name}"]`);
|
||
|
|
if (!el) continue;
|
||
|
|
|
||
|
|
const range = PARAM_RANGES[p.name];
|
||
|
|
|
||
|
|
if (p.type === 'toggle') {
|
||
|
|
const checkbox = el.querySelector('input[type="checkbox"]');
|
||
|
|
if (checkbox) checkbox.checked = !!val;
|
||
|
|
} else if (p.type === 'select') {
|
||
|
|
const select = el.querySelector('select');
|
||
|
|
if (select) select.value = val;
|
||
|
|
} else if (range) {
|
||
|
|
const slider = el.querySelector('input[type="range"]');
|
||
|
|
const valueSpan = el.querySelector('.cs-param-value');
|
||
|
|
if (slider) {
|
||
|
|
if (p.log || LOG_SCALE_PARAMS.has(p.name)) {
|
||
|
|
slider.value = toLogSlider(val, range[0], range[1]);
|
||
|
|
} else {
|
||
|
|
slider.value = val;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (valueSpan) valueSpan.textContent = formatValue(p.name, val);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Show override indicator
|
||
|
|
const overrideIndicator = el.querySelector('.cs-override-dot');
|
||
|
|
if (overrideIndicator) {
|
||
|
|
overrideIndicator.classList.toggle('active', surface.hasOverride(p.name));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Listen for external changes to the surface (e.g. from app code)
|
||
|
|
surface.onChange(() => {
|
||
|
|
if (drawerOpen) syncDrawerValues();
|
||
|
|
});
|
||
|
|
|
||
|
|
// --- Inject styles ---
|
||
|
|
injectStyles();
|
||
|
|
|
||
|
|
// --- Cleanup function ---
|
||
|
|
function destroy() {
|
||
|
|
axisContainer.remove();
|
||
|
|
gearBtn.remove();
|
||
|
|
overlay.remove();
|
||
|
|
const styleEl = document.getElementById('cs-styles');
|
||
|
|
if (styleEl) styleEl.remove();
|
||
|
|
}
|
||
|
|
|
||
|
|
return { surface, destroy };
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
// ---- Build drawer HTML ----
|
||
|
|
|
||
|
|
function buildDrawerHTML() {
|
||
|
|
let html = `
|
||
|
|
<div class="cs-drawer-header">
|
||
|
|
<span class="cs-drawer-title">Control Surface</span>
|
||
|
|
<button class="cs-drawer-close">×</button>
|
||
|
|
</div>
|
||
|
|
<div class="cs-drawer-body">
|
||
|
|
<div class="cs-preset-row">
|
||
|
|
<label class="cs-preset-label">Preset</label>
|
||
|
|
<select id="cs-preset-select" class="cs-preset-select">
|
||
|
|
${Object.keys(CONTROL_PRESETS).map(id =>
|
||
|
|
`<option value="${id}">${id.replace(/-/g, ' ')}</option>`
|
||
|
|
).join('')}
|
||
|
|
</select>
|
||
|
|
<button id="cs-reset-preset" class="cs-reset-btn" title="Reset to preset">Reset</button>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
|
||
|
|
for (const section of SECTIONS) {
|
||
|
|
html += `
|
||
|
|
<div class="cs-section" data-section="${section.id}">
|
||
|
|
<div class="cs-section-header">
|
||
|
|
<span>${section.label}</span>
|
||
|
|
<span class="cs-section-chevron">▼</span>
|
||
|
|
</div>
|
||
|
|
<div class="cs-section-body">
|
||
|
|
`;
|
||
|
|
|
||
|
|
for (const p of section.params) {
|
||
|
|
html += buildParamRow(p);
|
||
|
|
}
|
||
|
|
|
||
|
|
html += `
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
html += `</div>`;
|
||
|
|
return html;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildParamRow(p) {
|
||
|
|
const range = PARAM_RANGES[p.name];
|
||
|
|
|
||
|
|
if (p.type === 'toggle') {
|
||
|
|
return `
|
||
|
|
<div class="cs-param-row" data-param="${p.name}">
|
||
|
|
<label class="cs-param-label">
|
||
|
|
<span class="cs-override-dot"></span>
|
||
|
|
${p.label}
|
||
|
|
</label>
|
||
|
|
<input type="checkbox" class="cs-toggle" ${PARAM_DEFAULTS[p.name] ? 'checked' : ''}>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (p.type === 'select') {
|
||
|
|
const options = p.options || [];
|
||
|
|
return `
|
||
|
|
<div class="cs-param-row" data-param="${p.name}">
|
||
|
|
<label class="cs-param-label">
|
||
|
|
<span class="cs-override-dot"></span>
|
||
|
|
${p.label}
|
||
|
|
</label>
|
||
|
|
<select class="cs-select">
|
||
|
|
${options.map(o => `<option value="${o}">${o}</option>`).join('')}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Numeric slider
|
||
|
|
const isLog = p.log || LOG_SCALE_PARAMS.has(p.name);
|
||
|
|
const min = range ? range[0] : 0;
|
||
|
|
const max = range ? range[1] : 1;
|
||
|
|
const step = isLog ? 0.001 : (range ? (range[2] || 0.01) : 0.01);
|
||
|
|
const sliderMin = isLog ? 0 : min;
|
||
|
|
const sliderMax = isLog ? 1 : max;
|
||
|
|
const defaultVal = PARAM_DEFAULTS[p.name] ?? 0.5;
|
||
|
|
const sliderDefault = isLog ? toLogSlider(defaultVal, min, max) : defaultVal;
|
||
|
|
|
||
|
|
return `
|
||
|
|
<div class="cs-param-row" data-param="${p.name}">
|
||
|
|
<label class="cs-param-label">
|
||
|
|
<span class="cs-override-dot"></span>
|
||
|
|
${p.label}
|
||
|
|
</label>
|
||
|
|
<div class="cs-slider-row">
|
||
|
|
<input type="range" class="cs-slider" min="${sliderMin}" max="${sliderMax}" step="${step}" value="${sliderDefault}">
|
||
|
|
<span class="cs-param-value">${formatValue(p.name, defaultVal)}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
// ---- Wire parameter controls ----
|
||
|
|
|
||
|
|
function wireParamControls(drawer, surface, syncFn) {
|
||
|
|
for (const section of SECTIONS) {
|
||
|
|
for (const p of section.params) {
|
||
|
|
const el = drawer.querySelector(`[data-param="${p.name}"]`);
|
||
|
|
if (!el) continue;
|
||
|
|
|
||
|
|
const range = PARAM_RANGES[p.name];
|
||
|
|
|
||
|
|
if (p.type === 'toggle') {
|
||
|
|
const checkbox = el.querySelector('input[type="checkbox"]');
|
||
|
|
if (checkbox) {
|
||
|
|
checkbox.addEventListener('change', () => {
|
||
|
|
surface.setOverride(p.name, checkbox.checked);
|
||
|
|
syncFn();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else if (p.type === 'select') {
|
||
|
|
const select = el.querySelector('select');
|
||
|
|
if (select) {
|
||
|
|
select.addEventListener('change', () => {
|
||
|
|
surface.setOverride(p.name, select.value);
|
||
|
|
syncFn();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else if (range) {
|
||
|
|
const slider = el.querySelector('input[type="range"]');
|
||
|
|
const valueSpan = el.querySelector('.cs-param-value');
|
||
|
|
const isLog = p.log || LOG_SCALE_PARAMS.has(p.name);
|
||
|
|
|
||
|
|
if (slider) {
|
||
|
|
slider.addEventListener('input', () => {
|
||
|
|
let val;
|
||
|
|
if (isLog) {
|
||
|
|
val = fromLogSlider(parseFloat(slider.value), range[0], range[1]);
|
||
|
|
} else {
|
||
|
|
val = parseFloat(slider.value);
|
||
|
|
}
|
||
|
|
surface.setOverride(p.name, val);
|
||
|
|
if (valueSpan) valueSpan.textContent = formatValue(p.name, val);
|
||
|
|
// Update override dot
|
||
|
|
const dot = el.querySelector('.cs-override-dot');
|
||
|
|
if (dot) dot.classList.add('active');
|
||
|
|
});
|
||
|
|
|
||
|
|
// Double-tap slider to clear override for this param
|
||
|
|
let lastTap = 0;
|
||
|
|
slider.addEventListener('pointerdown', () => {
|
||
|
|
const now = Date.now();
|
||
|
|
if (now - lastTap < 350) {
|
||
|
|
surface.clearOverride(p.name);
|
||
|
|
syncFn();
|
||
|
|
}
|
||
|
|
lastTap = now;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
// ---- CSS injection ----
|
||
|
|
|
||
|
|
function injectStyles() {
|
||
|
|
if (document.getElementById('cs-styles')) return;
|
||
|
|
|
||
|
|
const style = document.createElement('style');
|
||
|
|
style.id = 'cs-styles';
|
||
|
|
style.textContent = `
|
||
|
|
/* ---- Control Surface: Compound Axes (floating bar) ---- */
|
||
|
|
.cs-axes {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
margin: 0 4px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-axis {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 3px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-axis-label {
|
||
|
|
font-size: 9px;
|
||
|
|
color: rgba(255, 255, 255, 0.45);
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.5px;
|
||
|
|
white-space: nowrap;
|
||
|
|
cursor: default;
|
||
|
|
user-select: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-axis-slider {
|
||
|
|
width: 48px;
|
||
|
|
height: 3px;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
appearance: none;
|
||
|
|
background: rgba(255, 255, 255, 0.12);
|
||
|
|
border-radius: 2px;
|
||
|
|
outline: none;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-axis-slider::-webkit-slider-thumb {
|
||
|
|
-webkit-appearance: none;
|
||
|
|
width: 10px;
|
||
|
|
height: 10px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--accent, #ff6a00);
|
||
|
|
cursor: pointer;
|
||
|
|
border: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-axis-slider::-moz-range-thumb {
|
||
|
|
width: 10px;
|
||
|
|
height: 10px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--accent, #ff6a00);
|
||
|
|
border: none;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Gear button ---- */
|
||
|
|
.cs-gear-btn {
|
||
|
|
position: fixed;
|
||
|
|
bottom: 96px;
|
||
|
|
right: 50px;
|
||
|
|
z-index: 45;
|
||
|
|
width: 32px;
|
||
|
|
height: 32px;
|
||
|
|
border-radius: 50%;
|
||
|
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.08));
|
||
|
|
background: var(--glass-bg, rgba(13, 13, 13, 0.65));
|
||
|
|
backdrop-filter: blur(16px);
|
||
|
|
-webkit-backdrop-filter: blur(16px);
|
||
|
|
color: var(--text-dim, #888);
|
||
|
|
cursor: pointer;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
transition: all 0.15s;
|
||
|
|
padding: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-gear-btn:hover,
|
||
|
|
.cs-gear-btn:active {
|
||
|
|
border-color: var(--accent, #ff6a00);
|
||
|
|
color: var(--accent, #ff6a00);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Drawer overlay ---- */
|
||
|
|
.cs-drawer-overlay {
|
||
|
|
position: fixed;
|
||
|
|
inset: 0;
|
||
|
|
z-index: 90;
|
||
|
|
background: rgba(0, 0, 0, 0);
|
||
|
|
transition: background 0.2s ease;
|
||
|
|
pointer-events: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-drawer-overlay.hidden {
|
||
|
|
display: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-drawer-overlay.open {
|
||
|
|
background: rgba(0, 0, 0, 0.4);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Drawer panel ---- */
|
||
|
|
.cs-drawer {
|
||
|
|
position: fixed;
|
||
|
|
top: 0;
|
||
|
|
right: -320px;
|
||
|
|
width: 300px;
|
||
|
|
height: 100%;
|
||
|
|
z-index: 91;
|
||
|
|
background: rgba(0, 0, 0, 0.88);
|
||
|
|
backdrop-filter: blur(24px);
|
||
|
|
-webkit-backdrop-filter: blur(24px);
|
||
|
|
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||
|
|
overflow-y: auto;
|
||
|
|
-webkit-overflow-scrolling: touch;
|
||
|
|
transition: right 0.2s ease;
|
||
|
|
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-drawer.open {
|
||
|
|
right: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Drawer header ---- */
|
||
|
|
.cs-drawer-header {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: space-between;
|
||
|
|
padding: 14px 16px 10px;
|
||
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-drawer-title {
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: 600;
|
||
|
|
color: rgba(255, 255, 255, 0.8);
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-drawer-close {
|
||
|
|
width: 28px;
|
||
|
|
height: 28px;
|
||
|
|
border-radius: 4px;
|
||
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
|
|
background: rgba(255, 255, 255, 0.04);
|
||
|
|
color: rgba(255, 255, 255, 0.5);
|
||
|
|
font-size: 16px;
|
||
|
|
cursor: pointer;
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
transition: all 0.15s;
|
||
|
|
padding: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-drawer-close:hover {
|
||
|
|
background: rgba(255, 255, 255, 0.1);
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Drawer body ---- */
|
||
|
|
.cs-drawer-body {
|
||
|
|
padding: 0 0 24px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Preset row ---- */
|
||
|
|
.cs-preset-row {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
padding: 10px 16px;
|
||
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-preset-label {
|
||
|
|
font-size: 10px;
|
||
|
|
color: rgba(255, 255, 255, 0.4);
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.5px;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-preset-select {
|
||
|
|
flex: 1;
|
||
|
|
height: 28px;
|
||
|
|
padding: 0 8px;
|
||
|
|
border-radius: 4px;
|
||
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
|
|
background: rgba(255, 255, 255, 0.04);
|
||
|
|
color: var(--text, #e0e0e0);
|
||
|
|
font-family: inherit;
|
||
|
|
font-size: 11px;
|
||
|
|
cursor: pointer;
|
||
|
|
appearance: none;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-preset-select option {
|
||
|
|
background: #1a1a1a;
|
||
|
|
color: var(--text, #e0e0e0);
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-reset-btn {
|
||
|
|
height: 28px;
|
||
|
|
padding: 0 10px;
|
||
|
|
border-radius: 4px;
|
||
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
|
|
background: rgba(255, 255, 255, 0.04);
|
||
|
|
color: rgba(255, 255, 255, 0.5);
|
||
|
|
font-family: inherit;
|
||
|
|
font-size: 10px;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: all 0.15s;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-reset-btn:hover {
|
||
|
|
background: rgba(255, 255, 255, 0.1);
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Sections ---- */
|
||
|
|
.cs-section {
|
||
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-section-header {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: space-between;
|
||
|
|
padding: 10px 16px;
|
||
|
|
cursor: pointer;
|
||
|
|
user-select: none;
|
||
|
|
transition: background 0.15s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-section-header:hover {
|
||
|
|
background: rgba(255, 255, 255, 0.03);
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-section-header span:first-child {
|
||
|
|
font-size: 10px;
|
||
|
|
font-weight: 500;
|
||
|
|
color: rgba(255, 255, 255, 0.5);
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 1px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-section-chevron {
|
||
|
|
font-size: 8px;
|
||
|
|
color: rgba(255, 255, 255, 0.3);
|
||
|
|
transition: transform 0.2s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-section.collapsed .cs-section-chevron {
|
||
|
|
transform: rotate(-90deg);
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-section-body {
|
||
|
|
padding: 0 16px 8px;
|
||
|
|
overflow: hidden;
|
||
|
|
transition: max-height 0.2s ease, padding 0.2s ease;
|
||
|
|
max-height: 600px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-section.collapsed .cs-section-body {
|
||
|
|
max-height: 0;
|
||
|
|
padding-top: 0;
|
||
|
|
padding-bottom: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Parameter rows ---- */
|
||
|
|
.cs-param-row {
|
||
|
|
margin-bottom: 8px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-param-label {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 4px;
|
||
|
|
font-size: 10px;
|
||
|
|
color: rgba(255, 255, 255, 0.45);
|
||
|
|
margin-bottom: 3px;
|
||
|
|
cursor: default;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* Override dot: shows when param has a manual offset */
|
||
|
|
.cs-override-dot {
|
||
|
|
width: 5px;
|
||
|
|
height: 5px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: transparent;
|
||
|
|
flex-shrink: 0;
|
||
|
|
transition: background 0.15s;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-override-dot.active {
|
||
|
|
background: var(--accent, #ff6a00);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Slider row ---- */
|
||
|
|
.cs-slider-row {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 6px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-slider {
|
||
|
|
flex: 1;
|
||
|
|
height: 3px;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
appearance: none;
|
||
|
|
background: rgba(255, 255, 255, 0.1);
|
||
|
|
border-radius: 2px;
|
||
|
|
outline: none;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-slider::-webkit-slider-thumb {
|
||
|
|
-webkit-appearance: none;
|
||
|
|
width: 10px;
|
||
|
|
height: 10px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--accent, #ff6a00);
|
||
|
|
cursor: pointer;
|
||
|
|
border: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-slider::-moz-range-thumb {
|
||
|
|
width: 10px;
|
||
|
|
height: 10px;
|
||
|
|
border-radius: 50%;
|
||
|
|
background: var(--accent, #ff6a00);
|
||
|
|
border: none;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-param-value {
|
||
|
|
font-family: 'JetBrains Mono', monospace;
|
||
|
|
font-size: 9px;
|
||
|
|
color: rgba(255, 255, 255, 0.5);
|
||
|
|
min-width: 36px;
|
||
|
|
text-align: right;
|
||
|
|
white-space: nowrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Toggle (checkbox) ---- */
|
||
|
|
.cs-toggle {
|
||
|
|
accent-color: var(--accent, #ff6a00);
|
||
|
|
cursor: pointer;
|
||
|
|
width: 14px;
|
||
|
|
height: 14px;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Select ---- */
|
||
|
|
.cs-select {
|
||
|
|
height: 24px;
|
||
|
|
padding: 0 8px;
|
||
|
|
border-radius: 4px;
|
||
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
|
|
background: rgba(255, 255, 255, 0.04);
|
||
|
|
color: var(--text, #e0e0e0);
|
||
|
|
font-family: inherit;
|
||
|
|
font-size: 10px;
|
||
|
|
cursor: pointer;
|
||
|
|
appearance: none;
|
||
|
|
-webkit-appearance: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
.cs-select option {
|
||
|
|
background: #1a1a1a;
|
||
|
|
color: var(--text, #e0e0e0);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- Responsive: shrink axes on narrow screens ---- */
|
||
|
|
@media (max-width: 600px) {
|
||
|
|
.cs-axes {
|
||
|
|
gap: 4px;
|
||
|
|
}
|
||
|
|
.cs-axis-slider {
|
||
|
|
width: 36px;
|
||
|
|
}
|
||
|
|
.cs-axis-label {
|
||
|
|
font-size: 8px;
|
||
|
|
}
|
||
|
|
.cs-drawer {
|
||
|
|
width: 280px;
|
||
|
|
right: -300px;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
document.head.appendChild(style);
|
||
|
|
}
|