diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 80a9b28..b2ffead 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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, diff --git a/server/src/wled_controller/static/js/core/api.js b/server/src/wled_controller/static/js/core/api.js index 9d4014b..43a7357 100644 --- a/server/src/wled_controller/static/js/core/api.js +++ b/server/src/wled_controller/static/js/core/api.js @@ -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')); } diff --git a/server/src/wled_controller/static/js/core/bg-anim.js b/server/src/wled_controller/static/js/core/bg-anim.js index 18563e5..f87192a 100644 --- a/server/src/wled_controller/static/js/core/bg-anim.js +++ b/server/src/wled_controller/static/js/core/bg-anim.js @@ -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); } diff --git a/server/src/wled_controller/static/js/core/cache.js b/server/src/wled_controller/static/js/core/cache.js index 2bc9893..f46298d 100644 --- a/server/src/wled_controller/static/js/core/cache.js +++ b/server/src/wled_controller/static/js/core/cache.js @@ -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; diff --git a/server/src/wled_controller/static/js/core/card-glare.js b/server/src/wled_controller/static/js/core/card-glare.js index d073f2d..d98ce70 100644 --- a/server/src/wled_controller/static/js/core/card-glare.js +++ b/server/src/wled_controller/static/js/core/card-glare.js @@ -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; } } diff --git a/server/src/wled_controller/static/js/core/card-sections.js b/server/src/wled_controller/static/js/core/card-sections.js index b5e59cb..75be11f 100644 --- a/server/src/wled_controller/static/js/core/card-sections.js +++ b/server/src/wled_controller/static/js/core/card-sections.js @@ -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 }; } diff --git a/server/src/wled_controller/static/js/core/chart-utils.js b/server/src/wled_controller/static/js/core/chart-utils.js new file mode 100644 index 0000000..5c14e1e --- /dev/null +++ b/server/src/wled_controller/static/js/core/chart-utils.js @@ -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 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 }, + }, + }); +} diff --git a/server/src/wled_controller/static/js/core/command-palette.js b/server/src/wled_controller/static/js/core/command-palette.js index ffe7731..c6b24a1 100644 --- a/server/src/wled_controller/static/js/core/command-palette.js +++ b/server/src/wled_controller/static/js/core/command-palette.js @@ -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 += `
` + `${item.icon}` + - `${_escHtml(item.name)}` + + `${escapeHtml(item.name)}` + (item.running ? '' : '') + - (item.detail ? `${_escHtml(item.detail)}` : '') + + (item.detail ? `${escapeHtml(item.detail)}` : '') + `
`; 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 ─── diff --git a/server/src/wled_controller/static/js/core/filter-list.js b/server/src/wled_controller/static/js/core/filter-list.js new file mode 100644 index 0000000..f0f9e0b --- /dev/null +++ b/server/src/wled_controller/static/js/core/filter-list.js @@ -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 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 = ``; + const items = []; + for (const f of filterDefs) { + const name = this._getFilterName(f.filter_id); + select.innerHTML += ``; + const pathData = _FILTER_ICONS[f.filter_id] || P.wrench; + items.push({ + value: f.filter_id, + icon: `${pathData}`, + 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 = `
${t('filters.empty')}
`; + 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 += `
+
+ + ${isExpanded ? '▼' : '▶'} + ${escapeHtml(filterName)} + ${summary ? `${escapeHtml(summary)}` : ''} +
+ +
+
+
`; + }); + + 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 }, + })); + } +} diff --git a/server/src/wled_controller/static/js/core/graph-edges.js b/server/src/wled_controller/static/js/core/graph-edges.js index 9d7d4ac..6256874 100644 --- a/server/src/wled_controller/static/js/core/graph-edges.js +++ b/server/src/wled_controller/static/js/core/graph-edges.js @@ -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; diff --git a/server/src/wled_controller/static/js/core/graph-layout.js b/server/src/wled_controller/static/js/core/graph-layout.js index aed79bb..6a7929b 100644 --- a/server/src/wled_controller/static/js/core/graph-layout.js +++ b/server/src/wled_controller/static/js/core/graph-layout.js @@ -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, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 5bc91f6..c7903cf 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -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; } diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 782a1df..a1933eb 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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']; diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 8a4789c..dddaec7 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -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) { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index 739cf90..dc4f2c7 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -651,6 +651,8 @@ export async function loadDevices() { await window.loadTargetsTab(); } +document.addEventListener('auth:keyChanged', () => loadDevices()); + // ===== OpenRGB zone count enrichment ===== // Cache: baseUrl → { zoneName: ledCount, ... } diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 0c8abb4..b7fac8b 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -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) {
${t('templates.test.results.avg_capture_time')}: ${Number(avgMs).toFixed(1)}ms
`; } html += ` -
Resolution: ${res}
`; +
${t('templates.test.results.resolution')} ${res}
`; 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 = ``; - const items = []; - for (const f of _availableFilters) { - const name = _getFilterName(f.filter_id); - select.innerHTML += ``; - const pathData = _FILTER_ICONS[f.filter_id] || P.wrench; - items.push({ - value: f.filter_id, - icon: `${pathData}`, - 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 = `
${t('filters.empty')}
`; - 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 += `
-
- - ${isExpanded ? '▼' : '▶'} - ${escapeHtml(filterName)} - ${summary ? `${escapeHtml(summary)}` : ''} -
- -
-
-
`; - }); - - 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 = ``; - const items = []; - for (const f of _stripFilters) { - const name = _getStripFilterName(f.filter_id); - select.innerHTML += ``; - const pathData = _FILTER_ICONS[f.filter_id] || P.wrench; - items.push({ - value: f.filter_id, - icon: `${pathData}`, - 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; } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 900d5f1..a9a7d4a 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -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) { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index ae05831..18dc609 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 131e0bf..c95dcf6 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Другое" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 9cbb99e..43949e4 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -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 单播,端口 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": "其他" }