/**
* 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 = `
Scene Layers
`;
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 = `
Presets
`;
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 += '';
html += builtInPresets.map((preset, index) => `
${preset.name}
${preset.description || `${preset.data.layers.length} layer(s)`}
`).join('');
}
// User presets section
html += '';
if (userPresets.length === 0) {
html += 'No saved presets yet
';
} else {
html += userPresets.map((preset, index) => `
${preset.name}
${preset.data.layers.length} layer(s)
`).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 =>
``
).join('');
const builtInPresets = this.getBuiltInPresets();
const builtInHtml = builtInPresets.map((preset, i) =>
``
).join('');
modal.innerHTML = `
Add Layer
`;
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 = 'No saved presets
';
return;
}
container.innerHTML = userPresets.map((preset, i) =>
``
).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 = `
▼
${layer.name}
`;
// 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();
}
}