feat(chain-ui): drawer layout + signal-based reactive updates
Replace the inline chain builder with a full-screen slide-up drawer (overlay + glass backdrop + drag handle). Param sliders now subscribe to a vanilla signal (createArraySignal) for O(changed params) updates instead of querySelectorAll sweeps. Card list uses keyed reconciliation.
This commit is contained in:
parent
4240b03c1c
commit
8bb965740e
3 changed files with 460 additions and 42 deletions
|
|
@ -1,16 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* ShapeSeq Chain Builder UI — vertical stack of primitive cards
|
* ShapeSeq Chain Builder UI — full-screen slide-up drawer with signal-based updates
|
||||||
*
|
*
|
||||||
* Pedalboard-style chain editor: expandable cards with param sliders,
|
* Pedalboard-style chain editor: expandable cards with param sliders,
|
||||||
* drag-to-reorder, delete, add-primitive palette, generator combine toggle.
|
* drag-to-reorder, delete, add-primitive palette, generator combine toggle.
|
||||||
* Mobile-first, dark glass theme matching the immersive playground.
|
* Mobile-first, dark glass theme matching the immersive playground.
|
||||||
*
|
*
|
||||||
|
* Uses vanilla signals for reactive slider updates at 60fps — O(changed params)
|
||||||
|
* instead of O(all sliders) via querySelectorAll.
|
||||||
|
*
|
||||||
* All styles are inline (no external CSS dependency).
|
* All styles are inline (no external CSS dependency).
|
||||||
*
|
*
|
||||||
* @module shapeseq/chain-ui
|
* @module shapeseq/chain-ui
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PRIMITIVE_REGISTRY } from './primitives.js';
|
import { PRIMITIVE_REGISTRY } from './primitives.js';
|
||||||
|
import { createArraySignal } from './signal.js';
|
||||||
|
|
||||||
// ── Category colors ──────────────────────────────────────────────────
|
// ── Category colors ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -30,6 +34,98 @@ function injectStyles() {
|
||||||
stylesInjected = true;
|
stylesInjected = true;
|
||||||
|
|
||||||
const css = `
|
const css = `
|
||||||
|
/* ── Chain drawer overlay + slide-up drawer ────────────────────── */
|
||||||
|
|
||||||
|
.chain-drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9998;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer-overlay.open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
max-height: 85vh;
|
||||||
|
z-index: 9999;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
background: rgba(13, 13, 13, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer-handle {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0 4px;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer-handle::after {
|
||||||
|
content: '';
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 16px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff6a00;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer-close {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: transparent;
|
||||||
|
color: #888;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chain-drawer-close:hover {
|
||||||
|
border-color: #ff6a00;
|
||||||
|
color: #ff6a00;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Chain Builder container ─────────────────────────────────── */
|
/* ── Chain Builder container ─────────────────────────────────── */
|
||||||
|
|
||||||
.chain-builder {
|
.chain-builder {
|
||||||
|
|
@ -49,9 +145,8 @@ function injectStyles() {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
flex: 1;
|
||||||
font-size: 13px;
|
min-height: 0;
|
||||||
color: var(--cb-text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Primitive card ──────────────────────────────────────────── */
|
/* ── Primitive card ──────────────────────────────────────────── */
|
||||||
|
|
@ -328,7 +423,7 @@ function injectStyles() {
|
||||||
.chain-palette-overlay {
|
.chain-palette-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9999;
|
z-index: 10000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -461,6 +556,15 @@ export class ChainBuilderUI {
|
||||||
/** @private @type {Float32Array|null} current param values for display */
|
/** @private @type {Float32Array|null} current param values for display */
|
||||||
this._currentParams = null;
|
this._currentParams = null;
|
||||||
|
|
||||||
|
/** @private @type {import('./signal.js').ArraySignal|null} */
|
||||||
|
this._paramSignal = null;
|
||||||
|
|
||||||
|
/** @private @type {Function[]} signal unsubscribers for cleanup */
|
||||||
|
this._signalUnsubs = [];
|
||||||
|
|
||||||
|
/** @private @type {Map<number, HTMLElement>} chainIndex -> card element */
|
||||||
|
this._cardMap = new Map();
|
||||||
|
|
||||||
// ── Drag state ──
|
// ── Drag state ──
|
||||||
/** @private */
|
/** @private */
|
||||||
this._dragIndex = -1;
|
this._dragIndex = -1;
|
||||||
|
|
@ -477,52 +581,96 @@ export class ChainBuilderUI {
|
||||||
/** @private @type {HTMLElement|null} */
|
/** @private @type {HTMLElement|null} */
|
||||||
this._paletteOverlay = null;
|
this._paletteOverlay = null;
|
||||||
|
|
||||||
|
// ── Drawer elements ──
|
||||||
|
/** @private @type {HTMLElement|null} */
|
||||||
|
this._drawerOverlay = null;
|
||||||
|
/** @private @type {HTMLElement|null} */
|
||||||
|
this._drawer = null;
|
||||||
|
/** @private @type {boolean} */
|
||||||
|
this._isOpen = false;
|
||||||
|
|
||||||
// ── Bound handlers for cleanup ──
|
// ── Bound handlers for cleanup ──
|
||||||
this._onPointerMove = this._handlePointerMove.bind(this);
|
this._onPointerMove = this._handlePointerMove.bind(this);
|
||||||
this._onPointerUp = this._handlePointerUp.bind(this);
|
this._onPointerUp = this._handlePointerUp.bind(this);
|
||||||
|
|
||||||
|
this._buildDrawer();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild the entire UI from current chain state.
|
* Open the drawer.
|
||||||
|
*/
|
||||||
|
open() {
|
||||||
|
if (this._isOpen) return;
|
||||||
|
this._isOpen = true;
|
||||||
|
if (this._drawerOverlay) this._drawerOverlay.classList.add('open');
|
||||||
|
if (this._drawer) this._drawer.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the drawer.
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (!this._isOpen) return;
|
||||||
|
this._isOpen = false;
|
||||||
|
if (this._drawerOverlay) this._drawerOverlay.classList.remove('open');
|
||||||
|
if (this._drawer) this._drawer.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the card list from current chain state.
|
||||||
|
* Uses keyed reconciliation: only adds/removes/moves the cards that changed.
|
||||||
*/
|
*/
|
||||||
render() {
|
render() {
|
||||||
this._container.innerHTML = '';
|
|
||||||
this._container.classList.add('chain-builder');
|
|
||||||
|
|
||||||
const primitives = this._chain.getPrimitives();
|
const primitives = this._chain.getPrimitives();
|
||||||
|
const cardList = this._cardList;
|
||||||
|
if (!cardList) return;
|
||||||
|
|
||||||
// Card list wrapper
|
// Clean up old signal subscriptions
|
||||||
const cardList = document.createElement('div');
|
for (const unsub of this._signalUnsubs) unsub();
|
||||||
cardList.style.display = 'flex';
|
this._signalUnsubs = [];
|
||||||
cardList.style.flexDirection = 'column';
|
|
||||||
cardList.style.gap = '6px';
|
// Compute total param count and create signal
|
||||||
this._cardList = cardList;
|
let totalParams = 0;
|
||||||
|
for (const p of primitives) totalParams += p.paramCount;
|
||||||
|
this._paramSignal = createArraySignal(totalParams);
|
||||||
|
|
||||||
|
// Build desired order: chainIndex list
|
||||||
|
const desiredKeys = primitives.map((_, i) => i);
|
||||||
|
const existingKeys = [...this._cardMap.keys()];
|
||||||
|
|
||||||
|
// Remove cards no longer present
|
||||||
|
for (const key of existingKeys) {
|
||||||
|
if (!desiredKeys.includes(key)) {
|
||||||
|
const el = this._cardMap.get(key);
|
||||||
|
if (el && el.parentElement) el.remove();
|
||||||
|
this._cardMap.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a full reconciliation with reindexing, rebuild all cards
|
||||||
|
// (primitive indices change on add/remove/reorder)
|
||||||
|
const oldMap = this._cardMap;
|
||||||
|
this._cardMap = new Map();
|
||||||
|
|
||||||
|
// Remove all existing cards from DOM
|
||||||
|
while (cardList.firstChild) cardList.removeChild(cardList.firstChild);
|
||||||
|
|
||||||
// Compute flat param offset for each primitive
|
|
||||||
let flatOffset = 0;
|
let flatOffset = 0;
|
||||||
for (let i = 0; i < primitives.length; i++) {
|
for (let i = 0; i < primitives.length; i++) {
|
||||||
const prim = primitives[i];
|
const prim = primitives[i];
|
||||||
const card = this._createCard(prim, i, flatOffset);
|
const card = this._createCard(prim, i, flatOffset);
|
||||||
|
this._cardMap.set(i, card);
|
||||||
cardList.appendChild(card);
|
cardList.appendChild(card);
|
||||||
flatOffset += prim.paramCount;
|
flatOffset += prim.paramCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._container.appendChild(cardList);
|
// Seed signal with current param values if available
|
||||||
|
if (this._currentParams && this._currentParams.length >= totalParams) {
|
||||||
// Add button
|
this._paramSignal.update(this._currentParams);
|
||||||
const addBtn = document.createElement('button');
|
}
|
||||||
addBtn.className = 'chain-add-btn';
|
|
||||||
addBtn.innerHTML = '<span style="font-size:18px;line-height:1">+</span> Add Primitive';
|
|
||||||
addBtn.addEventListener('click', () => this._openPalette());
|
|
||||||
this._container.appendChild(addBtn);
|
|
||||||
|
|
||||||
// Generator combine mode toggle
|
|
||||||
const toggle = this._createCombineToggle();
|
|
||||||
this._container.appendChild(toggle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -531,8 +679,8 @@ export class ChainBuilderUI {
|
||||||
*/
|
*/
|
||||||
setLiveParams(paramIndices) {
|
setLiveParams(paramIndices) {
|
||||||
this._liveParams = new Set(paramIndices);
|
this._liveParams = new Set(paramIndices);
|
||||||
// Update existing rows without full re-render
|
if (!this._cardList) return;
|
||||||
const rows = this._container.querySelectorAll('.chain-param-row');
|
const rows = this._cardList.querySelectorAll('.chain-param-row');
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
const idx = parseInt(row.dataset.flatIndex, 10);
|
const idx = parseInt(row.dataset.flatIndex, 10);
|
||||||
if (isNaN(idx)) return;
|
if (isNaN(idx)) return;
|
||||||
|
|
@ -554,22 +702,14 @@ export class ChainBuilderUI {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update displayed param values without re-rendering.
|
* Update displayed param values without re-rendering.
|
||||||
|
* Backed by signal — only subscribers for changed indices are notified.
|
||||||
* @param {Float32Array|Array<number>} params - flat param array
|
* @param {Float32Array|Array<number>} params - flat param array
|
||||||
*/
|
*/
|
||||||
updateParamValues(params) {
|
updateParamValues(params) {
|
||||||
this._currentParams = params;
|
this._currentParams = params;
|
||||||
const sliders = this._container.querySelectorAll('.chain-param-slider');
|
if (this._paramSignal) {
|
||||||
sliders.forEach((slider) => {
|
this._paramSignal.update(params);
|
||||||
const idx = parseInt(slider.dataset.flatIndex, 10);
|
}
|
||||||
if (!isNaN(idx) && idx < params.length) {
|
|
||||||
slider.value = params[idx];
|
|
||||||
// Update adjacent value display
|
|
||||||
const valEl = slider.parentElement && slider.parentElement.querySelector('.chain-param-value');
|
|
||||||
if (valEl) {
|
|
||||||
valEl.textContent = params[idx].toFixed(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -577,11 +717,97 @@ export class ChainBuilderUI {
|
||||||
*/
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
this._closePalette();
|
this._closePalette();
|
||||||
|
this.close();
|
||||||
document.removeEventListener('pointermove', this._onPointerMove);
|
document.removeEventListener('pointermove', this._onPointerMove);
|
||||||
document.removeEventListener('pointerup', this._onPointerUp);
|
document.removeEventListener('pointerup', this._onPointerUp);
|
||||||
|
|
||||||
|
// Clean up signal subscriptions
|
||||||
|
for (const unsub of this._signalUnsubs) unsub();
|
||||||
|
this._signalUnsubs = [];
|
||||||
|
|
||||||
|
// Remove drawer from DOM
|
||||||
|
if (this._drawerOverlay) {
|
||||||
|
this._drawerOverlay.remove();
|
||||||
|
this._drawerOverlay = null;
|
||||||
|
}
|
||||||
|
if (this._drawer) {
|
||||||
|
this._drawer.remove();
|
||||||
|
this._drawer = null;
|
||||||
|
}
|
||||||
|
|
||||||
this._container.innerHTML = '';
|
this._container.innerHTML = '';
|
||||||
this._container.classList.remove('chain-builder');
|
this._container.classList.remove('chain-builder');
|
||||||
this._paramChangeCb = null;
|
this._paramChangeCb = null;
|
||||||
|
this._cardMap.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawer construction ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* Build the drawer shell (overlay + drawer container + header).
|
||||||
|
* The card list and controls go inside the drawer body.
|
||||||
|
*/
|
||||||
|
_buildDrawer() {
|
||||||
|
// Overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'chain-drawer-overlay';
|
||||||
|
overlay.addEventListener('click', () => this.close());
|
||||||
|
this._drawerOverlay = overlay;
|
||||||
|
|
||||||
|
// Drawer
|
||||||
|
const drawer = document.createElement('div');
|
||||||
|
drawer.className = 'chain-drawer';
|
||||||
|
this._drawer = drawer;
|
||||||
|
|
||||||
|
// Drag handle
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = 'chain-drawer-handle';
|
||||||
|
drawer.appendChild(handle);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'chain-drawer-header';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.textContent = 'Chain Builder';
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'chain-drawer-close';
|
||||||
|
closeBtn.textContent = '\u00D7';
|
||||||
|
closeBtn.addEventListener('click', () => this.close());
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
drawer.appendChild(header);
|
||||||
|
|
||||||
|
// Body (scrollable card list + controls)
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.classList.add('chain-builder');
|
||||||
|
|
||||||
|
const cardList = document.createElement('div');
|
||||||
|
cardList.style.display = 'flex';
|
||||||
|
cardList.style.flexDirection = 'column';
|
||||||
|
cardList.style.gap = '6px';
|
||||||
|
this._cardList = cardList;
|
||||||
|
body.appendChild(cardList);
|
||||||
|
|
||||||
|
// Add button
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'chain-add-btn';
|
||||||
|
addBtn.innerHTML = '<span style="font-size:18px;line-height:1">+</span> Add Primitive';
|
||||||
|
addBtn.addEventListener('click', () => this._openPalette());
|
||||||
|
body.appendChild(addBtn);
|
||||||
|
|
||||||
|
// Generator combine mode toggle
|
||||||
|
this._combineToggle = this._createCombineToggle();
|
||||||
|
body.appendChild(this._combineToggle);
|
||||||
|
|
||||||
|
drawer.appendChild(body);
|
||||||
|
|
||||||
|
// Mount to document body
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.body.appendChild(drawer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Card creation ──────────────────────────────────────────────────
|
// ── Card creation ──────────────────────────────────────────────────
|
||||||
|
|
@ -681,6 +907,14 @@ export class ChainBuilderUI {
|
||||||
body.appendChild(paramsDiv);
|
body.appendChild(paramsDiv);
|
||||||
card.appendChild(body);
|
card.appendChild(body);
|
||||||
|
|
||||||
|
// Subscribe summary to signal updates
|
||||||
|
if (this._paramSignal) {
|
||||||
|
const unsub = this._paramSignal.subscribe(() => {
|
||||||
|
summary.textContent = this._buildSummary(prim, flatOffset);
|
||||||
|
});
|
||||||
|
this._signalUnsubs.push(unsub);
|
||||||
|
}
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -702,6 +936,8 @@ export class ChainBuilderUI {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
|
* Creates a param row with a slider that subscribes to the param signal
|
||||||
|
* for its specific flat index — O(1) updates per changed param.
|
||||||
*/
|
*/
|
||||||
_createParamRow(schema, flatIdx, prim) {
|
_createParamRow(schema, flatIdx, prim) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
|
|
@ -744,6 +980,18 @@ export class ChainBuilderUI {
|
||||||
row.appendChild(slider);
|
row.appendChild(slider);
|
||||||
row.appendChild(valDisplay);
|
row.appendChild(valDisplay);
|
||||||
|
|
||||||
|
// Subscribe to signal for reactive updates (only this slider's index)
|
||||||
|
if (this._paramSignal) {
|
||||||
|
const unsub = this._paramSignal.subscribe((params) => {
|
||||||
|
if (flatIdx < params.length) {
|
||||||
|
const newVal = params[flatIdx];
|
||||||
|
slider.value = newVal;
|
||||||
|
valDisplay.textContent = newVal.toFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._signalUnsubs.push(unsub);
|
||||||
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
playground/js/shapeseq/signal.js
Normal file
54
playground/js/shapeseq/signal.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/**
|
||||||
|
* Minimal signal/observable for reactive UI updates.
|
||||||
|
* No framework dependency. ~40 lines.
|
||||||
|
*
|
||||||
|
* @module shapeseq/signal
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function createSignal(initialValue) {
|
||||||
|
let value = initialValue;
|
||||||
|
const subscribers = new Set();
|
||||||
|
|
||||||
|
function get() { return value; }
|
||||||
|
|
||||||
|
function set(newValue) {
|
||||||
|
if (newValue === value) return;
|
||||||
|
value = newValue;
|
||||||
|
for (const fn of subscribers) fn(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn) {
|
||||||
|
subscribers.add(fn);
|
||||||
|
return () => subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get, set, subscribe };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal backed by a Float32Array for param vectors.
|
||||||
|
* Subscribers are called when any element changes.
|
||||||
|
*/
|
||||||
|
export function createArraySignal(length) {
|
||||||
|
const value = new Float32Array(length);
|
||||||
|
const subscribers = new Set();
|
||||||
|
|
||||||
|
function get() { return value; }
|
||||||
|
|
||||||
|
function update(newArray) {
|
||||||
|
let changed = false;
|
||||||
|
for (let i = 0; i < value.length && i < newArray.length; i++) {
|
||||||
|
if (value[i] !== newArray[i]) { value[i] = newArray[i]; changed = true; }
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
for (const fn of subscribers) fn(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(fn) {
|
||||||
|
subscribers.add(fn);
|
||||||
|
return () => subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get, update, subscribe };
|
||||||
|
}
|
||||||
116
playground/js/shapeseq/tests/signal.test.js
Normal file
116
playground/js/shapeseq/tests/signal.test.js
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import { strict as assert } from 'node:assert';
|
||||||
|
import { createSignal, createArraySignal } from '../signal.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createSignal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('createSignal', () => {
|
||||||
|
it('get returns the initial value', () => {
|
||||||
|
const s = createSignal(42);
|
||||||
|
assert.strictEqual(s.get(), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('set updates the value and notifies subscribers', () => {
|
||||||
|
const s = createSignal(0);
|
||||||
|
const calls = [];
|
||||||
|
s.subscribe(v => calls.push(v));
|
||||||
|
s.set(10);
|
||||||
|
assert.strictEqual(s.get(), 10);
|
||||||
|
assert.deepStrictEqual(calls, [10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not notify on same value', () => {
|
||||||
|
const s = createSignal('hello');
|
||||||
|
const calls = [];
|
||||||
|
s.subscribe(v => calls.push(v));
|
||||||
|
s.set('hello');
|
||||||
|
assert.deepStrictEqual(calls, []);
|
||||||
|
assert.strictEqual(s.get(), 'hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribe works', () => {
|
||||||
|
const s = createSignal(0);
|
||||||
|
const calls = [];
|
||||||
|
const unsub = s.subscribe(v => calls.push(v));
|
||||||
|
s.set(1);
|
||||||
|
unsub();
|
||||||
|
s.set(2);
|
||||||
|
assert.deepStrictEqual(calls, [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple subscribers each get notified', () => {
|
||||||
|
const s = createSignal(0);
|
||||||
|
const a = [], b = [];
|
||||||
|
s.subscribe(v => a.push(v));
|
||||||
|
s.subscribe(v => b.push(v));
|
||||||
|
s.set(5);
|
||||||
|
assert.deepStrictEqual(a, [5]);
|
||||||
|
assert.deepStrictEqual(b, [5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// createArraySignal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('createArraySignal', () => {
|
||||||
|
it('initial value is all zeros', () => {
|
||||||
|
const s = createArraySignal(4);
|
||||||
|
const v = s.get();
|
||||||
|
assert.strictEqual(v.length, 4);
|
||||||
|
for (let i = 0; i < 4; i++) assert.strictEqual(v[i], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update triggers only on actual changes', () => {
|
||||||
|
const s = createArraySignal(3);
|
||||||
|
const calls = [];
|
||||||
|
s.subscribe(() => calls.push('changed'));
|
||||||
|
|
||||||
|
// Same values (all zero) should not trigger
|
||||||
|
s.update(new Float32Array([0, 0, 0]));
|
||||||
|
assert.strictEqual(calls.length, 0);
|
||||||
|
|
||||||
|
// Different values should trigger
|
||||||
|
s.update(new Float32Array([0.5, 0, 0]));
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('partial updates work (shorter array)', () => {
|
||||||
|
const s = createArraySignal(4);
|
||||||
|
s.update(new Float32Array([1, 2, 3, 4]));
|
||||||
|
assert.strictEqual(s.get()[3], 4);
|
||||||
|
|
||||||
|
// Partial update: only first 2 elements
|
||||||
|
const calls = [];
|
||||||
|
s.subscribe(() => calls.push('changed'));
|
||||||
|
s.update(new Float32Array([10, 20]));
|
||||||
|
|
||||||
|
// First two updated, last two unchanged
|
||||||
|
assert.strictEqual(s.get()[0], 10);
|
||||||
|
assert.strictEqual(s.get()[1], 20);
|
||||||
|
assert.strictEqual(s.get()[2], 3);
|
||||||
|
assert.strictEqual(s.get()[3], 4);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subscriber receives the backing array', () => {
|
||||||
|
const s = createArraySignal(2);
|
||||||
|
let received = null;
|
||||||
|
s.subscribe(v => { received = v; });
|
||||||
|
s.update(new Float32Array([0.1, 0.2]));
|
||||||
|
assert.ok(received instanceof Float32Array);
|
||||||
|
assert.strictEqual(received[0], Float32Array.of(0.1)[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribe works', () => {
|
||||||
|
const s = createArraySignal(2);
|
||||||
|
const calls = [];
|
||||||
|
const unsub = s.subscribe(() => calls.push('x'));
|
||||||
|
s.update(new Float32Array([1, 0]));
|
||||||
|
unsub();
|
||||||
|
s.update(new Float32Array([2, 0]));
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue