Giant refactor. added layers. ui overhaul. added save/load and we now got presets
This commit is contained in:
Sam
2025-12-28 03:21:25 +13:00
parent f01076df57
commit 14ec23237f
90 changed files with 4971 additions and 22901 deletions

View File

@@ -0,0 +1,667 @@
/**
* ControlFactory - Creates DOM elements for different control types
*
* Separated from ControlManager for cleaner code organization
* Features collapsible settings panels and inline filter configuration
*/
class ControlFactory {
constructor(controlManager) {
this.controlManager = controlManager;
}
/**
* Create a control based on type
* @param {object} config - Control configuration
* @param {object} instance - Shape instance to bind to
* @param {HTMLElement} container - Container element
* @returns {object} Control data { element, listener, type, config }
*/
create(config, instance, container) {
switch (config.type) {
case 'range':
return this.createRange(config, instance, container);
case 'color':
return this.createColor(config, instance, container);
case 'checkbox':
return this.createCheckbox(config, instance, container);
case 'dropdown':
return this.createDropdown(config, instance, container);
case 'button':
return this.createButton(config, instance, container);
case 'header':
return this.createHeader(config, container);
default:
console.warn(`Unknown control type: ${config.type}`);
return null;
}
}
/**
* Create a collapsible section with a toggle arrow
*/
createCollapsible(title, className, defaultOpen = false) {
const wrapper = document.createElement('div');
wrapper.className = `collapsible-section ${className}`;
const header = document.createElement('div');
header.className = 'collapsible-header';
const arrow = document.createElement('span');
arrow.className = 'collapsible-arrow';
arrow.textContent = defaultOpen ? '▼' : '▶';
const titleSpan = document.createElement('span');
titleSpan.className = 'collapsible-title';
titleSpan.textContent = title;
header.appendChild(arrow);
header.appendChild(titleSpan);
const content = document.createElement('div');
content.className = 'collapsible-content';
content.style.display = defaultOpen ? 'block' : 'none';
header.addEventListener('click', () => {
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
arrow.textContent = isOpen ? '▶' : '▼';
});
wrapper.appendChild(header);
wrapper.appendChild(content);
return { wrapper, content, header, arrow };
}
/**
* Create a range slider control with collapsible settings and filters
*/
createRange(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container control-range-container';
// Main row: label + value + slider
const mainRow = document.createElement('div');
mainRow.className = 'control-main-row';
// Label showing property name
const label = document.createElement('span');
label.className = 'control-label-inline';
label.textContent = config.property;
// Value display (editable)
const valueDisplay = document.createElement('span');
valueDisplay.className = 'control-value';
valueDisplay.id = `elText${config.property}`;
valueDisplay.textContent = instance[config.property] ?? config.defaultValue;
// Range input
const input = document.createElement('input');
input.type = 'range';
input.className = 'control control-range';
input.id = `el${config.property}`;
input.min = config.min;
input.max = config.max;
input.value = instance[config.property] ?? config.defaultValue;
// Store original config bounds for reference
const configState = {
min: config.min,
max: config.max,
originalMin: config.min,
originalMax: config.max
};
// Event listener
const listener = (event) => {
const newValue = parseFloat(event.target.value);
instance[config.property] = newValue;
valueDisplay.textContent = Math.round(newValue * 100) / 100;
if (config.callback) {
config.callback(instance, newValue);
}
};
input.addEventListener('input', listener);
mainRow.appendChild(label);
mainRow.appendChild(valueDisplay);
wrapper.appendChild(mainRow);
wrapper.appendChild(input);
// Options row with toggle buttons
const optionsRow = document.createElement('div');
optionsRow.className = 'control-options-row';
// Settings toggle button
const settingsBtn = document.createElement('button');
settingsBtn.className = 'control-toggle-btn';
settingsBtn.innerHTML = '⚙';
settingsBtn.title = 'Settings';
// Filters toggle button
const filtersBtn = document.createElement('button');
filtersBtn.className = 'control-toggle-btn';
filtersBtn.innerHTML = '◇';
filtersBtn.title = 'Filters';
optionsRow.appendChild(settingsBtn);
optionsRow.appendChild(filtersBtn);
wrapper.appendChild(optionsRow);
// Settings panel (hidden by default)
const settingsPanel = document.createElement('div');
settingsPanel.className = 'control-settings-panel';
settingsPanel.style.display = 'none';
// Min setting
const minRow = this.createSettingRow('Min', configState.min, (val) => {
configState.min = val;
input.min = val;
});
// Max setting
const maxRow = this.createSettingRow('Max', configState.max, (val) => {
configState.max = val;
input.max = val;
});
settingsPanel.appendChild(minRow);
settingsPanel.appendChild(maxRow);
wrapper.appendChild(settingsPanel);
// Settings toggle
settingsBtn.addEventListener('click', () => {
const isOpen = settingsPanel.style.display !== 'none';
settingsPanel.style.display = isOpen ? 'none' : 'block';
settingsBtn.classList.toggle('active', !isOpen);
});
// Filters panel (hidden by default)
const filtersPanel = document.createElement('div');
filtersPanel.className = 'control-filters-panel';
filtersPanel.style.display = 'none';
// Add filter dropdown
const addFilterRow = document.createElement('div');
addFilterRow.className = 'add-filter-row';
const filterSelect = document.createElement('select');
filterSelect.className = 'filter-type-select';
filterSelect.innerHTML = '<option value="">+ Add Filter...</option>';
const filters = this.controlManager.filterManager.getAvailableFilters();
for (const filter of filters) {
const opt = document.createElement('option');
opt.value = filter.id;
opt.textContent = filter.name;
filterSelect.appendChild(opt);
}
addFilterRow.appendChild(filterSelect);
filtersPanel.appendChild(addFilterRow);
// Container for active filters
const filtersContainer = document.createElement('div');
filtersContainer.className = 'filters-list';
filtersPanel.appendChild(filtersContainer);
wrapper.appendChild(filtersPanel);
// Filters toggle
filtersBtn.addEventListener('click', () => {
const isOpen = filtersPanel.style.display !== 'none';
filtersPanel.style.display = isOpen ? 'none' : 'block';
filtersBtn.classList.toggle('active', !isOpen);
});
container.appendChild(wrapper);
const controlData = {
element: input,
listener,
type: 'range',
config,
configState,
wrapper,
filtersContainer,
filters: [],
label,
valueDisplay,
settingsPanel,
filtersPanel,
filtersBtn
};
// Filter dropdown handler
filterSelect.addEventListener('change', () => {
if (filterSelect.value) {
this.controlManager.addFilter(controlData, config, filterSelect.value);
filterSelect.value = ''; // Reset dropdown
// Update filter button to show filter count
this.updateFilterBadge(controlData);
}
});
return controlData;
}
/**
* Create a setting row with label and number input
*/
createSettingRow(label, value, onChange) {
const row = document.createElement('div');
row.className = 'setting-row';
const labelEl = document.createElement('span');
labelEl.className = 'setting-label';
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'number';
input.className = 'setting-input';
input.value = value;
input.addEventListener('change', (e) => {
onChange(parseFloat(e.target.value));
});
row.appendChild(labelEl);
row.appendChild(input);
return row;
}
/**
* Update filter badge on button
*/
updateFilterBadge(controlData) {
const count = controlData.filters.length;
if (count > 0) {
controlData.filtersBtn.innerHTML = `${count}`;
controlData.filtersBtn.classList.add('has-filters');
} else {
controlData.filtersBtn.innerHTML = '◇';
controlData.filtersBtn.classList.remove('has-filters');
}
}
/**
* Create a color picker control
*/
createColor(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container control-color-container';
const mainRow = document.createElement('div');
mainRow.className = 'control-main-row';
const label = document.createElement('span');
label.className = 'control-label-inline';
label.textContent = config.property;
const input = document.createElement('input');
input.type = 'color';
input.className = 'control control-color';
input.id = `el${config.property}`;
input.value = instance[config.property] ?? config.defaultValue;
const listener = (event) => {
const newValue = event.target.value;
instance[config.property] = newValue;
if (config.callback) {
config.callback(instance, newValue);
}
};
input.addEventListener('input', listener);
mainRow.appendChild(label);
mainRow.appendChild(input);
wrapper.appendChild(mainRow);
container.appendChild(wrapper);
return {
element: input,
listener,
type: 'color',
config,
wrapper,
label
};
}
/**
* Create a checkbox control
*/
createCheckbox(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container control-checkbox-container';
const mainRow = document.createElement('div');
mainRow.className = 'control-main-row';
const label = document.createElement('span');
label.className = 'control-label-inline';
label.textContent = config.property;
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'control control-checkbox';
input.id = `el${config.property}`;
input.checked = instance[config.property] ?? config.defaultValue;
const listener = (event) => {
const newValue = event.target.checked;
instance[config.property] = newValue;
if (config.callback) {
config.callback(instance, newValue);
}
};
input.addEventListener('change', listener);
mainRow.appendChild(label);
mainRow.appendChild(input);
wrapper.appendChild(mainRow);
container.appendChild(wrapper);
return {
element: input,
listener,
type: 'checkbox',
config,
wrapper,
label
};
}
/**
* Create a dropdown select control
*/
createDropdown(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container';
const label = document.createElement('p');
label.className = 'control-label';
label.id = `elText${config.property}`;
label.textContent = `${config.property}: ${config.defaultValue}`;
const select = document.createElement('select');
select.className = 'control control-dropdown';
select.id = `el${config.property}`;
for (const option of config.options || []) {
const opt = document.createElement('option');
opt.value = option.value;
opt.textContent = option.label;
select.appendChild(opt);
}
select.value = instance[config.property] ?? config.defaultValue;
const listener = (event) => {
const newValue = event.target.value;
instance[config.property] = newValue;
label.textContent = `${config.property}: ${newValue}`;
if (config.callback) {
config.callback(instance, newValue);
}
};
select.addEventListener('change', listener);
wrapper.appendChild(label);
wrapper.appendChild(select);
container.appendChild(wrapper);
return {
element: select,
listener,
type: 'dropdown',
config,
wrapper,
label
};
}
/**
* Create a button control
*/
createButton(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container';
const button = document.createElement('button');
button.className = 'control control-button button-8';
button.textContent = config.label || config.property;
const listener = () => {
if (config.method && typeof instance[config.method] === 'function') {
instance[config.method]();
}
if (config.callback) {
config.callback(instance);
}
};
button.addEventListener('click', listener);
wrapper.appendChild(button);
container.appendChild(wrapper);
return {
element: button,
listener,
type: 'button',
config,
wrapper
};
}
/**
* Create a header/separator
*/
createHeader(config, container) {
const header = document.createElement('p');
header.className = 'header';
header.id = `elHeader${(config.text || '').replace(/\s+/g, '')}`;
header.textContent = config.text || '';
container.appendChild(header);
return {
element: header,
listener: null,
type: 'header',
config,
wrapper: header
};
}
/**
* Create filter controls UI with collapsible settings
*/
createFilterUI(filterInstance, filterDef, controlData, controlConfig) {
const filterDiv = document.createElement('div');
filterDiv.className = 'filter-item';
// Header row with type dropdown and remove button
const headerRow = document.createElement('div');
headerRow.className = 'filter-item-header';
// Filter type dropdown (allows changing filter type)
const typeSelect = document.createElement('select');
typeSelect.className = 'filter-type-dropdown';
const filters = this.controlManager.filterManager.getAvailableFilters();
for (const filter of filters) {
const opt = document.createElement('option');
opt.value = filter.id;
opt.textContent = filter.name;
if (filter.id === filterInstance.type) {
opt.selected = true;
}
typeSelect.appendChild(opt);
}
// Settings toggle button
const settingsToggle = document.createElement('button');
settingsToggle.className = 'filter-settings-toggle';
settingsToggle.innerHTML = '⚙';
settingsToggle.title = 'Filter settings';
// Remove button
const removeBtn = document.createElement('button');
removeBtn.className = 'filter-remove-btn';
removeBtn.textContent = '×';
removeBtn.title = 'Remove filter';
headerRow.appendChild(typeSelect);
headerRow.appendChild(settingsToggle);
headerRow.appendChild(removeBtn);
filterDiv.appendChild(headerRow);
// Settings panel (collapsible)
const settingsPanel = document.createElement('div');
settingsPanel.className = 'filter-settings-panel';
settingsPanel.style.display = 'none';
// Create controls for filter parameters
const paramInputs = {};
this.buildFilterParams(settingsPanel, filterInstance, filterDef, controlConfig, paramInputs);
filterDiv.appendChild(settingsPanel);
// Toggle settings
settingsToggle.addEventListener('click', () => {
const isOpen = settingsPanel.style.display !== 'none';
settingsPanel.style.display = isOpen ? 'none' : 'block';
settingsToggle.classList.toggle('active', !isOpen);
});
// Handle filter type change
typeSelect.addEventListener('change', () => {
const newType = typeSelect.value;
const newFilterDef = this.controlManager.filterManager.getFilter(newType);
// Update filter instance type
filterInstance.type = newType;
// Reset params to defaults for new type
filterInstance.params = {};
for (const ctrl of newFilterDef.controls) {
if (ctrl.name === 'min') {
filterInstance.params.min = controlConfig.min ?? 0;
} else if (ctrl.name === 'max') {
filterInstance.params.max = controlConfig.max ?? 100;
} else {
filterInstance.params[ctrl.name] = ctrl.defaultValue ?? 1;
}
}
// Rebuild params UI
settingsPanel.innerHTML = '';
this.buildFilterParams(settingsPanel, filterInstance, newFilterDef, controlConfig, paramInputs);
});
// Remove handler
removeBtn.addEventListener('click', () => {
this.controlManager.removeFilter(controlData, filterInstance);
this.updateFilterBadge(controlData);
});
filterInstance.element = filterDiv;
return filterDiv;
}
/**
* Build filter parameter controls
*/
buildFilterParams(container, filterInstance, filterDef, controlConfig, paramInputs) {
for (const paramDef of filterDef.controls) {
const paramRow = document.createElement('div');
paramRow.className = 'filter-param-row';
const paramLabel = document.createElement('span');
paramLabel.className = 'filter-param-label';
paramLabel.textContent = paramDef.label;
const paramValue = document.createElement('span');
paramValue.className = 'filter-param-value';
paramValue.textContent = filterInstance.params[paramDef.name];
const paramInput = document.createElement('input');
paramInput.type = 'range';
paramInput.className = 'filter-param-slider';
// Determine bounds
if (paramDef.name === 'min' || paramDef.name === 'max') {
// Use much wider bounds for filter min/max - can go beyond control range
paramInput.min = (controlConfig.min ?? 0) - Math.abs(controlConfig.max - controlConfig.min);
paramInput.max = (controlConfig.max ?? 100) + Math.abs(controlConfig.max - controlConfig.min);
paramInput.value = filterInstance.params[paramDef.name];
} else {
paramInput.min = paramDef.min ?? 0.1;
paramInput.max = paramDef.max ?? 10;
paramInput.step = paramDef.step ?? 0.1;
paramInput.value = filterInstance.params[paramDef.name];
}
// Settings button for this parameter (to adjust its min/max)
const paramSettingsBtn = document.createElement('button');
paramSettingsBtn.className = 'param-settings-btn';
paramSettingsBtn.innerHTML = '⋯';
paramSettingsBtn.title = 'Adjust range';
// Mini settings panel for param bounds
const paramBoundsPanel = document.createElement('div');
paramBoundsPanel.className = 'param-bounds-panel';
paramBoundsPanel.style.display = 'none';
const boundsRow = document.createElement('div');
boundsRow.className = 'param-bounds-row';
const minInput = document.createElement('input');
minInput.type = 'number';
minInput.className = 'param-bound-input';
minInput.value = paramInput.min;
minInput.placeholder = 'min';
const maxInput = document.createElement('input');
maxInput.type = 'number';
maxInput.className = 'param-bound-input';
maxInput.value = paramInput.max;
maxInput.placeholder = 'max';
boundsRow.appendChild(minInput);
boundsRow.appendChild(maxInput);
paramBoundsPanel.appendChild(boundsRow);
minInput.addEventListener('change', () => {
paramInput.min = parseFloat(minInput.value);
});
maxInput.addEventListener('change', () => {
paramInput.max = parseFloat(maxInput.value);
});
paramSettingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = paramBoundsPanel.style.display !== 'none';
paramBoundsPanel.style.display = isOpen ? 'none' : 'flex';
});
paramInput.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
filterInstance.params[paramDef.name] = value;
paramValue.textContent = Math.round(value * 100) / 100;
});
const topRow = document.createElement('div');
topRow.className = 'filter-param-top-row';
topRow.appendChild(paramLabel);
topRow.appendChild(paramValue);
topRow.appendChild(paramSettingsBtn);
paramRow.appendChild(topRow);
paramRow.appendChild(paramInput);
paramRow.appendChild(paramBoundsPanel);
container.appendChild(paramRow);
paramInputs[paramDef.name] = paramInput;
}
}
}

View File

@@ -0,0 +1,116 @@
/**
* ControlManager - Manages UI controls for shapes
*
* Handles creation, updates, and cleanup of control panels
* Ready for Phase 2 multi-shape support (panels per shape)
*/
class ControlManager {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.filterManager = filterManager; // Use global FilterManager
this.factory = new ControlFactory(this);
// Track all active controls (for future multi-shape, keyed by shape id)
this.activeControls = new Map();
}
/**
* Add a filter to a control
*/
addFilter(controlData, controlConfig, filterType) {
const filterInstance = this.filterManager.createFilterInstance(filterType, controlConfig);
const filterDef = this.filterManager.getFilter(filterType);
if (!controlData.filters) {
controlData.filters = [];
}
// Create filter UI
const filterUI = this.factory.createFilterUI(
filterInstance,
filterDef,
controlData,
controlConfig
);
controlData.filtersContainer.appendChild(filterUI);
controlData.filters.push(filterInstance);
}
/**
* Remove a filter from a control
*/
removeFilter(controlData, filterInstance) {
const index = controlData.filters.indexOf(filterInstance);
if (index > -1) {
controlData.filters.splice(index, 1);
if (filterInstance.element && filterInstance.element.parentNode) {
filterInstance.element.parentNode.removeChild(filterInstance.element);
}
}
}
/**
* Add a control for a shape
* @param {object} config - Control configuration
* @param {BaseShape} instance - Shape instance
* @returns {object} Control data
*/
addControl(config, instance) {
return this.factory.create(config, instance, this.container);
}
/**
* Remove a control
* @param {object} controlData - Control data from addControl
*/
removeControl(controlData) {
if (!controlData) return;
// Remove event listeners
if (controlData.element && controlData.listener) {
const eventType = controlData.type === 'checkbox' ? 'change' :
controlData.type === 'dropdown' ? 'change' : 'input';
controlData.element.removeEventListener(eventType, controlData.listener);
}
// Remove filters
if (controlData.filters) {
for (const filter of controlData.filters) {
if (filter.element && filter.element.parentNode) {
filter.element.parentNode.removeChild(filter.element);
}
}
}
// Remove wrapper element
if (controlData.wrapper && controlData.wrapper.parentNode) {
controlData.wrapper.parentNode.removeChild(controlData.wrapper);
}
}
/**
* Clear all controls
*/
clearAll() {
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
this.activeControls.clear();
}
/**
* Update a control's displayed value programmatically
*/
updateControlValue(property, value) {
const element = document.getElementById(`el${property}`);
const label = document.getElementById(`elText${property}`);
if (element) {
element.value = value;
}
if (label) {
label.textContent = Math.round(value * 100) / 100;
}
}
}

View File

@@ -0,0 +1,195 @@
/**
* FilterManager - Manages filter plugins and computes filtered values
*
* Filters modify control values over time (e.g., oscillate a slider)
*/
class FilterManager {
constructor() {
this.filterTypes = new Map();
this.registerBuiltinFilters();
}
/**
* Register built-in filter types
*/
registerBuiltinFilters() {
// Sine wave filter
this.registerFilter('sin', {
name: 'Sine Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const halfRange = (max - min) / 2;
return min + halfRange + Math.sin(rate * elapsed * 2) * halfRange;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Triangle wave filter
this.registerFilter('triangle', {
name: 'Triangle Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const period = (2 * Math.PI) / rate;
const t = (elapsed % period) / period;
const triangleValue = t < 0.5 ? t * 2 : 2 - t * 2;
return min + (max - min) * triangleValue;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Sawtooth wave filter
this.registerFilter('sawtooth', {
name: 'Sawtooth Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const period = (2 * Math.PI) / rate;
const t = (elapsed % period) / period;
return min + (max - min) * t;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Square wave filter
this.registerFilter('square', {
name: 'Square Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const sinValue = Math.sin(rate * elapsed * 2);
return sinValue >= 0 ? max : min;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Random/noise filter
this.registerFilter('noise', {
name: 'Noise',
compute: (params, elapsed) => {
const { min, max, smoothness } = params;
// Simple smooth random using sin of elapsed with random-ish multiplier
const noise = Math.sin(elapsed * 17.3) * Math.cos(elapsed * 31.7) * 0.5 + 0.5;
return min + (max - min) * noise;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'smoothness', type: 'range', label: 'Smoothness', min: 1, max: 100, defaultValue: 50 }
]
});
// Linear interpolation (ping-pong)
this.registerFilter('linear', {
name: 'Linear (Ping-Pong)',
compute: (params, elapsed) => {
const { min, max, duration } = params;
const cycleTime = elapsed % (duration * 2);
const t = cycleTime < duration
? cycleTime / duration
: 1 - (cycleTime - duration) / duration;
return min + (max - min) * t;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'duration', type: 'range', label: 'Duration (s)', min: 0.5, max: 30, defaultValue: 5 }
]
});
}
/**
* Register a custom filter type
* @param {string} id - Unique filter identifier
* @param {object} filterDef - Filter definition with name, compute, controls
*/
registerFilter(id, filterDef) {
this.filterTypes.set(id, filterDef);
}
/**
* Get all available filter types
* @returns {Array} Array of {id, name} objects
*/
getAvailableFilters() {
const filters = [];
for (const [id, def] of this.filterTypes) {
filters.push({ id, name: def.name });
}
return filters;
}
/**
* Get filter definition by id
* @param {string} id - Filter type id
* @returns {object|null} Filter definition
*/
getFilter(id) {
return this.filterTypes.get(id) || null;
}
/**
* Compute the combined value from multiple filters
* @param {Array} filters - Array of active filter instances
* @param {number} elapsed - Elapsed time in seconds
* @returns {number|null} Computed value or null if no filters
*/
computeFilteredValue(filters, elapsed) {
if (!filters || filters.length === 0) return null;
let totalValue = 0;
for (const filter of filters) {
const filterDef = this.getFilter(filter.type);
if (filterDef) {
totalValue += filterDef.compute(filter.params, elapsed);
}
}
return totalValue;
}
/**
* Create a filter instance with default params
* @param {string} type - Filter type id
* @param {object} controlConfig - The control's config (for min/max defaults)
* @returns {object} Filter instance
*/
createFilterInstance(type, controlConfig) {
const filterDef = this.getFilter(type);
if (!filterDef) {
throw new Error(`Unknown filter type: ${type}`);
}
const params = {};
for (const ctrl of filterDef.controls) {
if (ctrl.name === 'min') {
params.min = controlConfig.min ?? 0;
} else if (ctrl.name === 'max') {
params.max = controlConfig.max ?? 100;
} else {
params[ctrl.name] = ctrl.defaultValue ?? 1;
}
}
return {
type,
params,
element: null // Will hold DOM reference for cleanup
};
}
}
// Global singleton
const filterManager = new FilterManager();

771
docs/js/controls/SceneUI.js Normal file
View File

@@ -0,0 +1,771 @@
/**
* SceneUI - Manages the UI for multi-shape scenes
*
* Creates collapsible panels for each shape with:
* - Layer controls (visibility, pause, reorder, remove)
* - Shape-specific controls
* - Add new shape interface
*/
class SceneUI {
constructor(scene, containerId) {
this.scene = scene;
this.container = document.getElementById(containerId);
this.layerPanels = new Map(); // layerId -> { panel, controlManager }
this.createSceneControls();
}
/**
* Create the main scene control area (add shape button, etc.)
*/
createSceneControls() {
// Scene header
const header = document.createElement('div');
header.className = 'scene-header';
header.innerHTML = `
<h3>Scene Layers</h3>
<div class="scene-header-buttons">
<button class="scene-btn" id="add-shape-btn" title="Add Shape">+ Add</button>
<button class="scene-btn" id="save-scene-btn" title="Save Scene">💾 Save</button>
<button class="scene-btn" id="load-scene-btn" title="Load Scene">📂 Load</button>
<button class="scene-btn" id="presets-btn" title="Presets">⭐ Presets</button>
</div>
`;
this.container.insertBefore(header, this.container.firstChild);
// Shape picker modal (hidden by default)
this.createShapePicker();
// Presets modal
this.createPresetsModal();
// Hidden file input for loading
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'scene-file-input';
fileInput.accept = '.json';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
// Add shape button handler
document.getElementById('add-shape-btn').addEventListener('click', () => {
this.showShapePicker();
});
// Save button handler
document.getElementById('save-scene-btn').addEventListener('click', () => {
this.saveScene();
});
// Load button handler
document.getElementById('load-scene-btn').addEventListener('click', () => {
document.getElementById('scene-file-input').click();
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
this.loadScene(e.target.files[0]);
e.target.value = ''; // Reset for same file
}
});
// Presets button handler
document.getElementById('presets-btn').addEventListener('click', () => {
this.showPresetsModal();
});
}
/**
* Save current scene to file
*/
saveScene() {
const data = this.scene.exportToJSON();
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `scene-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
/**
* Load scene from file
*/
loadScene(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
this.scene.importFromJSON(data, this);
} catch (err) {
alert('Failed to load scene: ' + err.message);
}
};
reader.readAsText(file);
}
/**
* Create presets modal
*/
createPresetsModal() {
const modal = document.createElement('div');
modal.className = 'presets-modal';
modal.id = 'presets-modal';
modal.style.display = 'none';
modal.innerHTML = `
<div class="presets-content">
<h3>Presets</h3>
<div class="presets-actions">
<button class="preset-save-current-btn">💾 Save Current as Preset</button>
</div>
<div class="presets-list" id="presets-list">
<p class="presets-empty">No presets saved yet</p>
</div>
<button class="button presets-cancel">Close</button>
</div>
`;
document.body.appendChild(modal);
// Load and display presets
this.refreshPresetsList();
// Event handlers
modal.querySelector('.presets-cancel').addEventListener('click', () => {
this.hidePresetsModal();
});
modal.querySelector('.preset-save-current-btn').addEventListener('click', () => {
this.saveCurrentAsPreset();
});
// Click outside to close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hidePresetsModal();
}
});
}
showPresetsModal() {
this.refreshPresetsList();
document.getElementById('presets-modal').style.display = 'flex';
}
hidePresetsModal() {
document.getElementById('presets-modal').style.display = 'none';
}
/**
* Get user presets from localStorage
*/
getPresets() {
try {
return JSON.parse(localStorage.getItem('animation-presets') || '[]');
} catch {
return [];
}
}
/**
* Get built-in presets
*/
getBuiltInPresets() {
return typeof BUILT_IN_PRESETS !== 'undefined' ? BUILT_IN_PRESETS : [];
}
/**
* Save presets to localStorage
*/
savePresets(presets) {
localStorage.setItem('animation-presets', JSON.stringify(presets));
}
/**
* Save current scene as a preset
*/
saveCurrentAsPreset() {
const name = prompt('Enter preset name:');
if (!name) return;
const presets = this.getPresets();
const data = this.scene.exportToJSON();
presets.push({
name: name,
date: new Date().toISOString(),
data: data
});
this.savePresets(presets);
this.refreshPresetsList();
}
/**
* Load a preset (built-in or user)
*/
loadPreset(index, isBuiltIn = false) {
const presets = isBuiltIn ? this.getBuiltInPresets() : this.getPresets();
if (index >= 0 && index < presets.length) {
this.scene.importFromJSON(presets[index].data, this);
this.hidePresetsModal();
}
}
/**
* Delete a user preset by index
*/
deletePreset(index) {
const presets = this.getPresets();
if (index >= 0 && index < presets.length) {
if (confirm(`Delete preset "${presets[index].name}"?`)) {
presets.splice(index, 1);
this.savePresets(presets);
this.refreshPresetsList();
}
}
}
/**
* Refresh the presets list UI
*/
refreshPresetsList() {
const container = document.getElementById('presets-list');
if (!container) return;
const builtInPresets = this.getBuiltInPresets();
const userPresets = this.getPresets();
let html = '';
// Built-in presets section
if (builtInPresets.length > 0) {
html += '<div class="presets-section-header">Built-in Presets</div>';
html += builtInPresets.map((preset, index) => `
<div class="preset-item preset-builtin" data-index="${index}" data-builtin="true">
<div class="preset-info">
<span class="preset-name">${preset.name}</span>
<span class="preset-description">${preset.description || `${preset.data.layers.length} layer(s)`}</span>
</div>
<div class="preset-actions">
<button class="preset-load-btn" data-index="${index}" data-builtin="true">Load</button>
</div>
</div>
`).join('');
}
// User presets section
html += '<div class="presets-section-header">My Presets</div>';
if (userPresets.length === 0) {
html += '<p class="presets-empty">No saved presets yet</p>';
} else {
html += userPresets.map((preset, index) => `
<div class="preset-item" data-index="${index}">
<div class="preset-info">
<span class="preset-name">${preset.name}</span>
<span class="preset-layers">${preset.data.layers.length} layer(s)</span>
</div>
<div class="preset-actions">
<button class="preset-load-btn" data-index="${index}">Load</button>
<button class="preset-delete-btn" data-index="${index}">🗑</button>
</div>
</div>
`).join('');
}
container.innerHTML = html;
// Add event listeners
container.querySelectorAll('.preset-load-btn').forEach(btn => {
btn.addEventListener('click', () => {
const isBuiltIn = btn.dataset.builtin === 'true';
this.loadPreset(parseInt(btn.dataset.index), isBuiltIn);
});
});
container.querySelectorAll('.preset-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.deletePreset(parseInt(btn.dataset.index));
});
});
}
/**
* Create the shape picker modal with tabs
*/
createShapePicker() {
const modal = document.createElement('div');
modal.className = 'shape-picker-modal';
modal.id = 'shape-picker-modal';
modal.style.display = 'none';
const shapes = shapeRegistry.getAvailableNames();
const shapesHtml = shapes.map(name =>
`<button class="shape-picker-btn" data-shape="${name}">${name}</button>`
).join('');
const builtInPresets = this.getBuiltInPresets();
const builtInHtml = builtInPresets.map((preset, i) =>
`<button class="shape-picker-btn preset-btn" data-preset-index="${i}" data-builtin="true">
<span class="preset-btn-name">${preset.name}</span>
<span class="preset-btn-info">${preset.data.layers.length} layer(s)</span>
</button>`
).join('');
modal.innerHTML = `
<div class="shape-picker-container">
<h3>Add Layer</h3>
<div class="shape-picker-tabs">
<button class="shape-picker-tab active" data-tab="shapes">Shapes</button>
<button class="shape-picker-tab" data-tab="presets">Presets</button>
</div>
<div class="shape-picker-content">
<div class="shape-picker-panel active" id="panel-shapes">
<div class="shape-picker-buttons">
${shapesHtml}
</div>
</div>
<div class="shape-picker-panel" id="panel-presets">
<div class="shape-picker-section">
<div class="shape-picker-section-title">Built-in</div>
<div class="shape-picker-buttons">
${builtInHtml}
</div>
</div>
<div class="shape-picker-section">
<div class="shape-picker-section-title">My Presets</div>
<div class="shape-picker-buttons" id="picker-user-presets">
<p class="presets-empty">No saved presets</p>
</div>
</div>
</div>
</div>
<button class="shape-picker-cancel">Cancel</button>
</div>
`;
document.body.appendChild(modal);
// Tab switching
modal.querySelectorAll('.shape-picker-tab').forEach(tab => {
tab.addEventListener('click', () => {
modal.querySelectorAll('.shape-picker-tab').forEach(t => t.classList.remove('active'));
modal.querySelectorAll('.shape-picker-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`panel-${tab.dataset.tab}`).classList.add('active');
});
});
// Cancel button
modal.querySelector('.shape-picker-cancel').addEventListener('click', () => {
this.hideShapePicker();
});
// Shape buttons
modal.querySelectorAll('.shape-picker-btn[data-shape]').forEach(btn => {
btn.addEventListener('click', () => {
this.addShapeToScene(btn.dataset.shape);
this.hideShapePicker();
});
});
// Preset buttons (add as layers, don't replace)
modal.querySelectorAll('.preset-btn[data-builtin="true"]').forEach(btn => {
btn.addEventListener('click', () => {
this.addPresetAsLayers(parseInt(btn.dataset.presetIndex), true);
this.hideShapePicker();
});
});
// Click outside to close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideShapePicker();
}
});
}
/**
* Refresh user presets in the picker
*/
refreshPickerUserPresets() {
const container = document.getElementById('picker-user-presets');
if (!container) return;
const userPresets = this.getPresets();
if (userPresets.length === 0) {
container.innerHTML = '<p class="presets-empty">No saved presets</p>';
return;
}
container.innerHTML = userPresets.map((preset, i) =>
`<button class="shape-picker-btn preset-btn" data-preset-index="${i}" data-builtin="false">
<span class="preset-btn-name">${preset.name}</span>
<span class="preset-btn-info">${preset.data.layers.length} layer(s)</span>
</button>`
).join('');
container.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.addPresetAsLayers(parseInt(btn.dataset.presetIndex), false);
this.hideShapePicker();
});
});
}
/**
* Add preset layers to scene (without clearing existing)
*/
addPresetAsLayers(index, isBuiltIn) {
const presets = isBuiltIn ? this.getBuiltInPresets() : this.getPresets();
if (index < 0 || index >= presets.length) return;
const preset = presets[index];
for (const layerData of preset.data.layers) {
try {
if (!shapeRegistry.get(layerData.name)) {
console.warn(`Shape "${layerData.name}" not found, skipping`);
continue;
}
const layerId = this.scene.addShape(layerData.name, layerData.values);
const layer = this.scene.getLayer(layerId);
if (!layer || !layer.shape) continue;
layer.visible = layerData.visible !== false;
layer.paused = layerData.paused || false;
layer.collapsed = layerData.collapsed !== false;
// Create panel with filters and bounds
this.createLayerPanel(layer, layerData.filters, layerData.controlBounds);
} catch (e) {
console.warn(`Failed to add layer "${layerData.name}":`, e.message);
}
}
}
showShapePicker() {
this.refreshPickerUserPresets();
document.getElementById('shape-picker-modal').style.display = 'flex';
}
hideShapePicker() {
document.getElementById('shape-picker-modal').style.display = 'none';
}
/**
* Add a shape to the scene and create its UI panel
* @param {string} shapeName
*/
addShapeToScene(shapeName) {
const layerId = this.scene.addShape(shapeName);
const layer = this.scene.getLayer(layerId);
// Create the layer panel UI first (it will contain the controls)
this.createLayerPanel(layer);
}
/**
* Create a collapsible panel for a layer
* @param {object|number} layerOrId - Layer object or layer ID
* @param {object} savedFilters - Optional saved filter settings to restore
* @param {object} savedBounds - Optional saved control bounds to restore
*/
createLayerPanel(layerOrId, savedFilters = null, savedBounds = null) {
// Handle both layer object and layer ID
const layer = typeof layerOrId === 'number'
? this.scene.getLayer(layerOrId)
: layerOrId;
if (!layer || !layer.shape) {
console.warn('Cannot create panel: layer or shape is undefined');
return;
}
const panel = document.createElement('div');
panel.className = 'layer-panel';
panel.id = `layer-panel-${layer.id}`;
// Layer header with controls
const header = document.createElement('div');
header.className = 'layer-header';
header.innerHTML = `
<span class="layer-collapse-btn" data-layer="${layer.id}">▼</span>
<span class="layer-name">${layer.name}</span>
<div class="layer-controls">
<button class="layer-btn layer-visibility" data-layer="${layer.id}" title="Toggle visibility">👁</button>
<button class="layer-btn layer-pause" data-layer="${layer.id}" title="Pause/Play">⏸</button>
<button class="layer-btn layer-up" data-layer="${layer.id}" title="Move up">↑</button>
<button class="layer-btn layer-down" data-layer="${layer.id}" title="Move down">↓</button>
<button class="layer-btn layer-remove" data-layer="${layer.id}" title="Remove">✕</button>
</div>
`;
// Layer content (shape controls container)
const content = document.createElement('div');
content.className = 'layer-content';
content.id = `layer-content-${layer.id}`;
panel.appendChild(header);
panel.appendChild(content);
// Add to layers container
const shapesContainer = document.getElementById('layers-container');
if (shapesContainer) {
shapesContainer.appendChild(panel);
} else {
this.container.appendChild(panel);
}
// Create a control manager specifically for this layer's content
const layerControlManager = new ControlManager(content.id);
// Initialize the shape's controls into the layer content
layer.shape.initializeControls(layerControlManager);
// Restore saved control bounds if provided
if (savedBounds) {
this.applyBoundsToShape(layer.shape, savedBounds);
}
// Restore saved filters if provided
if (savedFilters) {
this.applyFiltersToShape(layer.shape, layerControlManager, savedFilters);
}
// Store both panel and control manager
this.layerPanels.set(layer.id, { panel, controlManager: layerControlManager });
// Attach event handlers
this.attachLayerHandlers(layer.id, panel);
// Apply collapsed state
if (layer.collapsed) {
this.updateLayerUI(layer.id);
}
}
/**
* Apply saved filters to a shape's controls
*/
applyFiltersToShape(shape, controlManager, savedFilters) {
if (!savedFilters || !shape.controls) return;
for (const control of shape.controls) {
// Property is stored in control.config.property
const property = control.config?.property;
if (!property || !savedFilters[property]) continue;
const filterDataArray = savedFilters[property];
const controlConfig = control.config || {};
for (const filterData of filterDataArray) {
// Create filter instance with saved params
const filterInstance = {
type: filterData.type,
params: { ...filterData.params }
};
const filterDef = controlManager.filterManager.getFilter(filterData.type);
if (!filterDef) continue;
// Create filter UI
const filterUI = controlManager.factory.createFilterUI(
filterInstance,
filterDef,
control,
controlConfig
);
if (control.filtersContainer) {
control.filtersContainer.appendChild(filterUI);
}
if (!control.filters) {
control.filters = [];
}
control.filters.push(filterInstance);
// Update filter badge
controlManager.factory.updateFilterBadge(control);
}
}
}
/**
* Apply saved control bounds to a shape's controls
*/
applyBoundsToShape(shape, savedBounds) {
if (!savedBounds || !shape.controls) return;
for (const control of shape.controls) {
const property = control.config?.property;
if (!property || !savedBounds[property]) continue;
const bounds = savedBounds[property];
// Update configState
if (control.configState) {
control.configState.min = bounds.min;
control.configState.max = bounds.max;
}
// Update the input element's min/max
if (control.element) {
control.element.min = bounds.min;
control.element.max = bounds.max;
}
// Update the settings panel inputs if they exist
if (control.settingsPanel) {
const inputs = control.settingsPanel.querySelectorAll('.setting-input');
if (inputs[0]) inputs[0].value = bounds.min;
if (inputs[1]) inputs[1].value = bounds.max;
}
}
}
/**
* Attach event handlers for layer controls
*/
attachLayerHandlers(layerId, panel) {
// Collapse toggle
panel.querySelector('.layer-collapse-btn').addEventListener('click', () => {
this.toggleCollapse(layerId);
});
// Visibility toggle
panel.querySelector('.layer-visibility').addEventListener('click', () => {
this.scene.toggleVisibility(layerId);
this.updateLayerUI(layerId);
});
// Pause toggle
panel.querySelector('.layer-pause').addEventListener('click', () => {
this.scene.togglePause(layerId);
this.updateLayerUI(layerId);
});
// Move up
panel.querySelector('.layer-up').addEventListener('click', () => {
this.scene.moveUp(layerId);
this.reorderPanels();
});
// Move down
panel.querySelector('.layer-down').addEventListener('click', () => {
this.scene.moveDown(layerId);
this.reorderPanels();
});
// Remove
panel.querySelector('.layer-remove').addEventListener('click', () => {
this.removeLayer(layerId);
});
}
/**
* Toggle collapse state for a layer panel
*/
toggleCollapse(layerId) {
const layer = this.scene.getLayer(layerId);
if (!layer) return;
layer.collapsed = !layer.collapsed;
this.updateLayerUI(layerId);
}
/**
* Update layer UI to reflect current state
*/
updateLayerUI(layerId) {
const layer = this.scene.getLayer(layerId);
const layerData = this.layerPanels.get(layerId);
if (!layer || !layerData) return;
const panel = layerData.panel;
// Update collapse
const collapseBtn = panel.querySelector('.layer-collapse-btn');
const content = panel.querySelector('.layer-content');
if (layer.collapsed) {
collapseBtn.textContent = '▶';
content.style.display = 'none';
} else {
collapseBtn.textContent = '▼';
content.style.display = 'block';
}
// Update visibility button
const visBtn = panel.querySelector('.layer-visibility');
visBtn.style.opacity = layer.visible ? '1' : '0.4';
// Update pause button
const pauseBtn = panel.querySelector('.layer-pause');
pauseBtn.textContent = layer.paused ? '▶' : '⏸';
}
/**
* Reorder panels to match scene layer order
*/
reorderPanels() {
const container = document.getElementById('layers-container') || this.container;
for (const layer of this.scene.getLayers()) {
const layerData = this.layerPanels.get(layer.id);
if (layerData && layerData.panel) {
container.appendChild(layerData.panel); // Moves to end, in order
}
}
}
/**
* Remove a layer and its panel
*/
removeLayer(layerId) {
const layerData = this.layerPanels.get(layerId);
// Clean up control manager
if (layerData && layerData.controlManager) {
layerData.controlManager.clearAll();
}
// Remove from scene
this.scene.removeShape(layerId);
// Remove panel from DOM
if (layerData && layerData.panel && layerData.panel.parentNode) {
layerData.panel.parentNode.removeChild(layerData.panel);
}
this.layerPanels.delete(layerId);
}
/**
* Clear all layer panels (for import)
*/
clearAllPanels() {
for (const [layerId, layerData] of this.layerPanels) {
if (layerData.controlManager) {
layerData.controlManager.clearAll();
}
if (layerData.panel && layerData.panel.parentNode) {
layerData.panel.parentNode.removeChild(layerData.panel);
}
}
this.layerPanels.clear();
}
/**
* Clear all layer panels and scene
*/
clearAll() {
this.clearAllPanels();
this.scene.clear();
}
}