988 lines
41 KiB
JavaScript
988 lines
41 KiB
JavaScript
/**
|
|
* Calibration — calibration modal, canvas, drag handlers, edge test.
|
|
*/
|
|
|
|
import {
|
|
calibrationTestState, EDGE_TEST_COLORS,
|
|
} from '../core/state.js';
|
|
import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
|
|
import { showToast } from '../core/ui.js';
|
|
import { Modal } from '../core/modal.js';
|
|
import { closeTutorial, startCalibrationTutorial } from './tutorials.js';
|
|
|
|
/* ── CalibrationModal subclass ────────────────────────────────── */
|
|
|
|
class CalibrationModal extends Modal {
|
|
constructor() {
|
|
super('calibration-modal');
|
|
}
|
|
|
|
snapshotValues() {
|
|
return {
|
|
start_position: this.$('cal-start-position').value,
|
|
layout: this.$('cal-layout').value,
|
|
offset: this.$('cal-offset').value,
|
|
top: this.$('cal-top-leds').value,
|
|
right: this.$('cal-right-leds').value,
|
|
bottom: this.$('cal-bottom-leds').value,
|
|
left: this.$('cal-left-leds').value,
|
|
spans: JSON.stringify(window.edgeSpans),
|
|
skip_start: this.$('cal-skip-start').value,
|
|
skip_end: this.$('cal-skip-end').value,
|
|
border_width: this.$('cal-border-width').value,
|
|
led_count: this.$('cal-css-led-count').value,
|
|
};
|
|
}
|
|
|
|
onForceClose() {
|
|
closeTutorial();
|
|
if (_isCSS()) {
|
|
_clearCSSTestMode();
|
|
document.getElementById('calibration-css-id').value = '';
|
|
const testGroup = document.getElementById('calibration-css-test-group');
|
|
if (testGroup) testGroup.style.display = 'none';
|
|
} else {
|
|
const deviceId = this.$('calibration-device-id').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 = null;
|
|
let _previewRaf = null;
|
|
|
|
/* ── Helpers ──────────────────────────────────────────────────── */
|
|
|
|
function _isCSS() {
|
|
return !!(document.getElementById('calibration-css-id')?.value);
|
|
}
|
|
|
|
function _cssStateKey() {
|
|
return `css_${document.getElementById('calibration-css-id').value}`;
|
|
}
|
|
|
|
async function _clearCSSTestMode() {
|
|
const cssId = document.getElementById('calibration-css-id')?.value;
|
|
const stateKey = _cssStateKey();
|
|
if (!cssId || !calibrationTestState[stateKey] || calibrationTestState[stateKey].size === 0) return;
|
|
calibrationTestState[stateKey] = new Set();
|
|
const testDeviceId = document.getElementById('calibration-test-device')?.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);
|
|
}
|
|
}
|
|
|
|
/* ── Public API (exported names unchanged) ────────────────────── */
|
|
|
|
export async function showCalibration(deviceId) {
|
|
try {
|
|
const [response, displaysResponse] = await Promise.all([
|
|
fetchWithAuth(`/devices/${deviceId}`),
|
|
fetchWithAuth('/config/displays'),
|
|
]);
|
|
|
|
if (!response.ok) { showToast('Failed to load calibration', 'error'); return; }
|
|
|
|
const device = await response.json();
|
|
const calibration = device.calibration;
|
|
|
|
const preview = document.querySelector('.calibration-preview');
|
|
if (displaysResponse.ok) {
|
|
const displaysData = await displaysResponse.json();
|
|
const displayIndex = device.settings?.display_index ?? 0;
|
|
const display = (displaysData.displays || []).find(d => d.index === displayIndex);
|
|
if (display && display.width && display.height) {
|
|
preview.style.aspectRatio = `${display.width} / ${display.height}`;
|
|
} else {
|
|
preview.style.aspectRatio = '';
|
|
}
|
|
} else {
|
|
preview.style.aspectRatio = '';
|
|
}
|
|
|
|
document.getElementById('calibration-device-id').value = device.id;
|
|
document.getElementById('cal-device-led-count-inline').textContent = device.led_count;
|
|
document.getElementById('cal-css-led-count-group').style.display = 'none';
|
|
|
|
document.getElementById('cal-start-position').value = calibration.start_position;
|
|
document.getElementById('cal-layout').value = calibration.layout;
|
|
document.getElementById('cal-offset').value = calibration.offset || 0;
|
|
|
|
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
|
|
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
|
|
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
|
|
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
|
|
|
|
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
|
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
|
updateOffsetSkipLock();
|
|
|
|
document.getElementById('cal-border-width').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) {
|
|
if (error.isAuth) return;
|
|
console.error('Failed to load calibration:', error);
|
|
showToast('Failed to load calibration', '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) {
|
|
try {
|
|
const [cssResp, devicesResp] = await Promise.all([
|
|
fetchWithAuth(`/color-strip-sources/${cssId}`),
|
|
fetchWithAuth('/devices'),
|
|
]);
|
|
|
|
if (!cssResp.ok) { showToast('Failed to load color strip source', 'error'); return; }
|
|
const source = await cssResp.json();
|
|
const calibration = source.calibration || {};
|
|
|
|
// Set CSS mode — clear device-id, set css-id
|
|
document.getElementById('calibration-device-id').value = '';
|
|
document.getElementById('calibration-css-id').value = cssId;
|
|
|
|
// Populate device picker for edge test
|
|
const devices = devicesResp.ok ? ((await devicesResp.json()).devices || []) : [];
|
|
const testDeviceSelect = document.getElementById('calibration-test-device');
|
|
testDeviceSelect.innerHTML = '';
|
|
devices.forEach(d => {
|
|
const opt = document.createElement('option');
|
|
opt.value = d.id;
|
|
opt.textContent = d.name;
|
|
testDeviceSelect.appendChild(opt);
|
|
});
|
|
const testGroup = document.getElementById('calibration-css-test-group');
|
|
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 = null;
|
|
if (source.led_count > 0) {
|
|
selected = devices.find(d => d.led_count === source.led_count) || null;
|
|
}
|
|
if (!selected && rememberedId) {
|
|
selected = devices.find(d => 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');
|
|
preview.style.aspectRatio = '';
|
|
document.getElementById('cal-device-led-count-inline').textContent = '—';
|
|
const ledCountGroup = document.getElementById('cal-css-led-count-group');
|
|
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').value = source.led_count || calLeds || 0;
|
|
|
|
document.getElementById('cal-start-position').value = calibration.start_position || 'bottom_left';
|
|
document.getElementById('cal-layout').value = calibration.layout || 'clockwise';
|
|
document.getElementById('cal-offset').value = calibration.offset || 0;
|
|
|
|
document.getElementById('cal-top-leds').value = calibration.leds_top || 0;
|
|
document.getElementById('cal-right-leds').value = calibration.leds_right || 0;
|
|
document.getElementById('cal-bottom-leds').value = calibration.leds_bottom || 0;
|
|
document.getElementById('cal-left-leds').value = calibration.leds_left || 0;
|
|
|
|
document.getElementById('cal-skip-start').value = calibration.skip_leds_start || 0;
|
|
document.getElementById('cal-skip-end').value = calibration.skip_leds_end || 0;
|
|
updateOffsetSkipLock();
|
|
|
|
document.getElementById('cal-border-width').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[_cssStateKey()] = new Set();
|
|
|
|
updateCalibrationPreview();
|
|
|
|
calibModal.snapshot();
|
|
calibModal.open();
|
|
|
|
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) {
|
|
if (error.isAuth) return;
|
|
console.error('Failed to load CSS calibration:', error);
|
|
showToast('Failed to load calibration', 'error');
|
|
}
|
|
}
|
|
|
|
export function updateOffsetSkipLock() {
|
|
const offsetEl = document.getElementById('cal-offset');
|
|
const skipStartEl = document.getElementById('cal-skip-start');
|
|
const skipEndEl = document.getElementById('cal-skip-end');
|
|
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').value || 0) +
|
|
parseInt(document.getElementById('cal-right-leds').value || 0) +
|
|
parseInt(document.getElementById('cal-bottom-leds').value || 0) +
|
|
parseInt(document.getElementById('cal-left-leds').value || 0);
|
|
const totalEl = document.querySelector('.preview-screen-total');
|
|
const inCSS = _isCSS();
|
|
const declaredCount = inCSS
|
|
? parseInt(document.getElementById('cal-css-led-count').value || 0)
|
|
: parseInt(document.getElementById('cal-device-led-count-inline').textContent || 0);
|
|
if (inCSS) {
|
|
document.getElementById('cal-device-led-count-inline').textContent = 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').textContent = (mismatch ? '\u26A0 ' : '') + total;
|
|
if (totalEl) totalEl.classList.toggle('mismatch', mismatch);
|
|
|
|
const startPos = document.getElementById('cal-start-position').value;
|
|
['top_left', 'top_right', 'bottom_left', 'bottom_right'].forEach(corner => {
|
|
const cornerEl = document.querySelector(`.preview-corner.corner-${corner.replace('_', '-')}`);
|
|
if (cornerEl) {
|
|
if (corner === startPos) cornerEl.classList.add('active');
|
|
else cornerEl.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
const direction = document.getElementById('cal-layout').value;
|
|
const dirIcon = document.getElementById('direction-icon');
|
|
const dirLabel = document.getElementById('direction-label');
|
|
if (dirIcon) dirIcon.textContent = direction === 'clockwise' ? '↻' : '↺';
|
|
if (dirLabel) dirLabel.textContent = direction === 'clockwise' ? 'CW' : 'CCW';
|
|
|
|
const deviceId = document.getElementById('calibration-device-id').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}`);
|
|
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`).value) || 0;
|
|
const edgeEl = document.querySelector(`.preview-edge.edge-${edge}`);
|
|
const toggleEl = document.querySelector(`.edge-toggle.toggle-${edge}`);
|
|
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');
|
|
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').value;
|
|
const layout = document.getElementById('cal-layout').value;
|
|
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
|
const calibration = {
|
|
start_position: startPos,
|
|
layout: layout,
|
|
offset: offset,
|
|
leds_top: parseInt(document.getElementById('cal-top-leds').value || 0),
|
|
leds_right: parseInt(document.getElementById('cal-right-leds').value || 0),
|
|
leds_bottom: parseInt(document.getElementById('cal-bottom-leds').value || 0),
|
|
leds_left: parseInt(document.getElementById('cal-left-leds').value || 0),
|
|
};
|
|
const skipStart = parseInt(document.getElementById('cal-skip-start').value || 0);
|
|
const skipEnd = parseInt(document.getElementById('cal-skip-end').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 = {
|
|
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 = {
|
|
top: oy - toggleSize - 3,
|
|
bottom: oy + cH + toggleSize + 3,
|
|
left: ox - toggleSize - 3,
|
|
right: ox + cW + toggleSize + 3,
|
|
};
|
|
|
|
const arrowInset = 12;
|
|
const arrowPos = {
|
|
top: oy + ch + arrowInset,
|
|
bottom: oy + cH - ch - arrowInset,
|
|
left: ox + cw + arrowInset,
|
|
right: ox + cW - cw - arrowInset,
|
|
};
|
|
|
|
segments.forEach(seg => {
|
|
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) => {
|
|
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]);
|
|
const tickLinesOnly = new Set();
|
|
|
|
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 => {
|
|
const f = i / (count - 1);
|
|
return (seg.reverse ? (1 - f) : f) * edgeLen;
|
|
};
|
|
|
|
const placed = [];
|
|
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;
|
|
const px = tickPx(bi);
|
|
if (placed.some(p => Math.abs(px - p) < minSpacing)) {
|
|
tickLinesOnly.add(bi);
|
|
} else {
|
|
labelsToShow.add(bi);
|
|
placed.push(px);
|
|
}
|
|
});
|
|
} 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);
|
|
}
|
|
});
|
|
|
|
tickLinesOnly.forEach(i => {
|
|
const fraction = count > 1 ? i / (count - 1) : 0.5;
|
|
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
|
if (geo.horizontal) {
|
|
const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
|
const axisY = axisPos[seg.edge];
|
|
const tickDir = seg.edge === 'top' ? 1 : -1;
|
|
ctx.beginPath(); ctx.moveTo(tx, axisY); ctx.lineTo(tx, axisY + tickDir * tickLenLong); ctx.stroke();
|
|
} else {
|
|
const ty = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
|
const axisX = axisPos[seg.edge];
|
|
const tickDir = seg.edge === 'left' ? 1 : -1;
|
|
ctx.beginPath(); ctx.moveTo(axisX, ty); ctx.lineTo(axisX + tickDir * tickLenLong, ty); ctx.stroke();
|
|
}
|
|
});
|
|
|
|
const s = 7;
|
|
let mx, my, angle;
|
|
if (geo.horizontal) {
|
|
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');
|
|
['top', 'right', 'bottom', 'left'].forEach(edge => {
|
|
const bar = document.querySelector(`.edge-span-bar[data-edge="${edge}"]`);
|
|
if (!bar) return;
|
|
const span = spans[edge] || { start: 0, end: 1 };
|
|
const edgeEl = bar.parentElement;
|
|
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}`);
|
|
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.dataset.edge;
|
|
const isHorizontal = (edge === 'top' || edge === 'bottom');
|
|
|
|
bar.addEventListener('click', e => e.stopPropagation());
|
|
|
|
bar.querySelectorAll('.edge-span-handle').forEach(handle => {
|
|
handle.addEventListener('mousedown', e => {
|
|
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
|
if (edgeLeds === 0) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const handleType = handle.dataset.handle;
|
|
const edgeEl = bar.parentElement;
|
|
const rect = edgeEl.getBoundingClientRect();
|
|
|
|
function onMouseMove(ev) {
|
|
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 => {
|
|
if (e.target.classList.contains('edge-span-handle')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const edgeEl = bar.parentElement;
|
|
const rect = edgeEl.getBoundingClientRect();
|
|
const span = window.edgeSpans[edge];
|
|
const spanWidth = span.end - span.start;
|
|
|
|
let startFraction;
|
|
if (isHorizontal) startFraction = (e.clientX - rect.left) / rect.width;
|
|
else startFraction = (e.clientY - rect.top) / rect.height;
|
|
const offsetInSpan = startFraction - span.start;
|
|
|
|
function onMouseMove(ev) {
|
|
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) {
|
|
document.getElementById('cal-start-position').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');
|
|
select.value = select.value === 'clockwise' ? 'counterclockwise' : 'clockwise';
|
|
updateCalibrationPreview();
|
|
}
|
|
|
|
export async function toggleTestEdge(edge) {
|
|
const edgeLeds = parseInt(document.getElementById(`cal-${edge}-leds`).value) || 0;
|
|
if (edgeLeds === 0) return;
|
|
|
|
const error = document.getElementById('calibration-error');
|
|
|
|
if (_isCSS()) {
|
|
const cssId = document.getElementById('calibration-css-id').value;
|
|
const testDeviceId = document.getElementById('calibration-test-device')?.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 = {};
|
|
calibrationTestState[stateKey].forEach(e => { 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();
|
|
error.textContent = `Test failed: ${errorData.detail}`;
|
|
error.style.display = 'block';
|
|
}
|
|
} catch (err) {
|
|
if (err.isAuth) return;
|
|
console.error('Failed to toggle CSS test edge:', err);
|
|
error.textContent = 'Failed to toggle test edge';
|
|
error.style.display = 'block';
|
|
}
|
|
return;
|
|
}
|
|
|
|
const deviceId = document.getElementById('calibration-device-id').value;
|
|
if (!calibrationTestState[deviceId]) calibrationTestState[deviceId] = new Set();
|
|
|
|
if (calibrationTestState[deviceId].has(edge)) calibrationTestState[deviceId].delete(edge);
|
|
else calibrationTestState[deviceId].add(edge);
|
|
|
|
const edges = {};
|
|
calibrationTestState[deviceId].forEach(e => { 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();
|
|
error.textContent = `Test failed: ${errorData.detail}`;
|
|
error.style.display = 'block';
|
|
}
|
|
} catch (err) {
|
|
if (err.isAuth) return;
|
|
console.error('Failed to toggle test edge:', err);
|
|
error.textContent = 'Failed to toggle test edge';
|
|
error.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
async function clearTestMode(deviceId) {
|
|
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').value;
|
|
const cssId = document.getElementById('calibration-css-id').value;
|
|
const error = document.getElementById('calibration-error');
|
|
|
|
if (cssMode) {
|
|
await _clearCSSTestMode();
|
|
} else {
|
|
await clearTestMode(deviceId);
|
|
}
|
|
updateCalibrationPreview();
|
|
|
|
const topLeds = parseInt(document.getElementById('cal-top-leds').value || 0);
|
|
const rightLeds = parseInt(document.getElementById('cal-right-leds').value || 0);
|
|
const bottomLeds = parseInt(document.getElementById('cal-bottom-leds').value || 0);
|
|
const leftLeds = parseInt(document.getElementById('cal-left-leds').value || 0);
|
|
const total = topLeds + rightLeds + bottomLeds + leftLeds;
|
|
|
|
const declaredLedCount = cssMode
|
|
? parseInt(document.getElementById('cal-css-led-count').value) || 0
|
|
: parseInt(document.getElementById('cal-device-led-count-inline').textContent) || 0;
|
|
if (!cssMode) {
|
|
if (total !== declaredLedCount) {
|
|
error.textContent = `Total LEDs (${total}) must equal device LED count (${declaredLedCount})`;
|
|
error.style.display = 'block';
|
|
return;
|
|
}
|
|
} else {
|
|
if (declaredLedCount > 0 && total > declaredLedCount) {
|
|
error.textContent = `Calibrated LEDs (${total}) exceed total LED count (${declaredLedCount})`;
|
|
error.style.display = 'block';
|
|
return;
|
|
}
|
|
}
|
|
|
|
const startPosition = document.getElementById('cal-start-position').value;
|
|
const layout = document.getElementById('cal-layout').value;
|
|
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
|
|
|
const spans = window.edgeSpans || {};
|
|
const calibration = {
|
|
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').value || 0),
|
|
skip_leds_end: parseInt(document.getElementById('cal-skip-end').value || 0),
|
|
border_width: parseInt(document.getElementById('cal-border-width').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('Calibration saved', 'success');
|
|
calibModal.forceClose();
|
|
if (cssMode) {
|
|
if (window.loadTargetsTab) window.loadTargetsTab();
|
|
} else {
|
|
window.loadDevices();
|
|
}
|
|
} else {
|
|
const errorData = await response.json();
|
|
error.textContent = `Failed to save: ${errorData.detail}`;
|
|
error.style.display = 'block';
|
|
}
|
|
} catch (err) {
|
|
if (err.isAuth) return;
|
|
console.error('Failed to save calibration:', err);
|
|
error.textContent = 'Failed to save calibration';
|
|
error.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function getEdgeOrder(startPosition, layout) {
|
|
const orders = {
|
|
'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, startPosition, layout) {
|
|
const reverseRules = {
|
|
'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) {
|
|
const edgeOrder = getEdgeOrder(calibration.start_position, calibration.layout);
|
|
const edgeCounts = {
|
|
top: calibration.leds_top || 0,
|
|
right: calibration.leds_right || 0,
|
|
bottom: calibration.leds_bottom || 0,
|
|
left: calibration.leds_left || 0
|
|
};
|
|
|
|
const segments = [];
|
|
let ledStart = calibration.offset || 0;
|
|
|
|
edgeOrder.forEach(edge => {
|
|
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;
|
|
}
|