chain-ui.js (1002 lines): Vertical stack chain builder (pedalboard- style) with expandable primitive cards, category-colored borders, drag-to-reorder via pointer events, add primitive palette modal, NISPS live-param pulsing indicator, generator combine mode toggle. All styles inline matching dark glass theme. sequencer.js (304 lines): ShapeSeqEngine orchestrator wiring the full pipeline: input routing → sequence MLP (16 outputs) → param mapping → chain evaluation → projection → clock scheduling → C15 bridge. Default chain: Euclidean→ProbGate→PitchWalker→IntervalLock→ VelocityShaper. Tracks active notes to prevent orphaned noteOns.
1002 lines
27 KiB
JavaScript
1002 lines
27 KiB
JavaScript
/**
|
|
* ShapeSeq Chain Builder UI — vertical stack of primitive cards
|
|
*
|
|
* Pedalboard-style chain editor: expandable cards with param sliders,
|
|
* drag-to-reorder, delete, add-primitive palette, generator combine toggle.
|
|
* Mobile-first, dark glass theme matching the immersive playground.
|
|
*
|
|
* All styles are inline (no external CSS dependency).
|
|
*
|
|
* @module shapeseq/chain-ui
|
|
*/
|
|
|
|
import { PRIMITIVE_REGISTRY } from './primitives.js';
|
|
|
|
// ── Category colors ──────────────────────────────────────────────────
|
|
|
|
const CATEGORY_COLORS = {
|
|
generator: '#ff6a00',
|
|
processor: '#00ccff',
|
|
timing: '#ffcc00',
|
|
converter: '#88ff00',
|
|
};
|
|
|
|
// ── Inject styles once ───────────────────────────────────────────────
|
|
|
|
let stylesInjected = false;
|
|
|
|
function injectStyles() {
|
|
if (stylesInjected) return;
|
|
stylesInjected = true;
|
|
|
|
const css = `
|
|
/* ── Chain Builder container ─────────────────────────────────── */
|
|
|
|
.chain-builder {
|
|
--cb-glass-bg: rgba(13, 13, 13, 0.72);
|
|
--cb-glass-border: rgba(255, 255, 255, 0.08);
|
|
--cb-glass-blur: 16px;
|
|
--cb-text: #e0e0e0;
|
|
--cb-text-dim: #888;
|
|
--cb-radius: 12px;
|
|
--cb-radius-sm: 8px;
|
|
--cb-accent: #ff6a00;
|
|
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding: 8px;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
font-size: 13px;
|
|
color: var(--cb-text);
|
|
}
|
|
|
|
/* ── Primitive card ──────────────────────────────────────────── */
|
|
|
|
.chain-card {
|
|
position: relative;
|
|
background: var(--cb-glass-bg);
|
|
backdrop-filter: blur(var(--cb-glass-blur));
|
|
-webkit-backdrop-filter: blur(var(--cb-glass-blur));
|
|
border: 1px solid var(--cb-glass-border);
|
|
border-radius: var(--cb-radius);
|
|
overflow: hidden;
|
|
transition: box-shadow 0.2s, transform 0.15s;
|
|
}
|
|
|
|
.chain-card.dragging {
|
|
opacity: 0.7;
|
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
transform: scale(1.03);
|
|
z-index: 100;
|
|
}
|
|
|
|
.chain-card-left-border {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 4px;
|
|
border-radius: var(--cb-radius) 0 0 var(--cb-radius);
|
|
}
|
|
|
|
/* ── Card header ─────────────────────────────────────────────── */
|
|
|
|
.chain-card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 10px 10px 14px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
-webkit-user-select: none;
|
|
}
|
|
|
|
.chain-card-drag {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
cursor: grab;
|
|
touch-action: none;
|
|
padding: 4px 2px;
|
|
opacity: 0.4;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.chain-card-drag:hover,
|
|
.chain-card-drag:active {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.chain-card-drag span {
|
|
display: block;
|
|
width: 14px;
|
|
height: 2px;
|
|
background: var(--cb-text);
|
|
border-radius: 1px;
|
|
}
|
|
|
|
.chain-card-name {
|
|
flex: 1;
|
|
font-weight: 600;
|
|
font-size: 13px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.chain-card-badge {
|
|
font-size: 9px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
color: var(--cb-text-dim);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.chain-card-summary {
|
|
font-size: 11px;
|
|
color: var(--cb-text-dim);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 140px;
|
|
}
|
|
|
|
.chain-card-delete {
|
|
background: none;
|
|
border: none;
|
|
color: var(--cb-text-dim);
|
|
font-size: 16px;
|
|
padding: 4px 6px;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: color 0.15s, background 0.15s;
|
|
line-height: 1;
|
|
}
|
|
|
|
.chain-card-delete:hover,
|
|
.chain-card-delete:active {
|
|
color: #ff4466;
|
|
background: rgba(255, 68, 102, 0.12);
|
|
}
|
|
|
|
.chain-card-expand-icon {
|
|
font-size: 10px;
|
|
color: var(--cb-text-dim);
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.chain-card.expanded .chain-card-expand-icon {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
/* ── Card body (params) ──────────────────────────────────────── */
|
|
|
|
.chain-card-body {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.25s ease;
|
|
}
|
|
|
|
.chain-card.expanded .chain-card-body {
|
|
max-height: 600px;
|
|
}
|
|
|
|
.chain-card-params {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
padding: 4px 14px 12px 14px;
|
|
}
|
|
|
|
/* ── Param slider row ────────────────────────────────────────── */
|
|
|
|
.chain-param-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.chain-param-label {
|
|
font-size: 11px;
|
|
color: var(--cb-text-dim);
|
|
min-width: 80px;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.chain-param-slider {
|
|
flex: 1;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
outline: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chain-param-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: var(--cb-text);
|
|
border: 2px solid rgba(0, 0, 0, 0.3);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chain-param-slider::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
background: var(--cb-text);
|
|
border: 2px solid rgba(0, 0, 0, 0.3);
|
|
cursor: pointer;
|
|
}
|
|
|
|
.chain-param-value {
|
|
font-size: 11px;
|
|
color: var(--cb-text-dim);
|
|
min-width: 32px;
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* ── NISPS live indicator ────────────────────────────────────── */
|
|
|
|
.chain-param-row.nisps-live .chain-param-slider {
|
|
box-shadow: 0 0 0 2px rgba(0, 140, 255, 0.5);
|
|
animation: nisps-pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes nisps-pulse {
|
|
0%, 100% { box-shadow: 0 0 0 2px rgba(0, 140, 255, 0.3); }
|
|
50% { box-shadow: 0 0 0 3px rgba(0, 140, 255, 0.7); }
|
|
}
|
|
|
|
/* ── Add button ──────────────────────────────────────────────── */
|
|
|
|
.chain-add-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 12px;
|
|
border: 2px dashed rgba(255, 255, 255, 0.12);
|
|
border-radius: var(--cb-radius);
|
|
background: transparent;
|
|
color: var(--cb-text-dim);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
|
|
.chain-add-btn:hover,
|
|
.chain-add-btn:active {
|
|
border-color: var(--cb-accent);
|
|
color: var(--cb-accent);
|
|
}
|
|
|
|
/* ── Generator combine mode toggle ───────────────────────────── */
|
|
|
|
.chain-combine-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 8px 0 4px;
|
|
}
|
|
|
|
.chain-combine-label {
|
|
font-size: 11px;
|
|
color: var(--cb-text-dim);
|
|
}
|
|
|
|
.chain-combine-btn {
|
|
font-family: inherit;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
padding: 4px 14px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
color: var(--cb-text);
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
min-width: 60px;
|
|
text-align: center;
|
|
}
|
|
|
|
.chain-combine-btn:hover,
|
|
.chain-combine-btn:active {
|
|
border-color: var(--cb-accent);
|
|
background: rgba(255, 106, 0, 0.1);
|
|
}
|
|
|
|
/* ── Palette modal ───────────────────────────────────────────── */
|
|
|
|
.chain-palette-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9999;
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
animation: palette-fade-in 0.15s ease;
|
|
}
|
|
|
|
@keyframes palette-fade-in {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
.chain-palette {
|
|
width: 100%;
|
|
max-width: 420px;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
background: rgba(20, 20, 20, 0.95);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 16px 16px 0 0;
|
|
padding: 16px 12px 24px;
|
|
animation: palette-slide-up 0.2s ease;
|
|
}
|
|
|
|
@keyframes palette-slide-up {
|
|
from { transform: translateY(40px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
|
|
.chain-palette-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--cb-text, #e0e0e0);
|
|
text-align: center;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.chain-palette-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
border: none;
|
|
background: none;
|
|
width: 100%;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.chain-palette-item:hover,
|
|
.chain-palette-item:active {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
}
|
|
|
|
.chain-palette-item-color {
|
|
width: 4px;
|
|
height: 36px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.chain-palette-item-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.chain-palette-item-name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.chain-palette-item-meta {
|
|
font-size: 11px;
|
|
color: #888;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* ── Drop indicator ──────────────────────────────────────────── */
|
|
|
|
.chain-drop-indicator {
|
|
height: 3px;
|
|
background: var(--cb-accent, #ff6a00);
|
|
border-radius: 2px;
|
|
margin: -2px 8px;
|
|
opacity: 0;
|
|
transition: opacity 0.1s;
|
|
}
|
|
|
|
.chain-drop-indicator.active {
|
|
opacity: 1;
|
|
}
|
|
`;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = css;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// ── ChainBuilderUI ───────────────────────────────────────────────────
|
|
|
|
export class ChainBuilderUI {
|
|
/**
|
|
* @param {{ container: HTMLElement, chain: import('./chain.js').Chain, eventBus: { emit: Function, on: Function } }} opts
|
|
*/
|
|
constructor({ container, chain, eventBus }) {
|
|
injectStyles();
|
|
|
|
/** @type {HTMLElement} */
|
|
this._container = container;
|
|
/** @type {import('./chain.js').Chain} */
|
|
this._chain = chain;
|
|
/** @type {{ emit: Function, on: Function }} */
|
|
this._bus = eventBus;
|
|
|
|
/** @private @type {Set<number>} flat param indices controlled by NISPS */
|
|
this._liveParams = new Set();
|
|
|
|
/** @private @type {Function|null} */
|
|
this._paramChangeCb = null;
|
|
|
|
/** @private @type {Set<number>} indices of expanded cards */
|
|
this._expandedCards = new Set();
|
|
|
|
/** @private @type {Float32Array|null} current param values for display */
|
|
this._currentParams = null;
|
|
|
|
// ── Drag state ──
|
|
/** @private */
|
|
this._dragIndex = -1;
|
|
/** @private */
|
|
this._dragEl = null;
|
|
/** @private */
|
|
this._dragStartY = 0;
|
|
/** @private */
|
|
this._dragOffsetY = 0;
|
|
/** @private @type {HTMLElement|null} */
|
|
this._cardList = null;
|
|
|
|
// ── Palette overlay ref ──
|
|
/** @private @type {HTMLElement|null} */
|
|
this._paletteOverlay = null;
|
|
|
|
// ── Bound handlers for cleanup ──
|
|
this._onPointerMove = this._handlePointerMove.bind(this);
|
|
this._onPointerUp = this._handlePointerUp.bind(this);
|
|
|
|
this.render();
|
|
}
|
|
|
|
// ── Public API ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Rebuild the entire UI from current chain state.
|
|
*/
|
|
render() {
|
|
this._container.innerHTML = '';
|
|
this._container.classList.add('chain-builder');
|
|
|
|
const primitives = this._chain.getPrimitives();
|
|
|
|
// Card list wrapper
|
|
const cardList = document.createElement('div');
|
|
cardList.style.display = 'flex';
|
|
cardList.style.flexDirection = 'column';
|
|
cardList.style.gap = '6px';
|
|
this._cardList = cardList;
|
|
|
|
// Compute flat param offset for each primitive
|
|
let flatOffset = 0;
|
|
for (let i = 0; i < primitives.length; i++) {
|
|
const prim = primitives[i];
|
|
const card = this._createCard(prim, i, flatOffset);
|
|
cardList.appendChild(card);
|
|
flatOffset += prim.paramCount;
|
|
}
|
|
|
|
this._container.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());
|
|
this._container.appendChild(addBtn);
|
|
|
|
// Generator combine mode toggle
|
|
const toggle = this._createCombineToggle();
|
|
this._container.appendChild(toggle);
|
|
}
|
|
|
|
/**
|
|
* Update which flat param indices are NISPS-controlled (pulsing indicator).
|
|
* @param {Array<number>} paramIndices
|
|
*/
|
|
setLiveParams(paramIndices) {
|
|
this._liveParams = new Set(paramIndices);
|
|
// Update existing rows without full re-render
|
|
const rows = this._container.querySelectorAll('.chain-param-row');
|
|
rows.forEach((row) => {
|
|
const idx = parseInt(row.dataset.flatIndex, 10);
|
|
if (isNaN(idx)) return;
|
|
if (this._liveParams.has(idx)) {
|
|
row.classList.add('nisps-live');
|
|
} else {
|
|
row.classList.remove('nisps-live');
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Register a callback for manual slider changes.
|
|
* @param {(flatParamIndex: number, value: number) => void} callback
|
|
*/
|
|
onParamChange(callback) {
|
|
this._paramChangeCb = callback;
|
|
}
|
|
|
|
/**
|
|
* Update displayed param values without re-rendering.
|
|
* @param {Float32Array|Array<number>} params - flat param array
|
|
*/
|
|
updateParamValues(params) {
|
|
this._currentParams = params;
|
|
const sliders = this._container.querySelectorAll('.chain-param-slider');
|
|
sliders.forEach((slider) => {
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cleanup all DOM and listeners.
|
|
*/
|
|
destroy() {
|
|
this._closePalette();
|
|
document.removeEventListener('pointermove', this._onPointerMove);
|
|
document.removeEventListener('pointerup', this._onPointerUp);
|
|
this._container.innerHTML = '';
|
|
this._container.classList.remove('chain-builder');
|
|
this._paramChangeCb = null;
|
|
}
|
|
|
|
// ── Card creation ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @private
|
|
* @param {import('./primitive.js').Primitive} prim
|
|
* @param {number} chainIndex
|
|
* @param {number} flatOffset - flat param offset for this primitive
|
|
* @returns {HTMLElement}
|
|
*/
|
|
_createCard(prim, chainIndex, flatOffset) {
|
|
const card = document.createElement('div');
|
|
card.className = 'chain-card';
|
|
card.dataset.chainIndex = chainIndex;
|
|
if (this._expandedCards.has(chainIndex)) {
|
|
card.classList.add('expanded');
|
|
}
|
|
|
|
const catColor = CATEGORY_COLORS[prim.category] || '#888';
|
|
|
|
// Left color border
|
|
const leftBorder = document.createElement('div');
|
|
leftBorder.className = 'chain-card-left-border';
|
|
leftBorder.style.background = catColor;
|
|
card.appendChild(leftBorder);
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'chain-card-header';
|
|
|
|
// Drag handle
|
|
const drag = document.createElement('div');
|
|
drag.className = 'chain-card-drag';
|
|
drag.innerHTML = '<span></span><span></span><span></span>';
|
|
drag.addEventListener('pointerdown', (e) => this._handleDragStart(e, chainIndex, card));
|
|
header.appendChild(drag);
|
|
|
|
// Name
|
|
const name = document.createElement('span');
|
|
name.className = 'chain-card-name';
|
|
name.textContent = prim.name;
|
|
header.appendChild(name);
|
|
|
|
// Category badge
|
|
const badge = document.createElement('span');
|
|
badge.className = 'chain-card-badge';
|
|
badge.textContent = prim.category;
|
|
badge.style.color = catColor;
|
|
header.appendChild(badge);
|
|
|
|
// Summary (collapsed key params)
|
|
const summary = document.createElement('span');
|
|
summary.className = 'chain-card-summary';
|
|
summary.textContent = this._buildSummary(prim, flatOffset);
|
|
header.appendChild(summary);
|
|
|
|
// Expand chevron
|
|
const chevron = document.createElement('span');
|
|
chevron.className = 'chain-card-expand-icon';
|
|
chevron.textContent = '\u25B6'; // right-pointing triangle
|
|
header.appendChild(chevron);
|
|
|
|
// Delete button
|
|
const del = document.createElement('button');
|
|
del.className = 'chain-card-delete';
|
|
del.textContent = '\u00D7'; // multiplication sign as X
|
|
del.title = 'Remove';
|
|
del.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this._removePrimitive(chainIndex);
|
|
});
|
|
header.appendChild(del);
|
|
|
|
// Toggle expand on header click (but not on drag handle or delete)
|
|
header.addEventListener('click', (e) => {
|
|
if (e.target.closest('.chain-card-drag') || e.target.closest('.chain-card-delete')) return;
|
|
this._toggleExpand(chainIndex, card);
|
|
});
|
|
|
|
card.appendChild(header);
|
|
|
|
// Body (params)
|
|
const body = document.createElement('div');
|
|
body.className = 'chain-card-body';
|
|
|
|
const paramsDiv = document.createElement('div');
|
|
paramsDiv.className = 'chain-card-params';
|
|
|
|
for (let pi = 0; pi < prim.paramSchema.length; pi++) {
|
|
const schema = prim.paramSchema[pi];
|
|
const flatIdx = flatOffset + pi;
|
|
const row = this._createParamRow(schema, flatIdx, prim);
|
|
paramsDiv.appendChild(row);
|
|
}
|
|
|
|
body.appendChild(paramsDiv);
|
|
card.appendChild(body);
|
|
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_buildSummary(prim, flatOffset) {
|
|
const schema = prim.paramSchema;
|
|
const maxShow = Math.min(3, schema.length);
|
|
const parts = [];
|
|
for (let i = 0; i < maxShow; i++) {
|
|
const val = this._currentParams
|
|
? this._currentParams[flatOffset + i]
|
|
: schema[i].default;
|
|
parts.push(schema[i].name + ':' + (val !== undefined ? val.toFixed(2) : schema[i].default.toFixed(2)));
|
|
}
|
|
return parts.join(' ');
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_createParamRow(schema, flatIdx, prim) {
|
|
const row = document.createElement('div');
|
|
row.className = 'chain-param-row';
|
|
row.dataset.flatIndex = flatIdx;
|
|
if (this._liveParams.has(flatIdx)) {
|
|
row.classList.add('nisps-live');
|
|
}
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'chain-param-label';
|
|
label.textContent = schema.name;
|
|
row.appendChild(label);
|
|
|
|
const slider = document.createElement('input');
|
|
slider.type = 'range';
|
|
slider.className = 'chain-param-slider';
|
|
slider.min = '0';
|
|
slider.max = '1';
|
|
slider.step = '0.001';
|
|
slider.dataset.flatIndex = flatIdx;
|
|
|
|
const currentVal = this._currentParams
|
|
? this._currentParams[flatIdx]
|
|
: schema.default;
|
|
slider.value = currentVal !== undefined ? currentVal : schema.default;
|
|
|
|
const valDisplay = document.createElement('span');
|
|
valDisplay.className = 'chain-param-value';
|
|
valDisplay.textContent = parseFloat(slider.value).toFixed(2);
|
|
|
|
slider.addEventListener('input', () => {
|
|
const v = parseFloat(slider.value);
|
|
valDisplay.textContent = v.toFixed(2);
|
|
if (this._paramChangeCb) {
|
|
this._paramChangeCb(flatIdx, v);
|
|
}
|
|
});
|
|
|
|
row.appendChild(slider);
|
|
row.appendChild(valDisplay);
|
|
|
|
return row;
|
|
}
|
|
|
|
// ── Expand / collapse ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_toggleExpand(chainIndex, cardEl) {
|
|
if (this._expandedCards.has(chainIndex)) {
|
|
this._expandedCards.delete(chainIndex);
|
|
cardEl.classList.remove('expanded');
|
|
} else {
|
|
this._expandedCards.add(chainIndex);
|
|
cardEl.classList.add('expanded');
|
|
}
|
|
}
|
|
|
|
// ── Add / remove primitives ────────────────────────────────────────
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_removePrimitive(chainIndex) {
|
|
this._chain.removePrimitive(chainIndex);
|
|
// Adjust expanded set
|
|
const newExpanded = new Set();
|
|
for (const idx of this._expandedCards) {
|
|
if (idx < chainIndex) newExpanded.add(idx);
|
|
else if (idx > chainIndex) newExpanded.add(idx - 1);
|
|
// idx === chainIndex is removed
|
|
}
|
|
this._expandedCards = newExpanded;
|
|
this.render();
|
|
this._bus.emit('ui.chainEdit', { action: 'remove', index: chainIndex });
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_addPrimitive(registryKey) {
|
|
const Ctor = PRIMITIVE_REGISTRY[registryKey];
|
|
if (!Ctor) return;
|
|
const instance = new Ctor();
|
|
this._chain.addPrimitive(instance);
|
|
this.render();
|
|
this._bus.emit('ui.chainEdit', { action: 'add', name: registryKey });
|
|
}
|
|
|
|
// ── Palette ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_openPalette() {
|
|
if (this._paletteOverlay) return;
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'chain-palette-overlay';
|
|
|
|
const palette = document.createElement('div');
|
|
palette.className = 'chain-palette';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'chain-palette-title';
|
|
title.textContent = 'Add Primitive';
|
|
palette.appendChild(title);
|
|
|
|
const keys = Object.keys(PRIMITIVE_REGISTRY);
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
const CtorRef = PRIMITIVE_REGISTRY[key];
|
|
// Instantiate temporarily to read metadata
|
|
const temp = new CtorRef();
|
|
|
|
const item = document.createElement('button');
|
|
item.className = 'chain-palette-item';
|
|
|
|
const colorBar = document.createElement('div');
|
|
colorBar.className = 'chain-palette-item-color';
|
|
colorBar.style.background = CATEGORY_COLORS[temp.category] || '#888';
|
|
item.appendChild(colorBar);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'chain-palette-item-info';
|
|
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'chain-palette-item-name';
|
|
nameEl.textContent = temp.name;
|
|
info.appendChild(nameEl);
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'chain-palette-item-meta';
|
|
meta.textContent = temp.category + ' \u00B7 ' + temp.paramCount + ' params';
|
|
info.appendChild(meta);
|
|
|
|
item.appendChild(info);
|
|
|
|
item.addEventListener('click', () => {
|
|
this._closePalette();
|
|
this._addPrimitive(key);
|
|
});
|
|
|
|
palette.appendChild(item);
|
|
}
|
|
|
|
overlay.appendChild(palette);
|
|
|
|
// Close on overlay click (outside palette)
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) {
|
|
this._closePalette();
|
|
}
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
this._paletteOverlay = overlay;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_closePalette() {
|
|
if (this._paletteOverlay) {
|
|
this._paletteOverlay.remove();
|
|
this._paletteOverlay = null;
|
|
}
|
|
}
|
|
|
|
// ── Generator combine mode toggle ──────────────────────────────────
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_createCombineToggle() {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'chain-combine-toggle';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'chain-combine-label';
|
|
label.textContent = 'Gen combine:';
|
|
wrapper.appendChild(label);
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'chain-combine-btn';
|
|
btn.textContent = this._chain.generatorCombineMode === 'additive' ? 'Add' : 'Mul';
|
|
|
|
btn.addEventListener('click', () => {
|
|
if (this._chain.generatorCombineMode === 'additive') {
|
|
this._chain.generatorCombineMode = 'multiplicative';
|
|
btn.textContent = 'Mul';
|
|
} else {
|
|
this._chain.generatorCombineMode = 'additive';
|
|
btn.textContent = 'Add';
|
|
}
|
|
this._bus.emit('ui.chainEdit', { action: 'combineMode', mode: this._chain.generatorCombineMode });
|
|
});
|
|
|
|
wrapper.appendChild(btn);
|
|
return wrapper;
|
|
}
|
|
|
|
// ── Drag-to-reorder (pointer events) ───────────────────────────────
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_handleDragStart(e, chainIndex, cardEl) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
this._dragIndex = chainIndex;
|
|
this._dragEl = cardEl;
|
|
this._dragStartY = e.clientY;
|
|
this._dragOffsetY = 0;
|
|
|
|
cardEl.classList.add('dragging');
|
|
cardEl.setPointerCapture(e.pointerId);
|
|
|
|
document.addEventListener('pointermove', this._onPointerMove);
|
|
document.addEventListener('pointerup', this._onPointerUp);
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_handlePointerMove(e) {
|
|
if (this._dragIndex < 0 || !this._dragEl || !this._cardList) return;
|
|
|
|
const dy = e.clientY - this._dragStartY;
|
|
this._dragOffsetY = dy;
|
|
this._dragEl.style.transform = 'translateY(' + dy + 'px) scale(1.03)';
|
|
|
|
// Determine drop target by checking which card we're over
|
|
const cards = this._cardList.querySelectorAll('.chain-card');
|
|
const dragRect = this._dragEl.getBoundingClientRect();
|
|
const dragCenter = dragRect.top + dragRect.height / 2;
|
|
|
|
let targetIndex = this._dragIndex;
|
|
for (let i = 0; i < cards.length; i++) {
|
|
if (i === this._dragIndex) continue;
|
|
const rect = cards[i].getBoundingClientRect();
|
|
const center = rect.top + rect.height / 2;
|
|
if (this._dragIndex < i && dragCenter > center) {
|
|
targetIndex = i;
|
|
} else if (this._dragIndex > i && dragCenter < center) {
|
|
targetIndex = i;
|
|
}
|
|
}
|
|
|
|
this._dragTargetIndex = targetIndex;
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_handlePointerUp(e) {
|
|
document.removeEventListener('pointermove', this._onPointerMove);
|
|
document.removeEventListener('pointerup', this._onPointerUp);
|
|
|
|
if (this._dragEl) {
|
|
this._dragEl.classList.remove('dragging');
|
|
this._dragEl.style.transform = '';
|
|
}
|
|
|
|
const from = this._dragIndex;
|
|
const to = this._dragTargetIndex !== undefined ? this._dragTargetIndex : from;
|
|
|
|
this._dragIndex = -1;
|
|
this._dragEl = null;
|
|
this._dragTargetIndex = undefined;
|
|
|
|
if (from !== to && from >= 0) {
|
|
// Adjust expanded set for the move
|
|
const newExpanded = new Set();
|
|
for (const idx of this._expandedCards) {
|
|
if (idx === from) {
|
|
newExpanded.add(to);
|
|
} else {
|
|
let adjusted = idx;
|
|
if (from < to) {
|
|
if (idx > from && idx <= to) adjusted = idx - 1;
|
|
} else {
|
|
if (idx >= to && idx < from) adjusted = idx + 1;
|
|
}
|
|
newExpanded.add(adjusted);
|
|
}
|
|
}
|
|
this._expandedCards = newExpanded;
|
|
|
|
this._chain.movePrimitive(from, to);
|
|
this.render();
|
|
this._bus.emit('ui.chainEdit', { action: 'reorder', from: from, to: to });
|
|
}
|
|
}
|
|
}
|