Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/calibration.ts
alexei.dolgolyov f2871319cb
Some checks failed
Lint & Test / test (push) Failing after 48s
refactor: comprehensive code quality, security, and release readiness improvements
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
2026-03-22 00:38:28 +03:00

1024 lines
45 KiB
TypeScript

/**
* 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<number>();
edgeBounds.add(0);
if (count > 1) edgeBounds.add(count - 1);
const specialTicks = new Set<number>();
if (offset > 0 && totalLeds > 0) {
const zeroPos = (totalLeds - seg.led_start % totalLeds) % totalLeds;
if (zeroPos < count) specialTicks.add(zeroPos);
}
const labelsToShow = new Set<number>([...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;
}