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 @@ - -