2026-03-20 17:46:47 +04:00

911 lines
30 KiB
JavaScript

// =============================================================================
// 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 = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
`<svg width="${CANVAS_W}" height="${CANVAS_H}" viewBox="0 0 ${CANVAS_W} ${CANVAS_H}" version="1.1"`,
' xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">',
` <rect width="${CANVAS_W}" height="${CANVAS_H}" fill="#1e1e1e" />`,
];
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(` <!-- ${sym.name} -->`);
lines.push(` <svg x="${sym.x}" y="${sym.y}" width="${sym.w}" height="${sym.h}" viewBox="${viewBox}"${rotAttr}>`);
lines.push(` ${svgEl.innerHTML}`);
lines.push(' </svg>');
} catch (err) {
console.error('Failed to embed symbol:', sym.name, err);
}
}
lines.push('</svg>');
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();