diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js
index 616566a..f3cd461 100644
--- a/server/src/wled_controller/static/app.js
+++ b/server/src/wled_controller/static/app.js
@@ -1035,6 +1035,9 @@ async function showCalibration(deviceId) {
modal.style.display = 'flex';
lockBody();
+ // Render canvas after layout settles
+ requestAnimationFrame(() => renderCalibrationCanvas());
+
} catch (error) {
console.error('Failed to load calibration:', error);
showToast('Failed to load calibration', 'error');
@@ -1121,6 +1124,172 @@ function updateCalibrationPreview() {
edgeEl.style.boxShadow = '';
}
});
+
+ // Render canvas overlay (ticks, arrows, start label)
+ renderCalibrationCanvas();
+}
+
+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;
+
+ // Canvas extends beyond the container (matches CSS: left:-32px, top:-18px, +64px/+36px)
+ const padX = 32;
+ const padY = 18;
+
+ 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);
+
+ // Container origin within canvas coordinate system
+ const ox = padX;
+ const oy = padY;
+ const cW = containerRect.width; // container inner width
+ const cH = containerRect.height; // container inner height
+
+ // Read current form values
+ 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 segments = buildSegments(calibration);
+ if (segments.length === 0) return;
+
+ // Edge bar geometry (matches CSS: corner zones 56px × 36px proportional)
+ const cornerFracW = 56 / 500;
+ const cornerFracH = 36 / 312.5;
+ const cw = cornerFracW * cW;
+ const ch = cornerFracH * cH;
+
+ // Edge midlines (center of each edge bar) - in canvas coords
+ const edgeGeometry = {
+ top: { x1: ox + cw, x2: ox + cW - cw, midY: oy + ch / 2, horizontal: true },
+ bottom: { x1: ox + cw, x2: ox + cW - cw, midY: oy + cH - ch / 2, horizontal: true },
+ left: { y1: oy + ch, y2: oy + cH - ch, midX: ox + cw / 2, horizontal: false },
+ right: { y1: oy + ch, y2: oy + cH - ch, midX: ox + cW - cw / 2, horizontal: false },
+ };
+
+ // Axis positions for labels (outside the container bounds, in the padding area)
+ const axisPos = {
+ top: oy - 3, // labels above top edge
+ bottom: oy + cH + 3, // labels below bottom edge
+ left: ox - 3, // labels left of left edge
+ right: ox + cW + 3, // labels right of right edge
+ };
+
+ // Arrow positions (further out than tick labels)
+ const arrowPos = {
+ top: oy - 10,
+ bottom: oy + cH + 10,
+ left: ox - 10,
+ right: ox + cW + 10,
+ };
+
+ // Draw ticks and direction arrows for each segment
+ segments.forEach(seg => {
+ const geo = edgeGeometry[seg.edge];
+ if (!geo) return;
+
+ const count = seg.led_count;
+ if (count === 0) return;
+
+ // Show only first and last LED index per edge
+ const labelsToShow = new Set();
+ labelsToShow.add(0);
+ if (count > 1) labelsToShow.add(count - 1);
+
+ // Tick styling
+ const tickLen = 5;
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
+ ctx.lineWidth = 1;
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.65)';
+ 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 ledIndex = seg.led_start + i;
+
+ if (geo.horizontal) {
+ const tx = geo.x1 + displayFraction * (geo.x2 - geo.x1);
+ const axisY = axisPos[seg.edge];
+ const tickDir = seg.edge === 'top' ? 1 : -1; // tick toward container
+
+ // Tick line
+ ctx.beginPath();
+ ctx.moveTo(tx, axisY);
+ ctx.lineTo(tx, axisY + tickDir * tickLen);
+ ctx.stroke();
+
+ // Label outside
+ ctx.textAlign = 'center';
+ ctx.textBaseline = seg.edge === 'top' ? 'bottom' : 'top';
+ ctx.fillText(String(ledIndex), 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; // tick toward container
+
+ // Tick line
+ ctx.beginPath();
+ ctx.moveTo(axisX, ty);
+ ctx.lineTo(axisX + tickDir * tickLen, ty);
+ ctx.stroke();
+
+ // Label outside
+ ctx.textBaseline = 'middle';
+ ctx.textAlign = seg.edge === 'left' ? 'right' : 'left';
+ ctx.fillText(String(ledIndex), axisX - tickDir * 1, ty);
+ }
+ });
+
+ // Draw direction chevron at midpoint, outside the edge bar
+ const s = 5;
+ let mx, my, angle;
+ if (geo.horizontal) {
+ mx = (geo.x1 + geo.x2) / 2;
+ my = arrowPos[seg.edge];
+ angle = seg.reverse ? Math.PI : 0;
+ } else {
+ mx = arrowPos[seg.edge];
+ my = (geo.y1 + geo.y2) / 2;
+ angle = seg.reverse ? -Math.PI / 2 : Math.PI / 2;
+ }
+
+ ctx.save();
+ ctx.translate(mx, my);
+ ctx.rotate(angle);
+ ctx.strokeStyle = '#4CAF50';
+ ctx.lineWidth = 1.5;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.beginPath();
+ ctx.moveTo(-s * 0.4, -s * 0.5);
+ ctx.lineTo(s * 0.4, 0);
+ ctx.lineTo(-s * 0.4, s * 0.5);
+ ctx.stroke();
+ ctx.restore();
+ });
+
}
function setStartPosition(position) {
@@ -1325,398 +1494,6 @@ function buildSegments(calibration) {
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 `
-
- ${edgeNames[seg.edge] || seg.edge}: ${seg.led_count} LEDs (#${seg.led_start}\u2013${last})
-
`;
- });
-
- const dirText = calibration.layout === 'clockwise' ? t('preview.direction.cw') : t('preview.direction.ccw');
- items.push(`
- ${calibration.layout === 'clockwise' ? '\u21BB' : '\u21BA'} ${dirText}
-
`);
-
- if (calibration.offset > 0) {
- items.push(`
- \u2194 ${t('preview.offset_leds', { count: calibration.offset })}
-
`);
- }
-
- 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) => {
diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html
index e6f66a1..e03c279 100644
--- a/server/src/wled_controller/static/index.html
+++ b/server/src/wled_controller/static/index.html
@@ -100,22 +100,18 @@
- T
- R
- B
- L
@@ -125,6 +121,9 @@
●
●
●
+
+
+
Click an edge to toggle test LEDs on/off
@@ -156,7 +155,6 @@
@@ -285,16 +283,6 @@
-
-