Add inline calibration canvas with tick labels, direction arrows, and corner improvements
Some checks failed
Validate / validate (push) Failing after 8s

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 04:20:45 +03:00
parent 9eddaeafc3
commit cf770440c0
5 changed files with 192 additions and 519 deletions

View File

@@ -1035,6 +1035,9 @@ async function showCalibration(deviceId) {
modal.style.display = 'flex'; modal.style.display = 'flex';
lockBody(); lockBody();
// Render canvas after layout settles
requestAnimationFrame(() => renderCalibrationCanvas());
} catch (error) { } catch (error) {
console.error('Failed to load calibration:', error); console.error('Failed to load calibration:', error);
showToast('Failed to load calibration', 'error'); showToast('Failed to load calibration', 'error');
@@ -1121,6 +1124,172 @@ function updateCalibrationPreview() {
edgeEl.style.boxShadow = ''; 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) { function setStartPosition(position) {
@@ -1325,398 +1494,6 @@ function buildSegments(calibration) {
return segments; 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) // Close modals on backdrop click (only if mousedown also started on backdrop)
let backdropMouseDownTarget = null; let backdropMouseDownTarget = null;
document.addEventListener('mousedown', (e) => { document.addEventListener('mousedown', (e) => {

View File

@@ -100,22 +100,18 @@
<!-- Clickable edge bars with LED count inputs --> <!-- Clickable edge bars with LED count inputs -->
<div class="preview-edge edge-top" onclick="toggleTestEdge('top')"> <div class="preview-edge edge-top" onclick="toggleTestEdge('top')">
<span>T</span>
<input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-top-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
<div class="preview-edge edge-right" onclick="toggleTestEdge('right')"> <div class="preview-edge edge-right" onclick="toggleTestEdge('right')">
<span>R</span>
<input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-right-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
<div class="preview-edge edge-bottom" onclick="toggleTestEdge('bottom')"> <div class="preview-edge edge-bottom" onclick="toggleTestEdge('bottom')">
<span>B</span>
<input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-bottom-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
<div class="preview-edge edge-left" onclick="toggleTestEdge('left')"> <div class="preview-edge edge-left" onclick="toggleTestEdge('left')">
<span>L</span>
<input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0" <input type="number" id="cal-left-leds" class="edge-led-input" min="0" value="0"
oninput="updateCalibrationPreview()" onclick="event.stopPropagation()"> oninput="updateCalibrationPreview()" onclick="event.stopPropagation()">
</div> </div>
@@ -125,6 +121,9 @@
<div class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></div> <div class="preview-corner corner-top-right" onclick="setStartPosition('top_right')"></div>
<div class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></div> <div class="preview-corner corner-bottom-left" onclick="setStartPosition('bottom_left')"></div>
<div class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></div> <div class="preview-corner corner-bottom-right" onclick="setStartPosition('bottom_right')"></div>
<!-- Canvas overlay for ticks, arrows, start label -->
<canvas id="calibration-preview-canvas"></canvas>
</div> </div>
<p class="preview-hint" data-i18n="calibration.preview.click_hint">Click an edge to toggle test LEDs on/off</p> <p class="preview-hint" data-i18n="calibration.preview.click_hint">Click an edge to toggle test LEDs on/off</p>
</div> </div>
@@ -156,7 +155,6 @@
<div id="calibration-error" class="error-message" style="display: none;"></div> <div id="calibration-error" class="error-message" style="display: none;"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" onclick="showPixelPreview(document.getElementById('calibration-device-id').value)" data-i18n="preview.button" style="margin-right: auto;">Preview</button>
<button class="btn btn-secondary" onclick="closeCalibrationModal()" data-i18n="calibration.button.cancel">Cancel</button> <button class="btn btn-secondary" onclick="closeCalibrationModal()" data-i18n="calibration.button.cancel">Cancel</button>
<button class="btn btn-primary" onclick="saveCalibration()" data-i18n="calibration.button.save">Save</button> <button class="btn btn-primary" onclick="saveCalibration()" data-i18n="calibration.button.save">Save</button>
</div> </div>
@@ -285,16 +283,6 @@
</div> </div>
</div> </div>
<!-- Pixel Layout Preview Overlay -->
<div id="pixel-preview-overlay" class="pixel-preview-overlay" style="display: none;">
<div class="pixel-preview-header">
<span class="pixel-preview-title" data-i18n="preview.title">Pixel Layout Preview</span>
<span id="pixel-preview-device-name" class="pixel-preview-device-name"></span>
<button class="pixel-preview-close" onclick="closePixelPreview()" title="Close">&#x2715;</button>
</div>
<canvas id="pixel-preview-canvas"></canvas>
<div class="pixel-preview-legend" id="pixel-preview-legend"></div>
</div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script> <script>

View File

@@ -112,17 +112,6 @@
"calibration.button.save": "Save", "calibration.button.save": "Save",
"calibration.saved": "Calibration saved successfully", "calibration.saved": "Calibration saved successfully",
"calibration.failed": "Failed to save calibration", "calibration.failed": "Failed to save calibration",
"preview.title": "Pixel Layout Preview",
"preview.button": "Preview",
"preview.start": "START",
"preview.offset_leds": "Offset: {count} LEDs",
"preview.direction.cw": "Clockwise",
"preview.direction.ccw": "Counterclockwise",
"preview.edge.top": "Top",
"preview.edge.right": "Right",
"preview.edge.bottom": "Bottom",
"preview.edge.left": "Left",
"preview.no_calibration": "No calibration data. Please calibrate the device first.",
"server.healthy": "Server online", "server.healthy": "Server online",
"server.offline": "Server offline", "server.offline": "Server offline",
"error.unauthorized": "Unauthorized - please login", "error.unauthorized": "Unauthorized - please login",

View File

@@ -112,17 +112,6 @@
"calibration.button.save": "Сохранить", "calibration.button.save": "Сохранить",
"calibration.saved": "Калибровка успешно сохранена", "calibration.saved": "Калибровка успешно сохранена",
"calibration.failed": "Не удалось сохранить калибровку", "calibration.failed": "Не удалось сохранить калибровку",
"preview.title": "Предпросмотр Расположения Пикселей",
"preview.button": "Предпросмотр",
"preview.start": "СТАРТ",
"preview.offset_leds": "Смещение: {count} LED",
"preview.direction.cw": "По часовой",
"preview.direction.ccw": "Против часовой",
"preview.edge.top": "Верх",
"preview.edge.right": "Право",
"preview.edge.bottom": "Низ",
"preview.edge.left": "Лево",
"preview.no_calibration": "Нет данных калибровки. Сначала откалибруйте устройство.",
"server.healthy": "Сервер онлайн", "server.healthy": "Сервер онлайн",
"server.offline": "Сервер офлайн", "server.offline": "Сервер офлайн",
"error.unauthorized": "Не авторизован - пожалуйста, войдите", "error.unauthorized": "Не авторизован - пожалуйста, войдите",

View File

@@ -829,10 +829,21 @@ input:-webkit-autofill:focus {
width: 100%; width: 100%;
max-width: 500px; max-width: 500px;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
margin: 0 auto; margin: 20px auto;
background: var(--card-bg); background: var(--card-bg);
border: 2px solid var(--border-color); border: 2px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
overflow: visible;
}
#calibration-preview-canvas {
position: absolute;
top: -18px;
left: -32px;
width: calc(100% + 64px);
height: calc(100% + 36px);
pointer-events: none;
z-index: 3;
} }
.preview-screen { .preview-screen {
@@ -918,8 +929,10 @@ input:-webkit-autofill:focus {
.edge-led-input { .edge-led-input {
width: 46px; width: 46px;
padding: 3px 2px; padding: 2px 2px;
font-size: 12px; font-size: 12px;
box-sizing: border-box;
max-height: 100%;
text-align: center; text-align: center;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
@@ -956,6 +969,7 @@ input:-webkit-autofill:focus {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 18px; font-size: 18px;
line-height: 1;
color: rgba(128, 128, 128, 0.4); color: rgba(128, 128, 128, 0.4);
cursor: pointer; cursor: pointer;
z-index: 5; z-index: 5;
@@ -968,6 +982,10 @@ input:-webkit-autofill:focus {
transform: scale(1.2); transform: scale(1.2);
} }
.preview-corner.active:hover {
transform: none;
}
.preview-corner.active { .preview-corner.active {
color: #4CAF50; color: #4CAF50;
text-shadow: 0 0 8px rgba(76, 175, 80, 0.6); text-shadow: 0 0 8px rgba(76, 175, 80, 0.6);
@@ -1036,91 +1054,3 @@ input:-webkit-autofill:focus {
} }
} }
/* Pixel Layout Preview Overlay */
.pixel-preview-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #111111;
z-index: 3000;
display: flex;
flex-direction: column;
animation: fadeIn 0.2s ease-out;
}
.pixel-preview-header {
display: flex;
align-items: center;
padding: 12px 20px;
background: rgba(0, 0, 0, 0.6);
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.pixel-preview-title {
font-size: 1.1rem;
font-weight: 600;
color: #e0e0e0;
}
.pixel-preview-device-name {
font-size: 0.9rem;
color: #999;
margin-left: 12px;
}
.pixel-preview-close {
margin-left: auto;
background: none;
border: 1px solid #555;
color: #e0e0e0;
font-size: 1.2rem;
width: 36px;
height: 36px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, border-color 0.2s;
}
.pixel-preview-close:hover {
background: rgba(244, 67, 54, 0.3);
border-color: #f44336;
}
#pixel-preview-canvas {
flex: 1;
width: 100%;
min-height: 0;
}
.pixel-preview-legend {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 10px 20px;
background: rgba(0, 0, 0, 0.6);
border-top: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
.pixel-preview-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #ccc;
}
.pixel-preview-legend-swatch {
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.3);
}