Simplify calibration model, add pixel preview, and improve UI
Some checks failed
Validate / validate (push) Failing after 9s
Some checks failed
Validate / validate (push) Failing after 9s
- Replace segment-based calibration with core parameters (leds_top/right/bottom/left); segments are now derived at runtime via lookup tables - Fix clockwise/counterclockwise edge traversal order for all 8 start_position/layout combinations (e.g. bottom_left+clockwise now correctly goes up-left first) - Add pixel layout preview overlay with color-coded edges, LED index labels, direction arrows, and start position marker - Move "Add New Device" form into a modal dialog triggered by "+" button - Add display index selector to device settings modal - Migrate from requirements.txt to pyproject.toml for dependency management - Update Dockerfile and docs to use `pip install .` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -562,6 +562,9 @@ function createDeviceCard(device) {
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||||
📐
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showPixelPreview('${device.id}')" title="${t('preview.button')}">
|
||||
👁️
|
||||
</button>
|
||||
<button class="btn btn-icon btn-danger" onclick="removeDevice('${device.id}')" title="${t('device.button.remove')}">
|
||||
🗑️
|
||||
</button>
|
||||
@@ -663,34 +666,55 @@ async function removeDevice(deviceId) {
|
||||
|
||||
async function showSettings(deviceId) {
|
||||
try {
|
||||
// Fetch current device data
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
headers: getHeaders()
|
||||
});
|
||||
// Fetch device data and displays in parallel
|
||||
const [deviceResponse, displaysResponse] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||||
]);
|
||||
|
||||
if (response.status === 401) {
|
||||
if (deviceResponse.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!deviceResponse.ok) {
|
||||
showToast('Failed to load device settings', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await response.json();
|
||||
const device = await deviceResponse.json();
|
||||
|
||||
// Populate modal
|
||||
// Populate display index select
|
||||
const displaySelect = document.getElementById('settings-display-index');
|
||||
displaySelect.innerHTML = '';
|
||||
if (displaysResponse.ok) {
|
||||
const displaysData = await displaysResponse.json();
|
||||
(displaysData.displays || []).forEach(d => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.index;
|
||||
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
|
||||
displaySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (displaySelect.options.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '0';
|
||||
opt.textContent = '0';
|
||||
displaySelect.appendChild(opt);
|
||||
}
|
||||
displaySelect.value = String(device.settings.display_index ?? 0);
|
||||
|
||||
// Populate other fields
|
||||
document.getElementById('settings-device-id').value = device.id;
|
||||
document.getElementById('settings-device-name').value = device.name;
|
||||
document.getElementById('settings-device-url').value = device.url;
|
||||
// Set health check interval
|
||||
document.getElementById('settings-health-interval').value = device.settings.state_check_interval || 30;
|
||||
|
||||
// Snapshot initial values for dirty checking
|
||||
settingsInitialValues = {
|
||||
name: device.name,
|
||||
url: device.url,
|
||||
display_index: String(device.settings.display_index ?? 0),
|
||||
state_check_interval: String(device.settings.state_check_interval || 30),
|
||||
};
|
||||
|
||||
@@ -714,6 +738,7 @@ function isSettingsDirty() {
|
||||
return (
|
||||
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
|
||||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
|
||||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
|
||||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
|
||||
);
|
||||
}
|
||||
@@ -739,6 +764,7 @@ async function saveDeviceSettings() {
|
||||
const deviceId = document.getElementById('settings-device-id').value;
|
||||
const name = document.getElementById('settings-device-name').value.trim();
|
||||
const url = document.getElementById('settings-device-url').value.trim();
|
||||
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
|
||||
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
|
||||
const error = document.getElementById('settings-error');
|
||||
|
||||
@@ -773,7 +799,7 @@ async function saveDeviceSettings() {
|
||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ state_check_interval })
|
||||
body: JSON.stringify({ display_index, state_check_interval })
|
||||
});
|
||||
|
||||
if (settingsResponse.status === 401) {
|
||||
@@ -817,14 +843,36 @@ async function saveCardBrightness(deviceId, value) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add device form handler
|
||||
// Add device modal
|
||||
function showAddDevice() {
|
||||
const modal = document.getElementById('add-device-modal');
|
||||
const form = document.getElementById('add-device-form');
|
||||
const error = document.getElementById('add-device-error');
|
||||
form.reset();
|
||||
error.style.display = 'none';
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
setTimeout(() => document.getElementById('device-name').focus(), 100);
|
||||
}
|
||||
|
||||
function closeAddDeviceModal() {
|
||||
const modal = document.getElementById('add-device-modal');
|
||||
modal.style.display = 'none';
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
async function handleAddDevice(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const name = document.getElementById('device-name').value;
|
||||
const url = document.getElementById('device-url').value;
|
||||
const name = document.getElementById('device-name').value.trim();
|
||||
const url = document.getElementById('device-url').value.trim();
|
||||
const error = document.getElementById('add-device-error');
|
||||
|
||||
console.log(`Adding device: ${name} (${url})`);
|
||||
if (!name || !url) {
|
||||
error.textContent = 'Please fill in all fields';
|
||||
error.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices`, {
|
||||
@@ -842,15 +890,16 @@ async function handleAddDevice(event) {
|
||||
const result = await response.json();
|
||||
console.log('Device added successfully:', result);
|
||||
showToast('Device added successfully', 'success');
|
||||
event.target.reset();
|
||||
closeAddDeviceModal();
|
||||
loadDevices();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to add device:', error);
|
||||
showToast(`Failed to add device: ${error.detail}`, 'error');
|
||||
const errorData = await response.json();
|
||||
console.error('Failed to add device:', errorData);
|
||||
error.textContent = `Failed to add device: ${errorData.detail}`;
|
||||
error.style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add device:', error);
|
||||
} catch (err) {
|
||||
console.error('Failed to add device:', err);
|
||||
showToast('Failed to add device', 'error');
|
||||
}
|
||||
}
|
||||
@@ -945,25 +994,20 @@ async function showCalibration(deviceId) {
|
||||
document.getElementById('cal-offset').value = calibration.offset || 0;
|
||||
|
||||
// Set LED counts per edge
|
||||
const edgeCounts = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
calibration.segments.forEach(seg => {
|
||||
edgeCounts[seg.edge] = seg.led_count;
|
||||
});
|
||||
|
||||
document.getElementById('cal-top-leds').value = edgeCounts.top;
|
||||
document.getElementById('cal-right-leds').value = edgeCounts.right;
|
||||
document.getElementById('cal-bottom-leds').value = edgeCounts.bottom;
|
||||
document.getElementById('cal-left-leds').value = edgeCounts.left;
|
||||
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;
|
||||
|
||||
// Snapshot initial values for dirty checking
|
||||
calibrationInitialValues = {
|
||||
start_position: calibration.start_position,
|
||||
layout: calibration.layout,
|
||||
offset: String(calibration.offset || 0),
|
||||
top: String(edgeCounts.top),
|
||||
right: String(edgeCounts.right),
|
||||
bottom: String(edgeCounts.bottom),
|
||||
left: String(edgeCounts.left),
|
||||
top: String(calibration.leds_top || 0),
|
||||
right: String(calibration.leds_right || 0),
|
||||
bottom: String(calibration.leds_bottom || 0),
|
||||
left: String(calibration.leds_left || 0),
|
||||
};
|
||||
|
||||
// Initialize test mode state for this device
|
||||
@@ -1167,40 +1211,16 @@ async function saveCalibration() {
|
||||
// Build calibration config
|
||||
const startPosition = document.getElementById('cal-start-position').value;
|
||||
const layout = document.getElementById('cal-layout').value;
|
||||
|
||||
// Build segments based on start position and direction
|
||||
const segments = [];
|
||||
let ledStart = 0;
|
||||
|
||||
const edgeOrder = getEdgeOrder(startPosition, layout);
|
||||
|
||||
const edgeCounts = {
|
||||
top: topLeds,
|
||||
right: rightLeds,
|
||||
bottom: bottomLeds,
|
||||
left: leftLeds
|
||||
};
|
||||
|
||||
edgeOrder.forEach(edge => {
|
||||
const count = edgeCounts[edge];
|
||||
if (count > 0) {
|
||||
segments.push({
|
||||
edge: edge,
|
||||
led_start: ledStart,
|
||||
led_count: count,
|
||||
reverse: shouldReverse(edge, startPosition, layout)
|
||||
});
|
||||
ledStart += count;
|
||||
}
|
||||
});
|
||||
|
||||
const offset = parseInt(document.getElementById('cal-offset').value || 0);
|
||||
|
||||
const calibration = {
|
||||
layout: layout,
|
||||
start_position: startPosition,
|
||||
offset: offset,
|
||||
segments: segments
|
||||
leds_top: topLeds,
|
||||
leds_right: rightLeds,
|
||||
leds_bottom: bottomLeds,
|
||||
leds_left: leftLeds
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1232,43 +1252,465 @@ async function saveCalibration() {
|
||||
}
|
||||
|
||||
function getEdgeOrder(startPosition, layout) {
|
||||
const clockwise = ['bottom', 'right', 'top', 'left'];
|
||||
const counterclockwise = ['bottom', 'left', 'top', 'right'];
|
||||
|
||||
const orders = {
|
||||
'bottom_left_clockwise': clockwise,
|
||||
'bottom_left_counterclockwise': counterclockwise,
|
||||
'bottom_left_clockwise': ['left', 'top', 'right', 'bottom'],
|
||||
'bottom_left_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||||
'bottom_right_clockwise': ['bottom', 'left', 'top', 'right'],
|
||||
'bottom_right_counterclockwise': ['bottom', 'right', 'top', 'left'],
|
||||
'bottom_right_counterclockwise': ['right', 'top', 'left', 'bottom'],
|
||||
'top_left_clockwise': ['top', 'right', 'bottom', 'left'],
|
||||
'top_left_counterclockwise': ['top', 'left', 'bottom', 'right'],
|
||||
'top_right_clockwise': ['top', 'left', 'bottom', 'right'],
|
||||
'top_right_counterclockwise': ['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}`] || clockwise;
|
||||
return orders[`${startPosition}_${layout}`] || ['left', 'top', 'right', 'bottom'];
|
||||
}
|
||||
|
||||
function shouldReverse(edge, startPosition, layout) {
|
||||
// Determine if this edge should be reversed based on LED strip direction
|
||||
const reverseRules = {
|
||||
'bottom_left_clockwise': { bottom: false, right: false, top: true, left: true },
|
||||
'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, right: false, top: false, left: true },
|
||||
'bottom_right_counterclockwise': { bottom: true, right: true, top: false, 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': { top: false, right: true, bottom: true, left: false },
|
||||
'top_right_clockwise': { top: true, right: false, bottom: false, left: true },
|
||||
'top_right_counterclockwise': { top: true, right: true, bottom: false, left: false }
|
||||
'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;
|
||||
}
|
||||
|
||||
// Close modals on backdrop click
|
||||
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 = 0;
|
||||
|
||||
edgeOrder.forEach(edge => {
|
||||
const count = edgeCounts[edge];
|
||||
if (count > 0) {
|
||||
segments.push({
|
||||
edge: edge,
|
||||
led_start: ledStart,
|
||||
led_count: count,
|
||||
reverse: shouldReverse(edge, calibration.start_position, calibration.layout)
|
||||
});
|
||||
ledStart += count;
|
||||
}
|
||||
});
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Pixel Layout Preview functions
|
||||
|
||||
async function showPixelPreview(deviceId) {
|
||||
try {
|
||||
const [deviceResponse, displaysResponse] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||||
]);
|
||||
|
||||
if (deviceResponse.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceResponse.ok) {
|
||||
showToast('Failed to load device data', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await deviceResponse.json();
|
||||
const calibration = device.calibration;
|
||||
|
||||
const totalLeds = (calibration?.leds_top || 0) + (calibration?.leds_right || 0) +
|
||||
(calibration?.leds_bottom || 0) + (calibration?.leds_left || 0);
|
||||
if (!calibration || totalLeds === 0) {
|
||||
showToast(t('preview.no_calibration'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive segments from core parameters
|
||||
calibration.segments = buildSegments(calibration);
|
||||
|
||||
let displayWidth = 1920;
|
||||
let displayHeight = 1080;
|
||||
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) {
|
||||
displayWidth = display.width;
|
||||
displayHeight = display.height;
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = document.getElementById('pixel-preview-overlay');
|
||||
overlay.style.display = 'flex';
|
||||
lockBody();
|
||||
|
||||
document.getElementById('pixel-preview-device-name').textContent =
|
||||
`${device.name} (${device.led_count} LEDs)`;
|
||||
|
||||
buildPreviewLegend(calibration);
|
||||
|
||||
// Render after layout settles
|
||||
requestAnimationFrame(() => {
|
||||
renderPixelPreview(calibration, displayWidth, displayHeight);
|
||||
});
|
||||
|
||||
overlay._resizeHandler = () => {
|
||||
renderPixelPreview(calibration, displayWidth, displayHeight);
|
||||
};
|
||||
window.addEventListener('resize', overlay._resizeHandler);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to show pixel preview:', error);
|
||||
showToast('Failed to load pixel preview', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closePixelPreview() {
|
||||
const overlay = document.getElementById('pixel-preview-overlay');
|
||||
overlay.style.display = 'none';
|
||||
unlockBody();
|
||||
|
||||
if (overlay._resizeHandler) {
|
||||
window.removeEventListener('resize', overlay._resizeHandler);
|
||||
overlay._resizeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPreviewLegend(calibration) {
|
||||
const legendContainer = document.getElementById('pixel-preview-legend');
|
||||
const edgeNames = {
|
||||
top: t('preview.edge.top'),
|
||||
right: t('preview.edge.right'),
|
||||
bottom: t('preview.edge.bottom'),
|
||||
left: t('preview.edge.left')
|
||||
};
|
||||
|
||||
const items = calibration.segments.map(seg => {
|
||||
const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128];
|
||||
const last = seg.led_start + seg.led_count - 1;
|
||||
return `<div class="pixel-preview-legend-item">
|
||||
<div class="pixel-preview-legend-swatch" style="background: rgb(${r},${g},${b})"></div>
|
||||
${edgeNames[seg.edge] || seg.edge}: ${seg.led_count} LEDs (#${seg.led_start}\u2013${last})
|
||||
</div>`;
|
||||
});
|
||||
|
||||
const dirText = calibration.layout === 'clockwise' ? t('preview.direction.cw') : t('preview.direction.ccw');
|
||||
items.push(`<div class="pixel-preview-legend-item">
|
||||
${calibration.layout === 'clockwise' ? '\u21BB' : '\u21BA'} ${dirText}
|
||||
</div>`);
|
||||
|
||||
if (calibration.offset > 0) {
|
||||
items.push(`<div class="pixel-preview-legend-item">
|
||||
\u2194 ${t('preview.offset_leds', { count: calibration.offset })}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
legendContainer.innerHTML = items.join('');
|
||||
}
|
||||
|
||||
function renderPixelPreview(calibration, displayWidth, displayHeight) {
|
||||
const canvas = document.getElementById('pixel-preview-canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const W = rect.width;
|
||||
const H = rect.height;
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#111111';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Calculate screen rectangle with proper aspect ratio
|
||||
const padding = 80;
|
||||
const maxScreenW = W - padding * 2;
|
||||
const maxScreenH = H - padding * 2;
|
||||
if (maxScreenW <= 0 || maxScreenH <= 0) return;
|
||||
|
||||
const displayAspect = displayWidth / displayHeight;
|
||||
let screenW, screenH;
|
||||
|
||||
if (maxScreenW / maxScreenH > displayAspect) {
|
||||
screenH = maxScreenH;
|
||||
screenW = screenH * displayAspect;
|
||||
} else {
|
||||
screenW = maxScreenW;
|
||||
screenH = screenW / displayAspect;
|
||||
}
|
||||
|
||||
const screenX = (W - screenW) / 2;
|
||||
const screenY = (H - screenH) / 2;
|
||||
|
||||
// Draw screen rectangle
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(screenX, screenY, screenW, screenH);
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(screenX, screenY, screenW, screenH);
|
||||
|
||||
// Screen label
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = `${Math.min(24, screenH * 0.06)}px -apple-system, BlinkMacSystemFont, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`${displayWidth}\u00D7${displayHeight}`, screenX + screenW / 2, screenY + screenH / 2);
|
||||
|
||||
// LED rendering config
|
||||
const maxEdgeLeds = Math.max(...calibration.segments.map(s => s.led_count), 1);
|
||||
const ledMarkerSize = Math.max(2, Math.min(8, 500 / maxEdgeLeds));
|
||||
const stripOffset = ledMarkerSize + 10;
|
||||
|
||||
// Edge geometry
|
||||
const edgeGeometry = {
|
||||
top: { x1: screenX, y1: screenY, x2: screenX + screenW, y2: screenY, horizontal: true, outside: -1 },
|
||||
bottom: { x1: screenX, y1: screenY + screenH, x2: screenX + screenW, y2: screenY + screenH, horizontal: true, outside: 1 },
|
||||
left: { x1: screenX, y1: screenY, x2: screenX, y2: screenY + screenH, horizontal: false, outside: -1 },
|
||||
right: { x1: screenX + screenW, y1: screenY, x2: screenX + screenW, y2: screenY + screenH, horizontal: false, outside: 1 },
|
||||
};
|
||||
|
||||
// Draw each segment's LEDs
|
||||
calibration.segments.forEach(seg => {
|
||||
const [r, g, b] = EDGE_TEST_COLORS[seg.edge] || [128, 128, 128];
|
||||
const geo = edgeGeometry[seg.edge];
|
||||
if (!geo) return;
|
||||
|
||||
const positions = [];
|
||||
|
||||
for (let i = 0; i < seg.led_count; i++) {
|
||||
const fraction = seg.led_count > 1 ? i / (seg.led_count - 1) : 0.5;
|
||||
const displayFraction = seg.reverse ? (1 - fraction) : fraction;
|
||||
|
||||
let cx, cy;
|
||||
if (geo.horizontal) {
|
||||
cx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
|
||||
cy = geo.y1 + geo.outside * stripOffset;
|
||||
} else {
|
||||
cx = geo.x1 + geo.outside * stripOffset;
|
||||
cy = geo.y1 + displayFraction * (geo.y2 - geo.y1);
|
||||
}
|
||||
|
||||
positions.push({ cx, cy, ledIndex: seg.led_start + i });
|
||||
|
||||
// Draw LED marker
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},0.85)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, ledMarkerSize / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Draw LED index labels
|
||||
drawPreviewLedLabels(ctx, positions, ledMarkerSize, geo);
|
||||
});
|
||||
|
||||
// Draw start position marker
|
||||
drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH);
|
||||
|
||||
// Draw direction arrows
|
||||
drawPreviewDirectionArrows(ctx, calibration, edgeGeometry);
|
||||
|
||||
// Draw offset indicator
|
||||
if (calibration.offset > 0) {
|
||||
drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPreviewLedLabels(ctx, positions, markerSize, geo) {
|
||||
if (positions.length === 0) return;
|
||||
|
||||
const labelFontSize = Math.max(9, Math.min(12, 200 / Math.sqrt(positions.length)));
|
||||
ctx.font = `${labelFontSize}px -apple-system, BlinkMacSystemFont, sans-serif`;
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Adaptive label interval
|
||||
const count = positions.length;
|
||||
const labelInterval = count <= 20 ? 1
|
||||
: count <= 50 ? 5
|
||||
: count <= 100 ? 10
|
||||
: count <= 200 ? 25
|
||||
: 50;
|
||||
|
||||
const labelsToShow = new Set();
|
||||
labelsToShow.add(0);
|
||||
labelsToShow.add(count - 1);
|
||||
for (let i = labelInterval; i < count - 1; i += labelInterval) {
|
||||
labelsToShow.add(i);
|
||||
}
|
||||
|
||||
const labelOffset = markerSize / 2 + labelFontSize;
|
||||
|
||||
labelsToShow.forEach(i => {
|
||||
const pos = positions[i];
|
||||
const label = String(pos.ledIndex);
|
||||
|
||||
if (geo.horizontal) {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(label, pos.cx, pos.cy + geo.outside * labelOffset);
|
||||
} else {
|
||||
ctx.textAlign = geo.outside < 0 ? 'right' : 'left';
|
||||
ctx.fillText(label, pos.cx + geo.outside * labelOffset, pos.cy);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawPreviewStartPosition(ctx, calibration, screenX, screenY, screenW, screenH) {
|
||||
const corners = {
|
||||
top_left: { x: screenX, y: screenY },
|
||||
top_right: { x: screenX + screenW, y: screenY },
|
||||
bottom_left: { x: screenX, y: screenY + screenH },
|
||||
bottom_right: { x: screenX + screenW, y: screenY + screenH },
|
||||
};
|
||||
|
||||
const corner = corners[calibration.start_position];
|
||||
if (!corner) return;
|
||||
|
||||
// Green diamond
|
||||
const size = 10;
|
||||
ctx.save();
|
||||
ctx.translate(corner.x, corner.y);
|
||||
ctx.rotate(Math.PI / 4);
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
ctx.shadowColor = 'rgba(76, 175, 80, 0.6)';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fillRect(-size / 2, -size / 2, size, size);
|
||||
ctx.restore();
|
||||
|
||||
// START label
|
||||
ctx.save();
|
||||
ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const lx = calibration.start_position.includes('left') ? -28 : 28;
|
||||
const ly = calibration.start_position.includes('top') ? -18 : 18;
|
||||
ctx.fillText(t('preview.start'), corner.x + lx, corner.y + ly);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPreviewDirectionArrows(ctx, calibration, edgeGeometry) {
|
||||
const arrowSize = 8;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
|
||||
calibration.segments.forEach(seg => {
|
||||
const geo = edgeGeometry[seg.edge];
|
||||
if (!geo) return;
|
||||
|
||||
// Midpoint of edge, shifted outside
|
||||
const midFraction = 0.5;
|
||||
let mx, my;
|
||||
if (geo.horizontal) {
|
||||
mx = geo.x1 + midFraction * (geo.x2 - geo.x1);
|
||||
my = geo.y1 + geo.outside * (arrowSize + 20);
|
||||
} else {
|
||||
mx = geo.x1 + geo.outside * (arrowSize + 20);
|
||||
my = geo.y1 + midFraction * (geo.y2 - geo.y1);
|
||||
}
|
||||
|
||||
// Direction based on edge and reverse
|
||||
let angle;
|
||||
if (geo.horizontal) {
|
||||
angle = seg.reverse ? Math.PI : 0; // left or right
|
||||
} else {
|
||||
angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2; // up or down
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(mx, my);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(arrowSize, 0);
|
||||
ctx.lineTo(-arrowSize / 2, -arrowSize / 2);
|
||||
ctx.lineTo(-arrowSize / 2, arrowSize / 2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPreviewOffsetIndicator(ctx, calibration, screenX, screenY, screenW, screenH) {
|
||||
const corners = {
|
||||
top_left: { x: screenX, y: screenY },
|
||||
top_right: { x: screenX + screenW, y: screenY },
|
||||
bottom_left: { x: screenX, y: screenY + screenH },
|
||||
bottom_right: { x: screenX + screenW, y: screenY + screenH },
|
||||
};
|
||||
|
||||
const corner = corners[calibration.start_position];
|
||||
if (!corner) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
|
||||
ctx.fillStyle = '#ff9800';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const ox = calibration.start_position.includes('left') ? -45 : 45;
|
||||
const oy = calibration.start_position.includes('top') ? -35 : 35;
|
||||
ctx.fillText(
|
||||
t('preview.offset_leds', { count: calibration.offset }),
|
||||
corner.x + ox,
|
||||
corner.y + oy
|
||||
);
|
||||
|
||||
// Dashed arc
|
||||
ctx.strokeStyle = '#ff9800';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.arc(corner.x, corner.y, 18, 0, Math.PI * 0.5);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Close pixel preview on canvas click
|
||||
document.getElementById('pixel-preview-canvas').addEventListener('click', () => {
|
||||
closePixelPreview();
|
||||
});
|
||||
|
||||
// Close pixel preview on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
const overlay = document.getElementById('pixel-preview-overlay');
|
||||
if (overlay.style.display !== 'none') {
|
||||
closePixelPreview();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close modals on backdrop click (only if mousedown also started on backdrop)
|
||||
let backdropMouseDownTarget = null;
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
backdropMouseDownTarget = e.target;
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.classList.contains('modal')) return;
|
||||
if (backdropMouseDownTarget !== e.target) return;
|
||||
|
||||
const modalId = e.target.id;
|
||||
|
||||
@@ -1298,6 +1740,12 @@ document.addEventListener('click', (e) => {
|
||||
closeCalibrationModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Add device modal: close on backdrop
|
||||
if (modalId === 'add-device-modal') {
|
||||
closeAddDeviceModal();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
|
||||
Reference in New Issue
Block a user