Files
spore-ledlab/public/scripts/preset-editor.js
2025-10-12 17:02:47 +02:00

1204 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Preset Editor for LEDLab
class PresetEditor {
constructor() {
this.configuration = this.getDefaultConfiguration();
this.selectedLayerIndex = null;
this.previewActive = false;
this.selectedNode = null;
this.previewInterval = null;
this.canvasRenderer = null;
this.matrixWidth = 16;
this.matrixHeight = 16;
this.fps = 20;
this.frameCount = 0;
this.lastFpsUpdate = Date.now();
this.init();
}
init() {
this.setupCanvas();
this.setupEventListeners();
this.loadSavedPresets();
this.renderLayerList();
this.discoverNodes();
this.subscribeToNodeUpdates();
}
setupCanvas() {
const canvas = document.getElementById('editor-preview-canvas');
if (canvas) {
// Set canvas size explicitly
canvas.width = this.matrixWidth;
canvas.height = this.matrixHeight;
this.canvasRenderer = new CanvasRenderer(canvas, this.matrixWidth, this.matrixHeight);
this.updateCanvasSize();
// Clear the canvas initially
this.canvasRenderer.clear();
}
}
updateCanvasSize() {
const sizeDisplay = document.getElementById('editor-canvas-size');
if (sizeDisplay) {
sizeDisplay.textContent = `${this.matrixWidth}×${this.matrixHeight}`;
}
// Update canvas renderer size if it exists
if (this.canvasRenderer) {
this.canvasRenderer.setSize(this.matrixWidth, this.matrixHeight);
}
}
getDefaultConfiguration() {
return {
name: 'New Custom Preset',
description: 'A custom configurable preset',
layers: [],
parameters: {
speed: { type: 'range', min: 0.1, max: 2.0, step: 0.1, default: 1.0 },
brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 },
},
};
}
setupEventListeners() {
// Preset metadata
const nameInput = document.getElementById('editor-preset-name');
if (nameInput) {
nameInput.addEventListener('input', (e) => {
this.configuration.name = e.target.value;
});
}
// Add layer button
const addLayerBtn = document.getElementById('editor-add-layer');
if (addLayerBtn) {
addLayerBtn.addEventListener('click', () => this.addLayer());
}
// Save/Load buttons
const saveBtn = document.getElementById('editor-save-preset');
if (saveBtn) {
saveBtn.addEventListener('click', () => this.savePreset());
}
const newBtn = document.getElementById('editor-new-preset');
if (newBtn) {
newBtn.addEventListener('click', () => this.newPreset());
}
const loadBtn = document.getElementById('editor-load-preset');
if (loadBtn) {
loadBtn.addEventListener('change', (e) => this.loadPreset(e.target.value));
}
const exportBtn = document.getElementById('editor-export-json');
if (exportBtn) {
exportBtn.addEventListener('click', () => this.exportToJSON());
}
const importBtn = document.getElementById('editor-import-json');
if (importBtn) {
importBtn.addEventListener('change', (e) => this.importFromJSON(e));
}
// Preview toggle button
const previewToggle = document.getElementById('editor-preview-toggle');
if (previewToggle) {
previewToggle.addEventListener('click', () => this.togglePreview());
}
// Node selector
const nodeSelect = document.getElementById('editor-node-select');
if (nodeSelect) {
nodeSelect.addEventListener('change', (e) => {
this.selectedNode = e.target.value || null;
this.updatePreviewStatus();
});
}
// Delete preset button
const deleteBtn = document.getElementById('editor-delete-preset');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => this.deleteCurrentPreset());
}
}
showLayerTypeOptions(type) {
const shapeOptions = document.getElementById('editor-shape-options');
const patternOptions = document.getElementById('editor-pattern-options');
if (type === 'shape') {
shapeOptions?.classList.remove('hidden');
patternOptions?.classList.add('hidden');
} else if (type === 'pattern') {
shapeOptions?.classList.add('hidden');
patternOptions?.classList.remove('hidden');
}
}
addLayer() {
// Default to shape layer type
const layer = this.createShapeLayer();
this.configuration.layers.push(layer);
// Automatically expand the newly added layer
this.selectedLayerIndex = this.configuration.layers.length - 1;
this.renderLayerList();
this.refreshPreviewIfActive();
}
refreshPreviewIfActive() {
// If preview is active, restart it to reflect changes
if (this.previewActive) {
// Reset animation states for the new configuration
if (this.canvasRenderer) {
this.canvasRenderer.reset();
}
// Immediately render a frame to show changes
if (this.canvasRenderer && this.configuration) {
const frame = this.canvasRenderer.renderConfiguration(this.configuration, 1.0, 1.0);
this.canvasRenderer.drawFrame(frame);
}
// If streaming to node, restart streaming with new config
if (this.selectedNode) {
this.startNodeStreaming();
}
}
}
createShapeLayer() {
return {
type: 'shape',
shape: 'circle',
position: { x: 8, y: 8 },
size: { radius: 3, width: 5, height: 5 },
color: { type: 'solid', value: 'ff0000' },
intensity: 1.0
};
}
createPatternLayer() {
return {
type: 'pattern',
pattern: 'spiral',
color: {
type: 'palette',
stops: [
{ position: 0.0, color: 'ff0000' },
{ position: 0.17, color: 'ff8800' },
{ position: 0.33, color: 'ffff00' },
{ position: 0.5, color: '00ff00' },
{ position: 0.67, color: '0088ff' },
{ position: 0.83, color: '8800ff' },
{ position: 1.0, color: 'ff0088' }
]
},
intensity: 1.0,
params: {
centerX: 8,
centerY: 8,
arms: 5,
rotationSpeed: 1.0
}
};
}
getPatternParams(pattern) {
switch (pattern) {
case 'trail':
return {
decayFactor: 0.8
};
case 'radial':
return {
centerX: 8,
centerY: 8
};
case 'spiral':
return {
centerX: 8,
centerY: 8,
arms: 5,
rotationSpeed: 1.0
};
default:
return {};
}
}
createAnimation(type) {
const baseAnim = {
type: type,
params: {}
};
switch (type) {
case 'move':
baseAnim.params = {
startX: 0,
startY: 0,
endX: 16,
endY: 16,
duration: 2.0
};
break;
case 'rotate':
baseAnim.params = {
speed: 1.0
};
break;
case 'pulse':
baseAnim.params = {
minScale: 0.5,
maxScale: 1.5,
frequency: 1.0
};
break;
case 'oscillateX':
baseAnim.params = {
center: 8,
amplitude: 4,
frequency: 0.5,
phase: 0
};
baseAnim.axis = 'x';
break;
case 'oscillateY':
baseAnim.params = {
center: 8,
amplitude: 4,
frequency: 0.5,
phase: 0
};
baseAnim.axis = 'y';
break;
case 'fade':
baseAnim.params = {
duration: 2.0,
fadeIn: true
};
break;
}
return baseAnim;
}
renderLayerList() {
const layerList = document.getElementById('editor-layer-list');
if (!layerList) return;
layerList.innerHTML = '';
if (this.configuration.layers.length === 0) {
layerList.innerHTML = '<p class="editor-empty-state">No layers yet. Click "Add Layer" to get started!</p>';
return;
}
this.configuration.layers.forEach((layer, index) => {
const layerItem = document.createElement('div');
layerItem.className = 'editor-layer-item';
if (index === this.selectedLayerIndex) {
layerItem.classList.add('expanded');
}
// Layer Header (always visible)
const layerHeader = document.createElement('div');
layerHeader.className = 'editor-layer-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'editor-layer-header-left';
// Expand icon
const expandIcon = document.createElement('span');
expandIcon.className = 'editor-layer-expand-icon';
expandIcon.textContent = '▶';
// Layer info
const layerInfo = document.createElement('div');
layerInfo.className = 'editor-layer-info';
const layerTitle = document.createElement('div');
layerTitle.className = 'editor-layer-title';
layerTitle.textContent = `Layer ${index + 1}: ${this.getLayerDisplayName(layer)}`;
const layerSubtitle = document.createElement('div');
layerSubtitle.className = 'editor-layer-subtitle';
layerSubtitle.textContent = this.getLayerDescription(layer);
layerInfo.appendChild(layerTitle);
layerInfo.appendChild(layerSubtitle);
headerLeft.appendChild(expandIcon);
headerLeft.appendChild(layerInfo);
// Layer buttons
const layerButtons = document.createElement('div');
layerButtons.className = 'editor-layer-buttons';
const moveUpBtn = document.createElement('button');
moveUpBtn.className = 'btn-small';
moveUpBtn.textContent = '▲';
moveUpBtn.title = 'Move Up';
moveUpBtn.disabled = index === 0;
moveUpBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.moveLayer(index, -1);
});
const moveDownBtn = document.createElement('button');
moveDownBtn.className = 'btn-small';
moveDownBtn.textContent = '▼';
moveDownBtn.title = 'Move Down';
moveDownBtn.disabled = index === this.configuration.layers.length - 1;
moveDownBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.moveLayer(index, 1);
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn-small btn-danger';
deleteBtn.textContent = '✕';
deleteBtn.title = 'Delete';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteLayer(index);
});
layerButtons.appendChild(moveUpBtn);
layerButtons.appendChild(moveDownBtn);
layerButtons.appendChild(deleteBtn);
layerHeader.appendChild(headerLeft);
layerHeader.appendChild(layerButtons);
// Click handler to toggle expansion
layerHeader.addEventListener('click', (e) => {
// Only toggle if clicking on the header itself, not the buttons
if (!e.target.closest('.editor-layer-buttons')) {
this.toggleLayer(index);
}
});
// Layer Content (expandable)
const layerContent = document.createElement('div');
layerContent.className = 'editor-layer-content';
// Render layer configuration inside the content
if (layer.type === 'shape') {
this.renderShapeEditor(layerContent, layer, index);
} else if (layer.type === 'pattern') {
this.renderPatternEditor(layerContent, layer, index);
}
layerItem.appendChild(layerHeader);
layerItem.appendChild(layerContent);
layerList.appendChild(layerItem);
});
}
getLayerDisplayName(layer) {
if (layer.type === 'shape') {
return `${layer.shape.charAt(0).toUpperCase() + layer.shape.slice(1)}`;
} else if (layer.type === 'pattern') {
return `${layer.pattern.charAt(0).toUpperCase() + layer.pattern.slice(1)} Pattern`;
}
return layer.type;
}
getLayerDescription(layer) {
const parts = [];
if (layer.color && layer.color.type) {
parts.push(layer.color.type);
}
if (layer.animation && layer.animation.type) {
parts.push(layer.animation.type);
}
return parts.length > 0 ? parts.join(' • ') : 'No animation';
}
toggleLayer(index) {
if (this.selectedLayerIndex === index) {
// Collapse if already expanded
this.selectedLayerIndex = null;
} else {
// Expand this layer
this.selectedLayerIndex = index;
}
this.renderLayerList();
}
selectLayer(index) {
this.selectedLayerIndex = index;
this.renderLayerList();
}
renderShapeEditor(container, layer, index) {
// Layer type selector
this.addSelect(container, 'Layer Type', layer.type,
['shape', 'pattern'],
(value) => {
this.changeLayerType(index, value);
}
);
// Shape type
this.addSelect(container, 'Shape', layer.shape,
['circle', 'rectangle', 'triangle', 'blob', 'point', 'line'],
(value) => {
layer.shape = value;
this.renderLayerList();
this.refreshPreviewIfActive();
}
);
// Position
const posGroup = this.addGroup(container, 'Position');
this.addNumberInput(posGroup, 'X', layer.position.x, 0, 32, 0.5, (value) => {
layer.position.x = value;
this.refreshPreviewIfActive();
});
this.addNumberInput(posGroup, 'Y', layer.position.y, 0, 32, 0.5, (value) => {
layer.position.y = value;
this.refreshPreviewIfActive();
});
// Size
const sizeGroup = this.addGroup(container, 'Size');
if (layer.shape === 'circle' || layer.shape === 'blob' || layer.shape === 'triangle') {
this.addNumberInput(sizeGroup, 'Radius', layer.size.radius, 1, 20, 0.5, (value) => {
layer.size.radius = value;
this.refreshPreviewIfActive();
});
} else if (layer.shape === 'rectangle') {
this.addNumberInput(sizeGroup, 'Width', layer.size.width, 1, 32, 1, (value) => {
layer.size.width = value;
this.refreshPreviewIfActive();
});
this.addNumberInput(sizeGroup, 'Height', layer.size.height, 1, 32, 1, (value) => {
layer.size.height = value;
this.refreshPreviewIfActive();
});
}
// Color
this.renderColorEditor(container, layer, index);
// Intensity
this.addNumberInput(container, 'Intensity', layer.intensity, 0, 1, 0.1, (value) => {
layer.intensity = value;
this.refreshPreviewIfActive();
});
// Animation
this.renderAnimationEditor(container, layer, index);
}
renderPatternEditor(container, layer, index) {
// Layer type selector
this.addSelect(container, 'Layer Type', layer.type,
['shape', 'pattern'],
(value) => {
this.changeLayerType(index, value);
}
);
// Pattern type
this.addSelect(container, 'Pattern', layer.pattern,
['trail', 'radial', 'spiral'],
(value) => {
layer.pattern = value;
layer.params = this.getPatternParams(value);
this.renderLayerList();
this.refreshPreviewIfActive();
}
);
// Color
this.renderColorEditor(container, layer, index);
// Intensity
this.addNumberInput(container, 'Intensity', layer.intensity || 1.0, 0, 1, 0.1, (value) => {
layer.intensity = value;
this.refreshPreviewIfActive();
});
// Pattern-specific params
const paramsGroup = this.addGroup(container, 'Parameters');
if (layer.pattern === 'trail') {
this.addNumberInput(paramsGroup, 'Decay Factor', layer.params.decayFactor, 0.1, 1.0, 0.05, (value) => {
layer.params.decayFactor = value;
this.refreshPreviewIfActive();
});
} else if (layer.pattern === 'radial' || layer.pattern === 'spiral') {
this.addNumberInput(paramsGroup, 'Center X', layer.params.centerX, 0, 32, 0.5, (value) => {
layer.params.centerX = value;
this.refreshPreviewIfActive();
});
this.addNumberInput(paramsGroup, 'Center Y', layer.params.centerY, 0, 32, 0.5, (value) => {
layer.params.centerY = value;
this.refreshPreviewIfActive();
});
if (layer.pattern === 'spiral') {
this.addNumberInput(paramsGroup, 'Arms', layer.params.arms, 2, 12, 1, (value) => {
layer.params.arms = value;
this.refreshPreviewIfActive();
});
this.addNumberInput(paramsGroup, 'Rotation Speed', layer.params.rotationSpeed, 0.1, 5.0, 0.1, (value) => {
layer.params.rotationSpeed = value;
this.refreshPreviewIfActive();
});
}
}
}
renderColorEditor(container, layer, index) {
const colorGroup = this.addGroup(container, 'Color');
this.addSelect(colorGroup, 'Type', layer.color.type,
['solid', 'gradient', 'palette', 'rainbow'],
(value) => {
layer.color.type = value;
this.renderLayerList();
this.refreshPreviewIfActive();
}
);
if (layer.color.type === 'solid') {
this.addColorInput(colorGroup, 'Color', layer.color.value, (value) => {
layer.color.value = value;
this.refreshPreviewIfActive();
});
} else if (layer.color.type === 'gradient') {
this.addColorInput(colorGroup, 'Color 1', layer.color.color1 || 'ff0000', (value) => {
layer.color.color1 = value;
this.refreshPreviewIfActive();
});
this.addColorInput(colorGroup, 'Color 2', layer.color.color2 || '0000ff', (value) => {
layer.color.color2 = value;
this.refreshPreviewIfActive();
});
} else if (layer.color.type === 'palette') {
const stopsContainer = document.createElement('div');
stopsContainer.className = 'color-stops-container';
const stops = layer.color.stops || [
{ position: 0, color: '000000' },
{ position: 1, color: 'ffffff' }
];
stops.forEach((stop, stopIndex) => {
const stopDiv = document.createElement('div');
stopDiv.className = 'color-stop';
this.addNumberInput(stopDiv, 'Position', stop.position, 0, 1, 0.05, (value) => {
stops[stopIndex].position = value;
this.refreshPreviewIfActive();
});
this.addColorInput(stopDiv, 'Color', stop.color, (value) => {
stops[stopIndex].color = value;
this.refreshPreviewIfActive();
});
stopsContainer.appendChild(stopDiv);
});
layer.color.stops = stops;
colorGroup.appendChild(stopsContainer);
}
}
renderAnimationEditor(container, layer, index) {
const animGroup = this.addGroup(container, 'Animation');
const currentType = layer.animation?.type || 'none';
this.addSelect(animGroup, 'Type', currentType,
['none', 'move', 'rotate', 'pulse', 'oscillateX', 'oscillateY', 'fade'],
(value) => {
if (value === 'none') {
delete layer.animation;
} else {
layer.animation = this.createAnimation(value);
}
this.renderLayerList();
this.refreshPreviewIfActive();
}
);
if (layer.animation) {
const params = layer.animation.params;
const animParamsDiv = document.createElement('div');
animParamsDiv.className = 'animation-params';
Object.entries(params).forEach(([key, value]) => {
if (typeof value === 'number') {
const min = key.includes('Scale') ? 0 : (key.includes('duration') ? 0.1 : -16);
const max = key.includes('Scale') ? 3 : (key.includes('duration') ? 10 : 32);
const step = key.includes('duration') || key.includes('frequency') ? 0.1 : (key.includes('Scale') ? 0.1 : 0.5);
this.addNumberInput(animParamsDiv, key, value, min, max, step, (newValue) => {
params[key] = newValue;
this.refreshPreviewIfActive();
});
} else if (typeof value === 'boolean') {
this.addCheckbox(animParamsDiv, key, value, (newValue) => {
params[key] = newValue;
this.refreshPreviewIfActive();
});
}
});
animGroup.appendChild(animParamsDiv);
}
}
// UI Helper methods
addGroup(container, title) {
const group = document.createElement('div');
group.className = 'editor-group';
const groupTitle = document.createElement('h4');
groupTitle.textContent = title;
group.appendChild(groupTitle);
container.appendChild(group);
return group;
}
addNumberInput(container, label, value, min, max, step, onChange) {
const wrapper = document.createElement('div');
wrapper.className = 'editor-input-wrapper';
const labelElem = document.createElement('label');
labelElem.textContent = label;
const input = document.createElement('input');
input.type = 'number';
input.value = value;
input.min = min;
input.max = max;
input.step = step;
input.addEventListener('input', (e) => onChange(parseFloat(e.target.value)));
wrapper.appendChild(labelElem);
wrapper.appendChild(input);
container.appendChild(wrapper);
}
addColorInput(container, label, value, onChange) {
const wrapper = document.createElement('div');
wrapper.className = 'editor-input-wrapper';
const labelElem = document.createElement('label');
labelElem.textContent = label;
const input = document.createElement('input');
input.type = 'color';
input.value = '#' + value;
input.addEventListener('input', (e) => onChange(e.target.value.substring(1)));
wrapper.appendChild(labelElem);
wrapper.appendChild(input);
container.appendChild(wrapper);
}
addSelect(container, label, value, options, onChange) {
const wrapper = document.createElement('div');
wrapper.className = 'editor-input-wrapper';
const labelElem = document.createElement('label');
labelElem.textContent = label;
const select = document.createElement('select');
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt;
option.textContent = opt;
option.selected = opt === value;
select.appendChild(option);
});
select.addEventListener('change', (e) => onChange(e.target.value));
wrapper.appendChild(labelElem);
wrapper.appendChild(select);
container.appendChild(wrapper);
}
addCheckbox(container, label, value, onChange) {
const wrapper = document.createElement('div');
wrapper.className = 'editor-input-wrapper';
const labelElem = document.createElement('label');
labelElem.textContent = label;
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = value;
input.addEventListener('change', (e) => onChange(e.target.checked));
wrapper.appendChild(labelElem);
wrapper.appendChild(input);
container.appendChild(wrapper);
}
moveLayer(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= this.configuration.layers.length) return;
const temp = this.configuration.layers[index];
this.configuration.layers[index] = this.configuration.layers[newIndex];
this.configuration.layers[newIndex] = temp;
if (this.selectedLayerIndex === index) {
this.selectedLayerIndex = newIndex;
}
this.renderLayerList();
}
changeLayerType(index, newType) {
const layer = this.configuration.layers[index];
if (layer.type === newType) return;
// Create a new layer of the specified type
let newLayer;
if (newType === 'pattern') {
newLayer = this.createPatternLayer();
} else {
newLayer = this.createShapeLayer();
}
// Preserve some properties if they exist
if (layer.intensity !== undefined) {
newLayer.intensity = layer.intensity;
}
// Replace the layer
this.configuration.layers[index] = newLayer;
this.renderLayerList();
this.refreshPreviewIfActive();
}
deleteLayer(index) {
if (confirm('Delete this layer?')) {
this.configuration.layers.splice(index, 1);
if (this.selectedLayerIndex === index) {
this.selectedLayerIndex = null;
} else if (this.selectedLayerIndex > index) {
this.selectedLayerIndex--;
}
this.renderLayerList();
this.refreshPreviewIfActive();
}
}
newPreset() {
// Confirm if there are unsaved changes
if (this.configuration.layers.length > 0) {
if (!confirm('Create a new preset? Any unsaved changes will be lost.')) {
return;
}
}
// Stop preview if active
if (this.previewActive) {
this.togglePreview();
}
// Reset to default configuration
this.configuration = this.getDefaultConfiguration();
this.selectedLayerIndex = null;
// Update UI
const nameInput = document.getElementById('editor-preset-name');
if (nameInput) {
nameInput.value = this.configuration.name;
}
// Clear and re-render layer list
this.renderLayerList();
// Clear canvas
if (this.canvasRenderer) {
this.canvasRenderer.clear();
}
this.showNotification('New preset created', 'success');
}
savePreset() {
const presetName = this.configuration.name || 'Custom Preset';
const savedPresets = this.getSavedPresets();
savedPresets[presetName] = this.configuration;
localStorage.setItem('ledlab_custom_presets', JSON.stringify(savedPresets));
this.loadSavedPresets();
this.showNotification('Preset saved successfully!', 'success');
}
loadPreset(presetName) {
if (!presetName) return;
const savedPresets = this.getSavedPresets();
const preset = savedPresets[presetName];
if (preset) {
this.configuration = JSON.parse(JSON.stringify(preset));
document.getElementById('editor-preset-name').value = this.configuration.name;
this.renderLayerList();
this.refreshPreviewIfActive();
this.showNotification('Preset loaded successfully!', 'success');
}
}
deleteCurrentPreset() {
const presetName = this.configuration.name;
if (confirm(`Delete preset "${presetName}"?`)) {
const savedPresets = this.getSavedPresets();
delete savedPresets[presetName];
localStorage.setItem('ledlab_custom_presets', JSON.stringify(savedPresets));
this.configuration = this.getDefaultConfiguration();
document.getElementById('editor-preset-name').value = this.configuration.name;
this.loadSavedPresets();
this.renderLayerList();
this.showNotification('Preset deleted successfully!', 'success');
}
}
getSavedPresets() {
const saved = localStorage.getItem('ledlab_custom_presets');
return saved ? JSON.parse(saved) : {};
}
loadSavedPresets() {
const savedPresets = this.getSavedPresets();
const selector = document.getElementById('editor-load-preset');
if (selector) {
selector.innerHTML = '<option value="">Load saved preset...</option>';
Object.keys(savedPresets).forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
selector.appendChild(option);
});
}
}
exportToJSON() {
const json = JSON.stringify(this.configuration, 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 = `${this.configuration.name.replace(/\s+/g, '_')}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showNotification('Configuration exported!', 'success');
}
importFromJSON(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target.result);
this.configuration = imported;
document.getElementById('editor-preset-name').value = this.configuration.name;
this.renderLayerList();
this.refreshPreviewIfActive();
this.showNotification('Configuration imported!', 'success');
} catch (error) {
this.showNotification('Error importing JSON: ' + error.message, 'error');
}
};
reader.readAsText(file);
// Reset file input so the same file can be imported again
event.target.value = '';
}
togglePreview() {
this.previewActive = !this.previewActive;
const btn = document.getElementById('editor-preview-toggle');
const icon = btn.querySelector('.preview-icon');
const text = btn.querySelector('.preview-text');
if (this.previewActive) {
if (icon) icon.textContent = '⏸️';
if (text) text.textContent = 'Stop';
btn.classList.add('active');
this.startPreview();
} else {
if (icon) icon.textContent = '▶️';
if (text) text.textContent = 'Start';
btn.classList.remove('active');
this.stopPreview();
}
}
startPreview() {
if (!this.canvasRenderer) {
this.showNotification('Canvas not initialized', 'error');
return;
}
// Reset animation states
this.canvasRenderer.reset();
this.frameCount = 0;
this.lastFpsUpdate = Date.now();
// Start canvas preview loop
const intervalMs = Math.floor(1000 / this.fps);
this.previewInterval = setInterval(() => {
this.renderPreviewFrame();
}, intervalMs);
// If node is selected, also send to server
if (this.selectedNode) {
this.startNodeStreaming();
}
this.updatePreviewStatus();
this.showNotification('Preview started', 'success');
}
stopPreview() {
if (this.previewInterval) {
clearInterval(this.previewInterval);
this.previewInterval = null;
}
// Clear canvas
if (this.canvasRenderer) {
this.canvasRenderer.clear();
}
// Stop node streaming if active
if (this.selectedNode && window.ledlabApp) {
const message = {
type: 'stopStreaming',
nodeIp: this.selectedNode
};
window.ledlabApp.sendWebSocketMessage(message);
}
this.updatePreviewStatus();
this.updateFpsDisplay(0);
}
renderPreviewFrame() {
if (!this.canvasRenderer || !this.configuration) return;
// Get parameters
const speed = 1.0; // Could be made configurable
const brightness = 1.0; // Could be made configurable
// Render configuration to frame
const frame = this.canvasRenderer.renderConfiguration(this.configuration, speed, brightness);
// Draw to canvas
this.canvasRenderer.drawFrame(frame);
// Update FPS counter
this.frameCount++;
const now = Date.now();
if (now - this.lastFpsUpdate >= 1000) {
const actualFps = this.frameCount / ((now - this.lastFpsUpdate) / 1000);
this.updateFpsDisplay(actualFps);
this.frameCount = 0;
this.lastFpsUpdate = now;
}
}
startNodeStreaming() {
if (!window.ledlabApp || !this.selectedNode) return;
const message = {
type: 'startCustomPreset',
configuration: this.configuration,
nodeIp: this.selectedNode
};
window.ledlabApp.sendWebSocketMessage(message);
}
updatePreviewStatus() {
const statusElement = document.getElementById('editor-preview-status');
if (!statusElement) return;
if (this.previewActive) {
if (this.selectedNode) {
statusElement.textContent = `Streaming`;
statusElement.className = 'preview-status-compact streaming';
} else {
statusElement.textContent = 'Active';
statusElement.className = 'preview-status-compact active';
}
} else {
statusElement.textContent = 'Ready';
statusElement.className = 'preview-status-compact';
}
}
updateFpsDisplay(fps) {
const fpsElement = document.getElementById('editor-canvas-fps');
if (fpsElement) {
fpsElement.textContent = `${Math.round(fps)} fps`;
}
}
discoverNodes() {
// Fetch nodes from server API
fetch('/api/nodes')
.then(response => response.json())
.then(data => {
if (data.nodes) {
this.updateNodeDropdown(data.nodes);
}
})
.catch(error => {
console.error('Error fetching nodes:', error);
});
}
subscribeToNodeUpdates() {
// Subscribe to node discovery events from the main app
if (window.ledlabApp && window.ledlabApp.eventBus) {
window.ledlabApp.eventBus.subscribe('nodeDiscovered', (data) => {
console.log('Node discovered event received:', data);
setTimeout(() => this.discoverNodes(), 100);
});
window.ledlabApp.eventBus.subscribe('nodeLost', (data) => {
console.log('Node lost event received:', data);
setTimeout(() => this.discoverNodes(), 100);
});
// Also listen for status updates
window.ledlabApp.eventBus.subscribe('status', (data) => {
if (data.data && data.data.nodes) {
this.updateNodeDropdown(data.data.nodes);
}
});
}
// Poll for nodes initially and periodically
this.discoverNodes();
this.nodeDiscoveryInterval = setInterval(() => {
this.discoverNodes();
}, 5000); // Refresh every 5 seconds
}
updateNodeDropdown(nodes) {
const select = document.getElementById('editor-node-select');
if (!select) return;
// Save current selection
const currentSelection = select.value;
// Clear existing options except first
select.innerHTML = '<option value="">Canvas Only</option>';
// Add nodes
if (nodes && Array.isArray(nodes) && nodes.length > 0) {
nodes.forEach(node => {
const option = document.createElement('option');
option.value = node.ip;
// Format node display text
let displayText = node.ip;
if (node.hostname) {
displayText = `${node.hostname} (${node.ip})`;
} else if (node.name) {
displayText = `${node.name} (${node.ip})`;
}
option.textContent = displayText;
select.appendChild(option);
});
console.log(`Updated node dropdown with ${nodes.length} node(s)`);
} else {
console.log('No nodes available to populate dropdown');
}
// Restore selection if still available
if (currentSelection) {
const optionExists = Array.from(select.options).some(opt => opt.value === currentSelection);
if (optionExists) {
select.value = currentSelection;
this.selectedNode = currentSelection;
}
}
}
showNotification(message, type = 'info') {
// Simple notification system
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.classList.add('show');
}, 10);
setTimeout(() => {
notification.classList.remove('show');
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
}
// Initialize editor when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
const editorView = document.getElementById('editor-view');
if (editorView) {
window.presetEditor = new PresetEditor();
}
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = PresetEditor;
}