// ============================================================================= // Constants // ============================================================================= const CANVAS_W = 1920; const CANVAS_H = 1080; const ZOOM_MIN = 0.1; const ZOOM_MAX = 5; const ZOOM_IN_FACTOR = 1.1; const ZOOM_OUT_FACTOR = 0.9; const PDF_ZOOM_STEP = 1.1; const PDF_MIN_SCALE = 0.05; const SNAP_SEARCH_RADIUS = 20; const ROTATION_STEP = 15; const CONVEYOR_MIN_W = 40; const PRIORITY_TYPES = new Set([ 'conveyor', 'conveyor_v', 'chute', 'chute_v', 'tipper', 'tipper_v', 'extendo', 'extendo_v', 'induction', 'induction_v', ]); const SYMBOLS = [ { id: 'conveyor', name: 'Conveyor', file: 'symbols/conveyor_no_comm.svg', w: 154, h: 30 }, { id: 'conveyor_v', name: 'Conveyor (V)', file: 'symbols/conveyor_no_comm.svg', w: 154, h: 30, defaultRotation: 90 }, { id: 'chute', name: 'Chute', file: 'symbols/chute_no_comm.svg', w: 68, h: 20 }, { id: 'chute_v', name: 'Chute (V)', file: 'symbols/chute_no_comm.svg', w: 68, h: 20, defaultRotation: 90 }, { id: 'tipper', name: 'Tipper', file: 'symbols/tipper_no_comm.svg', w: 68, h: 20 }, { id: 'tipper_v', name: 'Tipper (V)', file: 'symbols/tipper_no_comm.svg', w: 68, h: 20, defaultRotation: 90 }, { id: 'extendo', name: 'Extendo', file: 'symbols/extendo_no_comm.svg', w: 73, h: 20 }, { id: 'extendo_v', name: 'Extendo (V)', file: 'symbols/extendo_no_comm.svg', w: 73, h: 20, defaultRotation: 90 }, { id: 'induction', name: 'Induction', file: 'symbols/induction_no_comm.svg',w: 42, h: 20 }, { id: 'induction_v',name: 'Induction (V)', file: 'symbols/induction_no_comm.svg',w: 42, h: 20, defaultRotation: 90 }, { id: 'pmm', name: 'PMM', file: 'symbols/pmm_no_comm.svg', w: 49, h: 20 }, { id: 'dpm', name: 'DPM', file: 'symbols/dpm_no_comm.svg', w: 35, h: 20 }, { id: 'fio_sio_fioh', name: 'FIO/SIO/FIOH', file: 'symbols/fio_sio_fioh_no_comm.svg', w: 14, h: 20 }, { id: 'pressure_sensor', name: 'Pressure Sensor', file: 'symbols/pressure_sensor_no_comm.svg', w: 20, h: 20 }, { id: 'epc', name: 'EPC', file: 'symbols/epc_no_comm.svg', w: 84, h: 20 }, { id: 'photoeye', name: 'Photoeye', file: 'symbols/photoeye_no_comm.svg', w: 56, h: 20 }, { id: 'ip_camera', name: 'IP Camera', file: 'symbols/ip_camera.svg', w: 20, h: 20 }, { id: 'mcm', name: 'MCM', file: 'symbols/mcm_no_comm.svg', w: 60, h: 20 }, { id: 'diverter', name: 'Diverter', file: 'symbols/diverter_no_comm.svg', w: 31, h: 20 }, { id: 'jam_reset', name: 'Jam Reset (JR)', file: 'symbols/jam_reset_no_comm.svg',w: 20, h: 20 }, { id: 'start', name: 'Start (S)', file: 'symbols/start_no_comm.svg', w: 20, h: 20 }, { id: 'chute_enable', name: 'Chute Enable', file: 'symbols/chute_enable_no_comm.svg', w: 20, h: 20 }, { id: 'package_release', name: 'Package Release', file: 'symbols/package_release_no_comm.svg', w: 20, h: 20 }, { id: 'start_stop', name: 'Start Stop (SS)', file: 'symbols/start_stop_no_comm.svg', w: 40, h: 20 }, ]; // ============================================================================= // Cached DOM References // ============================================================================= const dom = { gridSize: document.getElementById('gridSize'), minSpacing: document.getElementById('minSpacing'), snapEnabled: document.getElementById('snapEnabled'), showGrid: document.getElementById('showGrid'), gridOverlay: document.getElementById('grid-overlay'), dropZone: document.getElementById('drop-zone'), palette: document.getElementById('palette'), canvas: document.getElementById('canvas'), canvasWrapper: document.getElementById('canvas-wrapper'), pdfBackground: document.getElementById('pdf-background'), pdfInfo: document.getElementById('pdfInfo'), editBackgroundBtn: document.getElementById('editBackground'), importFile: document.getElementById('importFile'), pdfFile: document.getElementById('pdfFile'), }; // ============================================================================= // MCM State Management // ============================================================================= let currentMcm = document.getElementById('mcmSelect').value; function getMcmStorageKey(mcm) { return 'scada_mcm_' + mcm; } function saveMcmState() { const state = { symbols: placedSymbols.map(({ symbolId, name, file, x, y, w, h, rotation }) => ( { symbolId, name, file, x, y, w, h, rotation: rotation || 0 } )), nextId, gridSize: getGridSize(), minSpacing: getMinSpacing(), pdfScale, pdfOffsetX, pdfOffsetY, }; localStorage.setItem(getMcmStorageKey(currentMcm), JSON.stringify(state)); } function loadMcmState(mcm) { const raw = localStorage.getItem(getMcmStorageKey(mcm)); if (!raw) { placedSymbols = []; nextId = 1; selectedId = null; renderPlacedSymbols(); return; } try { const state = JSON.parse(raw); placedSymbols = []; nextId = 1; selectedId = null; if (state.gridSize) dom.gridSize.value = state.gridSize; if (state.minSpacing) dom.minSpacing.value = state.minSpacing; if (state.pdfScale) pdfScale = state.pdfScale; if (state.pdfOffsetX !== undefined) pdfOffsetX = state.pdfOffsetX; if (state.pdfOffsetY !== undefined) pdfOffsetY = state.pdfOffsetY; for (const s of (state.symbols || [])) { placedSymbols.push({ id: nextId++, symbolId: s.symbolId, name: s.name, file: s.file, x: s.x, y: s.y, w: s.w, h: s.h, rotation: s.rotation || 0, }); } if (state.nextId && state.nextId > nextId) nextId = state.nextId; drawGrid(); renderPlacedSymbols(); if (pdfPage) renderPDFBackground(); } catch (e) { console.error('Failed to load MCM state:', e); } } // ============================================================================= // Application State // ============================================================================= let placedSymbols = []; let nextId = 1; let selectedId = null; let dragState = null; // Zoom / Pan let zoomLevel = 1; let panX = 0; let panY = 0; let isPanning = false; let panStartX = 0; let panStartY = 0; // PDF Background let pdfScale = 1.0; let pdfPage = null; let pdfNatWidth = 0; let pdfNatHeight = 0; let pdfOffsetX = 0; let pdfOffsetY = 0; let editingBackground = false; let pdfDragState = null; // ============================================================================= // Settings Helpers // ============================================================================= function getGridSize() { return parseInt(dom.gridSize.value) || 20; } function getMinSpacing() { return parseInt(dom.minSpacing.value) || 10; } function isSnapEnabled() { return dom.snapEnabled.checked; } // ============================================================================= // Utilities // ============================================================================= function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function createSvgElement(tag, attrs) { const el = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const [key, val] of Object.entries(attrs)) { el.setAttribute(key, val); } return el; } // ============================================================================= // Coordinate Transforms // ============================================================================= function applyTransform() { dom.canvas.style.transform = `translate(${panX}px, ${panY}px) scale(${zoomLevel})`; } function screenToCanvas(clientX, clientY) { const rect = dom.canvasWrapper.getBoundingClientRect(); return { x: (clientX - rect.left - panX) / zoomLevel, y: (clientY - rect.top - panY) / zoomLevel, }; } function snapToGrid(x, y) { if (!isSnapEnabled()) return { x, y }; const size = getGridSize(); return { x: Math.round(x / size) * size, y: Math.round(y / size) * size, }; } // ============================================================================= // Grid Rendering // ============================================================================= function drawGrid() { const svg = dom.gridOverlay; const size = getGridSize(); svg.innerHTML = ''; if (!dom.showGrid.checked) return; const defs = createSvgElement('defs', {}); const pattern = createSvgElement('pattern', { id: 'gridPattern', width: size, height: size, patternUnits: 'userSpaceOnUse', }); pattern.appendChild(createSvgElement('line', { x1: 0, y1: 0, x2: size, y2: 0, stroke: '#333', 'stroke-width': '0.5', })); pattern.appendChild(createSvgElement('line', { x1: 0, y1: 0, x2: 0, y2: size, stroke: '#333', 'stroke-width': '0.5', })); defs.appendChild(pattern); svg.appendChild(defs); svg.appendChild(createSvgElement('rect', { width: CANVAS_W, height: CANVAS_H, fill: 'url(#gridPattern)', })); } // ============================================================================= // Rotation-Aware Bounding Box // ============================================================================= function getAABB(x, y, w, h, rotation) { if (!rotation) return { x, y, w, h }; const cx = x + w / 2; const cy = y + h / 2; const rad = rotation * Math.PI / 180; const cos = Math.abs(Math.cos(rad)); const sin = Math.abs(Math.sin(rad)); const bw = w * cos + h * sin; const bh = w * sin + h * cos; return { x: cx - bw / 2, y: cy - bh / 2, w: bw, h: bh }; } // ============================================================================= // Spacing & Collision // ============================================================================= function edgeDistance(ax, ay, aw, ah, bx, by, bw, bh) { const dx = Math.max(0, Math.max(ax - (bx + bw), bx - (ax + aw))); const dy = Math.max(0, Math.max(ay - (by + bh), by - (ay + ah))); if (dx === 0 && dy === 0) return 0; if (dx === 0) return dy; if (dy === 0) return dx; return Math.sqrt(dx * dx + dy * dy); } function checkSpacingViolation(id, x, y, w, h, rotation) { const spacing = getMinSpacing(); const a = getAABB(x, y, w, h, rotation); for (const sym of placedSymbols) { if (sym.id === id) continue; const b = getAABB(sym.x, sym.y, sym.w, sym.h, sym.rotation); if (edgeDistance(a.x, a.y, a.w, a.h, b.x, b.y, b.w, b.h) < spacing) return true; } return false; } function findValidPosition(id, x, y, w, h, symbolId, rotation) { const snapped = snapToGrid(x, y); let sx = snapped.x, sy = snapped.y; if (symbolId && PRIORITY_TYPES.has(symbolId)) return { x: sx, y: sy }; if (!checkSpacingViolation(id, sx, sy, w, h, rotation)) return { x: sx, y: sy }; const step = isSnapEnabled() ? getGridSize() : 1; for (let r = 1; r <= SNAP_SEARCH_RADIUS; r++) { for (let dx = -r; dx <= r; dx++) { for (let dy = -r; dy <= r; dy++) { if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; const cx = sx + dx * step; const cy = sy + dy * step; const bb = getAABB(cx, cy, w, h, rotation); if (bb.x < 0 || bb.y < 0 || bb.x + bb.w > CANVAS_W || bb.y + bb.h > CANVAS_H) continue; if (!checkSpacingViolation(id, cx, cy, w, h, rotation)) return { x: cx, y: cy }; } } } return { x: sx, y: sy }; } // ============================================================================= // Context Menu // ============================================================================= function removeContextMenu() { document.querySelectorAll('.context-menu').forEach(m => m.remove()); } function showContextMenu(x, y, symId) { removeContextMenu(); const menu = document.createElement('div'); menu.className = 'context-menu'; menu.style.left = x + 'px'; menu.style.top = y + 'px'; const actions = [ { label: 'Delete', fn: () => { placedSymbols = placedSymbols.filter(s => s.id !== symId); if (selectedId === symId) selectedId = null; renderPlacedSymbols(); }}, { label: 'Duplicate', fn: () => { const orig = placedSymbols.find(s => s.id === symId); if (!orig) return; const pos = findValidPosition(-1, orig.x + 20, orig.y + 20, orig.w, orig.h, orig.symbolId, orig.rotation); placedSymbols.push({ id: nextId++, symbolId: orig.symbolId, file: orig.file, name: orig.name, x: pos.x, y: pos.y, w: orig.w, h: orig.h, rotation: orig.rotation || 0, }); renderPlacedSymbols(); }}, ]; for (const action of actions) { const item = document.createElement('div'); item.className = 'context-menu-item'; item.textContent = action.label; item.addEventListener('click', () => { action.fn(); removeContextMenu(); }); menu.appendChild(item); } document.body.appendChild(menu); } // ============================================================================= // Render Placed Symbols // ============================================================================= function isConveyorType(symbolId) { return symbolId === 'conveyor' || symbolId === 'conveyor_v'; } function renderPlacedSymbols() { dom.dropZone.innerHTML = ''; saveMcmState(); for (const sym of placedSymbols) { const div = document.createElement('div'); div.className = 'placed-symbol'; if (sym.id === selectedId) div.classList.add('selected'); div.dataset.id = sym.id; Object.assign(div.style, { left: sym.x + 'px', top: sym.y + 'px', width: sym.w + 'px', height: sym.h + 'px', }); if (sym.rotation) { div.style.transform = `rotate(${sym.rotation}deg)`; div.style.transformOrigin = 'center center'; } const img = document.createElement('img'); img.src = sym.file; img.draggable = false; div.appendChild(img); // Resize handle for conveyors if (isConveyorType(sym.symbolId) && sym.id === selectedId) { const handle = document.createElement('div'); handle.className = 'resize-handle'; handle.addEventListener('mousedown', (e) => { if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); const pos = screenToCanvas(e.clientX, e.clientY); dragState = { type: 'resize', placedId: sym.id, startX: pos.x, startY: pos.y, origW: sym.w, }; }); div.appendChild(handle); } div.addEventListener('mousedown', (e) => { if (e.button !== 0) return; e.stopPropagation(); e.preventDefault(); selectedId = sym.id; const pos = screenToCanvas(e.clientX, e.clientY); dragState = { type: 'move', placedId: sym.id, offsetX: pos.x - sym.x, offsetY: pos.y - sym.y, }; div.classList.add('dragging'); renderPlacedSymbols(); }); div.addEventListener('contextmenu', (e) => { e.preventDefault(); e.stopPropagation(); showContextMenu(e.clientX, e.clientY, sym.id); }); dom.dropZone.appendChild(div); } } // ============================================================================= // Palette // ============================================================================= function buildPalette() { dom.palette.innerHTML = ''; for (const sym of SYMBOLS) { const item = document.createElement('div'); item.className = 'palette-item'; item.dataset.symbolId = sym.id; const img = document.createElement('img'); img.src = sym.file; img.draggable = false; if (sym.defaultRotation) { img.style.transform = `rotate(${sym.defaultRotation}deg)`; } item.appendChild(img); const label = document.createElement('span'); label.textContent = sym.name; item.appendChild(label); item.addEventListener('mousedown', (e) => { if (e.button !== 0) return; e.preventDefault(); const ghost = document.createElement('div'); ghost.className = 'drag-ghost'; ghost.style.width = sym.w + 'px'; ghost.style.height = sym.h + 'px'; const ghostImg = document.createElement('img'); ghostImg.src = sym.file; ghostImg.style.width = '100%'; ghostImg.style.height = '100%'; if (sym.defaultRotation) { ghost.style.transform = `rotate(${sym.defaultRotation}deg)`; } ghost.appendChild(ghostImg); ghost.style.left = (e.clientX - sym.w / 2) + 'px'; ghost.style.top = (e.clientY - sym.h / 2) + 'px'; document.body.appendChild(ghost); dragState = { type: 'palette', symbolDef: sym, ghost }; }); dom.palette.appendChild(item); } } // ============================================================================= // Consolidated Mouse Handlers // ============================================================================= document.addEventListener('mousemove', (e) => { // --- Pan --- if (isPanning) { panX = e.clientX - panStartX; panY = e.clientY - panStartY; applyTransform(); return; } // --- PDF drag --- if (pdfDragState && editingBackground) { const pos = screenToCanvas(e.clientX, e.clientY); pdfOffsetX = pdfDragState.origOffsetX + (pos.x - pdfDragState.startX); pdfOffsetY = pdfDragState.origOffsetY + (pos.y - pdfDragState.startY); const pdfCanvas = dom.pdfBackground.querySelector('canvas'); if (pdfCanvas) { pdfCanvas.style.left = pdfOffsetX + 'px'; pdfCanvas.style.top = pdfOffsetY + 'px'; } dom.pdfInfo.textContent = `PDF: pos: ${Math.round(pdfOffsetX)},${Math.round(pdfOffsetY)} (${Math.round(pdfScale * 100)}%)`; return; } // --- Symbol drag --- if (!dragState) return; if (dragState.type === 'palette') { const sym = dragState.symbolDef; dragState.ghost.style.left = (e.clientX - sym.w / 2) + 'px'; dragState.ghost.style.top = (e.clientY - sym.h / 2) + 'px'; } if (dragState.type === 'move') { const sym = placedSymbols.find(s => s.id === dragState.placedId); if (!sym) return; const pos = screenToCanvas(e.clientX, e.clientY); const bb = getAABB(0, 0, sym.w, sym.h, sym.rotation); const newX = clamp(pos.x - dragState.offsetX, -bb.x, CANVAS_W - bb.w - bb.x); const newY = clamp(pos.y - dragState.offsetY, -bb.y, CANVAS_H - bb.h - bb.y); const snapped = snapToGrid(newX, newY); sym.x = snapped.x; sym.y = snapped.y; renderPlacedSymbols(); const el = dom.dropZone.querySelector(`[data-id="${sym.id}"]`); if (el && checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation)) { el.classList.add('collision'); } } if (dragState.type === 'resize') { const sym = placedSymbols.find(s => s.id === dragState.placedId); if (!sym) return; const pos = screenToCanvas(e.clientX, e.clientY); const dx = pos.x - dragState.startX; const dy = pos.y - dragState.startY; const rad = (sym.rotation || 0) * Math.PI / 180; const projectedDelta = dx * Math.cos(rad) + dy * Math.sin(rad); let newW = dragState.origW + projectedDelta; if (isSnapEnabled()) { newW = Math.round(newW / getGridSize()) * getGridSize(); } sym.w = Math.max(CONVEYOR_MIN_W, newW); renderPlacedSymbols(); } }); document.addEventListener('mouseup', (e) => { // --- Pan end --- if (e.button === 1 && isPanning) { isPanning = false; document.body.style.cursor = ''; return; } // --- PDF drag end --- if (pdfDragState) { pdfDragState = null; saveMcmState(); return; } // --- Symbol drag end --- if (!dragState) return; if (dragState.type === 'palette') { dragState.ghost.remove(); const sym = dragState.symbolDef; const rot = sym.defaultRotation || 0; const pos = screenToCanvas(e.clientX, e.clientY); let dropX = pos.x - sym.w / 2; let dropY = pos.y - sym.h / 2; if (dropX >= -sym.w && dropX <= CANVAS_W && dropY >= -sym.h && dropY <= CANVAS_H) { dropX = clamp(dropX, 0, CANVAS_W - sym.w); dropY = clamp(dropY, 0, CANVAS_H - sym.h); const valid = findValidPosition(-1, dropX, dropY, sym.w, sym.h, sym.id, rot); placedSymbols.push({ id: nextId++, symbolId: sym.id, file: sym.file, name: sym.name, x: valid.x, y: valid.y, w: sym.w, h: sym.h, rotation: rot, }); renderPlacedSymbols(); } } if (dragState.type === 'move') { const sym = placedSymbols.find(s => s.id === dragState.placedId); if (sym) { const valid = findValidPosition(sym.id, sym.x, sym.y, sym.w, sym.h, sym.symbolId, sym.rotation); sym.x = valid.x; sym.y = valid.y; } renderPlacedSymbols(); } if (dragState.type === 'resize') { renderPlacedSymbols(); } dragState = null; }); // ============================================================================= // Canvas Interactions // ============================================================================= dom.dropZone.addEventListener('mousedown', (e) => { if (e.target === e.currentTarget) { selectedId = null; renderPlacedSymbols(); removeContextMenu(); } }); document.addEventListener('click', removeContextMenu); document.addEventListener('keydown', (e) => { if (e.key === 'Delete' && selectedId !== null) { placedSymbols = placedSymbols.filter(s => s.id !== selectedId); selectedId = null; renderPlacedSymbols(); } // Rotation: E = clockwise, Q = counter-clockwise if (selectedId !== null && (e.key === 'e' || e.key === 'E')) { const sym = placedSymbols.find(s => s.id === selectedId); if (sym) { sym.rotation = ((sym.rotation || 0) + ROTATION_STEP) % 360; renderPlacedSymbols(); } } if (selectedId !== null && (e.key === 'q' || e.key === 'Q')) { const sym = placedSymbols.find(s => s.id === selectedId); if (sym) { sym.rotation = ((sym.rotation || 0) - ROTATION_STEP + 360) % 360; renderPlacedSymbols(); } } }); // ============================================================================= // Zoom & Pan // ============================================================================= let pdfReRenderTimer = null; function schedulePdfReRender() { if (!pdfPage) return; clearTimeout(pdfReRenderTimer); pdfReRenderTimer = setTimeout(() => renderPDFBackground(), 150); } dom.canvasWrapper.addEventListener('wheel', (e) => { e.preventDefault(); const rect = dom.canvasWrapper.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const cx = (mx - panX) / zoomLevel; const cy = (my - panY) / zoomLevel; const factor = e.deltaY > 0 ? ZOOM_OUT_FACTOR : ZOOM_IN_FACTOR; zoomLevel = clamp(zoomLevel * factor, ZOOM_MIN, ZOOM_MAX); panX = mx - cx * zoomLevel; panY = my - cy * zoomLevel; applyTransform(); schedulePdfReRender(); }, { passive: false }); dom.canvasWrapper.addEventListener('mousedown', (e) => { if (e.button === 1) { e.preventDefault(); isPanning = true; panStartX = e.clientX - panX; panStartY = e.clientY - panY; document.body.style.cursor = 'grabbing'; } }); // ============================================================================= // Settings // ============================================================================= dom.gridSize.addEventListener('change', () => { drawGrid(); saveMcmState(); }); dom.showGrid.addEventListener('change', drawGrid); dom.minSpacing.addEventListener('change', () => saveMcmState()); document.getElementById('mcmSelect').addEventListener('change', (e) => { saveMcmState(); currentMcm = e.target.value; loadMcmState(currentMcm); }); document.getElementById('clearCanvas').addEventListener('click', () => { if (!confirm('Clear all placed symbols?')) return; placedSymbols = []; selectedId = null; renderPlacedSymbols(); }); // ============================================================================= // Export SVG // ============================================================================= document.getElementById('exportSVG').addEventListener('click', async () => { const lines = [ '', `', ` `, ]; for (const sym of placedSymbols) { try { const svgText = await (await fetch(sym.file)).text(); const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); const svgEl = doc.documentElement; const viewBox = svgEl.getAttribute('viewBox') || `0 0 ${sym.w} ${sym.h}`; const rot = sym.rotation || 0; const cx = sym.x + sym.w / 2; const cy = sym.y + sym.h / 2; const rotAttr = rot ? ` transform="rotate(${rot},${cx},${cy})"` : ''; lines.push(` `); lines.push(` `); lines.push(` ${svgEl.innerHTML}`); lines.push(' '); } catch (err) { console.error('Failed to embed symbol:', sym.name, err); } } lines.push(''); downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), 'scada_layout.svg'); }); // ============================================================================= // Save / Load Layout (JSON) // ============================================================================= document.getElementById('saveLayout').addEventListener('click', () => { const data = { gridSize: getGridSize(), minSpacing: getMinSpacing(), symbols: placedSymbols.map(({ symbolId, name, file, x, y, w, h, rotation }) => ( { symbolId, name, file, x, y, w, h, rotation: rotation || 0 } )), }; downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), 'scada_layout.json'); }); document.getElementById('loadLayout').addEventListener('click', () => dom.importFile.click()); dom.importFile.addEventListener('change', (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { try { const data = JSON.parse(ev.target.result); if (data.gridSize) dom.gridSize.value = data.gridSize; if (data.minSpacing) dom.minSpacing.value = data.minSpacing; placedSymbols = []; nextId = 1; for (const s of data.symbols) { placedSymbols.push({ id: nextId++, symbolId: s.symbolId, name: s.name, file: s.file, x: s.x, y: s.y, w: s.w, h: s.h, rotation: s.rotation || 0, }); } drawGrid(); renderPlacedSymbols(); } catch (err) { alert('Invalid layout file: ' + err.message); } }; reader.readAsText(file); e.target.value = ''; }); // ============================================================================= // PDF Background // ============================================================================= let pdfRenderTask = null; function renderPDFBackground() { dom.pdfBackground.innerHTML = ''; if (!pdfPage) { dom.pdfInfo.textContent = ''; return; } if (pdfRenderTask) { pdfRenderTask.cancel(); pdfRenderTask = null; } const dpr = window.devicePixelRatio || 1; const hiResScale = pdfScale * zoomLevel * dpr; const viewport = pdfPage.getViewport({ scale: hiResScale }); const displayViewport = pdfPage.getViewport({ scale: pdfScale }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; canvas.style.width = displayViewport.width + 'px'; canvas.style.height = displayViewport.height + 'px'; canvas.style.left = pdfOffsetX + 'px'; canvas.style.top = pdfOffsetY + 'px'; pdfRenderTask = pdfPage.render({ canvasContext: canvas.getContext('2d'), viewport }); pdfRenderTask.promise.then(() => { pdfRenderTask = null; }).catch(() => { pdfRenderTask = null; }); dom.pdfBackground.appendChild(canvas); dom.pdfInfo.textContent = `PDF: ${Math.round(displayViewport.width)}x${Math.round(displayViewport.height)} (${Math.round(pdfScale * 100)}%) pos: ${Math.round(pdfOffsetX)},${Math.round(pdfOffsetY)}`; } function setEditBackgroundMode(active) { editingBackground = active; if (active) { dom.editBackgroundBtn.classList.add('edit-bg-active'); dom.editBackgroundBtn.textContent = 'Done Editing'; dom.pdfBackground.classList.add('editing'); dom.pdfBackground.style.pointerEvents = 'auto'; dom.pdfBackground.style.zIndex = '3'; dom.dropZone.style.pointerEvents = 'none'; } else { dom.editBackgroundBtn.classList.remove('edit-bg-active'); dom.editBackgroundBtn.textContent = 'Edit Background'; dom.pdfBackground.classList.remove('editing'); dom.pdfBackground.style.pointerEvents = 'none'; dom.pdfBackground.style.zIndex = '0'; dom.dropZone.style.pointerEvents = 'auto'; pdfDragState = null; } } dom.editBackgroundBtn.addEventListener('click', () => { if (pdfPage) setEditBackgroundMode(!editingBackground); }); dom.pdfBackground.addEventListener('mousedown', (e) => { if (!editingBackground || e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const pos = screenToCanvas(e.clientX, e.clientY); pdfDragState = { startX: pos.x, startY: pos.y, origOffsetX: pdfOffsetX, origOffsetY: pdfOffsetY, }; }); document.getElementById('loadPDF').addEventListener('click', () => dom.pdfFile.click()); dom.pdfFile.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const pdf = await pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise; pdfPage = await pdf.getPage(1); const vp = pdfPage.getViewport({ scale: 1 }); pdfNatWidth = vp.width; pdfNatHeight = vp.height; pdfScale = Math.min(CANVAS_W / pdfNatWidth, CANVAS_H / pdfNatHeight); renderPDFBackground(); e.target.value = ''; }); document.getElementById('pdfZoomIn').addEventListener('click', () => { if (!pdfPage) return; pdfScale *= PDF_ZOOM_STEP; renderPDFBackground(); saveMcmState(); }); document.getElementById('pdfZoomOut').addEventListener('click', () => { if (!pdfPage) return; pdfScale = Math.max(PDF_MIN_SCALE, pdfScale / PDF_ZOOM_STEP); renderPDFBackground(); saveMcmState(); }); document.getElementById('pdfRemove').addEventListener('click', () => { pdfPage = null; pdfScale = 1.0; pdfOffsetX = 0; pdfOffsetY = 0; setEditBackgroundMode(false); renderPDFBackground(); }); // ============================================================================= // Init // ============================================================================= buildPalette(); loadMcmState(currentMcm); drawGrid(); renderPlacedSymbols(); applyTransform();