memlnaut-nisps/playground/js/shapeseq/freeze-ui.js
w1n5t0n 70da83d6dc feat(freeze-ui): freeze/live param toggle interaction
When frozen, param rows dim to indicate frozen state. Tapping a row
toggles it to "live" (highlighted, receives ML deltas). Unfreezing
clears all visual indicators.
2026-04-07 00:00:56 +01:00

159 lines
4.8 KiB
JavaScript

/**
* Freeze UI — adds freeze/live toggle interaction to chain param rows.
*
* When frozen:
* - Param rows are dimmed by default (frozen state)
* - Tapping a param row toggles it to "live" (highlighted, receives ML deltas)
* - Live params show a secondary indicator (delta range bar) on the slider
*
* When not frozen:
* - All params show normally (no freeze indicators)
*
* @module shapeseq/freeze-ui
*/
// ── Inject styles once ───────────────────────────────────────────────
let stylesInjected = false;
function injectStyles() {
if (stylesInjected) return;
stylesInjected = true;
const css = `
/* ── Freeze UI states ───────────────────────────────────────── */
.param-frozen {
cursor: pointer;
}
.param-frozen .chain-param-slider {
pointer-events: none;
}
.param-live {
cursor: pointer;
}
.param-live .chain-param-slider {
box-shadow: 0 0 0 2px rgba(0, 200, 100, 0.5);
}
`;
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
// ── FreezeUI ─────────────────────────────────────────────────────────
export class FreezeUI {
/**
* @param {{ container: HTMLElement, freezeManager: import('./freeze.js').FreezeManager, onToggle: (flatIndex: number) => void }} opts
*/
constructor({ container, freezeManager, onToggle }) {
injectStyles();
this._container = container;
this._fm = freezeManager;
this._onToggle = onToggle;
this._clickHandler = this._handleClick.bind(this);
// Delegate click handling on param rows
this._container.addEventListener('click', this._clickHandler);
}
/**
* Update visual state of all param rows based on freeze state.
* Call this after freeze/unfreeze/toggle.
*/
update() {
if (!this._fm.isFrozen) {
// Remove all freeze styling
this._container.querySelectorAll('.chain-param-row').forEach(row => {
row.classList.remove('param-frozen', 'param-live');
row.style.opacity = '';
});
return;
}
const liveFlags = this._fm.getLiveFlags();
if (!liveFlags) return;
this._container.querySelectorAll('.chain-param-row').forEach(row => {
const idx = parseInt(row.dataset.flatIndex, 10);
if (isNaN(idx) || idx >= liveFlags.length) return;
if (liveFlags[idx]) {
row.classList.add('param-live');
row.classList.remove('param-frozen');
row.style.opacity = '';
} else {
row.classList.add('param-frozen');
row.classList.remove('param-live');
row.style.opacity = '0.4';
}
});
}
/**
* Show delta indicators on live param sliders.
* @param {Float32Array} frozenParams
* @param {Float32Array} effectiveParams
*/
updateDeltaIndicators(frozenParams, effectiveParams) {
if (!this._fm.isFrozen || !frozenParams || !effectiveParams) return;
this._container.querySelectorAll('.chain-param-row.param-live').forEach(row => {
const idx = parseInt(row.dataset.flatIndex, 10);
if (isNaN(idx)) return;
// Show delta as a secondary mark on the slider
let indicator = row.querySelector('.delta-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'delta-indicator';
indicator.style.cssText = 'position:absolute;height:4px;background:rgba(0,140,255,0.6);border-radius:2px;bottom:0;pointer-events:none;transition:left 0.1s,width 0.1s;';
const slider = row.querySelector('.chain-param-slider');
if (slider) {
slider.parentElement.style.position = 'relative';
slider.parentElement.appendChild(indicator);
}
}
// Position indicator between frozen value and effective value
const frozen = frozenParams[idx];
const effective = effectiveParams[idx];
const left = Math.min(frozen, effective) * 100;
const width = Math.abs(effective - frozen) * 100;
indicator.style.left = left + '%';
indicator.style.width = width + '%';
});
}
/**
* @private
*/
_handleClick(e) {
if (!this._fm.isFrozen) return;
const row = e.target.closest('.chain-param-row');
if (!row) return;
// Don't toggle when interacting with the slider itself
if (e.target.closest('.chain-param-slider')) return;
const idx = parseInt(row.dataset.flatIndex, 10);
if (isNaN(idx)) return;
this._onToggle(idx);
this.update();
}
/**
* Cleanup all DOM listeners.
*/
destroy() {
this._container.removeEventListener('click', this._clickHandler);
}
}