diff --git a/server/src/wled_controller/static/css/advanced-calibration.css b/server/src/wled_controller/static/css/advanced-calibration.css
new file mode 100644
index 0000000..7589e22
--- /dev/null
+++ b/server/src/wled_controller/static/css/advanced-calibration.css
@@ -0,0 +1,166 @@
+/* ── Advanced Calibration ─────────────────────────────────── */
+
+.advcal-layout {
+ display: flex;
+ gap: 16px;
+ margin-bottom: 12px;
+ align-items: stretch;
+}
+
+.advcal-canvas-panel {
+ flex: 1 1 60%;
+ min-width: 0;
+}
+
+#advcal-canvas {
+ width: 100%;
+ height: auto;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: rgba(10, 15, 25, 0.9);
+}
+
+.advcal-canvas-hint {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: var(--text-secondary);
+ margin-top: 4px;
+}
+
+.advcal-lines-panel {
+ flex: 0 0 220px;
+ display: flex;
+ flex-direction: column;
+ align-self: stretch;
+}
+
+.advcal-line-list {
+ flex: 1;
+ overflow-y: auto;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--card-bg);
+}
+
+.advcal-leds-counter {
+ text-align: center;
+ font-size: 0.8em;
+ color: var(--text-secondary);
+ margin-top: 4px;
+}
+
+.advcal-line-add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px;
+ cursor: pointer;
+ color: var(--primary-color, #0078ff);
+ font-size: 1.2em;
+ font-weight: 600;
+ border-top: 1px dashed var(--border-color);
+ transition: background 0.15s, color 0.15s;
+ opacity: 0.7;
+}
+
+.advcal-line-add:hover {
+ background: rgba(0, 120, 255, 0.12);
+ opacity: 1;
+}
+
+.advcal-line-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ padding-left: 11px;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border-color);
+ border-left: 3px solid transparent;
+ font-size: 0.82em;
+ transition: background 0.15s, border-color 0.15s;
+}
+
+.advcal-line-item:last-child {
+ border-bottom: none;
+}
+
+.advcal-line-item:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.advcal-line-item.selected {
+ background: rgba(0, 120, 255, 0.15);
+ border-left-color: rgba(0, 180, 255, 0.8);
+}
+
+.advcal-line-color {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.advcal-line-label {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.advcal-line-leds {
+ font-size: 0.85em;
+ color: var(--text-secondary);
+ min-width: 24px;
+ text-align: right;
+}
+
+.advcal-line-actions {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.btn-micro {
+ background: none;
+ border: 1px solid transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+ padding: 1px 4px;
+ font-size: 0.75em;
+ border-radius: 3px;
+ line-height: 1;
+}
+
+.btn-micro:hover {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: var(--border-color);
+}
+
+.btn-micro.btn-danger:hover {
+ background: rgba(255, 50, 50, 0.15);
+ color: #ff5555;
+}
+
+.btn-micro:disabled {
+ opacity: 0.3;
+ cursor: default;
+}
+
+.advcal-line-props {
+ padding: 12px;
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ margin-top: 8px;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+ font-size: 0.85em;
+}
diff --git a/server/src/wled_controller/static/css/streams.css b/server/src/wled_controller/static/css/streams.css
index 95d9735..eeac03b 100644
--- a/server/src/wled_controller/static/css/streams.css
+++ b/server/src/wled_controller/static/css/streams.css
@@ -125,21 +125,38 @@
color: var(--text-muted);
}
-.template-config-details {
- margin: 12px 0;
- font-size: 13px;
+.template-config-collapse {
+ margin-top: 4px;
}
-
-.template-config-details summary {
+.template-config-toggle {
cursor: pointer;
- color: var(--primary-text-color);
- font-weight: 500;
- padding: 4px 0;
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ background: none;
+ padding: 0;
+ border: none;
+ font-family: inherit;
}
-
-.template-config-details summary:hover {
+.template-config-toggle::before {
+ content: '▸ ';
+}
+.template-config-collapse.open .template-config-toggle::before {
+ content: '▾ ';
+}
+.template-config-toggle:hover {
text-decoration: underline;
}
+.template-config-animate {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.25s ease;
+}
+.template-config-collapse.open .template-config-animate {
+ grid-template-rows: 1fr;
+}
+.template-config-inner {
+ overflow: hidden;
+}
.template-no-config {
margin: 12px 0;
diff --git a/server/src/wled_controller/static/js/features/advanced-calibration.js b/server/src/wled_controller/static/js/features/advanced-calibration.js
new file mode 100644
index 0000000..b045ce1
--- /dev/null
+++ b/server/src/wled_controller/static/js/features/advanced-calibration.js
@@ -0,0 +1,858 @@
+/**
+ * Advanced Calibration — multimonitor line-based calibration editor.
+ *
+ * Lines map LED segments to edges of different picture sources (monitors).
+ * The canvas shows monitor rectangles that can be repositioned for visual clarity.
+ */
+
+import { API_BASE, fetchWithAuth } from '../core/api.js';
+import { t } from '../core/i18n.js';
+import { showToast } from '../core/ui.js';
+import { Modal } from '../core/modal.js';
+import { EntitySelect } from '../core/entity-palette.js';
+import { getPictureSourceIcon } from '../core/icons.js';
+
+/* ── Constants ──────────────────────────────────────────────── */
+
+const EDGE_COLORS = {
+ top: 'rgba(0, 180, 255, 0.8)',
+ right: 'rgba(255, 100, 0, 0.8)',
+ bottom: 'rgba(0, 220, 100, 0.8)',
+ left: 'rgba(200, 0, 255, 0.8)',
+};
+
+const EDGE_COLORS_DIM = {
+ top: 'rgba(0, 180, 255, 0.3)',
+ right: 'rgba(255, 100, 0, 0.3)',
+ bottom: 'rgba(0, 220, 100, 0.3)',
+ left: 'rgba(200, 0, 255, 0.3)',
+};
+
+const MONITOR_BG = 'rgba(30, 40, 60, 0.9)';
+const MONITOR_BORDER = 'rgba(80, 100, 140, 0.7)';
+const MONITOR_SELECTED_BORDER = 'rgba(0, 180, 255, 0.9)';
+const LINE_THICKNESS_PX = 6;
+
+/* ── State ──────────────────────────────────────────────────── */
+
+let _state = {
+ cssId: null,
+ lines: [], // [{picture_source_id, edge, led_count, span_start, span_end, reverse, border_width}]
+ monitors: [], // [{id, name, width, height, cx, cy, cw, ch}] — canvas coords
+ pictureSources: [], // raw API data
+ totalLedCount: 0, // total LED count from the CSS source
+ selectedLine: -1,
+ dragging: null, // {type:'monitor'|'pan', ...}
+};
+
+// Zoom/pan view state
+let _view = { panX: 0, panY: 0, zoom: 1.0 };
+const MIN_ZOOM = 0.25;
+const MAX_ZOOM = 4.0;
+
+/* ── Modal ──────────────────────────────────────────────────── */
+
+class AdvancedCalibrationModal extends Modal {
+ constructor() { super('advanced-calibration-modal'); }
+ snapshotValues() {
+ return {
+ lines: JSON.stringify(_state.lines),
+ offset: document.getElementById('advcal-offset')?.value || '0',
+ skipStart: document.getElementById('advcal-skip-start')?.value || '0',
+ skipEnd: document.getElementById('advcal-skip-end')?.value || '0',
+ };
+ }
+ onForceClose() {
+ if (_lineSourceEntitySelect) { _lineSourceEntitySelect.destroy(); _lineSourceEntitySelect = null; }
+ _state.cssId = null;
+ _state.lines = [];
+ _state.totalLedCount = 0;
+ _state.selectedLine = -1;
+ }
+}
+
+const _modal = new AdvancedCalibrationModal();
+
+/* ── Public API ─────────────────────────────────────────────── */
+
+export async function showAdvancedCalibration(cssId) {
+ try {
+ const [cssResp, psResp] = await Promise.all([
+ fetchWithAuth(`/color-strip-sources/${cssId}`),
+ fetchWithAuth('/picture-sources'),
+ ]);
+ if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
+
+ const source = await cssResp.json();
+ const calibration = source.calibration || {};
+ const psList = psResp.ok ? ((await psResp.json()).streams || []) : [];
+
+ _state.cssId = cssId;
+ _state.pictureSources = psList;
+ _state.totalLedCount = source.led_count || 0;
+
+ document.getElementById('advcal-css-id').value = cssId;
+
+ // Populate picture source selector
+ _populateSourceSelect(psList);
+
+ // Load lines from existing advanced calibration, or start empty
+ if (calibration.mode === 'advanced' && Array.isArray(calibration.lines)) {
+ _state.lines = calibration.lines.map(l => ({ ...l }));
+ } else {
+ _state.lines = [];
+ }
+
+ document.getElementById('advcal-offset').value = calibration.offset || 0;
+ document.getElementById('advcal-skip-start').value = calibration.skip_leds_start || 0;
+ document.getElementById('advcal-skip-end').value = calibration.skip_leds_end || 0;
+
+ // Build monitor rectangles from used picture sources and fit view
+ _rebuildUsedMonitors();
+ _fitView();
+
+ _state.selectedLine = _state.lines.length > 0 ? 0 : -1;
+
+ _renderLineList();
+ _showLineProps();
+ _renderCanvas();
+ _updateTotalLeds();
+
+ _modal.open();
+ _modal.snapshot();
+
+ // Set up canvas interaction
+ _initCanvasHandlers();
+
+ } catch (error) {
+ if (error.isAuth) return;
+ console.error('Failed to load advanced calibration:', error);
+ showToast(t('calibration.error.load_failed'), 'error');
+ }
+}
+
+export async function closeAdvancedCalibration() {
+ await _modal.close();
+}
+
+export async function saveAdvancedCalibration() {
+ const cssId = _state.cssId;
+ if (!cssId) return;
+
+ if (_state.lines.length === 0) {
+ showToast(t('calibration.advanced.no_lines_warning') || 'Add at least one line', 'error');
+ return;
+ }
+
+ const calibration = {
+ mode: 'advanced',
+ lines: _state.lines.map(l => ({
+ picture_source_id: l.picture_source_id,
+ edge: l.edge,
+ led_count: l.led_count,
+ span_start: l.span_start,
+ span_end: l.span_end,
+ reverse: l.reverse,
+ border_width: l.border_width,
+ })),
+ offset: parseInt(document.getElementById('advcal-offset').value) || 0,
+ skip_leds_start: parseInt(document.getElementById('advcal-skip-start').value) || 0,
+ skip_leds_end: parseInt(document.getElementById('advcal-skip-end').value) || 0,
+ };
+
+ try {
+ const resp = await fetchWithAuth(`/color-strip-sources/${cssId}`, {
+ method: 'PUT',
+ body: JSON.stringify({ calibration }),
+ });
+
+ if (resp.ok) {
+ showToast(t('calibration.saved'), 'success');
+ _modal.forceClose();
+ } else {
+ const err = await resp.json().catch(() => ({}));
+ showToast(err.message || t('calibration.error.save_failed'), 'error');
+ }
+ } catch (error) {
+ if (error.isAuth) return;
+ showToast(t('calibration.error.save_failed'), 'error');
+ }
+}
+
+export function addCalibrationLine() {
+ const defaultSource = _state.pictureSources[0]?.id || '';
+ const hadMonitors = _state.monitors.length;
+ _state.lines.push({
+ picture_source_id: defaultSource,
+ edge: 'top',
+ led_count: 10,
+ span_start: 0.0,
+ span_end: 1.0,
+ reverse: false,
+ border_width: 10,
+ });
+ _state.selectedLine = _state.lines.length - 1;
+ _rebuildUsedMonitors();
+ // If a new monitor was added, position it next to existing ones and fit view
+ if (_state.monitors.length > hadMonitors) {
+ _placeNewMonitor();
+ _fitView();
+ }
+ _renderLineList();
+ _showLineProps();
+ _renderCanvas();
+ _updateTotalLeds();
+}
+
+export function removeCalibrationLine(idx) {
+ if (idx < 0 || idx >= _state.lines.length) return;
+ _state.lines.splice(idx, 1);
+ if (_state.selectedLine >= _state.lines.length) {
+ _state.selectedLine = _state.lines.length - 1;
+ }
+ _rebuildUsedMonitors();
+ _renderLineList();
+ _showLineProps();
+ _renderCanvas();
+ _updateTotalLeds();
+}
+
+export function selectCalibrationLine(idx) {
+ const prev = _state.selectedLine;
+ _state.selectedLine = idx;
+ // Update selection in-place without rebuilding the list DOM
+ const container = document.getElementById('advcal-line-list');
+ const items = container.querySelectorAll('.advcal-line-item');
+ if (prev >= 0 && prev < items.length) items[prev].classList.remove('selected');
+ if (idx >= 0 && idx < items.length) items[idx].classList.add('selected');
+ _showLineProps();
+ _renderCanvas();
+}
+
+export function moveCalibrationLine(idx, direction) {
+ const newIdx = idx + direction;
+ if (newIdx < 0 || newIdx >= _state.lines.length) return;
+ [_state.lines[idx], _state.lines[newIdx]] = [_state.lines[newIdx], _state.lines[idx]];
+ _state.selectedLine = newIdx;
+ _renderLineList();
+ _showLineProps();
+ _renderCanvas();
+}
+
+export function updateCalibrationLine() {
+ const idx = _state.selectedLine;
+ if (idx < 0 || idx >= _state.lines.length) return;
+ const line = _state.lines[idx];
+ const hadMonitors = _state.monitors.length;
+ line.picture_source_id = document.getElementById('advcal-line-source').value;
+ line.edge = document.getElementById('advcal-line-edge').value;
+ line.led_count = Math.max(1, parseInt(document.getElementById('advcal-line-leds').value) || 1);
+ line.span_start = parseFloat(document.getElementById('advcal-line-span-start').value) || 0;
+ line.span_end = parseFloat(document.getElementById('advcal-line-span-end').value) || 1;
+ line.border_width = Math.max(1, parseInt(document.getElementById('advcal-line-border-width').value) || 10);
+ line.reverse = document.getElementById('advcal-line-reverse').checked;
+ _rebuildUsedMonitors();
+ if (_state.monitors.length > hadMonitors) {
+ _placeNewMonitor();
+ _fitView();
+ }
+ _renderLineList();
+ _renderCanvas();
+ _updateTotalLeds();
+}
+
+/* ── Internals ──────────────────────────────────────────────── */
+
+let _lineSourceEntitySelect = null;
+
+function _populateSourceSelect(psList) {
+ const sel = document.getElementById('advcal-line-source');
+ sel.innerHTML = '';
+ psList.forEach(ps => {
+ const opt = document.createElement('option');
+ opt.value = ps.id;
+ opt.textContent = ps.name || ps.id;
+ sel.appendChild(opt);
+ });
+
+ // Entity palette for picture source per line
+ if (_lineSourceEntitySelect) _lineSourceEntitySelect.destroy();
+ _lineSourceEntitySelect = new EntitySelect({
+ target: sel,
+ getItems: () => psList.map(ps => ({
+ value: ps.id,
+ label: ps.name || ps.id,
+ icon: getPictureSourceIcon(ps.stream_type),
+ })),
+ placeholder: t('palette.search'),
+ onChange: () => updateCalibrationLine(),
+ });
+}
+
+function _rebuildUsedMonitors() {
+ const usedIds = new Set(_state.lines.map(l => l.picture_source_id));
+ const currentIds = new Set(_state.monitors.map(m => m.id));
+ // Only rebuild if the set of used sources changed
+ if (usedIds.size === currentIds.size && [...usedIds].every(id => currentIds.has(id))) return;
+ const usedSources = _state.pictureSources.filter(ps => usedIds.has(ps.id));
+ _buildMonitorLayout(usedSources, _state.cssId);
+}
+
+function _buildMonitorLayout(psList, cssId) {
+ // Load saved positions from localStorage
+ const savedKey = `advcal_positions_${cssId}`;
+ let saved = {};
+ try { saved = JSON.parse(localStorage.getItem(savedKey)) || {}; } catch { /* ignore */ }
+
+ const canvas = document.getElementById('advcal-canvas');
+ const canvasW = canvas.width;
+ const canvasH = canvas.height;
+
+ // Default layout: arrange monitors in a row
+ const monitors = [];
+ const padding = 20;
+ const maxMonW = (canvasW - padding * 2) / Math.max(psList.length, 1) - 10;
+ const monH = canvasH * 0.6;
+
+ psList.forEach((ps, i) => {
+ const ratio = (ps.width || 1920) / (ps.height || 1080);
+ let w = Math.min(maxMonW, monH * ratio);
+ let h = w / ratio;
+ if (h > monH) { h = monH; w = h * ratio; }
+
+ const defaultCx = padding + i * (maxMonW + 10) + maxMonW / 2 - w / 2;
+ const defaultCy = (canvasH - h) / 2;
+
+ const s = saved[ps.id];
+ monitors.push({
+ id: ps.id,
+ name: ps.name || `Monitor ${i + 1}`,
+ width: ps.width || 1920,
+ height: ps.height || 1080,
+ cx: s?.cx ?? defaultCx,
+ cy: s?.cy ?? defaultCy,
+ cw: w,
+ ch: h,
+ });
+ });
+
+ _state.monitors = monitors;
+}
+
+function _saveMonitorPositions() {
+ if (!_state.cssId) return;
+ const savedKey = `advcal_positions_${_state.cssId}`;
+ const positions = {};
+ _state.monitors.forEach(m => { positions[m.id] = { cx: m.cx, cy: m.cy }; });
+ localStorage.setItem(savedKey, JSON.stringify(positions));
+}
+
+/** Place the last monitor next to the rightmost existing one. */
+function _placeNewMonitor() {
+ if (_state.monitors.length < 2) return;
+ const newMon = _state.monitors[_state.monitors.length - 1];
+ const others = _state.monitors.slice(0, -1);
+ let rightmost = others[0];
+ for (const m of others) {
+ if (m.cx + m.cw > rightmost.cx + rightmost.cw) rightmost = m;
+ }
+ const gap = 20;
+ newMon.cx = rightmost.cx + rightmost.cw + gap;
+ newMon.cy = rightmost.cy + rightmost.ch / 2 - newMon.ch / 2;
+ _saveMonitorPositions();
+}
+
+function _updateTotalLeds() {
+ const used = _state.lines.reduce((s, l) => s + l.led_count, 0);
+ const el = document.getElementById('advcal-total-leds');
+ if (_state.totalLedCount > 0) {
+ el.textContent = `${used}/${_state.totalLedCount}`;
+ el.style.color = used > _state.totalLedCount ? 'var(--danger-color, #ff5555)' : '';
+ } else {
+ el.textContent = used;
+ el.style.color = '';
+ }
+}
+
+/* ── Line list rendering ────────────────────────────────────── */
+
+function _renderLineList() {
+ const container = document.getElementById('advcal-line-list');
+ container.innerHTML = '';
+
+ _state.lines.forEach((line, i) => {
+ const div = document.createElement('div');
+ div.className = 'advcal-line-item' + (i === _state.selectedLine ? ' selected' : '');
+ div.onclick = () => selectCalibrationLine(i);
+
+ const psName = _state.pictureSources.find(p => p.id === line.picture_source_id)?.name || '?';
+ const edgeLabel = line.edge.charAt(0).toUpperCase() + line.edge.slice(1);
+ const color = EDGE_COLORS[line.edge] || '#888';
+
+ div.innerHTML = `
+
+ ${psName} · ${edgeLabel}
+ ${line.led_count}
+
+
+
+
+
+ `;
+ container.appendChild(div);
+ });
+
+ // Add button as last item
+ const addDiv = document.createElement('div');
+ addDiv.className = 'advcal-line-add';
+ addDiv.onclick = () => addCalibrationLine();
+ addDiv.innerHTML = `+`;
+ container.appendChild(addDiv);
+}
+
+function _showLineProps() {
+ const propsEl = document.getElementById('advcal-line-props');
+ const idx = _state.selectedLine;
+ if (idx < 0 || idx >= _state.lines.length) {
+ propsEl.style.display = 'none';
+ return;
+ }
+ propsEl.style.display = '';
+ const line = _state.lines[idx];
+ document.getElementById('advcal-line-source').value = line.picture_source_id;
+ if (_lineSourceEntitySelect) _lineSourceEntitySelect.refresh();
+ document.getElementById('advcal-line-edge').value = line.edge;
+ document.getElementById('advcal-line-leds').value = line.led_count;
+ document.getElementById('advcal-line-span-start').value = line.span_start;
+ document.getElementById('advcal-line-span-end').value = line.span_end;
+ document.getElementById('advcal-line-border-width').value = line.border_width;
+ document.getElementById('advcal-line-reverse').checked = line.reverse;
+}
+
+/* ── Coordinate helpers ─────────────────────────────────────── */
+
+/** Convert a mouse event to world-space (pre-transform) canvas coordinates. */
+function _mouseToWorld(e, canvas) {
+ const rect = canvas.getBoundingClientRect();
+ const sx = (e.clientX - rect.left) * (canvas.width / rect.width);
+ const sy = (e.clientY - rect.top) * (canvas.height / rect.height);
+ return {
+ x: (sx - _view.panX) / _view.zoom,
+ y: (sy - _view.panY) / _view.zoom,
+ };
+}
+
+/** Convert a mouse event to raw screen-space canvas coordinates (for pan). */
+function _mouseToScreen(e, canvas) {
+ const rect = canvas.getBoundingClientRect();
+ return {
+ x: (e.clientX - rect.left) * (canvas.width / rect.width),
+ y: (e.clientY - rect.top) * (canvas.height / rect.height),
+ };
+}
+
+export function resetCalibrationView() {
+ _fitView();
+ _renderCanvas();
+}
+
+function _fitView() {
+ const canvas = document.getElementById('advcal-canvas');
+ if (!canvas || _state.monitors.length === 0) {
+ _view = { panX: 0, panY: 0, zoom: 1.0 };
+ return;
+ }
+ const W = canvas.width;
+ const H = canvas.height;
+ const padding = 40;
+
+ // Bounding box of all monitors
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const mon of _state.monitors) {
+ minX = Math.min(minX, mon.cx);
+ minY = Math.min(minY, mon.cy);
+ maxX = Math.max(maxX, mon.cx + mon.cw);
+ maxY = Math.max(maxY, mon.cy + mon.ch);
+ }
+ const bw = maxX - minX;
+ const bh = maxY - minY;
+ if (bw <= 0 || bh <= 0) {
+ _view = { panX: 0, panY: 0, zoom: 1.0 };
+ return;
+ }
+
+ const zoom = Math.min((W - padding * 2) / bw, (H - padding * 2) / bh, MAX_ZOOM);
+ const cx = (minX + maxX) / 2;
+ const cy = (minY + maxY) / 2;
+ _view.zoom = zoom;
+ _view.panX = W / 2 - cx * zoom;
+ _view.panY = H / 2 - cy * zoom;
+}
+
+/* ── Canvas rendering ───────────────────────────────────────── */
+
+function _renderCanvas() {
+ const canvas = document.getElementById('advcal-canvas');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width;
+ const H = canvas.height;
+
+ ctx.clearRect(0, 0, W, H);
+
+ // Apply zoom/pan
+ ctx.save();
+ ctx.translate(_view.panX, _view.panY);
+ ctx.scale(_view.zoom, _view.zoom);
+
+ // Draw monitors
+ _state.monitors.forEach((mon) => {
+ ctx.fillStyle = MONITOR_BG;
+ ctx.strokeStyle = MONITOR_BORDER;
+ ctx.lineWidth = 2;
+ ctx.fillRect(mon.cx, mon.cy, mon.cw, mon.ch);
+ ctx.strokeRect(mon.cx, mon.cy, mon.cw, mon.ch);
+
+ // Label (zoom-independent size)
+ ctx.fillStyle = 'rgba(200, 210, 230, 0.7)';
+ const nameFontSize = Math.min(11 / _view.zoom, 22);
+ ctx.font = `${nameFontSize}px sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.fillText(mon.name, mon.cx + mon.cw / 2, mon.cy + mon.ch / 2);
+ });
+
+ // Pass 1: Draw all line strokes
+ _state.lines.forEach((line, i) => {
+ const mon = _state.monitors.find(m => m.id === line.picture_source_id);
+ if (!mon) return;
+ const isSelected = i === _state.selectedLine;
+ const color = isSelected ? EDGE_COLORS[line.edge] : EDGE_COLORS_DIM[line.edge];
+ const thick = isSelected ? LINE_THICKNESS_PX + 2 : LINE_THICKNESS_PX;
+ const { x1, y1, x2, y2 } = _getLineCoords(mon, line);
+ ctx.strokeStyle = color;
+ ctx.lineWidth = thick;
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+ });
+
+ // Pass 2: Draw all ticks and arrows on top of lines
+ let ledStart = 0;
+ _state.lines.forEach((line, i) => {
+ const mon = _state.monitors.find(m => m.id === line.picture_source_id);
+ if (!mon) { ledStart += line.led_count; return; }
+ const isSelected = i === _state.selectedLine;
+ const { x1, y1, x2, y2 } = _getLineCoords(mon, line);
+ _drawLineTicks(ctx, line, x1, y1, x2, y2, ledStart, mon, isSelected);
+ if (isSelected) {
+ const color = EDGE_COLORS[line.edge];
+ _drawArrow(ctx, x1, y1, x2, y2, line.reverse, color);
+ }
+ ledStart += line.led_count;
+ });
+
+ ctx.restore();
+}
+
+function _getLineCoords(mon, line) {
+ const s = line.span_start;
+ const e = line.span_end;
+ let x1, y1, x2, y2;
+
+ switch (line.edge) {
+ case 'top':
+ x1 = mon.cx + mon.cw * s;
+ x2 = mon.cx + mon.cw * e;
+ y1 = y2 = mon.cy;
+ break;
+ case 'bottom':
+ x1 = mon.cx + mon.cw * s;
+ x2 = mon.cx + mon.cw * e;
+ y1 = y2 = mon.cy + mon.ch;
+ break;
+ case 'left':
+ y1 = mon.cy + mon.ch * s;
+ y2 = mon.cy + mon.ch * e;
+ x1 = x2 = mon.cx;
+ break;
+ case 'right':
+ y1 = mon.cy + mon.ch * s;
+ y2 = mon.cy + mon.ch * e;
+ x1 = x2 = mon.cx + mon.cw;
+ break;
+ }
+ return { x1, y1, x2, y2 };
+}
+
+function _drawLineTicks(ctx, line, x1, y1, x2, y2, ledStart, mon, isSelected) {
+ const count = line.led_count;
+ if (count <= 0) return;
+
+ const isHoriz = line.edge === 'top' || line.edge === 'bottom';
+ const edgeLen = isHoriz ? Math.abs(x2 - x1) : Math.abs(y2 - y1);
+
+ // Tick direction: outward from monitor (zoom-independent sizes)
+ const tickDir = (line.edge === 'top' || line.edge === 'left') ? -1 : 1;
+ const invZoom = 1 / _view.zoom;
+ const tickLenLong = (isSelected ? 10 : 7) * Math.min(invZoom, 2);
+ const tickLenShort = (isSelected ? 5 : 4) * Math.min(invZoom, 2);
+ const labelOffset = 12 * Math.min(invZoom, 2);
+
+ // Decide which ticks to show labels for (smart spacing like simple calibration)
+ const edgeBounds = new Set([0]);
+ if (count > 1) edgeBounds.add(count - 1);
+
+ const labelsToShow = new Set();
+
+ if (count > 2) {
+ const maxDigits = String(ledStart + count - 1).length;
+ const minSpacing = (isHoriz ? maxDigits * 6 + 8 : 18) * Math.min(invZoom, 2);
+ const niceSteps = [5, 10, 25, 50, 100, 250, 500];
+ let step = niceSteps[niceSteps.length - 1];
+ for (const s of niceSteps) {
+ if (Math.floor(count / s) <= 5) { step = s; break; }
+ }
+
+ const tickPx = i => {
+ const f = i / (count - 1);
+ return (line.reverse ? (1 - f) : f) * edgeLen;
+ };
+
+ const placed = [];
+
+ // Place intermediate labels at nice steps
+ for (let i = 0; i < count; i++) {
+ if ((ledStart + i) % step === 0 || edgeBounds.has(i)) {
+ const px = tickPx(i);
+ if (!placed.some(p => Math.abs(px - p) < minSpacing)) {
+ labelsToShow.add(i);
+ placed.push(px);
+ }
+ }
+ }
+
+ // Always show labels for edge bounds
+ edgeBounds.forEach(bi => {
+ if (labelsToShow.has(bi)) return;
+ labelsToShow.add(bi);
+ placed.push(tickPx(bi));
+ });
+ } else {
+ edgeBounds.forEach(i => labelsToShow.add(i));
+ }
+
+ ctx.save();
+ ctx.strokeStyle = isSelected ? 'rgba(255, 255, 255, 0.5)' : 'rgba(255, 255, 255, 0.35)';
+ ctx.fillStyle = isSelected ? 'rgba(255, 255, 255, 0.7)' : 'rgba(255, 255, 255, 0.45)';
+ ctx.lineWidth = Math.min(invZoom, 2);
+ const tickFontSize = Math.min(9 / _view.zoom, 18);
+ ctx.font = `${tickFontSize}px sans-serif`;
+
+ const drawTick = (i, withLabel) => {
+ const fraction = count > 1 ? i / (count - 1) : 0.5;
+ const displayFraction = line.reverse ? (1 - fraction) : fraction;
+ const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort;
+ const showLabel = withLabel;
+
+ if (isHoriz) {
+ const tx = x1 + displayFraction * (x2 - x1);
+ const ty = line.edge === 'top' ? mon.cy : mon.cy + mon.ch;
+ ctx.beginPath();
+ ctx.moveTo(tx, ty);
+ ctx.lineTo(tx, ty + tickDir * tickLen);
+ ctx.stroke();
+ if (showLabel) {
+ ctx.textAlign = 'center';
+ ctx.textBaseline = tickDir < 0 ? 'bottom' : 'top';
+ ctx.fillText(String(ledStart + i), tx, ty + tickDir * labelOffset);
+ }
+ } else {
+ const tx = line.edge === 'left' ? mon.cx : mon.cx + mon.cw;
+ const ty = y1 + displayFraction * (y2 - y1);
+ ctx.beginPath();
+ ctx.moveTo(tx, ty);
+ ctx.lineTo(tx + tickDir * tickLen, ty);
+ ctx.stroke();
+ if (showLabel) {
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = tickDir < 0 ? 'right' : 'left';
+ ctx.fillText(String(ledStart + i), tx + tickDir * labelOffset, ty);
+ }
+ }
+ };
+
+ labelsToShow.forEach(i => drawTick(i, true));
+
+ // For non-selected, also draw boundary ticks if not already labeled
+ if (!isSelected) {
+ edgeBounds.forEach(i => {
+ if (!labelsToShow.has(i)) drawTick(i, false);
+ });
+ }
+
+ ctx.restore();
+}
+
+function _drawArrow(ctx, x1, y1, x2, y2, reverse, color) {
+ if (reverse) { [x1, y1, x2, y2] = [x2, y2, x1, y1]; }
+ const angle = Math.atan2(y2 - y1, x2 - x1);
+ const headLen = 8;
+ const midX = (x1 + x2) / 2;
+ const midY = (y1 + y2) / 2;
+
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(midX - headLen * Math.cos(angle - Math.PI / 6), midY - headLen * Math.sin(angle - Math.PI / 6));
+ ctx.lineTo(midX, midY);
+ ctx.lineTo(midX - headLen * Math.cos(angle + Math.PI / 6), midY - headLen * Math.sin(angle + Math.PI / 6));
+ ctx.stroke();
+}
+
+/* ── Canvas interaction (drag monitors) ─────────────────────── */
+
+function _initCanvasHandlers() {
+ const canvas = document.getElementById('advcal-canvas');
+ if (!canvas) return;
+
+ canvas.oncontextmenu = (e) => e.preventDefault();
+
+ canvas.onmousedown = (e) => {
+ // Middle-click or right-click → pan
+ if (e.button === 1 || e.button === 2) {
+ e.preventDefault();
+ const s = _mouseToScreen(e, canvas);
+ _state.dragging = {
+ type: 'pan',
+ startX: s.x,
+ startY: s.y,
+ origPanX: _view.panX,
+ origPanY: _view.panY,
+ };
+ canvas.style.cursor = 'move';
+ return;
+ }
+
+ const { x, y } = _mouseToWorld(e, canvas);
+
+ // Check if clicking on a line
+ const hitThreshold = 10 / _view.zoom;
+ for (let i = _state.lines.length - 1; i >= 0; i--) {
+ const line = _state.lines[i];
+ const mon = _state.monitors.find(m => m.id === line.picture_source_id);
+ if (!mon) continue;
+ const { x1, y1, x2, y2 } = _getLineCoords(mon, line);
+ if (_pointNearLine(x, y, x1, y1, x2, y2, hitThreshold)) {
+ selectCalibrationLine(i);
+ return;
+ }
+ }
+
+ // Check if clicking on a monitor (for drag)
+ for (let i = _state.monitors.length - 1; i >= 0; i--) {
+ const mon = _state.monitors[i];
+ if (x >= mon.cx && x <= mon.cx + mon.cw && y >= mon.cy && y <= mon.cy + mon.ch) {
+ _state.dragging = {
+ type: 'monitor',
+ monIdx: i,
+ startX: x,
+ startY: y,
+ origCx: mon.cx,
+ origCy: mon.cy,
+ };
+ canvas.style.cursor = 'grabbing';
+ return;
+ }
+ }
+
+ // Click on empty space → pan
+ const s = _mouseToScreen(e, canvas);
+ _state.dragging = {
+ type: 'pan',
+ startX: s.x,
+ startY: s.y,
+ origPanX: _view.panX,
+ origPanY: _view.panY,
+ };
+ canvas.style.cursor = 'move';
+ };
+
+ canvas.onmousemove = (e) => {
+ if (!_state.dragging) {
+ // Change cursor on hover
+ const { x, y } = _mouseToWorld(e, canvas);
+ let cursor = 'default';
+ for (const mon of _state.monitors) {
+ if (x >= mon.cx && x <= mon.cx + mon.cw && y >= mon.cy && y <= mon.cy + mon.ch) {
+ cursor = 'grab';
+ break;
+ }
+ }
+ canvas.style.cursor = cursor;
+ return;
+ }
+
+ if (_state.dragging.type === 'pan') {
+ const s = _mouseToScreen(e, canvas);
+ _view.panX = _state.dragging.origPanX + (s.x - _state.dragging.startX);
+ _view.panY = _state.dragging.origPanY + (s.y - _state.dragging.startY);
+ _renderCanvas();
+ return;
+ }
+
+ if (_state.dragging.type === 'monitor') {
+ const { x, y } = _mouseToWorld(e, canvas);
+ const mon = _state.monitors[_state.dragging.monIdx];
+ mon.cx = _state.dragging.origCx + (x - _state.dragging.startX);
+ mon.cy = _state.dragging.origCy + (y - _state.dragging.startY);
+ _renderCanvas();
+ }
+ };
+
+ canvas.onmouseup = () => {
+ if (_state.dragging?.type === 'monitor') {
+ _saveMonitorPositions();
+ }
+ _state.dragging = null;
+ canvas.style.cursor = 'default';
+ };
+
+ canvas.onmouseleave = () => {
+ if (_state.dragging?.type === 'monitor') {
+ _saveMonitorPositions();
+ }
+ _state.dragging = null;
+ canvas.style.cursor = 'default';
+ };
+
+ canvas.ondblclick = () => {
+ _fitView();
+ _renderCanvas();
+ };
+
+ canvas.onwheel = (e) => {
+ e.preventDefault();
+ const s = _mouseToScreen(e, canvas);
+ const oldZoom = _view.zoom;
+ const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
+ _view.zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, oldZoom * factor));
+ // Zoom toward cursor: adjust pan so the world point under cursor stays fixed
+ _view.panX = s.x - (s.x - _view.panX) * (_view.zoom / oldZoom);
+ _view.panY = s.y - (s.y - _view.panY) * (_view.zoom / oldZoom);
+ _renderCanvas();
+ };
+}
+
+function _pointNearLine(px, py, x1, y1, x2, y2, threshold) {
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ const lenSq = dx * dx + dy * dy;
+ if (lenSq === 0) return Math.hypot(px - x1, py - y1) <= threshold;
+ let t = ((px - x1) * dx + (py - y1) * dy) / lenSq;
+ t = Math.max(0, Math.min(1, t));
+ const nearX = x1 + t * dx;
+ const nearY = y1 + t * dy;
+ return Math.hypot(px - nearX, py - nearY) <= threshold;
+}
diff --git a/server/src/wled_controller/static/js/features/calibration.js b/server/src/wled_controller/static/js/features/calibration.js
index 3f6fb3e..e43686f 100644
--- a/server/src/wled_controller/static/js/features/calibration.js
+++ b/server/src/wled_controller/static/js/features/calibration.js
@@ -238,7 +238,8 @@ export async function showCSSCalibration(cssId) {
if (!cssResp.ok) { showToast(t('calibration.error.css_load_failed'), 'error'); return; }
const source = await cssResp.json();
- const calibration = source.calibration || {};
+ const calibration = source.calibration || {
+ }
// Set CSS mode — clear device-id, set css-id
document.getElementById('calibration-device-id').value = '';
@@ -527,7 +528,6 @@ export function renderCalibrationCanvas() {
}
const labelsToShow = new Set([...specialTicks]);
- const tickLinesOnly = new Set();
if (count > 2) {
const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1);
@@ -563,13 +563,8 @@ export function renderCalibrationCanvas() {
edgeBounds.forEach(bi => {
if (labelsToShow.has(bi) || specialTicks.has(bi)) return;
- const px = tickPx(bi);
- if (placed.some(p => Math.abs(px - p) < minSpacing)) {
- tickLinesOnly.add(bi);
- } else {
- labelsToShow.add(bi);
- placed.push(px);
- }
+ labelsToShow.add(bi);
+ placed.push(tickPx(bi));
});
} else {
edgeBounds.forEach(i => labelsToShow.add(i));
@@ -607,22 +602,6 @@ export function renderCalibrationCanvas() {
}
});
- tickLinesOnly.forEach(i => {
- const fraction = count > 1 ? i / (count - 1) : 0.5;
- const displayFraction = seg.reverse ? (1 - fraction) : fraction;
- if (geo.horizontal) {
- const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
- const axisY = axisPos[seg.edge];
- const tickDir = seg.edge === 'top' ? 1 : -1;
- ctx.beginPath(); ctx.moveTo(tx, axisY); ctx.lineTo(tx, axisY + tickDir * tickLenLong); ctx.stroke();
- } else {
- const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
- const axisX = axisPos[seg.edge];
- const tickDir = seg.edge === 'left' ? 1 : -1;
- ctx.beginPath(); ctx.moveTo(axisX, ty); ctx.lineTo(axisX + tickDir * tickLenLong, ty); ctx.stroke();
- }
- });
-
const s = 7;
let mx, my, angle;
if (geo.horizontal) {
@@ -934,6 +913,7 @@ export async function saveCalibration() {
const spans = window.edgeSpans || {};
const calibration = {
+ mode: 'simple',
layout, start_position: startPosition, offset,
leds_top: topLeds, leds_right: rightLeds, leds_bottom: bottomLeds, leds_left: leftLeds,
span_top_start: spans.top?.start ?? 0, span_top_end: spans.top?.end ?? 1,
diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js
index 34838d7..e81204e 100644
--- a/server/src/wled_controller/static/js/features/streams.js
+++ b/server/src/wled_controller/static/js/features/streams.js
@@ -1262,17 +1262,21 @@ function renderPictureSourcesList(streams) {
${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
${configEntries.length > 0 ? `
-
- ${t('templates.config.show')}
-
- ${configEntries.map(([key, val]) => `
-
- | ${escapeHtml(key)} |
- ${escapeHtml(String(val))} |
-
- `).join('')}
-
-
+
+
+
+
+
+ ${configEntries.map(([key, val]) => `
+
+ | ${escapeHtml(key)} |
+ ${escapeHtml(String(val))} |
+
+ `).join('')}
+
+
+
+
` : ''}`,
actions: `
@@ -1389,17 +1393,21 @@ function renderPictureSourcesList(streams) {
${configEntries.length > 0 ? `${ICON_WRENCH} ${configEntries.length}` : ''}
${configEntries.length > 0 ? `
-
- ${t('audio_template.config.show')}
-
- ${configEntries.map(([key, val]) => `
-
- | ${escapeHtml(key)} |
- ${escapeHtml(String(val))} |
-
- `).join('')}
-
-
+
+
+
+
+
+ ${configEntries.map(([key, val]) => `
+
+ | ${escapeHtml(key)} |
+ ${escapeHtml(String(val))} |
+
+ `).join('')}
+
+
+
+
` : ''}`,
actions: `