Add inline calibration canvas with tick labels, direction arrows, and corner improvements
Some checks failed
Validate / validate (push) Failing after 8s
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:
@@ -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) => {
|
||||||
|
|||||||
@@ -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">✕</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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Не авторизован - пожалуйста, войдите",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user