1192 lines
41 KiB
JavaScript
1192 lines
41 KiB
JavaScript
// 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');
|
||
const descInput = document.getElementById('editor-preset-desc');
|
||
|
||
if (nameInput) {
|
||
nameInput.addEventListener('input', (e) => {
|
||
this.configuration.name = e.target.value;
|
||
});
|
||
}
|
||
|
||
if (descInput) {
|
||
descInput.addEventListener('input', (e) => {
|
||
this.configuration.description = e.target.value;
|
||
});
|
||
}
|
||
|
||
// Add layer button
|
||
const addLayerBtn = document.getElementById('editor-add-layer');
|
||
if (addLayerBtn) {
|
||
addLayerBtn.addEventListener('click', () => this.addLayer());
|
||
}
|
||
|
||
// Building block type selector
|
||
const typeSelector = document.getElementById('editor-layer-type');
|
||
if (typeSelector) {
|
||
typeSelector.addEventListener('change', (e) => {
|
||
this.showLayerTypeOptions(e.target.value);
|
||
});
|
||
}
|
||
|
||
// 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() {
|
||
const layerType = document.getElementById('editor-layer-type')?.value || 'shape';
|
||
|
||
let layer;
|
||
if (layerType === 'pattern') {
|
||
layer = this.createPatternLayer();
|
||
} else {
|
||
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) {
|
||
// 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) {
|
||
// 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();
|
||
}
|
||
|
||
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');
|
||
const descInput = document.getElementById('editor-preset-desc');
|
||
|
||
if (nameInput) {
|
||
nameInput.value = this.configuration.name;
|
||
}
|
||
|
||
if (descInput) {
|
||
descInput.value = this.configuration.description;
|
||
}
|
||
|
||
// 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;
|
||
document.getElementById('editor-preset-desc').value = this.configuration.description;
|
||
|
||
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;
|
||
document.getElementById('editor-preset-desc').value = this.configuration.description;
|
||
|
||
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;
|
||
document.getElementById('editor-preset-desc').value = this.configuration.description;
|
||
|
||
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;
|
||
}
|
||
|
||
|