This commit is contained in:
beabigegg
2025-07-30 11:24:58 +08:00
parent ede5af22f8
commit 9f7040ece9
10 changed files with 1667 additions and 175 deletions

View File

@@ -163,21 +163,109 @@ select {
background-color: var(--secondary-color-hover);
}
#controls {
display: flex;
flex-direction: column;
gap: 15px;
gap: 10px; /* Gap between control sections */
}
.control-section {
padding-top: 15px;
border-top: 1px solid var(--border-color);
}
.control-section:first-child {
padding-top: 0;
border-top: none;
}
.control-section-header {
font-size: 12px;
font-weight: 600;
color: var(--text-color-dark);
text-transform: uppercase;
margin-bottom: 15px;
}
.control-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px; /* row-gap column-gap */
margin-bottom: 15px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group label {
flex-shrink: 0;
font-weight: 500;
}
.control-group select,
.control-group input[type="number"],
.control-group .button-secondary {
flex-grow: 1; /* Allow items to grow */
min-width: 80px; /* Prevent items from becoming too small */
}
.control-group .btn-group {
display: flex;
flex-grow: 1;
}
.btn-group .button-secondary {
border-radius: 0;
}
.btn-group .button-secondary:first-child {
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
.btn-group .button-secondary:last-child {
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.btn-group .button-secondary.active {
background-color: var(--primary-color);
color: white;
}
.control-group.vertical {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.inset-control-row {
display: flex;
align-items: center;
gap: 10px;
}
.inset-control-row label {
width: 50px; /* Fixed width for alignment */
flex-shrink: 0;
}
.inset-control-row select,
.inset-control-row input {
width: 100%;
}
.control-group-divider {
height: 1px;
background-color: var(--border-color);
width: 100%;
margin: 5px 0;
}
.control-group-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.control-group-grid.four-cols {
grid-template-columns: 1fr 1fr;
}
.control-group-grid .button-primary,
.control-group-grid .button-secondary {
width: 100%;
@@ -187,10 +275,8 @@ select {
.hint-text {
font-size: 12px;
color: var(--text-color-dark);
text-align: center;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color);
text-align: left;
padding: 0 5px;
}
.hint-text strong {
color: var(--text-color);
@@ -214,7 +300,7 @@ select {
}
/* --- Main Editor Area --- */
#editor-section {
#editor-section, #viewer-section, #comparison-result-section {
display: flex;
flex-direction: column;
height: 100%;
@@ -244,6 +330,9 @@ select {
border: 1px solid var(--border-color);
}
.count-item {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.count-item span {
@@ -255,8 +344,14 @@ select {
display: inline-block;
text-align: center;
}
#result-legend .count-item span {
width: 20px;
height: 20px;
border: 1px solid rgba(255,255,255,0.2);
}
#map-container {
#map-container, #result-map-container {
flex-grow: 1;
position: relative;
cursor: grab;
@@ -265,31 +360,107 @@ select {
border: 1px solid var(--border-color);
background-color: var(--card-bg);
}
#map-container:active {
#map-container:active, #result-map-container:active {
cursor: grabbing;
}
#wafer-map-canvas {
#wafer-map-canvas, #result-map-canvas {
display: block;
width: 100%;
height: 100%;
}
.map-viewer-controls {
display: flex;
gap: 15px;
align-items: center;
margin-bottom: 15px;
}
.map-viewer-controls label {
font-weight: 500;
}
/* --- Comparison Page Specifics --- */
.reference-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-radius: 4px;
background-color: var(--secondary-color);
gap: 10px;
}
.reference-item .filename {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 1;
}
.reference-item .ref-details {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.ref-status {
font-weight: 500;
color: var(--text-color-dark);
}
.ref-status.set {
color: var(--primary-color);
font-weight: 600;
}
.clear-ref-btn {
background: none;
border: none;
color: var(--text-color-dark);
font-size: 16px;
font-weight: bold;
cursor: pointer;
padding: 0 5px;
display: none; /* Hidden by default */
}
.clear-ref-btn:hover {
color: var(--error-color);
}
/* --- Tooltip --- */
.map-tooltip {
position: absolute;
display: none;
background-color: rgba(0, 0, 0, 0.85);
color: white;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 8px 12px;
font-size: 12px;
pointer-events: none; /* So it doesn't interfere with mouse events */
white-space: pre; /* To respect newlines */
z-index: 100;
transition: opacity 0.1s ease;
}
.reference-item.has-ref .clear-ref-btn {
display: inline-block; /* Show only when ref is set */
}
/* --- Status & Spinner --- */
.status {
margin-top: 15px;
padding: 10px;
border-radius: 4px;
display: none;
font-weight: 500;
text-align: center;
display: none; /* Use classes to show */
}
.status.success {
background-color: var(--success-color);
background-color: rgba(40, 167, 69, 0.8);
color: white;
display: block;
}
.status.error {
background-color: var(--error-color);
background-color: rgba(220, 53, 69, 0.8);
color: white;
display: block;
}
@@ -307,4 +478,4 @@ select {
@keyframes spinner-border {
to { transform: rotate(360deg); }
}
}

430
static/js/compare.js Normal file
View File

@@ -0,0 +1,430 @@
document.addEventListener('DOMContentLoaded', () => {
// --- DOM Elements ---
const uploadForm = document.getElementById('upload-form');
const binDefinitionForm = document.getElementById('bin-definition-form');
const fileInput = document.getElementById('file-input');
const uploadSection = document.getElementById('upload-section');
const binDefinitionSection = document.getElementById('bin-definition-section');
const comparisonControlsSection = document.getElementById('comparison-controls-section');
const viewerSection = document.getElementById('viewer-section');
const comparisonResultSection = document.getElementById('comparison-result-section');
const welcomeMessage = document.getElementById('welcome-message');
const uploadStatus = document.getElementById('upload-status');
const binStatus = document.getElementById('bin-status');
const compareStatus = document.getElementById('compare-status');
const binMapContainer = document.getElementById('bin-map-container');
const referenceSetterContainer = document.getElementById('reference-setter-container');
const mapSelector = document.getElementById('map-selector');
const rotateMapBtn = document.getElementById('rotate-map-btn');
const compareBtn = document.getElementById('compare-btn');
const backToViewerBtn = document.getElementById('back-to-viewer-btn');
const canvas = document.getElementById('wafer-map-canvas');
const ctx = canvas.getContext('2d');
const resultCanvas = document.getElementById('result-map-canvas');
const resultCtx = resultCanvas.getContext('2d');
const resultLegendContainer = document.getElementById('result-legend');
const tooltip = document.getElementById('map-tooltip');
// --- State Management ---
let sessionMaps = {};
let activeMapFid = null;
let comparisonData = {}; // To store result_map, detailed_grid, etc.
// --- Canvas & View State ---
const BIN_CODE_MAP = { 2: 'Good', 1: 'NG', 0: 'Dummy', '-1': 'Ignore', '-10': 'N/A' };
const BIN_COLORS = { 2: '#28a745', 1: '#dc3545', 0: '#6c757d', '-1': '#3e3e42' };
const REFERENCE_COLOR = 'rgba(255, 193, 7, 1)';
let transform = { x: 0, y: 0, scale: 1 };
let isPanning = false;
let panStart = { x: 0, y: 0 };
let resultTransform = { x: 0, y: 0, scale: 1 };
let isResultPanning = false;
let resultPanStart = { x: 0, y: 0 };
let highlightedDie = null;
// --- UI Update Functions ---
function setStatus(element, message, isError = false) {
element.textContent = message;
element.className = 'status';
if (message) {
element.classList.add(isError ? 'error' : 'success');
}
}
function switchCard(from, to) {
if(from) from.classList.remove('active');
if(to) to.classList.add('active');
}
function updateReferenceUI() {
referenceSetterContainer.innerHTML = '';
Object.keys(sessionMaps).forEach(fid => {
const map = sessionMaps[fid];
const hasRef = map.reference !== null;
const refText = hasRef ? `(${map.reference.join(', ')})` : 'Not Set';
const item = document.createElement('div');
item.className = `reference-item ${hasRef ? 'has-ref' : ''}`;
item.id = `ref-item-${fid}`;
item.innerHTML = `
<span class="filename" title="${map.filename}">${map.filename}</span>
<div class="ref-details">
<span class="ref-status ${hasRef ? 'set' : ''}">${refText}</span>
<button class="clear-ref-btn" data-fid="${fid}" title="Clear reference">×</button>
</div>
`;
referenceSetterContainer.appendChild(item);
});
checkAllReferencesSet();
}
function checkAllReferencesSet() {
const allSet = Object.values(sessionMaps).every(m => m.reference !== null);
compareBtn.disabled = !allSet;
if (allSet) {
setStatus(compareStatus, 'All references set. Ready to compare.');
} else {
setStatus(compareStatus, 'Please set a reference point for each map.');
}
}
// --- Canvas & Drawing ---
function worldToScreen(x, y, t) { return { x: x * t.scale + t.x, y: y * t.scale + t.y }; }
function screenToWorld(x, y, t) { return { x: (x - t.x) / t.scale, y: (y - t.y) / t.scale }; }
function resetTransform(map, t, targetCanvas) {
const dieSize = 10;
const container = targetCanvas.parentElement;
targetCanvas.width = container.clientWidth;
targetCanvas.height = container.clientHeight;
const scaleX = targetCanvas.width / (map.cols * dieSize);
const scaleY = targetCanvas.height / (map.rows * dieSize);
t.scale = Math.min(scaleX, scaleY) * 0.9;
const mapRenderWidth = map.cols * dieSize * t.scale;
const mapRenderHeight = map.rows * dieSize * t.scale;
t.x = (targetCanvas.width - mapRenderWidth) / 2;
t.y = (targetCanvas.height - mapRenderHeight) / 2;
}
function drawActiveMap() {
if (!activeMapFid || !sessionMaps[activeMapFid] || !sessionMaps[activeMapFid].wafer_map) return;
const map = sessionMaps[activeMapFid];
requestAnimationFrame(() => {
const dieSize = 10;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(transform.x, transform.y);
ctx.scale(transform.scale, transform.scale);
for (let r = 0; r < map.rows; r++) {
for (let c = 0; c < map.cols; c++) {
const bin = map.wafer_map[r][c];
ctx.fillStyle = BIN_COLORS[bin] || '#ffffff';
ctx.fillRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
}
if (map.reference) {
const [r, c] = map.reference;
ctx.strokeStyle = REFERENCE_COLOR;
ctx.lineWidth = 2 / transform.scale;
ctx.strokeRect(c * dieSize - 1, r * dieSize - 1, dieSize + 2, dieSize + 2);
}
ctx.restore();
});
}
function drawResultMap() {
const { rows, cols, result_map, legend } = comparisonData;
requestAnimationFrame(() => {
const dieSize = 10;
resultCtx.clearRect(0, 0, resultCanvas.width, resultCanvas.height);
resultCtx.save();
resultCtx.translate(resultTransform.x, resultTransform.y);
resultCtx.scale(resultTransform.scale, resultTransform.scale);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const code = result_map[r][c];
resultCtx.fillStyle = legend[code] ? legend[code].color : '#ffffff';
resultCtx.fillRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
}
// Draw highlight on hovered die
if (highlightedDie) {
const { r, c } = highlightedDie;
resultCtx.strokeStyle = REFERENCE_COLOR;
resultCtx.lineWidth = 2 / resultTransform.scale;
resultCtx.strokeRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
resultCtx.restore();
});
}
// --- Event Handlers ---
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
setStatus(uploadStatus, '');
if (fileInput.files.length < 2) {
setStatus(uploadStatus, 'Please select at least two files for comparison.', true);
return;
}
const formData = new FormData(uploadForm);
try {
const response = await fetch('/upload', { method: 'POST', body: formData });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
binMapContainer.innerHTML = '';
result.unique_chars.forEach(char => {
const item = document.createElement('div');
item.className = 'bin-item';
item.innerHTML = `<label>'${char}':</label><select name="${char}"><option value="good">Good</option><option value="ng">NG</option><option value="dummy">Dummy</option><option value="ignore">Ignore</option></select>`;
binMapContainer.appendChild(item);
});
mapSelector.innerHTML = '';
sessionMaps = {}; // Clear previous session
Object.keys(result.maps).forEach(fid => {
sessionMaps[fid] = { filename: result.maps[fid], wafer_map: null, reference: null };
const option = document.createElement('option');
option.value = fid;
option.textContent = result.maps[fid];
mapSelector.appendChild(option);
});
activeMapFid = mapSelector.value;
switchCard(uploadSection, binDefinitionSection);
} catch (error) {
setStatus(uploadStatus, `Error: ${error.message}`, true);
}
});
binDefinitionForm.addEventListener('submit', async (e) => {
e.preventDefault();
setStatus(binStatus, '');
const formData = new FormData(binDefinitionForm);
const charMap = Object.fromEntries(formData.entries());
try {
const response = await fetch('/generate_map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ char_map: charMap }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
Object.keys(result.maps_data).forEach(fid => {
sessionMaps[fid] = { ...sessionMaps[fid], ...result.maps_data[fid] };
});
welcomeMessage.style.display = 'none';
viewerSection.style.display = 'flex';
resetTransform(sessionMaps[activeMapFid], transform, canvas);
drawActiveMap();
updateReferenceUI();
switchCard(binDefinitionSection, comparisonControlsSection);
} catch (error) {
setStatus(binStatus, `Error: ${error.message}`, true);
}
});
mapSelector.addEventListener('change', (e) => {
activeMapFid = e.target.value;
resetTransform(sessionMaps[activeMapFid], transform, canvas);
drawActiveMap();
});
rotateMapBtn.addEventListener('click', async () => {
if (!activeMapFid) return;
try {
const response = await fetch('/compare/rotate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fid: activeMapFid }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
sessionMaps[activeMapFid].wafer_map = result.wafer_map;
sessionMaps[activeMapFid].rows = result.rows;
sessionMaps[activeMapFid].cols = result.cols;
sessionMaps[activeMapFid].reference = null;
resetTransform(sessionMaps[activeMapFid], transform, canvas);
drawActiveMap();
updateReferenceUI();
} catch (error) {
setStatus(compareStatus, `Error rotating map: ${error.message}`, true);
}
});
referenceSetterContainer.addEventListener('click', async (e) => {
if (e.target.classList.contains('clear-ref-btn')) {
const fid = e.target.dataset.fid;
try {
const response = await fetch('/compare/clear_reference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fid: fid }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
sessionMaps[fid].reference = null;
updateReferenceUI();
if (fid === activeMapFid) drawActiveMap();
} catch (error) {
setStatus(compareStatus, `Error clearing reference: ${error.message}`, true);
}
}
});
canvas.addEventListener('click', async (e) => {
const wasDrag = Math.abs(e.clientX - panStart.x) > 2 || Math.abs(e.clientY - panStart.y) > 2;
if ((isPanning && wasDrag) || !activeMapFid) return;
const worldPos = screenToWorld(e.offsetX, e.offsetY, transform);
const col = Math.floor(worldPos.x / 10);
const row = Math.floor(worldPos.y / 10);
const map = sessionMaps[activeMapFid];
if (row >= 0 && row < map.rows && col >= 0 && col < map.cols) {
const reference = [row, col];
try {
const response = await fetch('/set_reference', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fid: activeMapFid, reference: reference }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
sessionMaps[activeMapFid].reference = result.reference;
updateReferenceUI();
drawActiveMap();
} catch (error) {
setStatus(compareStatus, `Error: ${error.message}`, true);
}
}
});
compareBtn.addEventListener('click', async () => {
setStatus(compareStatus, 'Running comparison...');
try {
const response = await fetch('/run_comparison', { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
comparisonData = result;
viewerSection.style.display = 'none';
comparisonResultSection.style.display = 'flex';
resetTransform(comparisonData, resultTransform, resultCanvas);
drawResultMap();
resultLegendContainer.innerHTML = '<h3>Legend</h3>';
for (const code in result.legend) {
const item = result.legend[code];
const legendItem = document.createElement('div');
legendItem.className = 'count-item';
legendItem.innerHTML = `${item.label}: <span style="background-color:${item.color}"></span>`;
resultLegendContainer.appendChild(legendItem);
}
} catch (error) {
setStatus(compareStatus, `Error: ${error.message}`, true);
}
});
backToViewerBtn.addEventListener('click', () => {
comparisonResultSection.style.display = 'none';
viewerSection.style.display = 'flex';
});
// --- Canvas Pan & Zoom ---
function setupCanvasEvents(targetCanvas, t, panFlagSetter, drawFn) {
targetCanvas.addEventListener('wheel', (e) => { e.preventDefault(); const rect = targetCanvas.getBoundingClientRect(); const mouse = { x: e.clientX - rect.left, y: e.clientY - rect.top }; const worldPos = screenToWorld(mouse.x, mouse.y, t); const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1; t.scale *= zoomFactor; const newWorldPos = screenToWorld(mouse.x, mouse.y, t); t.x += (newWorldPos.x - worldPos.x) * t.scale; t.y += (newWorldPos.y - worldPos.y) * t.scale; drawFn(); });
targetCanvas.addEventListener('mousedown', (e) => { panFlagSetter(true, e); });
targetCanvas.addEventListener('mousemove', (e) => { panFlagSetter(false, e, true, drawFn); });
}
setupCanvasEvents(canvas, transform, (val, e) => { isPanning = val; if(val) panStart = { x: e.clientX, y: e.clientY }; }, drawActiveMap);
setupCanvasEvents(resultCanvas, resultTransform, (val, e) => { isResultPanning = val; if(val) resultPanStart = { x: e.clientX, y: e.clientY }; }, drawResultMap);
window.addEventListener('mouseup', () => { isPanning = false; isResultPanning = false; });
window.addEventListener('mousemove', (e) => {
if (isPanning) { const dx = e.clientX - panStart.x; const dy = e.clientY - panStart.y; transform.x += dx; transform.y += dy; panStart = { x: e.clientX, y: e.clientY }; drawActiveMap(); }
if (isResultPanning) { const dx = e.clientX - resultPanStart.x; const dy = e.clientY - resultPanStart.y; resultTransform.x += dx; resultTransform.y += dy; resultPanStart = { x: e.clientX, y: e.clientY }; drawResultMap(); }
});
// --- Tooltip Logic ---
resultCanvas.addEventListener('mousemove', (e) => {
if (!comparisonData.detailed_grid || isResultPanning) return;
const rect = resultCanvas.getBoundingClientRect();
const mouse = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const worldPos = screenToWorld(mouse.x, mouse.y, resultTransform);
const c = Math.floor(worldPos.x / 10);
const r = Math.floor(worldPos.y / 10);
if (r >= 0 && r < comparisonData.rows && c >= 0 && c < comparisonData.cols) {
highlightedDie = { r, c };
const dieData = comparisonData.detailed_grid[r][c];
let tooltipContent = `Coord: (${r}, ${c})\n-------------------\n`;
tooltipContent += comparisonData.map_names.map((name, i) => {
const binCode = dieData[i];
return `${name}: ${BIN_CODE_MAP[binCode] || 'Unknown'}`;
}).join('\n');
tooltip.style.display = 'block';
tooltip.style.opacity = '1';
tooltip.textContent = tooltipContent;
// Position tooltip
const tooltipRect = tooltip.getBoundingClientRect();
let left = e.clientX + 15;
let top = e.clientY + 15;
if (left + tooltipRect.width > window.innerWidth) {
left = e.clientX - tooltipRect.width - 15;
}
if (top + tooltipRect.height > window.innerHeight) {
top = e.clientY - tooltipRect.height - 15;
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
} else {
highlightedDie = null;
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
}
drawResultMap();
});
resultCanvas.addEventListener('mouseleave', () => {
highlightedDie = null;
tooltip.style.display = 'none';
tooltip.style.opacity = '0';
drawResultMap();
});
new ResizeObserver(() => { if (activeMapFid) { resetTransform(sessionMaps[activeMapFid], transform, canvas); drawActiveMap(); } }).observe(canvas.parentElement);
new ResizeObserver(() => { if (comparisonData.rows) { resetTransform(comparisonData, resultTransform, resultCanvas); drawResultMap(); } }).observe(resultCanvas.parentElement);
});

View File

@@ -20,6 +20,11 @@ document.addEventListener('DOMContentLoaded', () => {
const updateSelectionBtn = document.getElementById('update-selection-btn');
const resetViewBtn = document.getElementById('reset-view-btn');
const rotateBtn = document.getElementById('rotate-btn');
const polygonSelectBtn = document.getElementById('polygon-select-btn');
const boxSelectBtn = document.getElementById('box-select-btn');
const insetMapBtn = document.getElementById('inset-map-btn');
const undoBtn = document.getElementById('undo-btn');
const redoBtn = document.getElementById('redo-btn');
const binCountsContainer = document.getElementById('bin-counts');
// --- State Management ---
@@ -33,10 +38,17 @@ document.addEventListener('DOMContentLoaded', () => {
const SELECTION_COLOR = 'rgba(0, 123, 255, 0.5)';
let transform = { x: 0, y: 0, scale: 1 };
let isPanning = false;
let isSelecting = false;
let panStart = { x: 0, y: 0 };
let selectionStart = { x: 0, y: 0 };
let selectionEnd = { x: 0, y: 0 };
// --- Selection modes ---
let selectionMode = 'box'; // 'box', 'polygon'
let isBoxSelecting = false;
let boxSelectionStart = { x: 0, y: 0 };
let boxSelectionEnd = { x: 0, y: 0 };
let isDrawingPolygon = false;
let polygonPoints = [];
let currentMousePos = { x: 0, y: 0 };
// --- UI/UX Functions ---
function setStatus(element, message, isError = false) {
@@ -62,6 +74,21 @@ document.addEventListener('DOMContentLoaded', () => {
if(to) to.classList.add('active');
}
function updateHistoryButtons(canUndo, canRedo) {
undoBtn.disabled = !canUndo;
redoBtn.disabled = !canRedo;
}
function handleMapUpdate(result) {
waferMap = result.wafer_map;
mapRows = result.rows;
mapCols = result.cols;
selection.clear();
updateBinCounts();
draw();
}
// --- Canvas & Data Functions ---
function worldToScreen(x, y) {
return { x: x * transform.scale + transform.x, y: y * transform.scale + transform.y };
@@ -121,17 +148,7 @@ document.addEventListener('DOMContentLoaded', () => {
function draw() {
requestAnimationFrame(() => {
const dieSize = 10;
const minScaleForGrid = 4;
const maxScaleForGrid = 10;
let gridAlpha = (transform.scale - minScaleForGrid) / (maxScaleForGrid - minScaleForGrid);
gridAlpha = Math.min(1, Math.max(0, gridAlpha));
// Dynamically set canvas background
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (gridAlpha > 0) {
ctx.fillStyle = `rgba(240, 240, 240, ${gridAlpha})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.save();
ctx.translate(transform.x, transform.y);
@@ -145,24 +162,6 @@ document.addEventListener('DOMContentLoaded', () => {
ctx.fillRect(c * dieSize, r * dieSize, dieSize, dieSize);
}
}
// Draw grid lines
if (gridAlpha > 0) {
ctx.strokeStyle = `rgba(0, 0, 0, ${gridAlpha})`;
ctx.lineWidth = 1 / transform.scale;
for (let r = 0; r <= mapRows; r++) {
ctx.beginPath();
ctx.moveTo(0, r * dieSize);
ctx.lineTo(mapCols * dieSize, r * dieSize);
ctx.stroke();
}
for (let c = 0; c <= mapCols; c++) {
ctx.beginPath();
ctx.moveTo(c * dieSize, 0);
ctx.lineTo(c * dieSize, mapRows * dieSize);
ctx.stroke();
}
}
// Draw selection highlights
ctx.fillStyle = SELECTION_COLOR;
@@ -174,19 +173,44 @@ document.addEventListener('DOMContentLoaded', () => {
ctx.restore();
// Draw selection rectangle (in screen space)
if (isSelecting) {
if (isBoxSelecting) {
ctx.fillStyle = 'rgba(0, 123, 255, 0.2)';
ctx.strokeStyle = 'rgba(0, 123, 255, 0.8)';
ctx.lineWidth = 1;
const rect = {
x: Math.min(selectionStart.x, selectionEnd.x),
y: Math.min(selectionStart.y, selectionEnd.y),
w: Math.abs(selectionStart.x - selectionEnd.x),
h: Math.abs(selectionStart.y - selectionEnd.y)
x: Math.min(boxSelectionStart.x, boxSelectionEnd.x),
y: Math.min(boxSelectionStart.y, boxSelectionEnd.y),
w: Math.abs(boxSelectionStart.x - boxSelectionEnd.x),
h: Math.abs(boxSelectionStart.y - boxSelectionEnd.y)
};
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
}
// Draw polygon (in screen space)
if (isDrawingPolygon && polygonPoints.length > 0) {
ctx.beginPath();
ctx.moveTo(polygonPoints[0].x, polygonPoints[0].y);
for (let i = 1; i < polygonPoints.length; i++) {
ctx.lineTo(polygonPoints[i].x, polygonPoints[i].y);
}
ctx.strokeStyle = 'rgba(255, 0, 0, 0.8)';
ctx.lineWidth = 2;
ctx.stroke();
// Draw line to current mouse position
ctx.lineTo(currentMousePos.x, currentMousePos.y);
ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
ctx.stroke();
// Draw vertices
ctx.fillStyle = 'red';
polygonPoints.forEach(p => {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, 2 * Math.PI);
ctx.fill();
});
}
});
}
@@ -241,16 +265,13 @@ document.addEventListener('DOMContentLoaded', () => {
const result = await response.json();
if (!response.ok) throw new Error(result.error);
waferMap = result.wafer_map;
mapRows = result.rows;
mapCols = result.cols;
welcomeMessage.style.display = 'none';
editorSection.style.display = 'flex';
handleMapUpdate(result);
resetTransform();
updateBinCounts();
draw();
updateHistoryButtons(false, false); // History starts here
switchCard(binDefinitionSection, editorControlsSection);
} catch (error) {
@@ -260,6 +281,7 @@ document.addEventListener('DOMContentLoaded', () => {
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
if (isDrawingPolygon) return;
const rect = canvas.getBoundingClientRect();
const mouse = { x: e.clientX - rect.left, y: e.clientY - rect.top };
const worldPos = screenToWorld(mouse.x, mouse.y);
@@ -275,10 +297,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
canvas.addEventListener('mousedown', (e) => {
if (e.shiftKey) {
isSelecting = true;
selectionStart = { x: e.offsetX, y: e.offsetY };
selectionEnd = selectionStart;
if (selectionMode === 'box' && e.shiftKey) {
isBoxSelecting = true;
boxSelectionStart = { x: e.offsetX, y: e.offsetY };
boxSelectionEnd = boxSelectionStart;
} else if (selectionMode === 'polygon') {
// Polygon drawing is handled in 'click' to avoid conflict with panning
} else {
isPanning = true;
panStart = { x: e.clientX, y: e.clientY };
@@ -286,6 +310,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
canvas.addEventListener('mousemove', (e) => {
currentMousePos = { x: e.offsetX, y: e.offsetY };
if (isPanning) {
const dx = e.clientX - panStart.x;
const dy = e.clientY - panStart.y;
@@ -293,55 +318,157 @@ document.addEventListener('DOMContentLoaded', () => {
transform.y += dy;
panStart = { x: e.clientX, y: e.clientY };
draw();
} else if (isSelecting) {
selectionEnd = { x: e.offsetX, y: e.offsetY };
} else if (isBoxSelecting) {
boxSelectionEnd = { x: e.offsetX, y: e.offsetY };
draw();
} else if (isDrawingPolygon) {
draw();
}
});
window.addEventListener('mouseup', (e) => {
if (isPanning) isPanning = false;
if (isSelecting) {
isSelecting = false;
const startWorld = screenToWorld(selectionStart.x, selectionStart.y);
const endWorld = screenToWorld(selectionEnd.x, selectionEnd.y);
const startCol = Math.floor(Math.min(startWorld.x, endWorld.x) / 10);
const endCol = Math.floor(Math.max(startWorld.x, endWorld.x) / 10);
const startRow = Math.floor(Math.min(startWorld.y, endWorld.y) / 10);
const endRow = Math.floor(Math.max(startWorld.y, endWorld.y) / 10);
if (!e.ctrlKey) selection.clear();
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r >= 0 && r < mapRows && c >= 0 && c < mapCols) {
const key = `${r},${c}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
}
}
}
setStatus(editorStatus, `${selection.size} die(s) selected.`);
if (isBoxSelecting) {
isBoxSelecting = false;
selectDiesInBox();
draw();
}
});
canvas.addEventListener('click', (e) => {
const wasDrag = Math.abs(e.clientX - panStart.x) > 2 || Math.abs(e.clientY - panStart.y) > 2;
if (e.shiftKey || (isPanning && wasDrag)) return;
if (isPanning && wasDrag) return;
const die = getDieAtScreen(e.offsetX, e.offsetY);
if (die) {
if (!e.ctrlKey) selection.clear();
const key = `${die.row},${die.col}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
setStatus(editorStatus, `${selection.size} die(s) selected.`);
draw();
if (selectionMode === 'polygon') {
handlePolygonClick(e);
} else {
if (e.shiftKey) return; // Box selection is handled in mousedown/up
const die = getDieAtScreen(e.offsetX, e.offsetY);
if (die) {
if (!e.ctrlKey) selection.clear();
const key = `${die.row},${die.col}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
setStatus(editorStatus, `${selection.size} die(s) selected.`);
draw();
}
}
});
canvas.addEventListener('dblclick', (e) => {
if (selectionMode === 'polygon' && isDrawingPolygon) {
e.preventDefault();
closeAndSelectPolygon();
}
});
// --- Selection Logic ---
function selectDiesInBox() {
const startWorld = screenToWorld(boxSelectionStart.x, boxSelectionStart.y);
const endWorld = screenToWorld(boxSelectionEnd.x, boxSelectionEnd.y);
const startCol = Math.floor(Math.min(startWorld.x, endWorld.x) / 10);
const endCol = Math.floor(Math.max(startWorld.x, endWorld.x) / 10);
const startRow = Math.floor(Math.min(startWorld.y, endWorld.y) / 10);
const endRow = Math.floor(Math.max(startWorld.y, endWorld.y) / 10);
if (!window.event.ctrlKey) selection.clear();
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (r >= 0 && r < mapRows && c >= 0 && c < mapCols) {
const key = `${r},${c}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
}
}
}
setStatus(editorStatus, `${selection.size} die(s) selected.`);
}
function handlePolygonClick(e) {
const point = { x: e.offsetX, y: e.offsetY };
if (!isDrawingPolygon) {
isDrawingPolygon = true;
polygonPoints = [point];
setStatus(editorStatus, 'Polygon drawing started. Click to add points, double-click to finish.');
} else {
// Check if closing polygon
const firstPoint = polygonPoints[0];
const dist = Math.sqrt(Math.pow(point.x - firstPoint.x, 2) + Math.pow(point.y - firstPoint.y, 2));
if (dist < 10 && polygonPoints.length > 2) {
closeAndSelectPolygon();
} else {
polygonPoints.push(point);
}
}
draw();
}
function closeAndSelectPolygon() {
if (!window.event.ctrlKey) selection.clear();
selectDiesInPolygon();
isDrawingPolygon = false;
polygonPoints = [];
draw();
}
function selectDiesInPolygon() {
const dieSize = 10;
const polygonInWorld = polygonPoints.map(p => screenToWorld(p.x, p.y));
for (let r = 0; r < mapRows; r++) {
for (let c = 0; c < mapCols; c++) {
const dieCenter = {
x: c * dieSize + dieSize / 2,
y: r * dieSize + dieSize / 2
};
if (pointInPolygon(dieCenter, polygonInWorld)) {
const key = `${r},${c}`;
if (selection.has(key)) selection.delete(key);
else selection.add(key);
}
}
}
setStatus(editorStatus, `${selection.size} die(s) selected.`);
}
function pointInPolygon(point, vs) {
// ray-casting algorithm based on
// https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html
const x = point.x, y = point.y;
let inside = false;
for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
const xi = vs[i].x, yi = vs[i].y;
const xj = vs[j].x, yj = vs[j].y;
const intersect = ((yi > y) !== (yj > y))
&& (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
// --- Action Buttons ---
function setSelectionMode(mode) {
selectionMode = mode;
if (mode === 'polygon') {
polygonSelectBtn.classList.add('active');
boxSelectBtn.classList.remove('active');
setStatus(editorStatus, 'Polygon select mode. Click to draw, dbl-click to finish.');
isDrawingPolygon = false;
polygonPoints = [];
} else { // box mode
boxSelectBtn.classList.add('active');
polygonSelectBtn.classList.remove('active');
setStatus(editorStatus, 'Box select mode. Hold Shift and drag to select.');
isDrawingPolygon = false; // Cancel any ongoing polygon drawing
}
draw();
}
boxSelectBtn.addEventListener('click', () => setSelectionMode('box'));
polygonSelectBtn.addEventListener('click', () => setSelectionMode('polygon'));
updateSelectionBtn.addEventListener('click', async () => {
if (selection.size === 0) {
setStatus(editorStatus, 'No dies selected to update.', true);
@@ -361,19 +488,30 @@ document.addEventListener('DOMContentLoaded', () => {
const result = await response.json();
if (!response.ok) throw new Error(result.error);
waferMap = result.wafer_map;
selection.clear();
updateBinCounts();
handleMapUpdate(result);
setStatus(editorStatus, 'Update successful.');
draw();
updateHistoryButtons(true, false);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
resetViewBtn.addEventListener('click', () => {
resetTransform();
draw();
resetViewBtn.addEventListener('click', async () => {
setStatus(editorStatus, 'Resetting map to original state...');
try {
const response = await fetch('/reset_map', { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
handleMapUpdate(result);
resetTransform(); // Also reset zoom and pan
draw();
setStatus(editorStatus, 'Map has been reset.');
updateHistoryButtons(result.can_undo, result.can_redo);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
rotateBtn.addEventListener('click', async () => {
@@ -383,19 +521,69 @@ document.addEventListener('DOMContentLoaded', () => {
const result = await response.json();
if (!response.ok) throw new Error(result.error);
waferMap = result.wafer_map;
mapRows = result.rows;
mapCols = result.cols;
selection.clear();
resetTransform();
updateBinCounts();
draw();
handleMapUpdate(result);
setStatus(editorStatus, 'Map rotated successfully.');
updateHistoryButtons(true, false);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
insetMapBtn.addEventListener('click', async () => {
const layers = document.getElementById('inset-layers').value;
const fromBin = document.getElementById('inset-from-bin').value;
const toBin = document.getElementById('inset-to-bin').value;
if (parseInt(layers) < 1) {
setStatus(editorStatus, 'Inset layers must be at least 1.', true);
return;
}
if (fromBin === toBin) {
setStatus(editorStatus, "'From' and 'To' bin types cannot be the same.", true);
return;
}
setStatus(editorStatus, `Insetting ${layers} layer(s)...`);
try {
const response = await fetch('/inset_map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
layers: layers,
from_bin: fromBin,
to_bin: toBin
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
handleMapUpdate(result);
setStatus(editorStatus, 'Inset operation successful.');
updateHistoryButtons(true, false);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
});
async function handleHistoryAction(action) {
setStatus(editorStatus, `Performing ${action}...`);
try {
const response = await fetch(`/${action}`, { method: 'POST' });
const result = await response.json();
if (!response.ok) throw new Error(result.error);
handleMapUpdate(result);
setStatus(editorStatus, `${action} successful.`);
updateHistoryButtons(result.can_undo, result.can_redo);
} catch (error) {
setStatus(editorStatus, `Error: ${error.message}`, true);
}
}
undoBtn.addEventListener('click', () => handleHistoryAction('undo'));
redoBtn.addEventListener('click', () => handleHistoryAction('redo'));
new ResizeObserver(() => {
if (mapRows > 0) { // Only resize if there is a map