- Add `tags: List[str]` field to all 13 entity types (devices, output targets, CSS sources, picture sources, audio sources, value sources, sync clocks, automations, scene presets, capture/audio/PP/pattern templates) - Update all stores, schemas, and route handlers for tag CRUD - Add GET /api/v1/tags endpoint aggregating unique tags across all stores - Create TagInput component with chip display, autocomplete dropdown, keyboard navigation, and API-backed suggestions - Display tag chips on all entity cards (searchable via existing text filter) - Add tag input to all 14 editor modals with dirty check support - Add CSS styles and i18n keys (en/ru/zh) for tag UI - Also includes code review fixes: thread safety, perf, store dedup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
860 lines
31 KiB
JavaScript
860 lines
31 KiB
JavaScript
/**
|
|
* 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(() => ({}));
|
|
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 = `
|
|
<span class="advcal-line-color" style="background:${color}"></span>
|
|
<span class="advcal-line-label">${psName} · ${edgeLabel}</span>
|
|
<span class="advcal-line-leds">${line.led_count}</span>
|
|
<span class="advcal-line-actions">
|
|
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, -1)" title="Move up" ${i === 0 ? 'disabled' : ''}>▲</button>
|
|
<button class="btn-micro" onclick="event.stopPropagation(); moveCalibrationLine(${i}, 1)" title="Move down" ${i === _state.lines.length - 1 ? 'disabled' : ''}>▼</button>
|
|
<button class="btn-micro btn-danger" onclick="event.stopPropagation(); removeCalibrationLine(${i})" title="Remove">✕</button>
|
|
</span>
|
|
`;
|
|
container.appendChild(div);
|
|
});
|
|
|
|
// Add button as last item
|
|
const addDiv = document.createElement('div');
|
|
addDiv.className = 'advcal-line-add';
|
|
addDiv.onclick = () => addCalibrationLine();
|
|
addDiv.innerHTML = `<span>+</span>`;
|
|
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;
|
|
}
|