911 lines
30 KiB
JavaScript
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();
|