/** * Calibration — calibration modal, canvas, drag handlers, edge test. */ import { calibrationTestState, EDGE_TEST_COLORS, displaysCache, } from '../core/state.ts'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.ts'; import { colorStripSourcesCache, devicesCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { closeTutorial, startCalibrationTutorial } from './tutorials.ts'; import { startCSSOverlay, stopCSSOverlay } from './color-strips.ts'; import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW } from '../core/icons.ts'; import type { Calibration } from '../types.ts'; /* ── CalibrationModal subclass ────────────────────────────────── */ class CalibrationModal extends Modal { constructor() { super('calibration-modal'); } snapshotValues() { return { start_position: (this.$('cal-start-position') as HTMLSelectElement).value, layout: (this.$('cal-layout') as HTMLSelectElement).value, offset: (this.$('cal-offset') as HTMLInputElement).value, top: (this.$('cal-top-leds') as HTMLInputElement).value, right: (this.$('cal-right-leds') as HTMLInputElement).value, bottom: (this.$('cal-bottom-leds') as HTMLInputElement).value, left: (this.$('cal-left-leds') as HTMLInputElement).value, spans: JSON.stringify(window.edgeSpans), skip_start: (this.$('cal-skip-start') as HTMLInputElement).value, skip_end: (this.$('cal-skip-end') as HTMLInputElement).value, border_width: (this.$('cal-border-width') as HTMLInputElement).value, led_count: (this.$('cal-css-led-count') as HTMLInputElement).value, }; } onForceClose() { closeTutorial(); if (_isCSS()) { const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value; if (_overlayStartedHere && cssId) { stopCSSOverlay(cssId); _overlayStartedHere = false; } _clearCSSTestMode(); (document.getElementById('calibration-css-id') as HTMLInputElement).value = ''; const testGroup = document.getElementById('calibration-css-test-group'); if (testGroup) testGroup.style.display = 'none'; } else { const deviceId = (this.$('calibration-device-id') as HTMLInputElement).value; if (deviceId) clearTestMode(deviceId); } if (window._calibrationResizeObserver) window._calibrationResizeObserver.disconnect(); const error = this.$('calibration-error'); if (error) error.style.display = 'none'; } } const calibModal = new CalibrationModal(); let _dragRaf: number | null = null; let _previewRaf: number | null = null; let _overlayStartedHere = false; /* ── Helpers ──────────────────────────────────────────────────── */ function _isCSS() { return !!((document.getElementById('calibration-css-id') as HTMLInputElement)?.value); } function _cssStateKey() { return `css_${(document.getElementById('calibration-css-id') as HTMLInputElement).value}`; } async function _clearCSSTestMode() { const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value; const stateKey = _cssStateKey(); if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return; calibrationTestState[stateKey] = new Set(); const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value; if (!testDeviceId) return; try { await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { method: 'PUT', body: JSON.stringify({ device_id: testDeviceId, edges: {} }), }); } catch (err) { console.error('Failed to clear CSS test mode:', err); } } function _setOverlayBtnActive(active: any) { const btn = document.getElementById('calibration-overlay-btn'); if (!btn) return; btn.classList.toggle('active', active); } async function _checkOverlayStatus(cssId: any) { try { const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); if (resp.ok) { const data = await resp.json(); _setOverlayBtnActive(data.active); } } catch { /* ignore */ } } export async function toggleCalibrationOverlay() { const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value; if (!cssId) return; try { const resp = await fetchWithAuth(`/color-strip-sources/${cssId}/overlay/status`); if (!resp.ok) return; const { active } = await resp.json(); if (active) { await stopCSSOverlay(cssId); _setOverlayBtnActive(false); _overlayStartedHere = false; } else { await startCSSOverlay(cssId); _setOverlayBtnActive(true); _overlayStartedHere = true; } } catch (err: any) { if (err.isAuth) return; console.error('Failed to toggle calibration overlay:', err); } } /* ── Public API (exported names unchanged) ────────────────────── */ export async function showCalibration(deviceId: any) { try { const [response, displays] = await Promise.all([ fetchWithAuth(`/devices/${deviceId}`), displaysCache.fetch().catch((): any[] => []), ]); if (!response.ok) { showToast(t('calibration.error.load_failed'), 'error'); return; } const device = await response.json(); const calibration = device.calibration; const preview = document.querySelector('.calibration-preview') as HTMLElement; const displayIndex = device.settings?.display_index ?? 0; const display = displays.find((d: any) => d.index === displayIndex); if (display && display.width && display.height) { preview.style.aspectRatio = `${display.width} / ${display.height}`; } else { preview.style.aspectRatio = ''; } (document.getElementById('calibration-device-id') as HTMLInputElement).value = device.id; (document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = device.led_count; (document.getElementById('cal-css-led-count-group') as HTMLElement).style.display = 'none'; (document.getElementById('calibration-overlay-btn') as HTMLElement).style.display = 'none'; (document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position; (document.getElementById('cal-layout') as HTMLSelectElement).value = calibration.layout; (document.getElementById('cal-offset') as HTMLInputElement).value = calibration.offset || 0; (document.getElementById('cal-top-leds') as HTMLInputElement).value = calibration.leds_top || 0; (document.getElementById('cal-right-leds') as HTMLInputElement).value = calibration.leds_right || 0; (document.getElementById('cal-bottom-leds') as HTMLInputElement).value = calibration.leds_bottom || 0; (document.getElementById('cal-left-leds') as HTMLInputElement).value = calibration.leds_left || 0; (document.getElementById('cal-skip-start') as HTMLInputElement).value = calibration.skip_leds_start || 0; (document.getElementById('cal-skip-end') as HTMLInputElement).value = calibration.skip_leds_end || 0; updateOffsetSkipLock(); (document.getElementById('cal-border-width') as HTMLInputElement).value = calibration.border_width || 10; window.edgeSpans = { top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 }, bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 }, left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, }; calibrationTestState[device.id] = new Set(); updateCalibrationPreview(); calibModal.snapshot(); calibModal.open(); initSpanDrag(); requestAnimationFrame(() => { renderCalibrationCanvas(); if (!localStorage.getItem('calibrationTutorialSeen')) { localStorage.setItem('calibrationTutorialSeen', '1'); startCalibrationTutorial(); } }); if (!window._calibrationResizeObserver) { window._calibrationResizeObserver = new ResizeObserver(() => { if (window._calibrationResizeRaf) return; window._calibrationResizeRaf = requestAnimationFrame(() => { window._calibrationResizeRaf = null; updateSpanBars(); renderCalibrationCanvas(); }); }); } window._calibrationResizeObserver.observe(preview); } catch (error: any) { if (error.isAuth) return; console.error('Failed to load calibration:', error); showToast(t('calibration.error.load_failed'), 'error'); } } function isCalibrationDirty() { return calibModal.isDirty(); } export function forceCloseCalibrationModal() { calibModal.forceClose(); } export async function closeCalibrationModal() { calibModal.close(); } /* ── CSS Calibration support ──────────────────────────────────── */ export async function showCSSCalibration(cssId: any) { try { const [cssSources, devices] = await Promise.all([ colorStripSourcesCache.fetch(), devicesCache.fetch().catch((): any[] => []), ]); const source = cssSources.find((s: any) => s.id === cssId); if (!source) { showToast(t('calibration.error.css_load_failed'), 'error'); return; } const calibration: Calibration = source.calibration || {} as Calibration; // Set CSS mode — clear device-id, set css-id (document.getElementById('calibration-device-id') as HTMLInputElement).value = ''; (document.getElementById('calibration-css-id') as HTMLInputElement).value = cssId; // Populate device picker for edge test const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement; testDeviceSelect.innerHTML = ''; devices.forEach((d: any) => { const opt = document.createElement('option'); opt.value = d.id; opt.textContent = d.name; testDeviceSelect.appendChild(opt); }); const testGroup = document.getElementById('calibration-css-test-group') as HTMLElement; testGroup.style.display = devices.length ? '' : 'none'; // Pre-select device: 1) LED count match, 2) last remembered, 3) first if (devices.length) { const rememberedId = localStorage.getItem('css_calibration_test_device'); let selected: any = null; if (source.led_count > 0) { selected = devices.find((d: any) => d.led_count === source.led_count) || null; } if (!selected && rememberedId) { selected = devices.find((d: any) => d.id === rememberedId) || null; } if (selected) testDeviceSelect.value = selected.id; testDeviceSelect.onchange = () => localStorage.setItem('css_calibration_test_device', testDeviceSelect.value); } // Populate calibration fields const preview = document.querySelector('.calibration-preview') as HTMLElement; preview.style.aspectRatio = ''; (document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = '—'; const ledCountGroup = document.getElementById('cal-css-led-count-group') as HTMLElement; ledCountGroup.style.display = ''; const calLeds = (calibration.leds_top || 0) + (calibration.leds_right || 0) + (calibration.leds_bottom || 0) + (calibration.leds_left || 0); (document.getElementById('cal-css-led-count') as HTMLInputElement).value = String(source.led_count || calLeds || 0); (document.getElementById('cal-start-position') as HTMLSelectElement).value = calibration.start_position || 'bottom_left'; (document.getElementById('cal-layout') as HTMLSelectElement).value = calibration.layout || 'clockwise'; (document.getElementById('cal-offset') as HTMLInputElement).value = String(calibration.offset || 0); (document.getElementById('cal-top-leds') as HTMLInputElement).value = String(calibration.leds_top || 0); (document.getElementById('cal-right-leds') as HTMLInputElement).value = String(calibration.leds_right || 0); (document.getElementById('cal-bottom-leds') as HTMLInputElement).value = String(calibration.leds_bottom || 0); (document.getElementById('cal-left-leds') as HTMLInputElement).value = String(calibration.leds_left || 0); (document.getElementById('cal-skip-start') as HTMLInputElement).value = String(calibration.skip_leds_start || 0); (document.getElementById('cal-skip-end') as HTMLInputElement).value = String(calibration.skip_leds_end || 0); updateOffsetSkipLock(); (document.getElementById('cal-border-width') as HTMLInputElement).value = String(calibration.border_width || 10); window.edgeSpans = { top: { start: calibration.span_top_start ?? 0, end: calibration.span_top_end ?? 1 }, right: { start: calibration.span_right_start ?? 0, end: calibration.span_right_end ?? 1 }, bottom: { start: calibration.span_bottom_start ?? 0, end: calibration.span_bottom_end ?? 1 }, left: { start: calibration.span_left_start ?? 0, end: calibration.span_left_end ?? 1 }, }; calibrationTestState[_cssStateKey()] = new Set(); updateCalibrationPreview(); calibModal.snapshot(); calibModal.open(); // Show overlay toggle and check current status _overlayStartedHere = false; const overlayBtn = document.getElementById('calibration-overlay-btn') as HTMLElement; overlayBtn.style.display = ''; _setOverlayBtnActive(false); _checkOverlayStatus(cssId); initSpanDrag(); requestAnimationFrame(() => renderCalibrationCanvas()); if (!window._calibrationResizeObserver) { window._calibrationResizeObserver = new ResizeObserver(() => { if (window._calibrationResizeRaf) return; window._calibrationResizeRaf = requestAnimationFrame(() => { window._calibrationResizeRaf = null; updateSpanBars(); renderCalibrationCanvas(); }); }); } window._calibrationResizeObserver.observe(preview); } catch (error: any) { if (error.isAuth) return; console.error('Failed to load CSS calibration:', error); showToast(t('calibration.error.load_failed'), 'error'); } } export function updateOffsetSkipLock() { const offsetEl = document.getElementById('cal-offset') as HTMLInputElement; const skipStartEl = document.getElementById('cal-skip-start') as HTMLInputElement; const skipEndEl = document.getElementById('cal-skip-end') as HTMLInputElement; const hasOffset = parseInt(offsetEl.value || '0') > 0; const hasSkip = parseInt(skipStartEl.value || '0') > 0 || parseInt(skipEndEl.value || '0') > 0; skipStartEl.disabled = hasOffset; skipEndEl.disabled = hasOffset; offsetEl.disabled = hasSkip; } export function updateCalibrationPreview() { const total = parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0') + parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0') + parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0') + parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0'); const totalEl = document.querySelector('.preview-screen-total') as HTMLElement; const inCSS = _isCSS(); const declaredCount = inCSS ? parseInt((document.getElementById('cal-css-led-count') as HTMLInputElement).value || '0') : parseInt((document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent || '0'); if (inCSS) { (document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent = String(declaredCount || '—'); } // In device mode: calibration total must exactly equal device LED count // In CSS mode: warn only if calibrated LEDs exceed the declared total (padding handles the rest) const mismatch = inCSS ? (declaredCount > 0 && total > declaredCount) : (total !== declaredCount); (document.getElementById('cal-total-leds-inline') as HTMLElement).innerHTML = (mismatch ? ICON_WARNING + ' ' : '') + total; if (totalEl) totalEl.classList.toggle('mismatch', mismatch); const startPos = (document.getElementById('cal-start-position') as HTMLSelectElement).value; ['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => { const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`) as HTMLElement; if (cornerEl) { if (corner === startPos) cornerEl.classList.add('active'); else cornerEl.classList.remove('active'); } }); const direction = (document.getElementById('cal-layout') as HTMLSelectElement).value; const dirIcon = document.getElementById('direction-icon'); const dirLabel = document.getElementById('direction-label'); if (dirIcon) dirIcon.innerHTML = direction === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW; if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW'; const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value; const stateKey = _isCSS() ? _cssStateKey() : deviceId; const activeEdges = calibrationTestState[stateKey] || new Set(); ['top', 'right', 'bottom', 'left'].forEach(edge => { const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`) as HTMLElement; if (!toggleEl) return; if (activeEdges.has(edge)) { const [r, g, b] = EDGE_TEST_COLORS[edge]; toggleEl.style.background = `rgba(${r}, ${g}, ${b}, 0.35)`; toggleEl.style.boxShadow = `inset 0 0 6px rgba(${r}, ${g}, ${b}, 0.5)`; } else { toggleEl.style.background = ''; toggleEl.style.boxShadow = ''; } }); ['top', 'right', 'bottom', 'left'].forEach(edge => { const count = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0; const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`) as HTMLElement; const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`) as HTMLElement; if (edgeEl) edgeEl.classList.toggle('edge-disabled', count === 0); if (toggleEl) toggleEl.classList.toggle('edge-disabled', count === 0); }); if (_previewRaf) cancelAnimationFrame(_previewRaf); _previewRaf = requestAnimationFrame(() => { _previewRaf = null; updateSpanBars(); renderCalibrationCanvas(); }); } export function renderCalibrationCanvas() { const canvas = document.getElementById('calibration-preview-canvas') as HTMLCanvasElement; if (!canvas) return; const container = canvas.parentElement; const containerRect = container!.getBoundingClientRect(); if (containerRect.width === 0 || containerRect.height === 0) return; const padX = 40; const padY = 40; const dpr = window.devicePixelRatio || 1; const canvasW = containerRect.width + padX * 2; const canvasH = containerRect.height + padY * 2; canvas.width = canvasW * dpr; canvas.height = canvasH * dpr; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, canvasW, canvasH); const ox = padX; const oy = padY; const cW = containerRect.width; const cH = containerRect.height; const startPos = (document.getElementById('cal-start-position') as HTMLSelectElement).value; const layout = (document.getElementById('cal-layout') as HTMLSelectElement).value; const offset = parseInt((document.getElementById('cal-offset') as HTMLInputElement).value || '0'); const calibration = { start_position: startPos, layout: layout, offset: offset, leds_top: parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0'), leds_right: parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0'), leds_bottom: parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0'), leds_left: parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0'), }; const skipStart = parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'); const skipEnd = parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'); const segments = buildSegments(calibration); if (segments.length === 0) return; const totalLeds = calibration.leds_top + calibration.leds_right + calibration.leds_bottom + calibration.leds_left; const hasSkip = (skipStart > 0 || skipEnd > 0) && totalLeds > 1; const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; const tickStroke = isDark ? 'rgba(255, 255, 255, 0.4)' : 'rgba(0, 0, 0, 0.3)'; const tickFill = isDark ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.6)'; const chevronStroke = isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.4)'; const cw = 56; const ch = 36; const spans = window.edgeSpans || {}; const edgeLenH = cW - 2 * cw; const edgeLenV = cH - 2 * ch; const edgeGeometry: any = { top: { x1: ox + cw + (spans.top?.start || 0) * edgeLenH, x2: ox + cw + (spans.top?.end || 1) * edgeLenH, midY: oy + ch / 2, horizontal: true }, bottom: { x1: ox + cw + (spans.bottom?.start || 0) * edgeLenH, x2: ox + cw + (spans.bottom?.end || 1) * edgeLenH, midY: oy + cH - ch / 2, horizontal: true }, left: { y1: oy + ch + (spans.left?.start || 0) * edgeLenV, y2: oy + ch + (spans.left?.end || 1) * edgeLenV, midX: ox + cw / 2, horizontal: false }, right: { y1: oy + ch + (spans.right?.start || 0) * edgeLenV, y2: oy + ch + (spans.right?.end || 1) * edgeLenV, midX: ox + cW - cw / 2, horizontal: false }, }; const toggleSize = 16; const axisPos: any = { top: oy - toggleSize - 3, bottom: oy + cH + toggleSize + 3, left: ox - toggleSize - 3, right: ox + cW + toggleSize + 3, }; const arrowInset = 12; const arrowPos: any = { top: oy + ch + arrowInset, bottom: oy + cH - ch - arrowInset, left: ox + cw + arrowInset, right: ox + cW - cw - arrowInset, }; segments.forEach((seg: any) => { const geo = edgeGeometry[seg.edge]; if (!geo) return; const count = seg.led_count; if (count === 0) return; const edgeDisplayStart = hasSkip ? Math.max(seg.led_start, skipStart) : seg.led_start; const edgeDisplayEnd = hasSkip ? Math.min(seg.led_start + count, totalLeds - skipEnd) : seg.led_start + count - 1; const edgeDisplayRange = edgeDisplayEnd - edgeDisplayStart; const toEdgeLabel = (i: number) => { if (!hasSkip) return totalLeds > 0 ? (seg.led_start + i) % totalLeds : seg.led_start + i; if (count <= 1) return edgeDisplayStart; return Math.round(edgeDisplayStart + i / (count - 1) * edgeDisplayRange); }; const edgeBounds = new Set(); edgeBounds.add(0); if (count > 1) edgeBounds.add(count - 1); const specialTicks = new Set(); if (offset > 0 && totalLeds > 0) { const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds; if (zeroPos < count) specialTicks.add(zeroPos); } const labelsToShow = new Set([...specialTicks]); if (count > 2) { const edgeLen = geo.horizontal ? (geo.x2 - geo.x1) : (geo.y2 - geo.y1); const maxDigits = String(totalLeds > 0 ? totalLeds - 1 : count - 1).length; const minSpacing = geo.horizontal ? maxDigits * 7 + 8 : 22; const allMandatory = new Set([...edgeBounds, ...specialTicks]); const maxIntermediate = Math.max(0, 5 - allMandatory.size); 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) <= maxIntermediate) { step = s; break; } } const tickPx = (i: number) => { const f = i / (count - 1); return (seg.reverse ? (1 - f) : f) * edgeLen; }; const placed: number[] = []; specialTicks.forEach(i => placed.push(tickPx(i))); for (let i = 1; i < count - 1; i++) { if (specialTicks.has(i)) continue; if (toEdgeLabel(i) % step === 0) { const px = tickPx(i); if (!placed.some(p => Math.abs(px - p) < minSpacing)) { labelsToShow.add(i); placed.push(px); } } } edgeBounds.forEach(bi => { if (labelsToShow.has(bi) || specialTicks.has(bi)) return; labelsToShow.add(bi); placed.push(tickPx(bi)); }); } else { edgeBounds.forEach(i => labelsToShow.add(i)); } const tickLenLong = toggleSize + 3; const tickLenShort = 4; ctx.strokeStyle = tickStroke; ctx.lineWidth = 1; ctx.fillStyle = tickFill; ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif'; labelsToShow.forEach(i => { const fraction = count > 1 ? i / (count - 1) : 0.5; const displayFraction = seg.reverse ? (1 - fraction) : fraction; const displayLabel = toEdgeLabel(i); const tickLen = edgeBounds.has(i) ? tickLenLong : tickLenShort; 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 * tickLen); ctx.stroke(); ctx.textAlign = 'center'; ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top'; ctx.fillText(String(displayLabel), tx, axisY - tickDir * 1); } 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 * tickLen, ty); ctx.stroke(); ctx.textBaseline = 'middle'; ctx.textAlign = seg.edge === 'left' ? 'right' : 'left'; ctx.fillText(String(displayLabel), axisX - tickDir * 1, ty); } }); const s = 7; let mx, my, angle; if (geo.horizontal) { mx = ox + cw + edgeLenH / 2; my = arrowPos[seg.edge]; angle = seg.reverse ? Math.PI : 0; } else { mx = arrowPos[seg.edge]; my = oy + ch + edgeLenV / 2; angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2; } ctx.save(); ctx.translate(mx, my); ctx.rotate(angle); ctx.fillStyle = 'rgba(76, 175, 80, 0.85)'; ctx.strokeStyle = chevronStroke; ctx.lineWidth = 1; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(-s * 0.5, -s * 0.6); ctx.lineTo(s * 0.5, 0); ctx.lineTo(-s * 0.5, s * 0.6); ctx.closePath(); ctx.fill(); ctx.stroke(); ctx.restore(); }); } function updateSpanBars() { const spans = window.edgeSpans || {}; const container = document.querySelector('.calibration-preview') as HTMLElement; ['top', 'right', 'bottom', 'left'].forEach(edge => { const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`) as HTMLElement; if (!bar) return; const span = spans[edge] || { start: 0, end: 1 }; const edgeEl = bar.parentElement as HTMLElement; const isHorizontal = (edge === 'top' || edge === 'bottom'); if (isHorizontal) { const totalWidth = edgeEl.clientWidth; bar.style.left = (span.start * totalWidth) + 'px'; bar.style.width = ((span.end - span.start) * totalWidth) + 'px'; } else { const totalHeight = edgeEl.clientHeight; bar.style.top = (span.start * totalHeight) + 'px'; bar.style.height = ((span.end - span.start) * totalHeight) + 'px'; } if (!container) return; const toggle = container.querySelector(`.toggle-${edge}`) as HTMLElement; if (!toggle) return; if (isHorizontal) { const cornerW = 56; const edgeW = container.clientWidth - 2 * cornerW; toggle.style.left = (cornerW + span.start * edgeW) + 'px'; toggle.style.right = 'auto'; toggle.style.width = ((span.end - span.start) * edgeW) + 'px'; } else { const cornerH = 36; const edgeH = container.clientHeight - 2 * cornerH; toggle.style.top = (cornerH + span.start * edgeH) + 'px'; toggle.style.bottom = 'auto'; toggle.style.height = ((span.end - span.start) * edgeH) + 'px'; } }); } function initSpanDrag() { const MIN_SPAN = 0.05; document.querySelectorAll('.edge-span-bar').forEach(bar => { const edge = (bar as HTMLElement).dataset.edge!; const isHorizontal = (edge === 'top' || edge === 'bottom'); bar.addEventListener('click', (e: Event) => e.stopPropagation()); bar.querySelectorAll('.edge-span-handle').forEach(handle => { handle.addEventListener('mousedown', (e: Event) => { const edgeLeds = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0; if (edgeLeds === 0) return; e.preventDefault(); e.stopPropagation(); const handleType = (handle as HTMLElement).dataset.handle; const edgeEl = bar.parentElement as HTMLElement; const rect = edgeEl.getBoundingClientRect(); function onMouseMove(ev: MouseEvent) { const span = window.edgeSpans[edge]; let fraction; if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width; else fraction = (ev.clientY - rect.top) / rect.height; fraction = Math.max(0, Math.min(1, fraction)); if (handleType === 'start') span.start = Math.min(fraction, span.end - MIN_SPAN); else span.end = Math.max(fraction, span.start + MIN_SPAN); if (!_dragRaf) { _dragRaf = requestAnimationFrame(() => { _dragRaf = null; updateSpanBars(); renderCalibrationCanvas(); }); } } function onMouseUp() { if (_dragRaf) { cancelAnimationFrame(_dragRaf); _dragRaf = null; } updateSpanBars(); renderCalibrationCanvas(); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); }); bar.addEventListener('mousedown', (e: Event) => { if ((e.target as HTMLElement).classList.contains('edge-span-handle')) return; e.preventDefault(); e.stopPropagation(); const edgeEl = bar.parentElement as HTMLElement; const rect = edgeEl.getBoundingClientRect(); const span = window.edgeSpans[edge]; const spanWidth = span.end - span.start; let startFraction; if (isHorizontal) startFraction = ((e as MouseEvent).clientX - rect.left) / rect.width; else startFraction = ((e as MouseEvent).clientY - rect.top) / rect.height; const offsetInSpan = startFraction - span.start; function onMouseMove(ev: MouseEvent) { let fraction; if (isHorizontal) fraction = (ev.clientX - rect.left) / rect.width; else fraction = (ev.clientY - rect.top) / rect.height; let newStart = fraction - offsetInSpan; newStart = Math.max(0, Math.min(1 - spanWidth, newStart)); span.start = newStart; span.end = newStart + spanWidth; if (!_dragRaf) { _dragRaf = requestAnimationFrame(() => { _dragRaf = null; updateSpanBars(); renderCalibrationCanvas(); }); } } function onMouseUp() { if (_dragRaf) { cancelAnimationFrame(_dragRaf); _dragRaf = null; } updateSpanBars(); renderCalibrationCanvas(); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); } document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); }); updateSpanBars(); } export function setStartPosition(position: any) { (document.getElementById('cal-start-position') as HTMLSelectElement).value = position; updateCalibrationPreview(); } export function toggleEdgeInputs() { const preview = document.querySelector('.calibration-preview'); if (preview) preview.classList.toggle('inputs-dimmed'); } export function toggleDirection() { const select = document.getElementById('cal-layout') as HTMLSelectElement; select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise'; updateCalibrationPreview(); } export async function toggleTestEdge(edge: any) { const edgeLeds = parseInt((document.getElementById(`cal-${edge}-leds`) as HTMLInputElement).value) || 0; if (edgeLeds === 0) return; const error = document.getElementById('calibration-error') as HTMLElement; if (_isCSS()) { const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value; const testDeviceId = (document.getElementById('calibration-test-device') as HTMLSelectElement)?.value; if (!testDeviceId) return; const stateKey = _cssStateKey(); if (!calibrationTestState[stateKey]) calibrationTestState[stateKey] = new Set(); if (calibrationTestState[stateKey].has(edge)) calibrationTestState[stateKey].delete(edge); else calibrationTestState[stateKey].add(edge); const edges: any = {}; calibrationTestState[stateKey].forEach((e: any) => { edges[e] = EDGE_TEST_COLORS[e]; }); updateCalibrationPreview(); try { const response = await fetchWithAuth(`/color-strip-sources/${cssId}/calibration/test`, { method: 'PUT', body: JSON.stringify({ device_id: testDeviceId, edges }), }); if (!response.ok) { const errorData = await response.json(); const detail = errorData.detail || errorData.message || ''; const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); error.textContent = detailStr || t('calibration.error.test_toggle_failed'); error.style.display = 'block'; } } catch (err: any) { if (err.isAuth) return; console.error('Failed to toggle CSS test edge:', err); error.textContent = err.message || t('calibration.error.test_toggle_failed'); error.style.display = 'block'; } return; } const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value; if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set(); if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge); else calibrationTestState[deviceId].add(edge); const edges: any = {}; calibrationTestState[deviceId].forEach((e: any) => { edges[e] = EDGE_TEST_COLORS[e]; }); updateCalibrationPreview(); try { const response = await fetchWithAuth(`/devices/${deviceId}/calibration/test`, { method: 'PUT', body: JSON.stringify({ edges }) }); if (!response.ok) { const errorData = await response.json(); const detail = errorData.detail || errorData.message || ''; const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); error.textContent = detailStr || t('calibration.error.test_toggle_failed'); error.style.display = 'block'; } } catch (err: any) { if (err.isAuth) return; console.error('Failed to toggle test edge:', err); error.textContent = err.message || t('calibration.error.test_toggle_failed'); error.style.display = 'block'; } } async function clearTestMode(deviceId: any) { if (!calibrationTestState[deviceId] || calibrationTestState[deviceId].size === 0) return; calibrationTestState[deviceId] = new Set(); try { await fetch(`${API_BASE}/devices/${deviceId}/calibration/test`, { method: 'PUT', headers: getHeaders(), body: JSON.stringify({ edges: {} }) }); } catch (err) { console.error('Failed to clear test mode:', err); } } export async function saveCalibration() { const cssMode = _isCSS(); const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement).value; const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement).value; const error = document.getElementById('calibration-error') as HTMLElement; if (cssMode) { await _clearCSSTestMode(); } else { await clearTestMode(deviceId); } updateCalibrationPreview(); const topLeds = parseInt((document.getElementById('cal-top-leds') as HTMLInputElement).value || '0'); const rightLeds = parseInt((document.getElementById('cal-right-leds') as HTMLInputElement).value || '0'); const bottomLeds = parseInt((document.getElementById('cal-bottom-leds') as HTMLInputElement).value || '0'); const leftLeds = parseInt((document.getElementById('cal-left-leds') as HTMLInputElement).value || '0'); const total = topLeds + rightLeds + bottomLeds + leftLeds; const declaredLedCount = cssMode ? parseInt((document.getElementById('cal-css-led-count') as HTMLInputElement).value) || 0 : parseInt((document.getElementById('cal-device-led-count-inline') as HTMLElement).textContent!) || 0; if (!cssMode) { if (total !== declaredLedCount) { error.textContent = t('calibration.error.led_count_mismatch'); error.style.display = 'block'; return; } } else { if (declaredLedCount > 0 && total > declaredLedCount) { error.textContent = t('calibration.error.led_count_exceeded'); error.style.display = 'block'; return; } } const startPosition = (document.getElementById('cal-start-position') as HTMLSelectElement).value; const layout = (document.getElementById('cal-layout') as HTMLSelectElement).value; const offset = parseInt((document.getElementById('cal-offset') as HTMLInputElement).value || '0'); 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, span_right_start: spans.right?.start ?? 0, span_right_end: spans.right?.end ?? 1, span_bottom_start: spans.bottom?.start ?? 0, span_bottom_end: spans.bottom?.end ?? 1, span_left_start: spans.left?.start ?? 0, span_left_end: spans.left?.end ?? 1, skip_leds_start: parseInt((document.getElementById('cal-skip-start') as HTMLInputElement).value || '0'), skip_leds_end: parseInt((document.getElementById('cal-skip-end') as HTMLInputElement).value || '0'), border_width: parseInt((document.getElementById('cal-border-width') as HTMLInputElement).value) || 10, }; try { let response; if (cssMode) { response = await fetchWithAuth(`/color-strip-sources/${cssId}`, { method: 'PUT', body: JSON.stringify({ calibration, led_count: declaredLedCount }), }); } else { response = await fetchWithAuth(`/devices/${deviceId}/calibration`, { method: 'PUT', body: JSON.stringify(calibration), }); } if (response.ok) { showToast(t('calibration.saved'), 'success'); if (cssMode) colorStripSourcesCache.invalidate(); calibModal.forceClose(); if (cssMode) { if (window.loadTargetsTab) window.loadTargetsTab(); } else { window.loadDevices(); } } else { const errorData = await response.json(); const detail = errorData.detail || errorData.message || ''; const detailStr = Array.isArray(detail) ? detail.map((d: any) => d.msg || d).join('; ') : String(detail); error.textContent = detailStr || t('calibration.error.save_failed'); error.style.display = 'block'; } } catch (err: any) { if (err.isAuth) return; console.error('Failed to save calibration:', err); error.textContent = err.message || t('calibration.error.save_failed'); error.style.display = 'block'; } } function getEdgeOrder(startPosition: any, layout: any) { const orders: any = { 'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'], 'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'], 'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'], 'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'], 'top_left_clockwise': ['top', 'right', 'bottom', 'left'], 'top_left_counterclockwise': ['left', 'bottom', 'right', 'top'], 'top_right_clockwise': ['right', 'bottom', 'left', 'top'], 'top_right_counterclockwise': ['top', 'left', 'bottom', 'right'] }; return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom']; } function shouldReverse(edge: any, startPosition: any, layout: any) { const reverseRules: any = { 'bottom_left_clockwise': { left: true, top: false, right: false, bottom: true }, 'bottom_left_counterclockwise': { bottom: false, right: true, top: true, left: false }, 'bottom_right_clockwise': { bottom: true, left: true, top: false, right: false }, 'bottom_right_counterclockwise': { right: true, top: true, left: false, bottom: false }, 'top_left_clockwise': { top: false, right: false, bottom: true, left: true }, 'top_left_counterclockwise': { left: false, bottom: false, right: true, top: true }, 'top_right_clockwise': { right: false, bottom: true, left: true, top: false }, 'top_right_counterclockwise': { top: true, left: false, bottom: false, right: true } }; const rules = reverseRules[`${startPosition}_${layout}`]; return rules ? rules[edge] : false; } function buildSegments(calibration: any) { const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout); const edgeCounts: any = { top: calibration.leds_top || 0, right: calibration.leds_right || 0, bottom: calibration.leds_bottom || 0, left: calibration.leds_left || 0 }; const segments: any[] = []; let ledStart = calibration.offset || 0; edgeOrder.forEach((edge: any) => { const count = edgeCounts[edge]; if (count > 0) { segments.push({ edge, led_start: ledStart, led_count: count, reverse: shouldReverse(edge, calibration.start_position, calibration.layout) }); ledStart += count; } }); return segments; }