/** * 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 { colorStripSourcesCache } from '../core/state.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 [cssSources, psResp] = await Promise.all([ colorStripSourcesCache.fetch(), fetchWithAuth('/picture-sources'), ]); const source = cssSources.find(s => s.id === cssId); if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } 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'); colorStripSourcesCache.invalidate(); _modal.forceClose(); } else { const err = await resp.json().catch(() => ({})); const detail = err.detail || err.message || ''; const detailStr = Array.isArray(detail) ? detail.map(d => d.msg || d).join('; ') : String(detail); showToast(detailStr || t('calibration.error.save_failed'), 'error'); } } catch (error) { if (error.isAuth) return; showToast(error.message || 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; }