From 32e0f0eb5c1a259644e99710512dee65eea7d768 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Mon, 9 Mar 2026 15:10:29 +0300 Subject: [PATCH] Improve calibration UI: animated config sections, always-visible tick labels, zoom-independent fonts, smooth line selection - Replace
with grid-template-rows animated expand for template config sections - Always show edge boundary tick labels in both simple and advanced calibration - Make tick labels, monitor names, and tick marks zoom-independent in advanced calibration - Place new monitors next to existing ones and fit view on add - Fix layout jump on line selection: toggle class in-place instead of DOM rebuild - Use transparent border-left on all line items to prevent content shift Co-Authored-By: Claude Opus 4.6 --- .../static/css/advanced-calibration.css | 166 ++++ .../wled_controller/static/css/streams.css | 37 +- .../js/features/advanced-calibration.js | 858 ++++++++++++++++++ .../static/js/features/calibration.js | 30 +- .../static/js/features/streams.js | 52 +- 5 files changed, 1086 insertions(+), 57 deletions(-) create mode 100644 server/src/wled_controller/static/css/advanced-calibration.css create mode 100644 server/src/wled_controller/static/js/features/advanced-calibration.js 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]) => ` - - - - - `).join('')} -
${escapeHtml(key)}${escapeHtml(String(val))}
-
+
+ +
+
+ + ${configEntries.map(([key, val]) => ` + + + + + `).join('')} +
${escapeHtml(key)}${escapeHtml(String(val))}
+
+
+
` : ''}`, 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]) => ` - - - - - `).join('')} -
${escapeHtml(key)}${escapeHtml(String(val))}
-
+
+ +
+
+ + ${configEntries.map(([key, val]) => ` + + + + + `).join('')} +
${escapeHtml(key)}${escapeHtml(String(val))}
+
+
+
` : ''}`, actions: `