Frontend performance and code quality improvements

Performance: cache getBoundingClientRect in card-glare and drag-drop,
build adjacency Maps for O(1) graph BFS, batch WebGL uniform uploads,
cache matchMedia/search text in card-sections, use Map in graph-layout.

Code quality: extract shared FPS chart factory (chart-utils.js) and
FilterListManager (filter-list.js), replace 14-way CSS editor dispatch
with type handler registry, move state to state.js, fix layer violation
in api.js, add i18n for hardcoded strings, sync 53 missing locale keys,
add HTTP error logging in DataCache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 18:14:26 +03:00
parent 014b4175b9
commit 50c40ed13f
20 changed files with 1070 additions and 716 deletions

View File

@@ -165,7 +165,7 @@ import {
// Layer 5.5: graph editor
import {
loadGraphEditor,
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter,
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter, toggleGraphFilterTypes,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
graphToggleFullscreen, graphAddEntity,
} from './features/graph-editor.js';
@@ -486,6 +486,7 @@ Object.assign(window, {
toggleGraphLegend,
toggleGraphMinimap,
toggleGraphFilter,
toggleGraphFilterTypes,
graphFitAll,
graphZoomIn,
graphZoomOut,

View File

@@ -3,6 +3,7 @@
*/
import { apiKey, setApiKey, refreshInterval, setRefreshInterval, displaysCache } from './state.js';
import { t } from './i18n.js';
export const API_BASE = '/api/v1';
@@ -132,9 +133,7 @@ export function handle401Error() {
window.updateAuthUI();
}
const expiredMsg = typeof window.t === 'function'
? window.t('auth.session_expired')
: 'Your session has expired. Please login again.';
const expiredMsg = t('auth.session_expired');
if (typeof window.showApiKeyModal === 'function') {
window.showApiKeyModal(expiredMsg, true);
@@ -222,8 +221,8 @@ export async function loadDisplays(engineType = null) {
export function configureApiKey() {
const currentKey = localStorage.getItem('wled_api_key');
const message = currentKey
? 'Current API key is set. Enter new key to update or leave blank to remove:'
: 'Enter your API key:';
? t('auth.prompt_update')
: t('auth.prompt_enter');
const key = prompt(message);
@@ -243,5 +242,5 @@ export function configureApiKey() {
loadServerInfo();
loadDisplays();
window.loadDevices();
document.dispatchEvent(new CustomEvent('auth:keyChanged'));
}

View File

@@ -107,7 +107,8 @@ void main() {
`;
let _canvas, _gl, _prog;
let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticles;
let _uTime, _uRes, _uAccent, _uBg, _uLight, _uParticlesBase;
let _particleBuf = null; // pre-allocated Float32Array for uniform3fv
let _raf = null;
let _startTime = 0;
let _accent = [76 / 255, 175 / 255, 80 / 255];
@@ -184,10 +185,8 @@ function _initGL() {
_uAccent = gl.getUniformLocation(_prog, 'u_accent');
_uBg = gl.getUniformLocation(_prog, 'u_bg');
_uLight = gl.getUniformLocation(_prog, 'u_light');
_uParticles = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
_uParticles.push(gl.getUniformLocation(_prog, `u_particles[${i}]`));
}
_uParticlesBase = gl.getUniformLocation(_prog, 'u_particles[0]');
_particleBuf = new Float32Array(PARTICLE_COUNT * 3);
return true;
}
@@ -216,8 +215,12 @@ function _draw(time) {
for (let i = 0; i < PARTICLE_COUNT; i++) {
const p = _particles[i];
gl.uniform3f(_uParticles[i], p.x, p.y, p.r);
const off = i * 3;
_particleBuf[off] = p.x;
_particleBuf[off + 1] = p.y;
_particleBuf[off + 2] = p.r;
}
gl.uniform3fv(_uParticlesBase, _particleBuf);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

View File

@@ -47,7 +47,10 @@ export class DataCache {
async _doFetch() {
try {
const resp = await fetchWithAuth(this._endpoint);
if (!resp.ok) return this._data;
if (!resp.ok) {
console.error(`[DataCache] ${this._endpoint}: HTTP ${resp.status}`);
return this._data;
}
const json = await resp.json();
this._data = this._extractData(json);
this._fresh = true;

View File

@@ -9,25 +9,27 @@
const CARD_SEL = '.card, .template-card';
let _active = null; // currently illuminated card element
let _cachedRect = null; // cached bounding rect for current card
function _onMove(e) {
const card = e.target.closest(CARD_SEL);
if (card && !card.classList.contains('add-device-card')) {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
card.style.setProperty('--glare-x', `${x}px`);
card.style.setProperty('--glare-y', `${y}px`);
if (_active !== card) {
if (_active) _active.classList.remove('card-glare');
card.classList.add('card-glare');
_active = card;
_cachedRect = card.getBoundingClientRect();
}
const x = e.clientX - _cachedRect.left;
const y = e.clientY - _cachedRect.top;
card.style.setProperty('--glare-x', `${x}px`);
card.style.setProperty('--glare-y', `${y}px`);
} else if (_active) {
_active.classList.remove('card-glare');
_active = null;
_cachedRect = null;
}
}
@@ -35,6 +37,7 @@ function _onLeave() {
if (_active) {
_active.classList.remove('card-glare');
_active = null;
_cachedRect = null;
}
}

View File

@@ -28,6 +28,7 @@ const ORDER_PREFIX = 'card_order_';
const DRAG_THRESHOLD = 5;
const SCROLL_EDGE = 60;
const SCROLL_SPEED = 12;
const _reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function _getCollapsedMap() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; }
@@ -57,6 +58,7 @@ export class CardSection {
this._lastItems = null;
this._dragState = null;
this._dragBound = false;
this._cachedCardRects = null;
}
/** True if this section's DOM element exists (i.e. not the first render). */
@@ -177,6 +179,9 @@ export class CardSection {
// Stagger card entrance animation
this._animateEntrance(content);
// Cache searchable text on cards for faster filtering
this._cacheSearchText(content);
}
/**
@@ -246,7 +251,7 @@ export class CardSection {
}
// Animate newly added cards
if (added.size > 0 && this.keyAttr && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
if (added.size > 0 && this.keyAttr && !_reducedMotion.matches) {
let delay = 0;
for (const key of added) {
const card = content.querySelector(`[${this.keyAttr}="${key}"]`);
@@ -264,6 +269,11 @@ export class CardSection {
this._injectDragHandles(content);
}
// Re-cache searchable text for new/replaced cards
if (added.size > 0 || replaced.size > 0) {
this._cacheSearchText(content);
}
// Re-apply filter
if (this._filterValue) {
this._applyFilter(content, this._filterValue);
@@ -363,7 +373,7 @@ export class CardSection {
_animateEntrance(content) {
if (this._animated) return;
this._animated = true;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (_reducedMotion.matches) return;
const selector = this.keyAttr ? `[${this.keyAttr}]` : '.card, .template-card:not(.add-template-card)';
const cards = content.querySelectorAll(selector);
cards.forEach((card, i) => {
@@ -383,6 +393,14 @@ export class CardSection {
});
}
/** Cache each card's lowercased text content in data-search for fast filtering. */
_cacheSearchText(content) {
const cards = content.querySelectorAll('.card, .template-card:not(.add-template-card)');
cards.forEach(card => {
card.dataset.search = card.textContent.toLowerCase();
});
}
_toggleCollapse(header, content) {
const map = _getCollapsedMap();
const nowCollapsed = !map[this.sectionKey];
@@ -399,7 +417,7 @@ export class CardSection {
if (content._csAnim) { content._csAnim.cancel(); content._csAnim = null; }
// Skip animation when user prefers reduced motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
if (_reducedMotion.matches) {
content.style.display = nowCollapsed ? 'none' : '';
return;
}
@@ -418,6 +436,8 @@ export class CardSection {
content._csAnim = null;
};
} else {
// Intentional forced layout: reading scrollHeight after setting display=''
// is required to measure the target height for the expand animation.
content.style.display = '';
content.style.overflow = 'hidden';
const h = content.scrollHeight;
@@ -454,7 +474,7 @@ export class CardSection {
let visible = 0;
cards.forEach(card => {
const text = card.textContent.toLowerCase();
const text = card.dataset.search || card.textContent.toLowerCase();
// Each group must have at least one matching term (AND of ORs)
const match = groups.every(orTerms => orTerms.some(term => text.includes(term)));
card.style.display = match ? '' : 'none';
@@ -572,11 +592,15 @@ export class CardSection {
ds.card.style.display = 'none';
ds.content.classList.add('cs-dragging');
document.body.classList.add('cs-drag-active');
// Cache card bounding rects for the duration of the drag
this._cachedCardRects = this._buildCardRectCache(ds.content);
}
_onDragEnd() {
const ds = this._dragState;
this._dragState = null;
this._cachedCardRects = null;
if (!ds || !ds.started) return;
// Cancel auto-scroll
@@ -602,11 +626,19 @@ export class CardSection {
}
}
_getDropTarget(x, y, content) {
_buildCardRectCache(content) {
const cards = content.querySelectorAll(`[${this.keyAttr}]`);
const rects = [];
for (const card of cards) {
if (card.style.display === 'none') continue;
const r = card.getBoundingClientRect();
rects.push({ card, rect: card.getBoundingClientRect() });
}
return rects;
}
_getDropTarget(x, y, content) {
const rects = this._cachedCardRects || [];
for (const { card, rect: r } of rects) {
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
return { card, before: x < r.left + r.width / 2 };
}

View File

@@ -0,0 +1,77 @@
/**
* Shared FPS sparkline chart factory.
*
* Both dashboard.js and targets.js need nearly identical Chart.js line charts
* for FPS visualization. This module provides a single factory so the config
* lives in one place.
*
* Requires Chart.js to be registered globally (done by perf-charts.js).
*/
/**
* Create an FPS sparkline Chart.js instance.
*
* @param {string} canvasId DOM id of the <canvas> element
* @param {number[]} actualHistory fps_actual samples
* @param {number[]} currentHistory fps_current samples
* @param {number} fpsTarget target FPS (used for y-axis max)
* @param {Object} [opts] optional overrides
* @param {number} [opts.maxHwFps] hardware max FPS — draws a dashed reference line
* @returns {Chart|null}
*/
export function createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, opts = {}) {
const canvas = document.getElementById(canvasId);
if (!canvas) return null;
const datasets = [
{
data: [...actualHistory],
borderColor: '#2196F3',
backgroundColor: 'rgba(33,150,243,0.12)',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
},
{
data: [...currentHistory],
borderColor: '#4CAF50',
borderWidth: 1.5,
tension: 0.3,
fill: false,
pointRadius: 0,
},
];
// Optional hardware-max reference line (used by target cards)
const maxHwFps = opts.maxHwFps;
if (maxHwFps && maxHwFps < fpsTarget * 1.15) {
datasets.push({
data: actualHistory.map(() => maxHwFps),
borderColor: 'rgba(255,152,0,0.5)',
borderWidth: 1,
borderDash: [4, 3],
pointRadius: 0,
fill: false,
});
}
return new Chart(canvas, {
type: 'line',
data: {
labels: actualHistory.map(() => ''),
datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
y: { min: 0, max: fpsTarget * 1.15, display: false },
},
layout: { padding: 0 },
},
});
}

View File

@@ -2,7 +2,7 @@
* Command Palette — global search & navigation (Ctrl+K / Cmd+K).
*/
import { fetchWithAuth } from './api.js';
import { fetchWithAuth, escapeHtml } from './api.js';
import { t } from './i18n.js';
import { navigateToCard } from './navigation.js';
import {
@@ -208,9 +208,9 @@ function _render() {
const colorStyle = color ? ` style="border-left:3px solid ${color}"` : '';
html += `<div class="cp-result${active}" data-cp-idx="${idx}"${colorStyle}>` +
`<span class="cp-icon">${item.icon}</span>` +
`<span class="cp-name">${_escHtml(item.name)}</span>` +
`<span class="cp-name">${escapeHtml(item.name)}</span>` +
(item.running ? '<span class="cp-running"></span>' : '') +
(item.detail ? `<span class="cp-detail">${_escHtml(item.detail)}</span>` : '') +
(item.detail ? `<span class="cp-detail">${escapeHtml(item.detail)}</span>` : '') +
`</div>`;
idx++;
}
@@ -224,12 +224,6 @@ function _scrollActive(container) {
if (active) active.scrollIntoView({ block: 'nearest' });
}
function _escHtml(text) {
if (!text) return '';
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
// ─── Open / Close ───

View File

@@ -0,0 +1,318 @@
/**
* FilterListManager — reusable class that encapsulates filter list DOM
* manipulation (add, remove, move, toggle-expand, update option, render,
* populate select, collect, auto-name).
*
* Both the PP template modal and the CSPT modal create an instance,
* parameterised by which filter array, filter definitions, DOM IDs, etc.
*/
import { t } from './i18n.js';
import { escapeHtml } from './api.js';
import { IconSelect } from './icon-select.js';
import * as P from './icon-paths.js';
const _FILTER_ICONS = {
brightness: P.sunDim,
saturation: P.palette,
gamma: P.sun,
downscaler: P.monitor,
pixelate: P.layoutDashboard,
auto_crop: P.target,
flip: P.rotateCw,
color_correction: P.palette,
filter_template: P.fileText,
frame_interpolation: P.fastForward,
noise_gate: P.volume2,
palette_quantization: P.sparkles,
css_filter_template: P.fileText,
};
export { _FILTER_ICONS };
/**
* @param {Object} opts
* @param {Function} opts.getFilters - () => filtersArr (mutable reference)
* @param {Function} opts.getFilterDefs - () => filterDefs array
* @param {Function} opts.getFilterName - (filterId) => display name
* @param {string} opts.selectId - DOM id of the <select> for adding filters
* @param {string} opts.containerId - DOM id of the filter list container
* @param {string} opts.prefix - handler prefix for onclick attrs: '' for PP, 'cspt' for CSPT
* @param {string} opts.editingIdInputId - DOM id of hidden input holding the editing template ID
* @param {string} opts.selfRefFilterId - filter_id that should exclude self (e.g. 'filter_template')
* @param {Function} [opts.autoNameFn] - optional callback after add/remove to auto-generate name
* @param {Function} [opts.initDrag] - drag initializer fn(containerId, filtersArr, rerenderFn)
* @param {Function} [opts.initPaletteGrids] - palette grid initializer fn(containerEl)
*/
export class FilterListManager {
constructor(opts) {
this._getFilters = opts.getFilters;
this._getFilterDefs = opts.getFilterDefs;
this._getFilterName = opts.getFilterName;
this._selectId = opts.selectId;
this._containerId = opts.containerId;
this._prefix = opts.prefix;
this._editingIdInputId = opts.editingIdInputId;
this._selfRefFilterId = opts.selfRefFilterId;
this._autoNameFn = opts.autoNameFn || null;
this._initDrag = opts.initDrag || null;
this._initPaletteGrids = opts.initPaletteGrids || null;
this._iconSelect = null;
}
/** Get the current IconSelect instance (for external access if needed). */
get iconSelect() { return this._iconSelect; }
/**
* Populate the filter <select> and attach/update IconSelect grid.
* @param {Function} onChangeCallback - called when user picks a filter from the icon grid
*/
populateSelect(onChangeCallback) {
const select = document.getElementById(this._selectId);
const filterDefs = this._getFilterDefs();
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
const items = [];
for (const f of filterDefs) {
const name = this._getFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
const pathData = _FILTER_ICONS[f.filter_id] || P.wrench;
items.push({
value: f.filter_id,
icon: `<svg class="icon" viewBox="0 0 24 24">${pathData}</svg>`,
label: name,
desc: t(`filters.${f.filter_id}.desc`),
});
}
if (this._iconSelect) {
this._iconSelect.updateItems(items);
} else if (items.length > 0) {
this._iconSelect = new IconSelect({
target: select,
items,
columns: 3,
placeholder: t('filters.select_type'),
onChange: onChangeCallback,
});
}
}
/**
* Render the filter list into the container.
*/
render() {
const container = document.getElementById(this._containerId);
const filtersArr = this._getFilters();
const filterDefs = this._getFilterDefs();
if (filtersArr.length === 0) {
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
return;
}
const toggleFn = this._prefix ? `${this._prefix}ToggleFilterExpand` : 'toggleFilterExpand';
const removeFn = this._prefix ? `${this._prefix}RemoveFilter` : 'removeFilter';
const updateFn = this._prefix ? `${this._prefix}UpdateFilterOption` : 'updateFilterOption';
const inputPrefix = this._prefix ? `${this._prefix}-filter` : 'filter';
const nameFn = this._getFilterName;
let html = '';
filtersArr.forEach((fi, index) => {
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
const filterName = nameFn(fi.filter_id);
const isExpanded = fi._expanded === true;
let summary = '';
if (filterDef && !isExpanded) {
summary = filterDef.options_schema.map(opt => {
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
return val;
}).join(', ');
}
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="${toggleFn}(${index})">
<span class="pp-filter-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<span class="pp-filter-card-chevron">${isExpanded ? '&#x25BC;' : '&#x25B6;'}</span>
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
<button type="button" class="btn-filter-action btn-filter-remove" onclick="${removeFn}(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
if (filterDef) {
for (const opt of filterDef.options_schema) {
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
const inputId = `${inputPrefix}-${index}-${opt.key}`;
if (opt.type === 'bool') {
const checked = currentVal === true || currentVal === 'true';
html += `<div class="pp-filter-option pp-filter-option-bool">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}</span>
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
</label>
</div>`;
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
const editingId = document.getElementById(this._editingIdInputId)?.value || '';
const filteredChoices = (fi.filter_id === this._selfRefFilterId && opt.key === 'template_id' && editingId)
? opt.choices.filter(c => c.value !== editingId)
: opt.choices;
let selectVal = currentVal;
if (filteredChoices.length > 0 && !filteredChoices.some(c => c.value === selectVal)) {
selectVal = filteredChoices[0].value;
fi.options[opt.key] = selectVal;
}
const hasPaletteColors = filteredChoices.some(c => c.colors);
const options = filteredChoices.map(c =>
`<option value="${escapeHtml(c.value)}"${c.value === selectVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
).join('');
const gridAttr = hasPaletteColors ? ` data-palette-grid="${escapeHtml(JSON.stringify(filteredChoices))}"` : '';
const isTemplateRef = opt.key === 'template_id';
const entityAttr = isTemplateRef ? ' data-entity-select="template"' : '';
html += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<select id="${inputId}"${gridAttr}${entityAttr}
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
${options}
</select>
</div>`;
} else if (opt.type === 'string') {
const maxLen = opt.max_length || 500;
html += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<input type="text" id="${inputId}" value="${escapeHtml(String(currentVal))}"
maxlength="${maxLen}" class="pp-filter-text-input"
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
</div>`;
} else {
html += `<div class="pp-filter-option">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}:</span>
<span id="${inputId}-display">${currentVal}</span>
</label>
<input type="range" id="${inputId}"
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
oninput="${updateFn}(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
</div>`;
}
}
}
html += `</div></div>`;
});
container.innerHTML = html;
if (this._initDrag) {
this._initDrag(this._containerId, filtersArr, () => this.render());
}
if (this._initPaletteGrids) {
this._initPaletteGrids(container);
}
}
/**
* Add a filter from the select element into the filters array.
*/
addFromSelect() {
const select = document.getElementById(this._selectId);
const filterId = select.value;
if (!filterId) return;
const filterDefs = this._getFilterDefs();
const filterDef = filterDefs.find(f => f.filter_id === filterId);
if (!filterDef) return;
const options = {};
for (const opt of filterDef.options_schema) {
if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) {
options[opt.key] = opt.choices[0].value;
} else {
options[opt.key] = opt.default;
}
}
const filtersArr = this._getFilters();
filtersArr.push({ filter_id: filterId, options, _expanded: true });
select.value = '';
if (this._iconSelect) this._iconSelect.setValue('');
this.render();
if (this._autoNameFn) this._autoNameFn();
}
/**
* Toggle expand/collapse of a filter card.
*/
toggleExpand(index) {
const filtersArr = this._getFilters();
if (filtersArr[index]) {
filtersArr[index]._expanded = !filtersArr[index]._expanded;
this.render();
}
}
/**
* Remove a filter at the given index.
*/
remove(index) {
const filtersArr = this._getFilters();
filtersArr.splice(index, 1);
this.render();
if (this._autoNameFn) this._autoNameFn();
}
/**
* Move a filter up or down by swapping with its neighbour.
* @param {number} index - current index
* @param {number} direction - -1 for up, +1 for down
*/
move(index, direction) {
const filtersArr = this._getFilters();
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= filtersArr.length) return;
const tmp = filtersArr[index];
filtersArr[index] = filtersArr[newIndex];
filtersArr[newIndex] = tmp;
this.render();
if (this._autoNameFn) this._autoNameFn();
}
/**
* Update a single option value on a filter.
*/
updateOption(filterIndex, optionKey, value) {
const filtersArr = this._getFilters();
const filterDefs = this._getFilterDefs();
if (filtersArr[filterIndex]) {
const fi = filtersArr[filterIndex];
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'bool') {
fi.options[optionKey] = !!value;
} else if (optDef && optDef.type === 'select') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'string') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {
fi.options[optionKey] = parseFloat(value);
}
} else {
fi.options[optionKey] = parseFloat(value);
}
}
}
/**
* Collect the current filters into a clean array (no internal _expanded flag).
*/
collect() {
return this._getFilters().map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
}
}

View File

@@ -139,13 +139,23 @@ function _defaultBezier(fromNode, toNode, fromPortY, toPortY) {
* Highlight edges that connect to a specific node (upstream chain).
*/
export function highlightChain(edgeGroup, nodeId, edges) {
// Build adjacency indexes for O(E) BFS instead of O(N*E)
const toIndex = new Map(); // toId → [edge]
const fromIndex = new Map(); // fromId → [edge]
for (const e of edges) {
if (!toIndex.has(e.to)) toIndex.set(e.to, []);
toIndex.get(e.to).push(e);
if (!fromIndex.has(e.from)) fromIndex.set(e.from, []);
fromIndex.get(e.from).push(e);
}
// Find all ancestors recursively
const upstream = new Set();
const stack = [nodeId];
while (stack.length) {
const current = stack.pop();
for (const e of edges) {
if (e.to === current && !upstream.has(e.from)) {
for (const e of (toIndex.get(current) || [])) {
if (!upstream.has(e.from)) {
upstream.add(e.from);
stack.push(e.from);
}
@@ -158,8 +168,8 @@ export function highlightChain(edgeGroup, nodeId, edges) {
const dStack = [nodeId];
while (dStack.length) {
const current = dStack.pop();
for (const e of edges) {
if (e.from === current && !downstream.has(e.to)) {
for (const e of (fromIndex.get(current) || [])) {
if (!downstream.has(e.to)) {
downstream.add(e.to);
dStack.push(e.to);
}
@@ -234,6 +244,21 @@ export function renderFlowDots(group, edges, runningIds) {
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
if (!runningIds || runningIds.size === 0) return;
// Build adjacency index for O(E) BFS instead of O(N*E)
const toIndex = new Map(); // toId → [{edge, idx}]
for (let i = 0; i < edges.length; i++) {
const e = edges[i];
if (!toIndex.has(e.to)) toIndex.set(e.to, []);
toIndex.get(e.to).push({ edge: e, idx: i });
}
// Build a Map from edge key to path element for O(1) lookup instead of querySelector
const pathIndex = new Map();
group.querySelectorAll('.graph-edge').forEach(pathEl => {
const key = `${pathEl.getAttribute('data-from')}|${pathEl.getAttribute('data-to')}|${pathEl.getAttribute('data-field') || ''}`;
pathIndex.set(key, pathEl);
});
// Collect all upstream edges that feed into running nodes (full chain)
const activeEdges = new Set();
const visited = new Set();
@@ -242,19 +267,15 @@ export function renderFlowDots(group, edges, runningIds) {
const cur = stack.pop();
if (visited.has(cur)) continue;
visited.add(cur);
for (let i = 0; i < edges.length; i++) {
if (edges[i].to === cur) {
activeEdges.add(i);
stack.push(edges[i].from);
}
for (const { edge, idx } of (toIndex.get(cur) || [])) {
activeEdges.add(idx);
stack.push(edge.from);
}
}
for (const idx of activeEdges) {
const edge = edges[idx];
const pathEl = group.querySelector(
`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"][data-field="${edge.field || ''}"]`
);
const pathEl = pathIndex.get(`${edge.from}|${edge.to}|${edge.field || ''}`);
if (!pathEl) continue;
const d = pathEl.getAttribute('d');
if (!d) continue;

View File

@@ -48,8 +48,9 @@ export async function computeLayout(entities) {
const layout = await elk.layout(elkGraph);
const nodeMap = new Map();
const nodeById = new Map(nodeList.map(n => [n.id, n]));
for (const child of layout.children) {
const src = nodeList.find(n => n.id === child.id);
const src = nodeById.get(child.id);
if (src) {
nodeMap.set(child.id, {
...src,

View File

@@ -95,6 +95,9 @@ export let _stripFilters = [];
export let _cachedCSPTemplates = [];
// Stream test state
export let currentTestingTemplate = null;
export function setCurrentTestingTemplate(v) { currentTestingTemplate = v; }
export let _currentTestStreamId = null;
export function set_currentTestStreamId(v) { _currentTestStreamId = v; }

View File

@@ -1406,6 +1406,360 @@ function _autoGenerateCSSName() {
document.getElementById('css-editor-name').value = detail ? `${typeLabel} · ${detail}` : typeLabel;
}
/* ── Per-type handler registry ─────────────────────────────────
* Each handler has:
* load(css) — populate editor fields from a saved/cloned source object
* reset() — set editor fields to default values for "new" mode
* getPayload(name) — read editor fields and return the API payload (or null to signal validation error)
* The handlers delegate to existing _loadXState / _resetXState helpers where available.
*/
const _typeHandlers = {
static: {
load(css) {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
_loadAnimationState(css.animation);
},
reset() {
document.getElementById('css-editor-color').value = '#ffffff';
_loadAnimationState(null);
},
getPayload(name) {
return {
name,
color: hexToRgbArray(document.getElementById('css-editor-color').value),
animation: _getAnimationPayload(),
};
},
},
color_cycle: {
load(css) {
_loadColorCycleState(css);
},
reset() {
_loadColorCycleState(null);
},
getPayload(name) {
const cycleColors = _colorCycleGetColors();
if (cycleColors.length < 2) {
cssEditorModal.showError(t('color_strip.color_cycle.min_colors'));
return null;
}
return { name, colors: cycleColors };
},
},
gradient: {
load(css) {
document.getElementById('css-editor-gradient-preset').value = '';
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue('');
gradientInit(css.stops || [
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(css.animation);
},
reset() {
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(null);
},
getPayload(name) {
const gStops = getGradientStops();
if (gStops.length < 2) {
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
return null;
}
return {
name,
stops: gStops.map(s => ({
position: s.position,
color: s.color,
...(s.colorRight ? { color_right: s.colorRight } : {}),
})),
animation: _getAnimationPayload(),
};
},
},
effect: {
load(css) {
document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire';
if (_effectTypeIconSelect) _effectTypeIconSelect.setValue(css.effect_type || 'fire');
onEffectTypeChange();
document.getElementById('css-editor-effect-palette').value = css.palette || 'fire';
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(css.palette || 'fire');
document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]);
document.getElementById('css-editor-effect-intensity').value = css.intensity ?? 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-scale').value = css.scale ?? 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
},
reset() {
document.getElementById('css-editor-effect-type').value = 'fire';
document.getElementById('css-editor-effect-palette').value = 'fire';
document.getElementById('css-editor-effect-color').value = '#ff5000';
document.getElementById('css-editor-effect-intensity').value = 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = '1.0';
document.getElementById('css-editor-effect-scale').value = 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false;
},
getPayload(name) {
const payload = {
name,
effect_type: document.getElementById('css-editor-effect-type').value,
palette: document.getElementById('css-editor-effect-palette').value,
intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value),
scale: parseFloat(document.getElementById('css-editor-effect-scale').value),
mirror: document.getElementById('css-editor-effect-mirror').checked,
};
// Meteor uses a color picker
if (payload.effect_type === 'meteor') {
const hex = document.getElementById('css-editor-effect-color').value;
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
return payload;
},
},
audio: {
async load(css) {
await _loadAudioSources();
_loadAudioState(css);
},
reset() {
_resetAudioState();
},
getPayload(name) {
return {
name,
visualization_mode: document.getElementById('css-editor-audio-viz').value,
audio_source_id: document.getElementById('css-editor-audio-source').value || null,
sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value),
smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value),
palette: document.getElementById('css-editor-audio-palette').value,
color: hexToRgbArray(document.getElementById('css-editor-audio-color').value),
color_peak: hexToRgbArray(document.getElementById('css-editor-audio-color-peak').value),
mirror: document.getElementById('css-editor-audio-mirror').checked,
};
},
},
composite: {
load(css) {
_loadCompositeState(css);
},
reset() {
_loadCompositeState(null);
},
getPayload(name) {
const layers = _compositeGetLayers();
if (layers.length < 1) {
cssEditorModal.showError(t('color_strip.composite.error.min_layers'));
return null;
}
const hasEmpty = layers.some(l => !l.source_id);
if (hasEmpty) {
cssEditorModal.showError(t('color_strip.composite.error.no_source'));
return null;
}
return { name, layers };
},
},
mapped: {
load(css) {
_loadMappedState(css);
},
reset() {
_resetMappedState();
},
getPayload(name) {
const zones = _mappedGetZones();
const hasEmpty = zones.some(z => !z.source_id);
if (hasEmpty) {
cssEditorModal.showError(t('color_strip.mapped.error.no_source'));
return null;
}
return { name, zones };
},
},
api_input: {
load(css) {
document.getElementById('css-editor-api-input-fallback-color').value =
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
document.getElementById('css-editor-api-input-timeout').value = css.timeout ?? 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1);
_showApiInputEndpoints(css.id);
},
reset() {
document.getElementById('css-editor-api-input-fallback-color').value = '#000000';
document.getElementById('css-editor-api-input-timeout').value = 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
_showApiInputEndpoints(null);
},
getPayload(name) {
const fbHex = document.getElementById('css-editor-api-input-fallback-color').value;
return {
name,
fallback_color: hexToRgbArray(fbHex),
timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value),
};
},
},
notification: {
load(css) {
_loadNotificationState(css);
},
reset() {
_resetNotificationState();
},
getPayload(name) {
const filterList = document.getElementById('css-editor-notification-filter-list').value
.split('\n').map(s => s.trim()).filter(Boolean);
return {
name,
notification_effect: document.getElementById('css-editor-notification-effect').value,
duration_ms: parseInt(document.getElementById('css-editor-notification-duration').value) || 1500,
default_color: document.getElementById('css-editor-notification-default-color').value,
app_filter_mode: document.getElementById('css-editor-notification-filter-mode').value,
app_filter_list: filterList,
app_colors: _notificationGetAppColorsDict(),
};
},
},
daylight: {
load(css) {
document.getElementById('css-editor-daylight-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
document.getElementById('css-editor-daylight-real-time').checked = css.use_real_time || false;
document.getElementById('css-editor-daylight-latitude').value = css.latitude ?? 50.0;
document.getElementById('css-editor-daylight-latitude-val').textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
_syncDaylightSpeedVisibility();
},
reset() {
document.getElementById('css-editor-daylight-speed').value = 1.0;
document.getElementById('css-editor-daylight-speed-val').textContent = '1.0';
document.getElementById('css-editor-daylight-real-time').checked = false;
document.getElementById('css-editor-daylight-latitude').value = 50.0;
document.getElementById('css-editor-daylight-latitude-val').textContent = '50';
},
getPayload(name) {
return {
name,
speed: parseFloat(document.getElementById('css-editor-daylight-speed').value),
use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value),
};
},
},
candlelight: {
load(css) {
document.getElementById('css-editor-candlelight-color').value = rgbArrayToHex(css.color || [255, 147, 41]);
document.getElementById('css-editor-candlelight-intensity').value = css.intensity ?? 1.0;
document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
},
reset() {
document.getElementById('css-editor-candlelight-color').value = '#ff9329';
document.getElementById('css-editor-candlelight-intensity').value = 1.0;
document.getElementById('css-editor-candlelight-intensity-val').textContent = '1.0';
document.getElementById('css-editor-candlelight-num-candles').value = 3;
document.getElementById('css-editor-candlelight-speed').value = 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
},
getPayload(name) {
return {
name,
color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value),
intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value),
num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3,
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
};
},
},
processed: {
async load(css) {
await csptCache.fetch();
await colorStripSourcesCache.fetch();
_populateProcessedSelectors();
document.getElementById('css-editor-processed-input').value = css.input_source_id || '';
document.getElementById('css-editor-processed-template').value = css.processing_template_id || '';
},
async reset(presetType) {
if (presetType === 'processed') {
await csptCache.fetch();
await colorStripSourcesCache.fetch();
_populateProcessedSelectors();
}
},
getPayload(name) {
const inputId = document.getElementById('css-editor-processed-input').value;
const templateId = document.getElementById('css-editor-processed-template').value;
if (!inputId) {
cssEditorModal.showError(t('color_strip.processed.error.no_input'));
return null;
}
return {
name,
input_source_id: inputId,
processing_template_id: templateId || null,
};
},
},
picture_advanced: {
load(css) {
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
},
reset() {
document.getElementById('css-editor-interpolation').value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
},
getPayload(name) {
return {
name,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
},
},
picture: {
load(css, sourceSelect) {
sourceSelect.value = css.picture_source_id || '';
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
},
reset() {
document.getElementById('css-editor-interpolation').value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
},
getPayload(name) {
return {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
},
},
};
/* ── Editor open/close ────────────────────────────────────────── */
export async function showCSSEditor(cssId = null, cloneData = null, presetType = null) {
@@ -1465,78 +1819,8 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
onCSSClockChange();
}
if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
_loadAnimationState(css.animation);
} else if (sourceType === 'color_cycle') {
_loadColorCycleState(css);
} else if (sourceType === 'gradient') {
document.getElementById('css-editor-gradient-preset').value = '';
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue('');
gradientInit(css.stops || [
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(css.animation);
} else if (sourceType === 'effect') {
document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire';
if (_effectTypeIconSelect) _effectTypeIconSelect.setValue(css.effect_type || 'fire');
onEffectTypeChange();
document.getElementById('css-editor-effect-palette').value = css.palette || 'fire';
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(css.palette || 'fire');
document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]);
document.getElementById('css-editor-effect-intensity').value = css.intensity ?? 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-scale').value = css.scale ?? 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
} else if (sourceType === 'audio') {
await _loadAudioSources();
_loadAudioState(css);
} else if (sourceType === 'composite') {
_loadCompositeState(css);
} else if (sourceType === 'mapped') {
_loadMappedState(css);
} else if (sourceType === 'api_input') {
document.getElementById('css-editor-api-input-fallback-color').value =
rgbArrayToHex(css.fallback_color || [0, 0, 0]);
document.getElementById('css-editor-api-input-timeout').value = css.timeout ?? 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent =
parseFloat(css.timeout ?? 5.0).toFixed(1);
_showApiInputEndpoints(css.id);
} else if (sourceType === 'notification') {
_loadNotificationState(css);
} else if (sourceType === 'daylight') {
document.getElementById('css-editor-daylight-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-daylight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
document.getElementById('css-editor-daylight-real-time').checked = css.use_real_time || false;
document.getElementById('css-editor-daylight-latitude').value = css.latitude ?? 50.0;
document.getElementById('css-editor-daylight-latitude-val').textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
_syncDaylightSpeedVisibility();
} else if (sourceType === 'candlelight') {
document.getElementById('css-editor-candlelight-color').value = rgbArrayToHex(css.color || [255, 147, 41]);
document.getElementById('css-editor-candlelight-intensity').value = css.intensity ?? 1.0;
document.getElementById('css-editor-candlelight-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
} else if (sourceType === 'processed') {
await csptCache.fetch();
await colorStripSourcesCache.fetch();
_populateProcessedSelectors();
document.getElementById('css-editor-processed-input').value = css.input_source_id || '';
document.getElementById('css-editor-processed-template').value = css.processing_template_id || '';
} else {
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue(css.interpolation_mode || 'average');
const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
}
const handler = _typeHandlers[sourceType] || _typeHandlers.picture;
await handler.load(css, sourceSelect);
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
};
@@ -1576,58 +1860,18 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
} else {
document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = '';
document.getElementById('css-editor-type').value = presetType || 'picture';
const effectiveType = presetType || 'picture';
document.getElementById('css-editor-type').value = effectiveType;
onCSSTypeChange();
document.getElementById('css-editor-interpolation').value = 'average';
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
document.getElementById('css-editor-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0;
_loadAnimationState(null);
_loadColorCycleState(null);
document.getElementById('css-editor-effect-type').value = 'fire';
document.getElementById('css-editor-effect-palette').value = 'fire';
document.getElementById('css-editor-effect-color').value = '#ff5000';
document.getElementById('css-editor-effect-intensity').value = 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = '1.0';
document.getElementById('css-editor-effect-scale').value = 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false;
_loadCompositeState(null);
_resetMappedState();
_resetAudioState();
document.getElementById('css-editor-api-input-fallback-color').value = '#000000';
document.getElementById('css-editor-api-input-timeout').value = 5.0;
document.getElementById('css-editor-api-input-timeout-val').textContent = '5.0';
_showApiInputEndpoints(null);
_resetNotificationState();
// Daylight defaults
document.getElementById('css-editor-daylight-speed').value = 1.0;
document.getElementById('css-editor-daylight-speed-val').textContent = '1.0';
document.getElementById('css-editor-daylight-real-time').checked = false;
document.getElementById('css-editor-daylight-latitude').value = 50.0;
document.getElementById('css-editor-daylight-latitude-val').textContent = '50';
// Candlelight defaults
document.getElementById('css-editor-candlelight-color').value = '#ff9329';
document.getElementById('css-editor-candlelight-intensity').value = 1.0;
document.getElementById('css-editor-candlelight-intensity-val').textContent = '1.0';
document.getElementById('css-editor-candlelight-num-candles').value = 3;
document.getElementById('css-editor-candlelight-speed').value = 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
// Processed defaults
if (presetType === 'processed') {
await csptCache.fetch();
await colorStripSourcesCache.fetch();
_populateProcessedSelectors();
// Reset all type handlers to defaults
for (const handler of Object.values(_typeHandlers)) {
await handler.reset(effectiveType);
}
const typeIcon = getColorStripIcon(presetType || 'picture');
document.getElementById('css-editor-title').innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${presetType || 'picture'}`)}`;
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
const typeIcon = getColorStripIcon(effectiveType);
document.getElementById('css-editor-title').innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${effectiveType}`)}`;
_autoGenerateCSSName();
}
@@ -1673,163 +1917,12 @@ export async function saveCSSEditor() {
return;
}
let payload;
if (sourceType === 'static') {
payload = {
name,
color: hexToRgbArray(document.getElementById('css-editor-color').value),
animation: _getAnimationPayload(),
};
if (!cssId) payload.source_type = 'static';
} else if (sourceType === 'color_cycle') {
const cycleColors = _colorCycleGetColors();
if (cycleColors.length < 2) {
cssEditorModal.showError(t('color_strip.color_cycle.min_colors'));
return;
}
payload = {
name,
colors: cycleColors,
};
if (!cssId) payload.source_type = 'color_cycle';
} else if (sourceType === 'gradient') {
const gStops = getGradientStops();
if (gStops.length < 2) {
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
return;
}
payload = {
name,
stops: gStops.map(s => ({
position: s.position,
color: s.color,
...(s.colorRight ? { color_right: s.colorRight } : {}),
})),
animation: _getAnimationPayload(),
};
if (!cssId) payload.source_type = 'gradient';
} else if (sourceType === 'effect') {
payload = {
name,
effect_type: document.getElementById('css-editor-effect-type').value,
palette: document.getElementById('css-editor-effect-palette').value,
intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value),
scale: parseFloat(document.getElementById('css-editor-effect-scale').value),
mirror: document.getElementById('css-editor-effect-mirror').checked,
};
// Meteor uses a color picker
if (payload.effect_type === 'meteor') {
const hex = document.getElementById('css-editor-effect-color').value;
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
if (!cssId) payload.source_type = 'effect';
} else if (sourceType === 'audio') {
payload = {
name,
visualization_mode: document.getElementById('css-editor-audio-viz').value,
audio_source_id: document.getElementById('css-editor-audio-source').value || null,
sensitivity: parseFloat(document.getElementById('css-editor-audio-sensitivity').value),
smoothing: parseFloat(document.getElementById('css-editor-audio-smoothing').value),
palette: document.getElementById('css-editor-audio-palette').value,
color: hexToRgbArray(document.getElementById('css-editor-audio-color').value),
color_peak: hexToRgbArray(document.getElementById('css-editor-audio-color-peak').value),
mirror: document.getElementById('css-editor-audio-mirror').checked,
};
if (!cssId) payload.source_type = 'audio';
} else if (sourceType === 'composite') {
const layers = _compositeGetLayers();
if (layers.length < 1) {
cssEditorModal.showError(t('color_strip.composite.error.min_layers'));
return;
}
const hasEmpty = layers.some(l => !l.source_id);
if (hasEmpty) {
cssEditorModal.showError(t('color_strip.composite.error.no_source'));
return;
}
payload = {
name,
layers,
};
if (!cssId) payload.source_type = 'composite';
} else if (sourceType === 'mapped') {
const zones = _mappedGetZones();
const hasEmpty = zones.some(z => !z.source_id);
if (hasEmpty) {
cssEditorModal.showError(t('color_strip.mapped.error.no_source'));
return;
}
payload = { name, zones };
if (!cssId) payload.source_type = 'mapped';
} else if (sourceType === 'api_input') {
const fbHex = document.getElementById('css-editor-api-input-fallback-color').value;
payload = {
name,
fallback_color: hexToRgbArray(fbHex),
timeout: parseFloat(document.getElementById('css-editor-api-input-timeout').value),
};
if (!cssId) payload.source_type = 'api_input';
} else if (sourceType === 'notification') {
const filterList = document.getElementById('css-editor-notification-filter-list').value
.split('\n').map(s => s.trim()).filter(Boolean);
payload = {
name,
notification_effect: document.getElementById('css-editor-notification-effect').value,
duration_ms: parseInt(document.getElementById('css-editor-notification-duration').value) || 1500,
default_color: document.getElementById('css-editor-notification-default-color').value,
app_filter_mode: document.getElementById('css-editor-notification-filter-mode').value,
app_filter_list: filterList,
app_colors: _notificationGetAppColorsDict(),
};
if (!cssId) payload.source_type = 'notification';
} else if (sourceType === 'daylight') {
payload = {
name,
speed: parseFloat(document.getElementById('css-editor-daylight-speed').value),
use_real_time: document.getElementById('css-editor-daylight-real-time').checked,
latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value),
};
if (!cssId) payload.source_type = 'daylight';
} else if (sourceType === 'candlelight') {
payload = {
name,
color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value),
intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value),
num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3,
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
};
if (!cssId) payload.source_type = 'candlelight';
} else if (sourceType === 'processed') {
const inputId = document.getElementById('css-editor-processed-input').value;
const templateId = document.getElementById('css-editor-processed-template').value;
if (!inputId) {
cssEditorModal.showError(t('color_strip.processed.error.no_input'));
return;
}
payload = {
name,
input_source_id: inputId,
processing_template_id: templateId || null,
};
if (!cssId) payload.source_type = 'processed';
} else if (sourceType === 'picture_advanced') {
payload = {
name,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) payload.source_type = 'picture_advanced';
} else {
payload = {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) payload.source_type = 'picture';
}
const knownType = sourceType in _typeHandlers;
const handler = knownType ? _typeHandlers[sourceType] : _typeHandlers.picture;
const payload = handler.getPayload(name);
if (payload === null) return; // validation error already shown
if (!cssId) payload.source_type = knownType ? sourceType : 'picture';
// Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];

View File

@@ -14,6 +14,7 @@ import {
} from '../core/icons.js';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
import { cardColorStyle } from '../core/card-colors.js';
import { createFpsSparkline } from '../core/chart-utils.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120;
@@ -88,44 +89,7 @@ function _destroyFpsCharts() {
}
function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) {
const canvas = document.getElementById(canvasId);
if (!canvas) return null;
return new Chart(canvas, {
type: 'line',
data: {
labels: actualHistory.map(() => ''),
datasets: [
{
data: [...actualHistory],
borderColor: '#2196F3',
backgroundColor: 'rgba(33,150,243,0.12)',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
},
{
data: [...currentHistory],
borderColor: '#4CAF50',
borderWidth: 1.5,
tension: 0.3,
fill: false,
pointRadius: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: {
x: { display: false },
y: { min: 0, max: fpsTarget * 1.15, display: false },
},
layout: { padding: 0 },
},
});
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget);
}
async function _initFpsCharts(runningTargetIds) {

View File

@@ -651,6 +651,8 @@ export async function loadDevices() {
await window.loadTargetsTab();
}
document.addEventListener('auth:keyChanged', () => loadDevices());
// ===== OpenRGB zone count enrichment =====
// Cache: baseUrl → { zoneName: ledCount, ... }

View File

@@ -16,6 +16,7 @@ import {
_streamModalPPTemplates, set_streamModalPPTemplates,
_modalFilters, set_modalFilters,
_ppTemplateNameManuallyEdited, set_ppTemplateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_currentTestStreamId, set_currentTestStreamId,
_currentTestPPTemplateId, set_currentTestPPTemplateId,
_lastValidatedImageSource, set_lastValidatedImageSource,
@@ -59,7 +60,7 @@ import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { IconSelect } from '../core/icon-select.js';
import { EntitySelect } from '../core/entity-palette.js';
import * as P from '../core/icon-paths.js';
import { FilterListManager } from '../core/filter-list.js';
// ── TagInput instances for modals ──
let _captureTemplateTagsInput = null;
@@ -315,7 +316,7 @@ export async function showTestTemplateModal(templateId) {
return;
}
window.currentTestingTemplate = template;
setCurrentTestingTemplate(template);
await loadDisplaysForTest();
restoreCaptureDuration();
@@ -328,7 +329,7 @@ export async function showTestTemplateModal(templateId) {
export function closeTestTemplateModal() {
testTemplateModal.forceClose();
window.currentTestingTemplate = null;
setCurrentTestingTemplate(null);
}
async function loadAvailableEngines() {
@@ -470,7 +471,7 @@ function collectEngineConfig() {
async function loadDisplaysForTest() {
try {
// Use engine-specific display list for engines with own devices (camera, scrcpy)
const engineType = window.currentTestingTemplate?.engine_type;
const engineType = currentTestingTemplate?.engine_type;
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
const url = engineHasOwnDisplays
? `/config/displays?engine_type=${engineType}`
@@ -508,7 +509,7 @@ async function loadDisplaysForTest() {
}
export function runTemplateTest() {
if (!window.currentTestingTemplate) {
if (!currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
@@ -521,7 +522,7 @@ export function runTemplateTest() {
return;
}
const template = window.currentTestingTemplate;
const template = currentTestingTemplate;
localStorage.setItem('lastTestDisplayIndex', displayIndex);
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
@@ -559,7 +560,7 @@ function buildTestStatsHtml(result) {
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
return html;
}
@@ -1672,7 +1673,7 @@ export function onStreamDisplaySelected(displayIndex, display) {
export function onTestDisplaySelected(displayIndex, display) {
document.getElementById('test-template-display').value = displayIndex;
const engineType = window.currentTestingTemplate?.engine_type || null;
const engineType = currentTestingTemplate?.engine_type || null;
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display, engineType);
}
@@ -2242,169 +2243,22 @@ function _getStripFilterName(filterId) {
return translated;
}
let _filterIconSelect = null;
// ── PP FilterListManager instance ──
const ppFilterManager = new FilterListManager({
getFilters: () => _modalFilters,
getFilterDefs: () => _availableFilters,
getFilterName: _getFilterName,
selectId: 'pp-add-filter-select',
containerId: 'pp-filter-list',
prefix: '',
editingIdInputId: 'pp-template-id',
selfRefFilterId: 'filter_template',
autoNameFn: () => _autoGeneratePPTemplateName(),
initDrag: _initFilterDragForContainer,
initPaletteGrids: _initFilterPaletteGrids,
});
const _FILTER_ICONS = {
brightness: P.sunDim,
saturation: P.palette,
gamma: P.sun,
downscaler: P.monitor,
pixelate: P.layoutDashboard,
auto_crop: P.target,
flip: P.rotateCw,
color_correction: P.palette,
filter_template: P.fileText,
frame_interpolation: P.fastForward,
noise_gate: P.volume2,
palette_quantization: P.sparkles,
css_filter_template: P.fileText,
};
function _populateFilterSelect() {
const select = document.getElementById('pp-add-filter-select');
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
const items = [];
for (const f of _availableFilters) {
const name = _getFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
const pathData = _FILTER_ICONS[f.filter_id] || P.wrench;
items.push({
value: f.filter_id,
icon: `<svg class="icon" viewBox="0 0 24 24">${pathData}</svg>`,
label: name,
desc: t(`filters.${f.filter_id}.desc`),
});
}
if (_filterIconSelect) {
_filterIconSelect.updateItems(items);
} else if (items.length > 0) {
_filterIconSelect = new IconSelect({
target: select,
items,
columns: 3,
placeholder: t('filters.select_type'),
onChange: () => addFilterFromSelect(),
});
}
}
/**
* Generic filter list renderer — shared by PP template and CSPT modals.
* @param {string} containerId - DOM container ID for filter cards
* @param {Array} filtersArr - mutable array of {filter_id, options, _expanded}
* @param {Array} filterDefs - available filter definitions (with options_schema)
* @param {string} prefix - handler prefix: '' for PP, 'cspt' for CSPT
* @param {string} editingIdInputId - ID of hidden input holding the editing template ID
* @param {string} selfRefFilterId - filter_id that should exclude self ('filter_template' or 'css_filter_template')
*/
function _renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId) {
const container = document.getElementById(containerId);
if (filtersArr.length === 0) {
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
return;
}
const toggleFn = prefix ? `${prefix}ToggleFilterExpand` : 'toggleFilterExpand';
const removeFn = prefix ? `${prefix}RemoveFilter` : 'removeFilter';
const updateFn = prefix ? `${prefix}UpdateFilterOption` : 'updateFilterOption';
const inputPrefix = prefix ? `${prefix}-filter` : 'filter';
const nameFn = prefix ? _getStripFilterName : _getFilterName;
let html = '';
filtersArr.forEach((fi, index) => {
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
const filterName = nameFn(fi.filter_id);
const isExpanded = fi._expanded === true;
let summary = '';
if (filterDef && !isExpanded) {
summary = filterDef.options_schema.map(opt => {
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
return val;
}).join(', ');
}
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="${toggleFn}(${index})">
<span class="pp-filter-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<span class="pp-filter-card-chevron">${isExpanded ? '&#x25BC;' : '&#x25B6;'}</span>
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
<button type="button" class="btn-filter-action btn-filter-remove" onclick="${removeFn}(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
if (filterDef) {
for (const opt of filterDef.options_schema) {
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
const inputId = `${inputPrefix}-${index}-${opt.key}`;
if (opt.type === 'bool') {
const checked = currentVal === true || currentVal === 'true';
html += `<div class="pp-filter-option pp-filter-option-bool">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}</span>
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
</label>
</div>`;
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
const editingId = document.getElementById(editingIdInputId)?.value || '';
const filteredChoices = (fi.filter_id === selfRefFilterId && opt.key === 'template_id' && editingId)
? opt.choices.filter(c => c.value !== editingId)
: opt.choices;
let selectVal = currentVal;
if (filteredChoices.length > 0 && !filteredChoices.some(c => c.value === selectVal)) {
selectVal = filteredChoices[0].value;
fi.options[opt.key] = selectVal;
}
const hasPaletteColors = filteredChoices.some(c => c.colors);
const options = filteredChoices.map(c =>
`<option value="${escapeHtml(c.value)}"${c.value === selectVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
).join('');
const gridAttr = hasPaletteColors ? ` data-palette-grid="${escapeHtml(JSON.stringify(filteredChoices))}"` : '';
const isTemplateRef = opt.key === 'template_id';
const entityAttr = isTemplateRef ? ' data-entity-select="template"' : '';
html += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<select id="${inputId}"${gridAttr}${entityAttr}
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
${options}
</select>
</div>`;
} else if (opt.type === 'string') {
const maxLen = opt.max_length || 500;
html += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<input type="text" id="${inputId}" value="${escapeHtml(String(currentVal))}"
maxlength="${maxLen}" class="pp-filter-text-input"
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
</div>`;
} else {
html += `<div class="pp-filter-option">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}:</span>
<span id="${inputId}-display">${currentVal}</span>
</label>
<input type="range" id="${inputId}"
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
oninput="${updateFn}(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
</div>`;
}
}
}
html += `</div></div>`;
});
container.innerHTML = html;
_initFilterDragForContainer(containerId, filtersArr, () => {
_renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId);
});
// Initialize palette icon grids on select elements
_initFilterPaletteGrids(container);
}
// _renderFilterListGeneric has been replaced by FilterListManager.render()
/** Stored IconSelect instances for filter option selects (keyed by select element id). */
const _filterOptionIconSelects = {};
@@ -2449,11 +2303,11 @@ function _initFilterPaletteGrids(container) {
}
export function renderModalFilterList() {
_renderFilterListGeneric('pp-filter-list', _modalFilters, _availableFilters, '', 'pp-template-id', 'filter_template');
ppFilterManager.render();
}
export function renderCSPTModalFilterList() {
_renderFilterListGeneric('cspt-filter-list', _csptModalFilters, _stripFilters, 'cspt', 'cspt-id', 'css_filter_template');
csptFilterManager.render();
}
/* ── Generic filter drag-and-drop reordering ── */
@@ -2611,104 +2465,23 @@ function _filterAutoScroll(clientY, ds) {
ds.scrollRaf = requestAnimationFrame(scroll);
}
/**
* Generic: add a filter from a select element into a filters array.
*/
function _addFilterGeneric(selectId, filtersArr, filterDefs, iconSelect, renderFn, autoNameFn) {
const select = document.getElementById(selectId);
const filterId = select.value;
if (!filterId) return;
// _addFilterGeneric and _updateFilterOptionGeneric have been replaced by FilterListManager methods
const filterDef = filterDefs.find(f => f.filter_id === filterId);
if (!filterDef) return;
// ── PP filter actions (delegate to ppFilterManager) ──
export function addFilterFromSelect() { ppFilterManager.addFromSelect(); }
export function toggleFilterExpand(index) { ppFilterManager.toggleExpand(index); }
export function removeFilter(index) { ppFilterManager.remove(index); }
export function moveFilter(index, direction) { ppFilterManager.move(index, direction); }
export function updateFilterOption(filterIndex, optionKey, value) { ppFilterManager.updateOption(filterIndex, optionKey, value); }
const options = {};
for (const opt of filterDef.options_schema) {
if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) {
options[opt.key] = opt.choices[0].value;
} else {
options[opt.key] = opt.default;
}
}
filtersArr.push({ filter_id: filterId, options, _expanded: true });
select.value = '';
if (iconSelect) iconSelect.setValue('');
renderFn();
if (autoNameFn) autoNameFn();
}
function _updateFilterOptionGeneric(filterIndex, optionKey, value, filtersArr, filterDefs) {
if (filtersArr[filterIndex]) {
const fi = filtersArr[filterIndex];
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'bool') {
fi.options[optionKey] = !!value;
} else if (optDef && optDef.type === 'select') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'string') {
fi.options[optionKey] = String(value);
} else if (optDef && optDef.type === 'int') {
fi.options[optionKey] = parseInt(value);
} else {
fi.options[optionKey] = parseFloat(value);
}
} else {
fi.options[optionKey] = parseFloat(value);
}
}
}
// ── PP filter actions ──
export function addFilterFromSelect() {
_addFilterGeneric('pp-add-filter-select', _modalFilters, _availableFilters, _filterIconSelect, renderModalFilterList, _autoGeneratePPTemplateName);
}
export function toggleFilterExpand(index) {
if (_modalFilters[index]) { _modalFilters[index]._expanded = !_modalFilters[index]._expanded; renderModalFilterList(); }
}
export function removeFilter(index) {
_modalFilters.splice(index, 1); renderModalFilterList(); _autoGeneratePPTemplateName();
}
export function moveFilter(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
const tmp = _modalFilters[index];
_modalFilters[index] = _modalFilters[newIndex];
_modalFilters[newIndex] = tmp;
renderModalFilterList(); _autoGeneratePPTemplateName();
}
export function updateFilterOption(filterIndex, optionKey, value) {
_updateFilterOptionGeneric(filterIndex, optionKey, value, _modalFilters, _availableFilters);
}
// ── CSPT filter actions ──
export function csptAddFilterFromSelect() {
_addFilterGeneric('cspt-add-filter-select', _csptModalFilters, _stripFilters, _csptFilterIconSelect, renderCSPTModalFilterList, _autoGenerateCSPTName);
}
export function csptToggleFilterExpand(index) {
if (_csptModalFilters[index]) { _csptModalFilters[index]._expanded = !_csptModalFilters[index]._expanded; renderCSPTModalFilterList(); }
}
export function csptRemoveFilter(index) {
_csptModalFilters.splice(index, 1); renderCSPTModalFilterList(); _autoGenerateCSPTName();
}
export function csptUpdateFilterOption(filterIndex, optionKey, value) {
_updateFilterOptionGeneric(filterIndex, optionKey, value, _csptModalFilters, _stripFilters);
}
// ── CSPT filter actions (delegate to csptFilterManager) ──
export function csptAddFilterFromSelect() { csptFilterManager.addFromSelect(); }
export function csptToggleFilterExpand(index) { csptFilterManager.toggleExpand(index); }
export function csptRemoveFilter(index) { csptFilterManager.remove(index); }
export function csptUpdateFilterOption(filterIndex, optionKey, value) { csptFilterManager.updateOption(filterIndex, optionKey, value); }
function collectFilters() {
return _modalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
return ppFilterManager.collect();
}
function _autoGeneratePPTemplateName() {
@@ -2743,7 +2516,7 @@ export async function showAddPPTemplateModal(cloneData = null) {
}
document.getElementById('pp-template-name').oninput = () => { set_ppTemplateNameManuallyEdited(true); };
_populateFilterSelect();
ppFilterManager.populateSelect(() => addFilterFromSelect());
renderModalFilterList();
// Pre-fill from clone data after form is set up
@@ -2780,7 +2553,7 @@ export async function editPPTemplate(templateId) {
options: { ...fi.options },
})));
_populateFilterSelect();
ppFilterManager.populateSelect(() => addFilterFromSelect());
renderModalFilterList();
// Tags
@@ -2894,7 +2667,20 @@ export async function closePPTemplateModal() {
// ===== Color Strip Processing Templates (CSPT) =====
let _csptFilterIconSelect = null;
// ── CSPT FilterListManager instance ──
const csptFilterManager = new FilterListManager({
getFilters: () => _csptModalFilters,
getFilterDefs: () => _stripFilters,
getFilterName: _getStripFilterName,
selectId: 'cspt-add-filter-select',
containerId: 'cspt-filter-list',
prefix: 'cspt',
editingIdInputId: 'cspt-id',
selfRefFilterId: 'css_filter_template',
autoNameFn: () => _autoGenerateCSPTName(),
initDrag: _initFilterDragForContainer,
initPaletteGrids: _initFilterPaletteGrids,
});
async function loadStripFilters() {
await stripFiltersCache.fetch();
@@ -2910,34 +2696,6 @@ async function loadCSPTemplates() {
}
}
function _populateCSPTFilterSelect() {
const select = document.getElementById('cspt-add-filter-select');
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
const items = [];
for (const f of _stripFilters) {
const name = _getStripFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
const pathData = _FILTER_ICONS[f.filter_id] || P.wrench;
items.push({
value: f.filter_id,
icon: `<svg class="icon" viewBox="0 0 24 24">${pathData}</svg>`,
label: name,
desc: t(`filters.${f.filter_id}.desc`),
});
}
if (_csptFilterIconSelect) {
_csptFilterIconSelect.updateItems(items);
} else if (items.length > 0) {
_csptFilterIconSelect = new IconSelect({
target: select,
items,
columns: 3,
placeholder: t('filters.select_type'),
onChange: () => csptAddFilterFromSelect(),
});
}
}
function _autoGenerateCSPTName() {
if (_csptNameManuallyEdited) return;
if (document.getElementById('cspt-id').value) return;
@@ -2951,10 +2709,7 @@ function _autoGenerateCSPTName() {
}
function collectCSPTFilters() {
return _csptModalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
return csptFilterManager.collect();
}
export async function showAddCSPTModal(cloneData = null) {
@@ -2977,7 +2732,7 @@ export async function showAddCSPTModal(cloneData = null) {
}
document.getElementById('cspt-name').oninput = () => { set_csptNameManuallyEdited(true); };
_populateCSPTFilterSelect();
csptFilterManager.populateSelect(() => csptAddFilterFromSelect());
renderCSPTModalFilterList();
if (cloneData) {
@@ -3012,7 +2767,7 @@ export async function editCSPT(templateId) {
options: { ...fi.options },
})));
_populateCSPTFilterSelect();
csptFilterManager.populateSelect(() => csptAddFilterFromSelect());
renderCSPTModalFilterList();
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }

View File

@@ -31,6 +31,7 @@ import { IconSelect } from '../core/icon-select.js';
import * as P from '../core/icon-paths.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { createFpsSparkline } from '../core/chart-utils.js';
import { CardSection } from '../core/card-sections.js';
import { TreeNav } from '../core/tree-nav.js';
import { updateSubTabHash, updateTabBadge } from './tabs.js';
@@ -68,52 +69,7 @@ function _pushTargetFps(targetId, actual, current) {
}
function _createTargetFpsChart(canvasId, actualHistory, currentHistory, fpsTarget, maxHwFps) {
const canvas = document.getElementById(canvasId);
if (!canvas) return null;
const labels = actualHistory.map(() => '');
const datasets = [
{
data: [...actualHistory],
borderColor: '#2196F3',
backgroundColor: 'rgba(33,150,243,0.12)',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
},
{
data: [...currentHistory],
borderColor: '#4CAF50',
borderWidth: 1.5,
tension: 0.3,
fill: false,
pointRadius: 0,
},
];
// Flat line showing hardware max FPS
if (maxHwFps && maxHwFps < fpsTarget * 1.15) {
datasets.push({
data: actualHistory.map(() => maxHwFps),
borderColor: 'rgba(255,152,0,0.5)',
borderWidth: 1,
borderDash: [4, 3],
pointRadius: 0,
fill: false,
});
}
return new Chart(canvas, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { display: false } },
scales: {
x: { display: false },
y: { display: false, min: 0, max: fpsTarget * 1.15 },
},
},
});
return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget, { maxHwFps });
}
function _updateTargetFpsChart(targetId, fpsTarget) {

View File

@@ -26,6 +26,8 @@
"auth.logout.success": "Logged out successfully",
"auth.please_login": "Please login to view",
"auth.session_expired": "Your session has expired or the API key is invalid. Please login again.",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"auth.prompt_enter": "Enter your API key:",
"auth.toggle_password": "Toggle password visibility",
"displays.title": "Available Displays",
"displays.layout": "Displays",
@@ -108,6 +110,7 @@
"templates.test.results.frame_count": "Frames",
"templates.test.results.actual_fps": "Actual FPS",
"templates.test.results.avg_capture_time": "Avg Capture",
"templates.test.results.resolution": "Resolution:",
"templates.test.error.no_engine": "Please select a capture engine",
"templates.test.error.no_display": "Please select a display",
"templates.test.error.failed": "Test failed",
@@ -1512,5 +1515,11 @@
"graph.filter_placeholder": "Filter by name...",
"graph.filter_clear": "Clear filter",
"graph.filter_running": "Running",
"graph.filter_stopped": "Stopped"
"graph.filter_stopped": "Stopped",
"graph.filter_types": "Types",
"graph.filter_group.capture": "Capture",
"graph.filter_group.strip": "Color Strip",
"graph.filter_group.audio": "Audio",
"graph.filter_group.targets": "Targets",
"graph.filter_group.other": "Other"
}

View File

@@ -27,6 +27,8 @@
"auth.please_login": "Пожалуйста, войдите для просмотра",
"auth.session_expired": "Ваша сессия истекла или API ключ недействителен. Пожалуйста, войдите снова.",
"auth.toggle_password": "Показать/скрыть пароль",
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"displays.title": "Доступные Дисплеи",
"displays.layout": "Дисплеи",
"displays.information": "Информация о Дисплеях",
@@ -108,6 +110,7 @@
"templates.test.results.frame_count": "Кадры",
"templates.test.results.actual_fps": "Факт. FPS",
"templates.test.results.avg_capture_time": "Средн. Захват",
"templates.test.results.resolution": "Разрешение:",
"templates.test.error.no_engine": "Пожалуйста, выберите движок захвата",
"templates.test.error.no_display": "Пожалуйста, выберите дисплей",
"templates.test.error.failed": "Тест не удался",
@@ -147,6 +150,57 @@
"device.type.dmx.desc": "Art-Net / sACN (E1.31) сценическое освещение",
"device.type.mock": "Mock",
"device.type.mock.desc": "Виртуальное устройство для тестов",
"device.type.espnow": "ESP-NOW",
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
"device.type.hue": "Philips Hue",
"device.type.hue.desc": "Hue Entertainment API streaming",
"device.type.usbhid": "USB HID",
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
"device.type.spi": "SPI Direct",
"device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips",
"device.type.chroma": "Razer Chroma",
"device.type.chroma.desc": "Razer peripherals via Chroma SDK",
"device.type.gamesense": "SteelSeries",
"device.type.gamesense.desc": "SteelSeries peripherals via GameSense",
"device.chroma.device_type": "Peripheral Type:",
"device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK",
"device.gamesense.device_type": "Peripheral Type:",
"device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense",
"device.espnow.peer_mac": "Peer MAC:",
"device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)",
"device.espnow.channel": "WiFi Channel:",
"device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver's channel.",
"device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:",
"device.hue.username.hint": "Hue bridge application key from pairing",
"device.hue.client_key": "Client Key:",
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.usbhid.url": "VID:PID:",
"device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)",
"device.spi.url": "GPIO/SPI Path:",
"device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)",
"device.spi.speed": "SPI Speed (Hz):",
"device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.",
"device.spi.led_type": "LED Chipset:",
"device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin",
"device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB",
"device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB",
"device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips",
"device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB",
"device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel",
"device.gamesense.peripheral.keyboard": "Keyboard",
"device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination",
"device.gamesense.peripheral.mouse": "Mouse",
"device.gamesense.peripheral.mouse.desc": "Mouse RGB zones",
"device.gamesense.peripheral.headset": "Headset",
"device.gamesense.peripheral.headset.desc": "Headset earcup lighting",
"device.gamesense.peripheral.mousepad": "Mousepad",
"device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones",
"device.gamesense.peripheral.indicator": "Indicator",
"device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator",
"device.dmx_protocol": "Протокол DMX:",
"device.dmx_protocol.hint": "Art-Net использует UDP порт 6454, sACN (E1.31) — UDP порт 5568",
"device.dmx_protocol.artnet.desc": "UDP unicast, порт 6454",
@@ -1461,5 +1515,11 @@
"graph.filter_placeholder": "Фильтр по имени...",
"graph.filter_clear": "Очистить фильтр",
"graph.filter_running": "Запущен",
"graph.filter_stopped": "Остановлен"
"graph.filter_stopped": "Остановлен",
"graph.filter_types": "Типы",
"graph.filter_group.capture": "Захват",
"graph.filter_group.strip": "Цвет. полосы",
"graph.filter_group.audio": "Аудио",
"graph.filter_group.targets": "Цели",
"graph.filter_group.other": "Другое"
}

View File

@@ -27,6 +27,8 @@
"auth.please_login": "请先登录",
"auth.session_expired": "会话已过期或 API 密钥无效,请重新登录。",
"auth.toggle_password": "切换密码可见性",
"auth.prompt_enter": "Enter your API key:",
"auth.prompt_update": "Current API key is set. Enter new key to update or leave blank to remove:",
"displays.title": "可用显示器",
"displays.layout": "显示器",
"displays.information": "显示器信息",
@@ -108,6 +110,7 @@
"templates.test.results.frame_count": "帧数",
"templates.test.results.actual_fps": "实际 FPS",
"templates.test.results.avg_capture_time": "平均采集",
"templates.test.results.resolution": "分辨率:",
"templates.test.error.no_engine": "请选择采集引擎",
"templates.test.error.no_display": "请选择显示器",
"templates.test.error.failed": "测试失败",
@@ -147,6 +150,57 @@
"device.type.dmx.desc": "Art-Net / sACN (E1.31) 舞台灯光",
"device.type.mock": "Mock",
"device.type.mock.desc": "用于测试的虚拟设备",
"device.type.espnow": "ESP-NOW",
"device.type.espnow.desc": "Ultra-low-latency via ESP32 gateway",
"device.type.hue": "Philips Hue",
"device.type.hue.desc": "Hue Entertainment API streaming",
"device.type.usbhid": "USB HID",
"device.type.usbhid.desc": "USB RGB peripherals (keyboards, mice)",
"device.type.spi": "SPI Direct",
"device.type.spi.desc": "Raspberry Pi GPIO/SPI LED strips",
"device.type.chroma": "Razer Chroma",
"device.type.chroma.desc": "Razer peripherals via Chroma SDK",
"device.type.gamesense": "SteelSeries",
"device.type.gamesense.desc": "SteelSeries peripherals via GameSense",
"device.chroma.device_type": "Peripheral Type:",
"device.chroma.device_type.hint": "Which Razer peripheral to control via Chroma SDK",
"device.gamesense.device_type": "Peripheral Type:",
"device.gamesense.device_type.hint": "Which SteelSeries peripheral to control via GameSense",
"device.espnow.peer_mac": "Peer MAC:",
"device.espnow.peer_mac.hint": "MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)",
"device.espnow.channel": "WiFi Channel:",
"device.espnow.channel.hint": "WiFi channel (1-14). Must match the receiver's channel.",
"device.hue.url": "Bridge IP:",
"device.hue.url.hint": "IP address of your Hue bridge",
"device.hue.username": "Bridge Username:",
"device.hue.username.hint": "Hue bridge application key from pairing",
"device.hue.client_key": "Client Key:",
"device.hue.client_key.hint": "Entertainment API client key (hex string from pairing)",
"device.hue.group_id": "Entertainment Group:",
"device.hue.group_id.hint": "Entertainment configuration ID from your Hue bridge",
"device.usbhid.url": "VID:PID:",
"device.usbhid.url.hint": "USB Vendor:Product ID in hex (e.g. 1532:0084)",
"device.spi.url": "GPIO/SPI Path:",
"device.spi.url.hint": "GPIO pin or SPI device path (e.g. spi://gpio:18)",
"device.spi.speed": "SPI Speed (Hz):",
"device.spi.speed.hint": "SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.",
"device.spi.led_type": "LED Chipset:",
"device.spi.led_type.hint": "Type of addressable LED strip connected to the GPIO/SPI pin",
"device.spi.led_type.ws2812b.desc": "Most common, 800 KHz data, 3-wire RGB",
"device.spi.led_type.ws2812.desc": "Original WS2812, 800 KHz, 3-wire RGB",
"device.spi.led_type.ws2811.desc": "External driver IC, 400 KHz, 12V strips",
"device.spi.led_type.sk6812.desc": "Samsung LED, 800 KHz, 3-wire RGB",
"device.spi.led_type.sk6812_rgbw.desc": "SK6812 with dedicated white channel",
"device.gamesense.peripheral.keyboard": "Keyboard",
"device.gamesense.peripheral.keyboard.desc": "Per-key RGB illumination",
"device.gamesense.peripheral.mouse": "Mouse",
"device.gamesense.peripheral.mouse.desc": "Mouse RGB zones",
"device.gamesense.peripheral.headset": "Headset",
"device.gamesense.peripheral.headset.desc": "Headset earcup lighting",
"device.gamesense.peripheral.mousepad": "Mousepad",
"device.gamesense.peripheral.mousepad.desc": "Mousepad edge lighting zones",
"device.gamesense.peripheral.indicator": "Indicator",
"device.gamesense.peripheral.indicator.desc": "OLED/LED status indicator",
"device.dmx_protocol": "DMX 协议:",
"device.dmx_protocol.hint": "Art-Net 使用 UDP 端口 6454sACN (E1.31) 使用 UDP 端口 5568",
"device.dmx_protocol.artnet.desc": "UDP 单播,端口 6454",
@@ -1461,5 +1515,11 @@
"graph.filter_placeholder": "按名称筛选...",
"graph.filter_clear": "清除筛选",
"graph.filter_running": "运行中",
"graph.filter_stopped": "已停止"
"graph.filter_stopped": "已停止",
"graph.filter_types": "类型",
"graph.filter_group.capture": "捕获",
"graph.filter_group.strip": "色带",
"graph.filter_group.audio": "音频",
"graph.filter_group.targets": "目标",
"graph.filter_group.other": "其他"
}