From cf770440c028ea870b83b836f848fe2302a6b5c8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 8 Feb 2026 04:20:45 +0300 Subject: [PATCH] Add inline calibration canvas with tick labels, direction arrows, and corner improvements Replace fullscreen pixel preview with a canvas overlay inside the calibration modal that shows LED index ticks, direction chevrons, and interactive corner start position buttons. Fix corner hover centering and disable grow animation for the active corner. Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/static/app.js | 561 ++++++------------ server/src/wled_controller/static/index.html | 18 +- .../wled_controller/static/locales/en.json | 11 - .../wled_controller/static/locales/ru.json | 11 - server/src/wled_controller/static/style.css | 110 +--- 5 files changed, 192 insertions(+), 519 deletions(-) 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 @@ - -