real commit

This commit is contained in:
igurielidze 2026-03-20 17:46:47 +04:00
commit eda97fc5bc
115 changed files with 44316 additions and 0 deletions

910
app.js Normal file
View File

@ -0,0 +1,910 @@
// =============================================================================
// 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();

352
browser_render.xml Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-D5Q66DLP.css vendored Normal file
View File

@ -0,0 +1 @@
*{margin:0;padding:0;box-sizing:border-box}body{font-family:Arial,sans-serif;background:#1a1a2e;color:#e0e0e0;overflow:hidden;height:100vh}#app{display:flex;height:100vh}#toolbar{width:220px;min-width:220px;background:#16213e;border-right:2px solid #0f3460;padding:10px;overflow-y:auto;display:flex;flex-direction:column}#toolbar h2{font-size:14px;color:#e94560;margin:10px 0 6px;text-transform:uppercase;letter-spacing:1px}.setting{margin-bottom:8px}.setting label{display:block;font-size:12px;margin-bottom:3px;color:#aaa}.setting input[type=number]{width:100%;padding:4px 6px;background:#0f3460;border:1px solid #1a1a5e;color:#e0e0e0;border-radius:3px;font-size:13px}.setting input[type=checkbox]{margin-right:5px}.setting button{width:100%;padding:6px;margin-bottom:4px;background:#0f3460;border:1px solid #1a1a5e;color:#e0e0e0;border-radius:3px;cursor:pointer;font-size:12px}.setting button:hover{background:#e94560}#palette{flex:1;overflow-y:auto}.palette-item{display:flex;align-items:center;gap:8px;padding:6px 4px;margin-bottom:4px;background:#0f3460;border:1px solid #1a1a5e;border-radius:4px;cursor:grab;-webkit-user-select:none;user-select:none;transition:background .15s}.palette-item:hover{background:#1a1a5e;border-color:#e94560}.palette-item:active{cursor:grabbing}.palette-item img{width:40px;height:30px;object-fit:contain;background:#222;border-radius:3px;padding:2px}.palette-item span{font-size:11px;color:#ccc}#canvas-wrapper{flex:1;overflow:hidden;background:#111;position:relative}#canvas{width:1920px;height:1080px;position:absolute;background:#1e1e1e;border:1px solid #333;transform-origin:0 0}#pdf-background{position:absolute;top:0;left:0;overflow:hidden;width:1920px;height:1080px;pointer-events:none;z-index:0}#pdf-background canvas{position:absolute;transform-origin:0 0;opacity:.35}#pdf-background.editing canvas{opacity:.6;cursor:move}.edit-bg-active{background:#e94560!important;color:#fff!important}#grid-overlay{position:absolute;top:0;left:0;width:1920px;height:1080px;pointer-events:none;z-index:1}#drop-zone{position:absolute;top:0;left:0;width:1920px;height:1080px;z-index:2}.placed-symbol{position:absolute;cursor:move;-webkit-user-select:none;user-select:none;border:1px solid transparent;transition:border-color .1s;resize:none}.placed-symbol:hover{border-color:#e94560}.placed-symbol.selected{border-color:#0f8;box-shadow:0 0 6px #0f86}.placed-symbol.dragging{opacity:.7;z-index:1000}.placed-symbol.collision{border-color:red;box-shadow:0 0 8px #f009}.placed-symbol img{width:100%;height:100%;pointer-events:none;display:block}.drag-ghost{position:fixed;pointer-events:none;opacity:.6;z-index:9999}.context-menu{position:fixed;background:#16213e;border:1px solid #0f3460;border-radius:4px;padding:4px 0;z-index:10000;min-width:140px}.context-menu-item{padding:6px 14px;font-size:12px;color:#e0e0e0;cursor:pointer}.context-menu-item:hover{background:#e94560}.resize-handle{position:absolute;top:50%;right:-5px;width:10px;height:10px;transform:translateY(-50%);background:#0f8;border:1px solid #009955;border-radius:2px;cursor:ew-resize;z-index:10}

83
dist/index.html vendored Normal file
View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCADA Device Layout Tool</title>
<link rel="stylesheet" crossorigin href="/assets/index-D5Q66DLP.css">
</head>
<body>
<div id="app">
<!-- Toolbar -->
<div id="toolbar">
<h2>Project</h2>
<div class="setting">
<label for="mcmSelect">MCM:</label>
<select id="mcmSelect">
<option value="MCM08">MCM08</option>
</select>
</div>
<h2>Background PDF</h2>
<div class="setting">
<button id="loadPDF">Load PDF</button>
<input type="file" id="pdfFile" accept=".pdf" style="display:none">
</div>
<div class="setting">
<button id="pdfZoomIn">PDF +</button>
<button id="pdfZoomOut">PDF -</button>
</div>
<div class="setting">
<button id="editBackground">Edit Background</button>
<button id="pdfRemove">Remove PDF</button>
</div>
<div class="setting" id="pdfInfo" style="font-size:11px;color:#888;"></div>
<h2>Settings</h2>
<div class="setting">
<label for="gridSize">Grid Size (px):</label>
<input type="number" id="gridSize" value="20" min="5" max="100" step="5">
</div>
<div class="setting">
<label for="minSpacing">Min Edge Spacing (px):</label>
<input type="number" id="minSpacing" value="10" min="0" max="100" step="5">
</div>
<div class="setting">
<label>
<input type="checkbox" id="showGrid" checked> Show Grid
</label>
</div>
<div class="setting">
<label>
<input type="checkbox" id="snapEnabled" checked> Snap to Grid
</label>
</div>
<div class="setting">
<button id="clearCanvas">Clear Canvas</button>
<button id="exportSVG">Export SVG</button>
<button id="saveLayout">Save Layout</button>
<button id="loadLayout">Load Layout</button>
<input type="file" id="importFile" accept=".json" style="display:none">
</div>
<h2>Symbols</h2>
<div id="palette"></div>
</div>
<!-- Canvas -->
<div id="canvas-wrapper">
<div id="canvas">
<div id="pdf-background"></div>
<svg id="grid-overlay" width="1920" height="1080"></svg>
<div id="drop-zone"></div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="app.js"></script>
</body>
</html>

83
index.html Normal file
View File

@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SCADA Device Layout Tool</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<!-- Toolbar -->
<div id="toolbar">
<h2>Project</h2>
<div class="setting">
<label for="mcmSelect">MCM:</label>
<select id="mcmSelect">
<option value="MCM08">MCM08</option>
</select>
</div>
<h2>Background PDF</h2>
<div class="setting">
<button id="loadPDF">Load PDF</button>
<input type="file" id="pdfFile" accept=".pdf" style="display:none">
</div>
<div class="setting">
<button id="pdfZoomIn">PDF +</button>
<button id="pdfZoomOut">PDF -</button>
</div>
<div class="setting">
<button id="editBackground">Edit Background</button>
<button id="pdfRemove">Remove PDF</button>
</div>
<div class="setting" id="pdfInfo" style="font-size:11px;color:#888;"></div>
<h2>Settings</h2>
<div class="setting">
<label for="gridSize">Grid Size (px):</label>
<input type="number" id="gridSize" value="20" min="5" max="100" step="5">
</div>
<div class="setting">
<label for="minSpacing">Min Edge Spacing (px):</label>
<input type="number" id="minSpacing" value="10" min="0" max="100" step="5">
</div>
<div class="setting">
<label>
<input type="checkbox" id="showGrid" checked> Show Grid
</label>
</div>
<div class="setting">
<label>
<input type="checkbox" id="snapEnabled" checked> Snap to Grid
</label>
</div>
<div class="setting">
<button id="clearCanvas">Clear Canvas</button>
<button id="exportSVG">Export SVG</button>
<button id="saveLayout">Save Layout</button>
<button id="loadLayout">Load Layout</button>
<input type="file" id="importFile" accept=".json" style="display:none">
</div>
<h2>Symbols</h2>
<div id="palette"></div>
</div>
<!-- Canvas -->
<div id="canvas-wrapper">
<div id="canvas">
<div id="pdf-background"></div>
<svg id="grid-overlay" width="1920" height="1080"></svg>
<div id="drop-zone"></div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="app.js"></script>
</body>
</html>

Binary file not shown.

267
style.css Normal file
View File

@ -0,0 +1,267 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: #1a1a2e;
color: #e0e0e0;
overflow: hidden;
height: 100vh;
}
#app {
display: flex;
height: 100vh;
}
/* --- Toolbar / Palette --- */
#toolbar {
width: 220px;
min-width: 220px;
background: #16213e;
border-right: 2px solid #0f3460;
padding: 10px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
#toolbar h2 {
font-size: 14px;
color: #e94560;
margin: 10px 0 6px 0;
text-transform: uppercase;
letter-spacing: 1px;
}
.setting {
margin-bottom: 8px;
}
.setting label {
display: block;
font-size: 12px;
margin-bottom: 3px;
color: #aaa;
}
.setting input[type="number"] {
width: 100%;
padding: 4px 6px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 3px;
font-size: 13px;
}
.setting input[type="checkbox"] {
margin-right: 5px;
}
.setting button {
width: 100%;
padding: 6px;
margin-bottom: 4px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
.setting button:hover {
background: #e94560;
}
/* --- Palette symbols --- */
#palette {
flex: 1;
overflow-y: auto;
}
.palette-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 4px;
margin-bottom: 4px;
background: #0f3460;
border: 1px solid #1a1a5e;
border-radius: 4px;
cursor: grab;
user-select: none;
transition: background 0.15s;
}
.palette-item:hover {
background: #1a1a5e;
border-color: #e94560;
}
.palette-item:active {
cursor: grabbing;
}
.palette-item img {
width: 40px;
height: 30px;
object-fit: contain;
background: #222;
border-radius: 3px;
padding: 2px;
}
.palette-item span {
font-size: 11px;
color: #ccc;
}
/* --- Canvas area --- */
#canvas-wrapper {
flex: 1;
overflow: hidden;
background: #111;
position: relative;
}
#canvas {
width: 1920px;
height: 1080px;
position: absolute;
background: #1e1e1e;
border: 1px solid #333;
transform-origin: 0 0;
}
#pdf-background {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
width: 1920px;
height: 1080px;
pointer-events: none;
z-index: 0;
}
#pdf-background canvas {
position: absolute;
transform-origin: 0 0;
opacity: 0.35;
}
#pdf-background.editing canvas {
opacity: 0.6;
cursor: move;
}
/* Edit background active indicator */
.edit-bg-active {
background: #e94560 !important;
color: #fff !important;
}
#grid-overlay {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
pointer-events: none;
z-index: 1;
}
#drop-zone {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
z-index: 2;
}
/* --- Placed symbols on canvas --- */
.placed-symbol {
position: absolute;
cursor: move;
user-select: none;
border: 1px solid transparent;
transition: border-color 0.1s;
resize: none;
}
.placed-symbol:hover {
border-color: #e94560;
}
.placed-symbol.selected {
border-color: #00ff88;
box-shadow: 0 0 6px rgba(0, 255, 136, 0.4);
}
.placed-symbol.dragging {
opacity: 0.7;
z-index: 1000;
}
.placed-symbol.collision {
border-color: #ff0000;
box-shadow: 0 0 8px rgba(255, 0, 0, 0.6);
}
.placed-symbol img {
width: 100%;
height: 100%;
pointer-events: none;
display: block;
}
/* --- Drag ghost --- */
.drag-ghost {
position: fixed;
pointer-events: none;
opacity: 0.6;
z-index: 9999;
}
/* --- Context menu --- */
.context-menu {
position: fixed;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 4px;
padding: 4px 0;
z-index: 10000;
min-width: 140px;
}
.context-menu-item {
padding: 6px 14px;
font-size: 12px;
color: #e0e0e0;
cursor: pointer;
}
.context-menu-item:hover {
background: #e94560;
}
/* --- Resize handle (conveyor width) --- */
.resize-handle {
position: absolute;
top: 50%;
right: -5px;
width: 10px;
height: 10px;
transform: translateY(-50%);
background: #00ff88;
border: 1px solid #009955;
border-radius: 2px;
cursor: ew-resize;
z-index: 10;
}

23
svelte-app/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
svelte-app/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

3
svelte-app/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
svelte-app/README.md Normal file
View File

@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.12.5 create --template minimal --types ts --install npm svelte-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

1911
svelte-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
svelte-app/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "svelte-app",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"pdfjs-dist": "^5.5.207"
}
}

49
svelte-app/src/app.css Normal file
View File

@ -0,0 +1,49 @@
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-input: #0f3460;
--border-color: #1a1a5e;
--accent: #e94560;
--accent-glow: rgba(233, 69, 96, 0.3);
--text-primary: #e0e0e0;
--text-secondary: #aaa;
--success: #00ff88;
--danger: #ff0000;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #334;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #556;
}
::selection {
background: var(--accent);
color: white;
}

13
svelte-app/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
svelte-app/src/app.html Normal file
View File

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@ -0,0 +1,214 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { layout } from '$lib/stores/layout.svelte.js';
import { setCanvas, startRenderLoop, stopRenderLoop } from '$lib/canvas/renderer.js';
import { initInteractions, destroyInteractions, setContextMenuCallback } from '$lib/canvas/interactions.js';
import { setImageUpdateCallback } from '$lib/pdf.js';
let wrapperEl = $state<HTMLDivElement>(null!);
let canvasEl = $state<HTMLCanvasElement>(null!);
let pdfContainerEl = $state<HTMLDivElement>(null!);
let contextMenu = $state<{ x: number; y: number; symId: number } | null>(null);
let pdfNatW = $state(0);
let pdfNatH = $state(0);
let hasPdfImage = $state(false);
onMount(() => {
setCanvas(canvasEl);
initInteractions(canvasEl, wrapperEl);
startRenderLoop();
setContextMenuCallback((x, y, symId) => {
contextMenu = { x, y, symId };
});
setImageUpdateCallback((imgCanvas, natW, natH) => {
pdfNatW = natW;
pdfNatH = natH;
if (pdfContainerEl) {
pdfContainerEl.innerHTML = '';
if (imgCanvas) {
pdfContainerEl.appendChild(imgCanvas);
hasPdfImage = true;
} else {
hasPdfImage = false;
}
}
});
// PDF restore is triggered by Toolbar after project/MCM are initialized
function onClick() {
contextMenu = null;
}
document.addEventListener('click', onClick);
return () => {
document.removeEventListener('click', onClick);
};
});
onDestroy(() => {
stopRenderLoop();
destroyInteractions();
});
function handleDelete() {
if (!contextMenu) return;
if (layout.selectedIds.has(contextMenu.symId) && layout.selectedIds.size > 1) {
layout.removeSelected();
} else {
layout.removeSymbol(contextMenu.symId);
}
contextMenu = null;
}
function handleDuplicate() {
if (!contextMenu) return;
if (layout.selectedIds.has(contextMenu.symId) && layout.selectedIds.size > 1) {
layout.duplicateSelected();
} else {
layout.duplicateSymbol(contextMenu.symId);
}
contextMenu = null;
}
function handleSetLabel() {
if (!contextMenu) return;
const sym = layout.symbols.find(s => s.id === contextMenu!.symId);
if (!sym) return;
const value = prompt('Set symbol ID (used as id and inkscape:label in export):', sym.label || '');
if (value !== null) {
sym.label = value;
layout.markDirty();
layout.saveMcmState();
}
contextMenu = null;
}
let pdfDisplayW = $derived(pdfNatW * layout.pdfScale);
let pdfDisplayH = $derived(pdfNatH * layout.pdfScale);
</script>
<div class="canvas-wrapper" bind:this={wrapperEl}>
<div
class="canvas-transform"
style="transform: translate({layout.panX}px, {layout.panY}px) scale({layout.zoomLevel}); transform-origin: 0 0;"
>
<!-- Dark background layer -->
<div class="canvas-bg" style="width: {layout.canvasW}px; height: {layout.canvasH}px;"></div>
<!-- PDF hi-res image: rendered once, CSS-scaled, stays crisp up to ~5x zoom -->
<div
class="pdf-background"
class:editing={layout.editingBackground}
class:hidden={!hasPdfImage}
bind:this={pdfContainerEl}
style="
left: {layout.pdfOffsetX}px;
top: {layout.pdfOffsetY}px;
width: {pdfDisplayW}px;
height: {pdfDisplayH}px;
"
></div>
<!-- Canvas: grid + symbols (transparent so PDF shows through) -->
<canvas
bind:this={canvasEl}
width={layout.canvasW}
height={layout.canvasH}
style="width: {layout.canvasW}px; height: {layout.canvasH}px;{layout.editingBackground ? ' cursor: move;' : ''}"
></canvas>
</div>
</div>
{#if contextMenu}
<div
class="context-menu"
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
role="menu"
>
<button class="context-menu-item" onclick={handleSetLabel} role="menuitem">Set ID</button>
<button class="context-menu-item" onclick={handleDuplicate} role="menuitem">Duplicate</button>
<button class="context-menu-item" onclick={handleDelete} role="menuitem">Delete</button>
</div>
{/if}
<style>
.canvas-wrapper {
flex: 1;
overflow: hidden;
background: #111;
position: relative;
}
.canvas-transform {
position: absolute;
}
.canvas-bg {
position: absolute;
top: 0;
left: 0;
background: #1e1e1e;
border: 1px solid #333;
z-index: 0;
}
.pdf-background {
position: absolute;
pointer-events: none;
opacity: 0.35;
overflow: hidden;
z-index: 1;
}
.pdf-background.editing {
opacity: 0.6;
}
.pdf-background.hidden {
display: none;
}
.pdf-background :global(canvas) {
width: 100% !important;
height: 100% !important;
display: block;
}
canvas {
display: block;
position: relative;
z-index: 2;
}
.context-menu {
position: fixed;
background: #16213e;
border: 1px solid #0f3460;
border-radius: 6px;
padding: 4px 0;
z-index: 10000;
min-width: 140px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
}
.context-menu-item {
display: block;
width: 100%;
padding: 8px 16px;
font-size: 12px;
color: #e0e0e0;
cursor: pointer;
background: none;
border: none;
text-align: left;
transition: background 0.15s;
}
.context-menu-item:hover {
background: #e94560;
}
</style>

View File

@ -0,0 +1,289 @@
<script lang="ts">
import { layout } from '$lib/stores/layout.svelte.js';
import { SYMBOLS } from '$lib/symbols.js';
import { startPaletteDrag } from '$lib/canvas/interactions.js';
interface DeviceEntry {
id: string;
svg: string;
zone: string;
}
let allDevices = $state<Record<string, DeviceEntry[]>>({});
let collapsed = $state(false);
let collapsedZones = $state<Set<string>>(new Set());
// Load manifest
async function loadManifest() {
try {
const resp = await fetch('/projectes/devices-manifest.json');
if (resp.ok) allDevices = await resp.json();
} catch {}
}
loadManifest();
// Devices for current MCM, filtered out already-placed ones
let mcmDevices = $derived.by(() => {
const list = allDevices[layout.currentMcm] || [];
const placedLabels = new Set(layout.symbols.map(s => s.label).filter(Boolean));
return list.filter(d => !placedLabels.has(d.id));
});
// Group by SVG type
let svgGroups = $derived.by(() => {
const map = new Map<string, DeviceEntry[]>();
for (const d of mcmDevices) {
const name = SYMBOLS.find(s => s.id === d.svg)?.name || d.svg;
if (!map.has(name)) map.set(name, []);
map.get(name)!.push(d);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
});
let totalCount = $derived(mcmDevices.length);
let totalAll = $derived((allDevices[layout.currentMcm] || []).length);
function toggleGroup(group: string) {
const next = new Set(collapsedZones);
if (next.has(group)) next.delete(group);
else next.add(group);
collapsedZones = next;
}
function onMousedown(e: MouseEvent, device: DeviceEntry) {
if (e.button !== 0) return;
e.preventDefault();
const symDef = SYMBOLS.find(s => s.id === device.svg);
if (!symDef) return;
startPaletteDrag(e, symDef, device.id);
}
function getSymbolFile(svgId: string): string {
return SYMBOLS.find(s => s.id === svgId)?.file || '';
}
</script>
<div class="device-dock" class:collapsed>
<button class="collapse-btn" onclick={() => collapsed = !collapsed}>
{collapsed ? '<' : '>'}
</button>
{#if !collapsed}
<div class="dock-content">
<div class="dock-header">
Devices
<span class="dock-count">{totalAll - totalCount}/{totalAll}</span>
</div>
{#if totalCount === 0}
<div class="dock-empty">All placed</div>
{/if}
<div class="dock-list">
{#each svgGroups as [groupName, devices] (groupName)}
<button class="zone-toggle" onclick={() => toggleGroup(groupName)}>
<span class="chevron" class:open={!collapsedZones.has(groupName)}></span>
{groupName}
<span class="zone-count">{devices.length}</span>
</button>
{#if !collapsedZones.has(groupName)}
<div class="zone-items">
{#each devices as device (device.id)}
<button
class="device-item"
title={device.id}
onmousedown={(e) => onMousedown(e, device)}
>
<img
src={getSymbolFile(device.svg)}
alt={device.svg}
draggable="false"
/>
<span class="device-label">{device.id}</span>
</button>
{/each}
</div>
{/if}
{/each}
</div>
</div>
{/if}
</div>
<style>
.device-dock {
width: 200px;
min-width: 200px;
background: #16213e;
border-left: 2px solid #0f3460;
display: flex;
flex-direction: column;
position: relative;
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
}
.device-dock.collapsed {
width: 32px;
min-width: 32px;
}
.collapse-btn {
position: absolute;
top: 8px;
left: 4px;
width: 24px;
height: 24px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
z-index: 10;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.collapse-btn:hover {
background: #e94560;
}
.dock-content {
padding: 6px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.dock-header {
font-size: 10px;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
padding: 5px 6px 6px 30px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.dock-count {
font-size: 9px;
color: #667;
font-weight: 400;
}
.dock-empty {
font-size: 11px;
color: #556;
text-align: center;
padding: 20px 0;
}
.dock-list {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.zone-toggle {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 6px;
margin-bottom: 1px;
background: #0d2847;
border: 1px solid #1a1a5e;
border-radius: 4px;
color: #8899aa;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
text-align: left;
flex-shrink: 0;
}
.zone-toggle:hover {
color: #e94560;
}
.zone-count {
margin-left: auto;
font-size: 8px;
color: #556;
font-weight: 400;
}
.chevron {
display: inline-block;
width: 0;
height: 0;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
border-left: 4px solid currentColor;
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.zone-items {
display: flex;
flex-direction: column;
gap: 1px;
padding: 1px 0 3px;
}
.device-item {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 3px 6px;
background: #0f3460;
border: 1px solid #1a1a5e;
border-radius: 3px;
cursor: grab;
user-select: none;
transition: all 0.15s ease;
color: #ccc;
text-align: left;
}
.device-item:hover {
background: #1a1a5e;
border-color: #e94560;
transform: translateX(-2px);
}
.device-item:active {
cursor: grabbing;
transform: scale(0.98);
}
.device-item img {
width: 28px;
height: 18px;
object-fit: contain;
flex-shrink: 0;
}
.device-label {
font-size: 9px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,216 @@
<script lang="ts">
import { SYMBOLS, SYMBOL_GROUPS } from '$lib/symbols.js';
import { startPaletteDrag } from '$lib/canvas/interactions.js';
function onMousedown(e: MouseEvent, sym: (typeof SYMBOLS)[number]) {
if (e.button !== 0) return;
e.preventDefault();
startPaletteDrag(e, sym);
}
interface SubgroupData {
name: string;
symbols: (typeof SYMBOLS)[number][];
}
interface GroupData {
name: string;
subgroups: SubgroupData[];
symbols: (typeof SYMBOLS)[number][]; // symbols without subgroup
}
const grouped: GroupData[] = SYMBOL_GROUPS.map(g => {
const groupSymbols = SYMBOLS.filter(s => s.group === g);
const subgroupNames = [...new Set(groupSymbols.filter(s => s.subgroup).map(s => s.subgroup!))];
return {
name: g,
subgroups: subgroupNames.map(sg => ({
name: sg,
symbols: groupSymbols.filter(s => s.subgroup === sg),
})),
symbols: groupSymbols.filter(s => !s.subgroup),
};
});
let expandedSubgroups = $state<Set<string>>(new Set());
function toggleSubgroup(key: string) {
const next = new Set(expandedSubgroups);
if (next.has(key)) next.delete(key);
else next.add(key);
expandedSubgroups = next;
}
</script>
<div class="palette">
{#each grouped as group (group.name)}
<div class="group">
<div class="group-header">{group.name}</div>
{#if group.subgroups.length > 0}
{#each group.subgroups as sub (sub.name)}
{@const key = `${group.name}/${sub.name}`}
<button class="subgroup-header" onclick={() => toggleSubgroup(key)}>
<span class="chevron" class:open={expandedSubgroups.has(key)}></span>
{sub.name}
</button>
{#if expandedSubgroups.has(key)}
<div class="group-items subgroup-items">
{#each sub.symbols as sym (sym.id)}
<button
class="palette-item"
title={sym.name}
onmousedown={(e) => onMousedown(e, sym)}
>
<img
src={sym.file}
alt={sym.name}
draggable="false"
style={sym.defaultRotation ? `transform: rotate(${sym.defaultRotation}deg)` : ''}
/>
<span>{sym.name}</span>
</button>
{/each}
</div>
{/if}
{/each}
{/if}
{#if group.symbols.length > 0}
<div class="group-items">
{#each group.symbols as sym (sym.id)}
<button
class="palette-item"
title={sym.name}
onmousedown={(e) => onMousedown(e, sym)}
>
<img
src={sym.file}
alt={sym.name}
draggable="false"
style={sym.defaultRotation ? `transform: rotate(${sym.defaultRotation}deg)` : ''}
/>
<span>{sym.name}</span>
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<style>
.palette {
flex: 1;
overflow-y: auto;
padding: 0 2px;
min-height: 0;
}
.group {
margin-bottom: 6px;
}
.group-header {
font-size: 10px;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
padding: 4px 6px 2px;
position: sticky;
top: 0;
background: #16213e;
z-index: 1;
}
.subgroup-header {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 3px 6px 3px 12px;
background: none;
border: none;
color: #8899aa;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.subgroup-header:hover {
color: #e94560;
}
.chevron {
display: inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 5px solid currentColor;
transition: transform 0.15s ease;
}
.chevron.open {
transform: rotate(90deg);
}
.subgroup-items {
padding-left: 8px;
}
.group-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.palette-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 6px;
background: #0f3460;
border: 1px solid #1a1a5e;
border-radius: 4px;
cursor: grab;
user-select: none;
transition: all 0.15s ease;
color: #ccc;
text-align: left;
}
.palette-item:hover {
background: #1a1a5e;
border-color: #e94560;
transform: translateX(2px);
}
.palette-item:active {
cursor: grabbing;
transform: scale(0.98);
}
.palette-item img {
width: 36px;
height: 24px;
object-fit: contain;
background: transparent;
border-radius: 3px;
padding: 2px;
flex-shrink: 0;
}
.palette-item span {
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,465 @@
<script lang="ts">
import { layout } from '$lib/stores/layout.svelte.js';
import { exportSVG, loadLayoutJSON } from '$lib/export.js';
import { loadPdfFile, loadPdfFromPath, pdfZoomIn, pdfZoomOut, removePdf, toggleEditBackground, restorePdf } from '$lib/pdf.js';
import { discoverProjects } from '$lib/projects.js';
import { onMount } from 'svelte';
import Palette from './Palette.svelte';
let importFileEl = $state<HTMLInputElement>(null!);
let pdfFileEl = $state<HTMLInputElement>(null!);
let toolbarCollapsed = $state(false);
let projectOpen = $state(false);
let pdfOpen = $state(false);
let settingsOpen = $state(false);
onMount(async () => {
const projects = await discoverProjects();
const savedProject = localStorage.getItem('scada_lastProject');
const savedMcm = localStorage.getItem('scada_lastMcm');
if (projects.length > 0) {
layout.projects = projects;
const proj = (savedProject && projects.find(p => p.name === savedProject)) || projects[0];
layout.currentProject = proj.name;
const mcm = (savedMcm && proj.mcms.find(m => m.name === savedMcm)) || proj.mcms[0];
if (mcm) layout.currentMcm = mcm.name;
layout.loadMcmState();
} else {
layout.currentProject = savedProject || 'default';
layout.currentMcm = savedMcm || 'MCM08';
layout.loadMcmState();
}
await restorePdf();
});
function onProjectChange(e: Event) {
layout.saveMcmState();
const val = (e.target as HTMLSelectElement).value;
layout.currentProject = val;
const proj = layout.projects.find(p => p.name === val);
if (proj && proj.mcms.length > 0) {
layout.currentMcm = proj.mcms[0].name;
}
localStorage.setItem('scada_lastProject', layout.currentProject);
localStorage.setItem('scada_lastMcm', layout.currentMcm);
layout.loadMcmState();
autoLoadPdf();
}
function onMcmChange(e: Event) {
layout.saveMcmState();
layout.currentMcm = (e.target as HTMLSelectElement).value;
localStorage.setItem('scada_lastMcm', layout.currentMcm);
layout.loadMcmState();
autoLoadPdf();
}
async function autoLoadPdf() {
const proj = layout.projects.find(p => p.name === layout.currentProject);
if (!proj) return;
const mcm = proj.mcms.find(m => m.name === layout.currentMcm);
if (!mcm?.pdfPath) return;
await loadPdfFromPath(mcm.pdfPath);
}
function onCanvasSizeChange() {
layout.markDirty();
layout.saveMcmState();
}
function onGridSizeChange() {
layout.markDirty();
layout.saveMcmState();
}
function onShowGridChange() {
layout.markDirty();
}
function onMinSpacingChange() {
layout.saveMcmState();
}
function clearCanvas() {
if (!confirm('Clear all placed symbols?')) return;
layout.clearAll();
}
async function onImportFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
await loadLayoutJSON(file);
} catch (err) {
alert('Invalid layout file: ' + (err instanceof Error ? err.message : String(err)));
}
(e.target as HTMLInputElement).value = '';
}
async function onPdfFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
await loadPdfFile(file);
(e.target as HTMLInputElement).value = '';
}
let currentMcms = $derived(
layout.projects.find(p => p.name === layout.currentProject)?.mcms ?? []
);
</script>
<div class="toolbar" class:collapsed={toolbarCollapsed}>
<button class="collapse-btn" onclick={() => toolbarCollapsed = !toolbarCollapsed}>
{toolbarCollapsed ? '>' : '<'}
</button>
{#if !toolbarCollapsed}
<div class="toolbar-content">
<!-- Project section (collapsible) -->
<button class="section-toggle" onclick={() => projectOpen = !projectOpen}>
<span class="toggle-arrow">{projectOpen ? '\u25BC' : '\u25B6'}</span>
Project
<span class="section-summary">{layout.currentProject || '...'} / {layout.currentMcm || '...'}</span>
</button>
{#if projectOpen}
<div class="section-body">
{#if layout.projects.length > 0}
<div class="setting">
<label for="projectSelect">Project:</label>
<select id="projectSelect" value={layout.currentProject} onchange={onProjectChange}>
{#each layout.projects as proj (proj.name)}
<option value={proj.name}>{proj.name}</option>
{/each}
</select>
</div>
<div class="setting">
<label for="mcmSelect">MCM:</label>
<select id="mcmSelect" value={layout.currentMcm} onchange={onMcmChange}>
{#each currentMcms as mcm (mcm.name)}
<option value={mcm.name}>{mcm.name}</option>
{/each}
</select>
</div>
{:else}
<div class="setting">
<label for="mcmSelect">MCM:</label>
<select id="mcmSelect" value={layout.currentMcm} onchange={onMcmChange}>
<option value="MCM08">MCM08</option>
</select>
</div>
{/if}
</div>
{/if}
<!-- Background PDF section (collapsible) -->
<button class="section-toggle" onclick={() => pdfOpen = !pdfOpen}>
<span class="toggle-arrow">{pdfOpen ? '\u25BC' : '\u25B6'}</span>
Background PDF
{#if layout.pdfLoaded}
<span class="section-summary">{Math.round(layout.pdfScale * 100)}%</span>
{/if}
</button>
{#if pdfOpen}
<div class="section-body">
<div class="setting btn-row">
<button onclick={() => pdfFileEl.click()}>Load PDF</button>
<input bind:this={pdfFileEl} type="file" accept=".pdf" style="display:none" onchange={onPdfFile}>
</div>
<div class="setting btn-row">
<button onclick={pdfZoomIn}>PDF +</button>
<button onclick={pdfZoomOut}>PDF -</button>
</div>
<div class="setting btn-row">
<button
class:edit-bg-active={layout.editingBackground}
onclick={toggleEditBackground}
>
{layout.editingBackground ? 'Done' : 'Edit BG'}
</button>
<button onclick={removePdf}>Remove</button>
</div>
{#if layout.pdfLoaded}
<div class="pdf-info">
scale {Math.round(layout.pdfScale * 100)}% pos: {Math.round(layout.pdfOffsetX)},{Math.round(layout.pdfOffsetY)}
</div>
{/if}
</div>
{/if}
<!-- Settings section (collapsible) -->
<button class="section-toggle" onclick={() => settingsOpen = !settingsOpen}>
<span class="toggle-arrow">{settingsOpen ? '\u25BC' : '\u25B6'}</span>
Settings
</button>
{#if settingsOpen}
<div class="section-body">
<div class="setting canvas-size-row">
<div>
<label for="canvasW">W:</label>
<input type="number" id="canvasW" bind:value={layout.canvasW} min="800" max="7680" step="10" onchange={onCanvasSizeChange}>
</div>
<div>
<label for="canvasH">H:</label>
<input type="number" id="canvasH" bind:value={layout.canvasH} min="600" max="4320" step="10" onchange={onCanvasSizeChange}>
</div>
</div>
<div class="setting">
<label for="gridSize">Grid Size (px):</label>
<input
type="number" id="gridSize"
bind:value={layout.gridSize}
min="5" max="100" step="5"
onchange={onGridSizeChange}
>
</div>
<div class="setting">
<label for="minSpacing">Min Edge Spacing (px):</label>
<input
type="number" id="minSpacing"
bind:value={layout.minSpacing}
min="0" max="100" step="5"
onchange={onMinSpacingChange}
>
</div>
<div class="setting">
<label class="checkbox-label">
<input type="checkbox" bind:checked={layout.showGrid} onchange={onShowGridChange}>
Show Grid
</label>
</div>
<div class="setting">
<label class="checkbox-label">
<input type="checkbox" bind:checked={layout.snapEnabled}>
Snap to Grid
</label>
</div>
<div class="setting btn-row">
<button onclick={exportSVG}>Save SVG</button>
<button onclick={() => importFileEl.click()}>Load JSON</button>
<button onclick={clearCanvas}>Clear</button>
<input bind:this={importFileEl} type="file" accept=".json" style="display:none" onchange={onImportFile}>
</div>
</div>
{/if}
<!-- Symbols section (always open, takes remaining space) -->
<div class="symbols-header">Symbols</div>
<Palette />
</div>
{/if}
</div>
<style>
.toolbar {
width: 240px;
min-width: 240px;
background: #16213e;
border-right: 2px solid #0f3460;
display: flex;
flex-direction: column;
position: relative;
transition: width 0.2s ease, min-width 0.2s ease;
overflow: hidden;
}
.toolbar.collapsed {
width: 32px;
min-width: 32px;
}
.collapse-btn {
position: absolute;
top: 8px;
right: 4px;
width: 24px;
height: 24px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
z-index: 10;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.collapse-btn:hover {
background: #e94560;
}
.toolbar-content {
padding: 6px;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Collapsible section toggle */
.section-toggle {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 5px 6px;
margin-bottom: 2px;
background: #0d2847;
border: 1px solid #1a1a5e;
border-radius: 4px;
color: #e94560;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
transition: background 0.15s;
text-align: left;
flex-shrink: 0;
}
.section-toggle:hover {
background: #122d52;
}
.toggle-arrow {
font-size: 8px;
width: 10px;
flex-shrink: 0;
}
.section-summary {
margin-left: auto;
font-size: 9px;
color: #667;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.section-body {
padding: 4px 2px 6px;
flex-shrink: 0;
}
/* Symbols header (always visible) */
.symbols-header {
font-size: 10px;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
padding: 5px 6px 3px;
flex-shrink: 0;
}
.setting {
margin-bottom: 4px;
}
.setting label {
display: block;
font-size: 10px;
margin-bottom: 2px;
color: #8899aa;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px !important;
color: #ccc !important;
}
.setting input[type="number"],
.setting select {
width: 100%;
padding: 4px 6px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 4px;
font-size: 12px;
transition: border-color 0.15s;
outline: none;
}
.setting input[type="number"]:focus,
.setting select:focus {
border-color: #e94560;
}
.setting select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238899aa'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
padding-right: 24px;
}
.setting input[type="checkbox"] {
accent-color: #e94560;
width: 13px;
height: 13px;
}
.btn-row {
display: flex;
gap: 3px;
}
.btn-row button,
.setting > button {
flex: 1;
padding: 5px 6px;
background: #0f3460;
border: 1px solid #1a1a5e;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
font-size: 10px;
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn-row button:hover,
.setting > button:hover {
background: #e94560;
border-color: #e94560;
}
.btn-row button:active,
.setting > button:active {
transform: translateY(0);
}
.edit-bg-active {
background: #e94560 !important;
border-color: #e94560 !important;
color: #fff !important;
}
.canvas-size-row {
display: flex;
gap: 4px;
}
.canvas-size-row > div {
flex: 1;
}
.pdf-info {
font-size: 9px;
color: #667;
padding: 2px 0;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,707 @@
import type { AABB, EpcWaypoint } from '../types.js';
import { SPACING_EXEMPT, isCurvedType, isSpurType, isEpcType, getCurveBandWidth, EPC_LEFT_BOX, EPC_RIGHT_BOX, EPC_LINE_WIDTH, EPC_DEFAULT_WAYPOINTS } from '../symbols.js';
import { layout } from '../stores/layout.svelte.js';
import { orientedBoxCorners, createCurveTransforms } from './geometry.js';
export function getAABB(x: number, y: number, w: number, h: number, rotation: number): AABB {
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 };
}
// ─── OBB collision via Separating Axis Theorem ───
function obbOverlapWithSpacing(
ax: number, ay: number, aw: number, ah: number, arot: number,
bx: number, by: number, bw: number, bh: number, brot: number,
spacing: number
): boolean {
const acx = ax + aw / 2, acy = ay + ah / 2;
const bcx = bx + bw / 2, bcy = by + bh / 2;
const ahw = aw / 2 + spacing, ahh = ah / 2 + spacing;
const bhw = bw / 2, bhh = bh / 2;
const arad = (arot || 0) * Math.PI / 180;
const brad = (brot || 0) * Math.PI / 180;
const cosA = Math.cos(arad), sinA = Math.sin(arad);
const cosB = Math.cos(brad), sinB = Math.sin(brad);
const tx = bcx - acx, ty = bcy - acy;
const axes: [number, number][] = [
[cosA, sinA], [-sinA, cosA],
[cosB, sinB], [-sinB, cosB],
];
for (const [nx, ny] of axes) {
const projA = ahw * Math.abs(cosA * nx + sinA * ny) + ahh * Math.abs(-sinA * nx + cosA * ny);
const projB = bhw * Math.abs(cosB * nx + sinB * ny) + bhh * Math.abs(-sinB * nx + cosB * ny);
const dist = Math.abs(tx * nx + ty * ny);
if (dist > projA + projB) return false;
}
return true;
}
// ─── Convex polygon helpers ───
/** Get the 4 vertices of a spur trapezoid in world space (with rotation) */
function getSpurVertices(
x: number, y: number, w: number, h: number, w2: number, rotation: number
): [number, number][] {
const cx = x + w / 2;
const cy = y + h / 2;
const local: [number, number][] = [
[x, y], // TL
[x + w2, y], // TR (top base end)
[x + w, y + h], // BR (bottom base end)
[x, y + h], // BL
];
if (!rotation) return local;
const rad = rotation * Math.PI / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
return local.map(([px, py]) => {
const dx = px - cx, dy = py - cy;
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos] as [number, number];
});
}
/** Get the 4 vertices of an OBB in world space */
function getOBBVertices(
x: number, y: number, w: number, h: number, rotation: number
): [number, number][] {
const cx = x + w / 2, cy = y + h / 2;
const rad = (rotation || 0) * Math.PI / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const hw = w / 2, hh = h / 2;
return [
[cx - hw * cos + hh * sin, cy - hw * sin - hh * cos], // TL
[cx + hw * cos + hh * sin, cy + hw * sin - hh * cos], // TR
[cx + hw * cos - hh * sin, cy + hw * sin + hh * cos], // BR
[cx - hw * cos - hh * sin, cy - hw * sin + hh * cos], // BL
];
}
/** General SAT for two convex polygons with spacing tolerance */
function polygonSATWithSpacing(
aVerts: [number, number][],
bVerts: [number, number][],
spacing: number
): boolean {
const allVerts = [aVerts, bVerts];
for (const verts of allVerts) {
for (let i = 0; i < verts.length; i++) {
const j = (i + 1) % verts.length;
const ex = verts[j][0] - verts[i][0];
const ey = verts[j][1] - verts[i][1];
const len = Math.sqrt(ex * ex + ey * ey);
if (len === 0) continue;
const nx = -ey / len, ny = ex / len;
let aMin = Infinity, aMax = -Infinity;
for (const [px, py] of aVerts) {
const proj = px * nx + py * ny;
aMin = Math.min(aMin, proj);
aMax = Math.max(aMax, proj);
}
let bMin = Infinity, bMax = -Infinity;
for (const [px, py] of bVerts) {
const proj = px * nx + py * ny;
bMin = Math.min(bMin, proj);
bMax = Math.max(bMax, proj);
}
const gap = Math.max(bMin - aMax, aMin - bMax);
if (gap > spacing) return false;
}
}
return true;
}
/** Check if point is inside a convex polygon (CCW or CW winding) */
function pointInConvexPolygon(px: number, py: number, verts: [number, number][]): boolean {
let sign = 0;
for (let i = 0; i < verts.length; i++) {
const [x1, y1] = verts[i];
const [x2, y2] = verts[(i + 1) % verts.length];
const cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1);
if (cross !== 0) {
if (sign === 0) sign = cross > 0 ? 1 : -1;
else if ((cross > 0 ? 1 : -1) !== sign) return false;
}
}
return true;
}
/** Distance from a point to a convex polygon (0 if inside) */
function pointToConvexPolygonDist(px: number, py: number, verts: [number, number][]): number {
if (pointInConvexPolygon(px, py, verts)) return 0;
let minDist = Infinity;
for (let i = 0; i < verts.length; i++) {
const [x1, y1] = verts[i];
const [x2, y2] = verts[(i + 1) % verts.length];
minDist = Math.min(minDist, distPointToSegment(px, py, x1, y1, x2, y2));
}
return minDist;
}
// ─── Geometric helpers for annular sector distance ───
/** Distance from a point to a line segment */
function distPointToSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1, dy = y2 - y1;
const len2 = dx * dx + dy * dy;
if (len2 === 0) return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2);
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / len2));
const nearX = x1 + t * dx, nearY = y1 + t * dy;
return Math.sqrt((px - nearX) ** 2 + (py - nearY) ** 2);
}
/**
* Distance from a point to an annular sector (arc band).
* Point is in local space: arc center at origin, Y points UP,
* sector sweeps CCW from angle 0 to sweepRad.
*/
function distToAnnularSector(px: number, py: number, innerR: number, outerR: number, sweepRad: number): number {
const r = Math.sqrt(px * px + py * py);
let theta = Math.atan2(py, px);
if (theta < -0.001) theta += 2 * Math.PI;
const inAngle = theta >= -0.001 && theta <= sweepRad + 0.001;
if (inAngle) {
if (r >= innerR && r <= outerR) return 0; // inside sector
if (r < innerR) return innerR - r;
return r - outerR;
}
// Outside angular range — check distance to radial edges and corner points
let minDist = Infinity;
// Start radial edge (angle 0, from innerR to outerR along +X)
minDist = Math.min(minDist, distPointToSegment(px, py, innerR, 0, outerR, 0));
// End radial edge (angle sweepRad)
const ec = Math.cos(sweepRad), es = Math.sin(sweepRad);
minDist = Math.min(minDist, distPointToSegment(px, py, innerR * ec, innerR * es, outerR * ec, outerR * es));
// Also check distance to the arcs at the clamped angle
const clampedTheta = Math.max(0, Math.min(sweepRad, theta < 0 ? theta + 2 * Math.PI : theta));
if (clampedTheta >= 0 && clampedTheta <= sweepRad) {
const oc = Math.cos(clampedTheta), os = Math.sin(clampedTheta);
minDist = Math.min(minDist, Math.sqrt((px - outerR * oc) ** 2 + (py - outerR * os) ** 2));
if (innerR > 0) {
minDist = Math.min(minDist, Math.sqrt((px - innerR * oc) ** 2 + (py - innerR * os) ** 2));
}
}
return minDist;
}
/** Distance from a point in world space to an OBB */
function pointToOBBDist(px: number, py: number, bx: number, by: number, bw: number, bh: number, brot: number): number {
const bcx = bx + bw / 2, bcy = by + bh / 2;
const rad = -(brot || 0) * Math.PI / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const dx = px - bcx, dy = py - bcy;
const lx = dx * cos - dy * sin;
const ly = dx * sin + dy * cos;
const hw = bw / 2, hh = bh / 2;
const cx = Math.max(-hw, Math.min(hw, lx));
const cy = Math.max(-hh, Math.min(hh, ly));
return Math.sqrt((lx - cx) ** 2 + (ly - cy) ** 2);
}
// ─── Curved vs OBB: true geometric check ───
interface CurveParams {
x: number; y: number; w: number; h: number;
rotation: number; symbolId: string; curveAngle?: number;
}
/**
* Check if an OBB violates spacing against an annular sector (curved conveyor).
* Uses true geometric distance instead of AABB approximation.
*/
function checkCurvedVsOBB(
curve: CurveParams,
bx: number, by: number, bw: number, bh: number, brot: number,
spacing: number
): boolean {
const angle = curve.curveAngle || 90;
const outerR = curve.w;
const bandW = getCurveBandWidth(curve.symbolId);
const innerR = Math.max(0, outerR - bandW);
const curveRot = (curve.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * Math.PI) / 180;
// Arc center in unrotated local space (bottom-left of bbox)
const acx = curve.x;
const acy = curve.y + curve.h;
// Symbol center (rotation pivot)
const scx = curve.x + curve.w / 2;
const scy = curve.y + curve.h / 2;
const { toLocal, toWorld } = createCurveTransforms(acx, acy, scx, scy, curveRot);
// Get OBB corners in world space
const otherCx = bx + bw / 2, otherCy = by + bh / 2;
const oRad = (brot || 0) * Math.PI / 180;
const oCos = Math.cos(oRad), oSin = Math.sin(oRad);
const ohw = bw / 2, ohh = bh / 2;
const corners: [number, number][] = [
[otherCx + ohw * oCos - ohh * oSin, otherCy + ohw * oSin + ohh * oCos],
[otherCx - ohw * oCos - ohh * oSin, otherCy - ohw * oSin + ohh * oCos],
[otherCx - ohw * oCos + ohh * oSin, otherCy - ohw * oSin - ohh * oCos],
[otherCx + ohw * oCos + ohh * oSin, otherCy + ohw * oSin - ohh * oCos],
];
// Check OBB corners against the annular sector
for (const [wx, wy] of corners) {
const [lx, ly] = toLocal(wx, wy);
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
}
// Check edge midpoints and quarter-points for better coverage on long edges
for (let i = 0; i < 4; i++) {
const [x1, y1] = corners[i];
const [x2, y2] = corners[(i + 1) % 4];
for (const t of [0.25, 0.5, 0.75]) {
const [lx, ly] = toLocal(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t);
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
}
}
// Check arc sample points against the OBB
const SAMPLES = Math.max(4, Math.ceil(angle / 10));
for (let i = 0; i <= SAMPLES; i++) {
const a = (i / SAMPLES) * sweepRad;
const cos = Math.cos(a), sin = Math.sin(a);
const [owx, owy] = toWorld(acx + outerR * cos, acy - outerR * sin);
if (pointToOBBDist(owx, owy, bx, by, bw, bh, brot) < spacing) return true;
if (innerR > 0) {
const [iwx, iwy] = toWorld(acx + innerR * cos, acy - innerR * sin);
if (pointToOBBDist(iwx, iwy, bx, by, bw, bh, brot) < spacing) return true;
}
}
return false;
}
// ─── Curved vs Spur: geometric check ───
function checkCurvedVsSpur(
curve: CurveParams,
spurVerts: [number, number][],
spacing: number
): boolean {
const angle = curve.curveAngle || 90;
const outerR = curve.w;
const bandW = getCurveBandWidth(curve.symbolId);
const innerR = Math.max(0, outerR - bandW);
const curveRot = (curve.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * Math.PI) / 180;
const acx = curve.x;
const acy = curve.y + curve.h;
const scx = curve.x + curve.w / 2;
const scy = curve.y + curve.h / 2;
const { toLocal, toWorld } = createCurveTransforms(acx, acy, scx, scy, curveRot);
// Check spur vertices + edge sample points against annular sector
for (const [wx, wy] of spurVerts) {
const [lx, ly] = toLocal(wx, wy);
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
}
for (let i = 0; i < spurVerts.length; i++) {
const [x1, y1] = spurVerts[i];
const [x2, y2] = spurVerts[(i + 1) % spurVerts.length];
for (const t of [0.25, 0.5, 0.75]) {
const [lx, ly] = toLocal(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t);
if (distToAnnularSector(lx, ly, innerR, outerR, sweepRad) < spacing) return true;
}
}
// Check arc sample points against spur polygon
const SAMPLES = Math.max(4, Math.ceil(angle / 10));
for (let i = 0; i <= SAMPLES; i++) {
const a = (i / SAMPLES) * sweepRad;
const cos = Math.cos(a), sin = Math.sin(a);
const [owx, owy] = toWorld(acx + outerR * cos, acy - outerR * sin);
if (pointToConvexPolygonDist(owx, owy, spurVerts) < spacing) return true;
if (innerR > 0) {
const [iwx, iwy] = toWorld(acx + innerR * cos, acy - innerR * sin);
if (pointToConvexPolygonDist(iwx, iwy, spurVerts) < spacing) return true;
}
}
return false;
}
// ─── Legacy AABB distance (for curved vs curved fallback) ───
function edgeDistance(
ax: number, ay: number, aw: number, ah: number,
bx: number, by: number, bw: number, bh: number
): number {
const dx = Math.max(0, ax - (bx + bw), bx - (ax + aw));
const dy = Math.max(0, ay - (by + bh), by - (ay + ah));
return Math.sqrt(dx * dx + dy * dy);
}
// ─── Curved band AABBs (only for curved vs curved fallback) ───
function getCurvedBandAABBs(sym: CurveParams): AABB[] {
const angle = sym.curveAngle || 90;
const outerR = sym.w;
const bandW = getCurveBandWidth(sym.symbolId);
const innerR = Math.max(0, outerR - bandW);
const rot = (sym.rotation || 0) * Math.PI / 180;
const sweepRad = (angle * Math.PI) / 180;
const acx = sym.x;
const acy = sym.y + sym.h;
const scx = sym.x + sym.w / 2;
const scy = sym.y + sym.h / 2;
const cosR = Math.cos(rot), sinR = Math.sin(rot);
function rotatePoint(px: number, py: number): [number, number] {
if (!rot) return [px, py];
const dx = px - scx, dy = py - scy;
return [scx + dx * cosR - dy * sinR, scy + dx * sinR + dy * cosR];
}
const STEPS = Math.max(3, Math.ceil(angle / 10));
const stepRad = sweepRad / STEPS;
const boxes: AABB[] = [];
for (let i = 0; i < STEPS; i++) {
const a0 = i * stepRad, a1 = (i + 1) * stepRad;
const p1 = rotatePoint(acx + outerR * Math.cos(a0), acy - outerR * Math.sin(a0));
const p2 = rotatePoint(acx + outerR * Math.cos(a1), acy - outerR * Math.sin(a1));
const p3 = rotatePoint(acx + innerR * Math.cos(a0), acy - innerR * Math.sin(a0));
const p4 = rotatePoint(acx + innerR * Math.cos(a1), acy - innerR * Math.sin(a1));
const minX = Math.min(p1[0], p2[0], p3[0], p4[0]);
const minY = Math.min(p1[1], p2[1], p3[1], p4[1]);
const maxX = Math.max(p1[0], p2[0], p3[0], p4[0]);
const maxY = Math.max(p1[1], p2[1], p3[1], p4[1]);
boxes.push({ x: minX, y: minY, w: maxX - minX, h: maxY - minY });
}
return boxes;
}
// ─── EPC decomposition into oriented quads ───
/** Get all collision quads for an EPC symbol in world space */
function getEpcCollisionQuads(
symX: number, symY: number, symW: number, symH: number, rotation: number,
waypoints: EpcWaypoint[]
): [number, number][][] {
const quads: [number, number][][] = [];
if (waypoints.length < 2) return quads;
const rot = (rotation || 0) * Math.PI / 180;
const cosR = Math.cos(rot), sinR = Math.sin(rot);
const symCx = symX + symW / 2, symCy = symY + symH / 2;
function toWorld(lx: number, ly: number): [number, number] {
if (!rotation) return [lx, ly];
const dx = lx - symCx, dy = ly - symCy;
return [symCx + dx * cosR - dy * sinR, symCy + dx * sinR + dy * cosR];
}
/** Build an oriented quad from a segment (p0→p1) with given half-width */
function segmentQuad(
p0x: number, p0y: number, p1x: number, p1y: number, halfW: number
): [number, number][] {
const dx = p1x - p0x, dy = p1y - p0y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len === 0) return [];
const nx = -dy / len * halfW, ny = dx / len * halfW;
return [
toWorld(p0x - nx, p0y - ny),
toWorld(p1x - nx, p1y - ny),
toWorld(p1x + nx, p1y + ny),
toWorld(p0x + nx, p0y + ny),
];
}
/** Build an oriented box quad using shared geometry, then transform to world space. */
function boxQuad(
anchorX: number, anchorY: number,
dirX: number, dirY: number,
boxW: number, boxH: number,
anchorSide: 'left' | 'right'
): [number, number][] {
const corners = orientedBoxCorners(anchorX, anchorY, dirX, dirY, boxW, boxH, anchorSide);
if (corners.length < 4) return [];
return corners.map(([cx, cy]) => toWorld(cx, cy)) as [number, number][];
}
const ox = symX, oy = symY;
// Line segment quads (use half-width of at least 1px for collision)
const segHalfW = Math.max(EPC_LINE_WIDTH / 2, 0.5);
for (let i = 0; i < waypoints.length - 1; i++) {
const q = segmentQuad(
ox + waypoints[i].x, oy + waypoints[i].y,
ox + waypoints[i + 1].x, oy + waypoints[i + 1].y,
segHalfW
);
if (q.length === 4) quads.push(q);
}
// Left box quad
const p0 = waypoints[0], p1 = waypoints[1];
const lbQ = boxQuad(
ox + p0.x, oy + p0.y,
p1.x - p0.x, p1.y - p0.y,
EPC_LEFT_BOX.w, EPC_LEFT_BOX.h,
'right'
);
if (lbQ.length === 4) quads.push(lbQ);
// Right box quad
const last = waypoints[waypoints.length - 1];
const prev = waypoints[waypoints.length - 2];
const rbQ = boxQuad(
ox + last.x, oy + last.y,
last.x - prev.x, last.y - prev.y,
EPC_RIGHT_BOX.w, EPC_RIGHT_BOX.h,
'left'
);
if (rbQ.length === 4) quads.push(rbQ);
return quads;
}
/** Look up EPC waypoints for a symbol (from layout store or defaults) */
function getSymWaypoints(id: number): EpcWaypoint[] {
const sym = layout.symbols.find(s => s.id === id);
return sym?.epcWaypoints || EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp }));
}
// ─── Main spacing violation check ───
/** Check if any quad from a set collides with a single polygon */
function anyQuadVsPolygon(
quads: [number, number][][],
bVerts: [number, number][],
spacing: number
): boolean {
for (const q of quads) {
if (polygonSATWithSpacing(q, bVerts, spacing)) return true;
}
return false;
}
/** Check if any quad from set A collides with any quad from set B */
function anyQuadVsQuads(
aQuads: [number, number][][],
bQuads: [number, number][][],
spacing: number
): boolean {
for (const aq of aQuads) {
for (const bq of bQuads) {
if (polygonSATWithSpacing(aq, bq, spacing)) return true;
}
}
return false;
}
/** Check any EPC quad against a curved symbol */
function anyQuadVsCurved(
quads: [number, number][][],
curve: CurveParams,
spacing: number
): boolean {
// Use the curved-vs-spur logic: each quad is a convex polygon
for (const q of quads) {
if (checkCurvedVsSpur(curve, q, spacing)) return true;
}
return false;
}
export function checkSpacingViolation(
id: number, x: number, y: number, w: number, h: number, rotation: number,
symbolId?: string, curveAngle?: number, w2?: number
): boolean {
if (symbolId && SPACING_EXEMPT.has(symbolId)) return false;
const spacing = layout.minSpacing;
const isCurved = !!(symbolId && isCurvedType(symbolId));
const isSpur = !!(symbolId && isSpurType(symbolId));
const isEpc = !!(symbolId && isEpcType(symbolId));
// Get EPC quads for "this" symbol if it's an EPC
let epcQuads: [number, number][][] | null = null;
if (isEpc) {
const wps = getSymWaypoints(id);
epcQuads = getEpcCollisionQuads(x, y, w, h, rotation, wps);
}
for (const sym of layout.symbols) {
if (sym.id === id) continue;
if (SPACING_EXEMPT.has(sym.symbolId)) continue;
const symIsCurved = isCurvedType(sym.symbolId);
const symIsSpur = isSpurType(sym.symbolId);
const symIsEpc = isEpcType(sym.symbolId);
// Get EPC quads for "other" symbol if it's EPC
let symEpcQuads: [number, number][][] | null = null;
if (symIsEpc) {
const symWps = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
symEpcQuads = getEpcCollisionQuads(sym.x, sym.y, sym.w, sym.h, sym.rotation, symWps);
}
// ── EPC vs EPC ──
if (isEpc && symIsEpc && epcQuads && symEpcQuads) {
if (anyQuadVsQuads(epcQuads, symEpcQuads, spacing)) return true;
continue;
}
// ── EPC vs Curved ──
if (isEpc && symIsCurved && epcQuads) {
if (anyQuadVsCurved(epcQuads, sym, spacing)) return true;
continue;
}
if (isCurved && symIsEpc && symEpcQuads) {
if (anyQuadVsCurved(symEpcQuads, { x, y, w, h, rotation, symbolId: symbolId!, curveAngle }, spacing)) return true;
continue;
}
// ── EPC vs Spur ──
if (isEpc && symIsSpur && epcQuads) {
const spurVerts = getSpurVertices(sym.x, sym.y, sym.w, sym.h, sym.w2 ?? sym.w, sym.rotation);
if (anyQuadVsPolygon(epcQuads, spurVerts, spacing)) return true;
continue;
}
if (isSpur && symIsEpc && symEpcQuads) {
const spurVerts = getSpurVertices(x, y, w, h, w2 ?? w, rotation);
if (anyQuadVsPolygon(symEpcQuads, spurVerts, spacing)) return true;
continue;
}
// ── EPC vs OBB (regular symbol) ──
if (isEpc && epcQuads && !symIsCurved && !symIsSpur && !symIsEpc) {
const bVerts = getOBBVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation);
if (anyQuadVsPolygon(epcQuads, bVerts, spacing)) return true;
continue;
}
if (symIsEpc && symEpcQuads && !isCurved && !isSpur && !isEpc) {
const aVerts = getOBBVertices(x, y, w, h, rotation);
if (anyQuadVsPolygon(symEpcQuads, aVerts, spacing)) return true;
continue;
}
// ── Non-EPC logic (existing) ──
if (!isCurved && !symIsCurved) {
if (isSpur || symIsSpur) {
const aVerts = isSpur
? getSpurVertices(x, y, w, h, w2 ?? w, rotation)
: getOBBVertices(x, y, w, h, rotation);
const bVerts = symIsSpur
? getSpurVertices(sym.x, sym.y, sym.w, sym.h, sym.w2 ?? sym.w, sym.rotation)
: getOBBVertices(sym.x, sym.y, sym.w, sym.h, sym.rotation);
if (polygonSATWithSpacing(aVerts, bVerts, spacing)) return true;
} else {
if (obbOverlapWithSpacing(x, y, w, h, rotation, sym.x, sym.y, sym.w, sym.h, sym.rotation, spacing)) {
return true;
}
}
} else if (isCurved && !symIsCurved) {
if (symIsSpur) {
const spurVerts = getSpurVertices(sym.x, sym.y, sym.w, sym.h, sym.w2 ?? sym.w, sym.rotation);
if (checkCurvedVsSpur(
{ x, y, w, h, rotation, symbolId: symbolId!, curveAngle },
spurVerts, spacing
)) return true;
} else {
if (checkCurvedVsOBB(
{ x, y, w, h, rotation, symbolId: symbolId!, curveAngle },
sym.x, sym.y, sym.w, sym.h, sym.rotation, spacing
)) return true;
}
} else if (!isCurved && symIsCurved) {
if (isSpur) {
const spurVerts = getSpurVertices(x, y, w, h, w2 ?? w, rotation);
if (checkCurvedVsSpur(sym, spurVerts, spacing)) return true;
} else {
if (checkCurvedVsOBB(
sym,
x, y, w, h, rotation, spacing
)) return true;
}
} else {
// Both curved: use band AABBs as fallback
const aBoxes = getCurvedBandAABBs({ x, y, w, h, rotation, symbolId: symbolId!, curveAngle });
const bBoxes = getCurvedBandAABBs(sym);
for (const a of aBoxes) {
for (const b of bBoxes) {
if (edgeDistance(a.x, a.y, a.w, a.h, b.x, b.y, b.w, b.h) < spacing) return true;
}
}
}
}
return false;
}
// ─── Grid snapping ───
export function snapToGrid(x: number, y: number, w?: number, h?: number, rotation?: number): { x: number; y: number } {
if (!layout.snapEnabled) return { x, y };
const size = layout.gridSize;
if (!rotation || !w || !h) {
return { x: Math.round(x / size) * size, y: Math.round(y / size) * size };
}
const aabb = getAABB(x, y, w, h, rotation);
const snappedAabbX = Math.round(aabb.x / size) * size;
const snappedAabbY = Math.round(aabb.y / size) * size;
return {
x: snappedAabbX + aabb.w / 2 - w / 2,
y: snappedAabbY + aabb.h / 2 - h / 2,
};
}
// ─── Find valid position ───
export function findValidPosition(
id: number, x: number, y: number, w: number, h: number,
symbolId: string, rotation: number, curveAngle?: number, w2?: number
): { x: number; y: number } {
const snapped = snapToGrid(x, y, w, h, rotation);
let sx = snapped.x, sy = snapped.y;
if (!checkSpacingViolation(id, sx, sy, w, h, rotation, symbolId, curveAngle, w2)) return { x: sx, y: sy };
const step = layout.snapEnabled ? layout.gridSize : 1;
const searchRadius = 20;
for (let r = 1; r <= searchRadius; 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 > layout.canvasW || bb.y + bb.h > layout.canvasH) continue;
if (!checkSpacingViolation(id, cx, cy, w, h, rotation, symbolId, curveAngle, w2)) return { x: cx, y: cy };
}
}
}
return { x: sx, y: sy };
}
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}

View File

@ -0,0 +1,77 @@
/** Shared geometry helpers used by both collision.ts and interactions.ts */
export type Vec2 = [number, number];
/** Compute 4 corners of an oriented box anchored at a point along a direction.
* anchorSide='right': box extends backward from anchor (left box, right-center at anchor).
* anchorSide='left': box extends forward from anchor (right box, left-center at anchor). */
export function orientedBoxCorners(
anchorX: number, anchorY: number,
dirX: number, dirY: number,
boxW: number, boxH: number,
anchorSide: 'left' | 'right'
): Vec2[] {
const len = Math.sqrt(dirX * dirX + dirY * dirY);
if (len === 0) return [[anchorX, anchorY]];
const ux = dirX / len, uy = dirY / len;
const nx = -uy, ny = ux;
const hh = boxH / 2;
if (anchorSide === 'right') {
return [
[anchorX - ux * boxW - nx * hh, anchorY - uy * boxW - ny * hh],
[anchorX - nx * hh, anchorY - ny * hh],
[anchorX + nx * hh, anchorY + ny * hh],
[anchorX - ux * boxW + nx * hh, anchorY - uy * boxW + ny * hh],
];
} else {
return [
[anchorX - nx * hh, anchorY - ny * hh],
[anchorX + ux * boxW - nx * hh, anchorY + uy * boxW - ny * hh],
[anchorX + ux * boxW + nx * hh, anchorY + uy * boxW + ny * hh],
[anchorX + nx * hh, anchorY + ny * hh],
];
}
}
/** Check if mouse has moved past a drag threshold */
export function pastDragThreshold(
posX: number, posY: number,
startX: number, startY: number,
threshold: number
): boolean {
const dx = posX - startX;
const dy = posY - startY;
return dx * dx + dy * dy >= threshold * threshold;
}
/** Create a world-transform pair (toLocal / toWorld) for a rotated curve symbol.
* Arc center is at (acx, acy), symbol center at (scx, scy). */
export function createCurveTransforms(
acx: number, acy: number,
scx: number, scy: number,
curveRotRad: number
) {
const invCos = Math.cos(-curveRotRad), invSin = Math.sin(-curveRotRad);
const fwdCos = Math.cos(curveRotRad), fwdSin = Math.sin(curveRotRad);
/** World → arc-local (Y up) */
function toLocal(wx: number, wy: number): Vec2 {
let lx = wx, ly = wy;
if (curveRotRad) {
const dx = wx - scx, dy = wy - scy;
lx = scx + dx * invCos - dy * invSin;
ly = scy + dx * invSin + dy * invCos;
}
return [lx - acx, acy - ly];
}
/** Unrotated-local → world */
function toWorld(localX: number, localY: number): Vec2 {
if (!curveRotRad) return [localX, localY];
const dx = localX - scx, dy = localY - scy;
return [scx + dx * fwdCos - dy * fwdSin, scy + dx * fwdSin + dy * fwdCos];
}
return { toLocal, toWorld };
}

View File

@ -0,0 +1,776 @@
import { layout } from '../stores/layout.svelte.js';
import { isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, SYMBOLS, EPC_DEFAULT_WAYPOINTS, EPC_LEFT_BOX, EPC_RIGHT_BOX, INDUCTION_STRIP_TOP_FRAC, INDUCTION_STRIP_BOTTOM_FRAC } from '../symbols.js';
import type { EpcWaypoint } from '../types.js';
import { getAABB, snapToGrid, clamp, findValidPosition } from './collision.js';
import { orientedBoxCorners, pastDragThreshold } from './geometry.js';
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 5;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 0.9;
const ROTATION_STEP = 15;
const CONVEYOR_MIN_W = 40;
const DRAG_THRESHOLD = 4; // pixels before drag actually starts
interface DragState {
type: 'palette' | 'move' | 'multi-move' | 'resize-left' | 'resize-right' | 'resize-spur-top' | 'resize-spur-bottom' | 'epc-waypoint' | 'pdf';
placedId?: number;
offsetX?: number;
offsetY?: number;
startX?: number;
startY?: number;
origW?: number;
origW2?: number;
origX?: number;
origY?: number;
origPdfOffsetX?: number;
origPdfOffsetY?: number;
symbolDef?: (typeof SYMBOLS)[number];
ghost?: HTMLDivElement;
multiOffsets?: { id: number; offsetX: number; offsetY: number }[];
dragActivated?: boolean; // true once mouse moves past threshold
waypointIndex?: number; // which EPC waypoint is being dragged
deviceLabel?: string; // label to assign when dropping from device dock
}
let dragState: DragState | null = null;
let isPanning = false;
let panStartX = 0;
let panStartY = 0;
let canvasEl: HTMLCanvasElement | null = null;
let wrapperEl: HTMLDivElement | null = null;
// Context menu callback
let contextMenuCallback: ((x: number, y: number, symId: number) => void) | null = null;
export function setContextMenuCallback(cb: (x: number, y: number, symId: number) => void) {
contextMenuCallback = cb;
}
export function initInteractions(canvas: HTMLCanvasElement, wrapper: HTMLDivElement) {
canvasEl = canvas;
wrapperEl = wrapper;
canvas.addEventListener('mousedown', onCanvasMousedown);
canvas.addEventListener('contextmenu', onContextMenu);
wrapper.addEventListener('wheel', onWheel, { passive: false });
wrapper.addEventListener('mousedown', onWrapperMousedown);
document.addEventListener('mousemove', onMousemove);
document.addEventListener('mouseup', onMouseup);
document.addEventListener('keydown', onKeydown);
}
export function destroyInteractions() {
if (canvasEl) {
canvasEl.removeEventListener('mousedown', onCanvasMousedown);
canvasEl.removeEventListener('contextmenu', onContextMenu);
}
if (wrapperEl) {
wrapperEl.removeEventListener('wheel', onWheel);
wrapperEl.removeEventListener('mousedown', onWrapperMousedown);
}
document.removeEventListener('mousemove', onMousemove);
document.removeEventListener('mouseup', onMouseup);
document.removeEventListener('keydown', onKeydown);
}
function screenToCanvas(clientX: number, clientY: number): { x: number; y: number } {
if (!wrapperEl) return { x: 0, y: 0 };
const rect = wrapperEl.getBoundingClientRect();
return {
x: (clientX - rect.left - layout.panX) / layout.zoomLevel,
y: (clientY - rect.top - layout.panY) / layout.zoomLevel,
};
}
/** Transform a point into a symbol's unrotated local space */
function toSymbolLocal(px: number, py: number, sym: typeof layout.symbols[0]): { x: number; y: number } {
if (!sym.rotation) return { x: px, y: py };
const scx = sym.x + sym.w / 2;
const scy = sym.y + sym.h / 2;
const rad = (-sym.rotation * Math.PI) / 180;
const cos = Math.cos(rad), sin = Math.sin(rad);
const dx = px - scx, dy = py - scy;
return { x: dx * cos - dy * sin + scx, y: dx * sin + dy * cos + scy };
}
function pointInRect(px: number, py: number, rx: number, ry: number, rw: number, rh: number): boolean {
return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;
}
function pointInTrapezoid(px: number, py: number, sym: typeof layout.symbols[0]): boolean {
const w2 = sym.w2 ?? sym.w;
// The right edge of the trapezoid is a slanted line from (x+w2, y) to (x+w, y+h)
// For a given py, the max x is interpolated between w2 (top) and w (bottom)
const t = (py - sym.y) / sym.h;
const maxX = sym.x + w2 + t * (sym.w - w2);
return px >= sym.x && px <= maxX && py >= sym.y && py <= sym.y + sym.h;
}
function hitTest(cx: number, cy: number): number | null {
for (let i = layout.symbols.length - 1; i >= 0; i--) {
const sym = layout.symbols[i];
const local = toSymbolLocal(cx, cy, sym);
if (isSpurType(sym.symbolId)) {
if (pointInTrapezoid(local.x, local.y, sym)) return sym.id;
} else if (pointInRect(local.x, local.y, sym.x, sym.y, sym.w, sym.h)) {
return sym.id;
}
}
return null;
}
/** Hit test EPC waypoint handles. Returns waypoint index or -1 */
function hitEpcWaypoint(cx: number, cy: number, sym: typeof layout.symbols[0]): number {
if (!isEpcType(sym.symbolId)) return -1;
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym);
const hs = 6;
for (let i = 0; i < waypoints.length; i++) {
const wx = sym.x + waypoints[i].x;
const wy = sym.y + waypoints[i].y;
const dx = lx - wx, dy = ly - wy;
if (dx * dx + dy * dy <= hs * hs) return i;
}
return -1;
}
/** Hit test EPC line segment midpoints for adding waypoints. Returns insert index or -1 */
function hitEpcSegmentMidpoint(cx: number, cy: number, sym: typeof layout.symbols[0]): number {
if (!isEpcType(sym.symbolId)) return -1;
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym);
const hs = 8;
for (let i = 0; i < waypoints.length - 1; i++) {
const mx = sym.x + (waypoints[i].x + waypoints[i + 1].x) / 2;
const my = sym.y + (waypoints[i].y + waypoints[i + 1].y) / 2;
const dx = lx - mx, dy = ly - (my - 4); // offset for the "+" indicator position
if (dx * dx + dy * dy <= hs * hs) return i + 1;
}
return -1;
}
function hitResizeHandle(cx: number, cy: number, sym: typeof layout.symbols[0]): 'left' | 'right' | 'spur-top' | 'spur-bottom' | null {
if (!isResizable(sym.symbolId) || layout.selectedIds.size !== 1 || !layout.selectedIds.has(sym.id)) return null;
const hs = 10;
const { x: lx, y: ly } = toSymbolLocal(cx, cy, sym);
if (isCurvedType(sym.symbolId)) {
const arcAngle = sym.curveAngle || 90;
const arcRad = (arcAngle * Math.PI) / 180;
const outerR = sym.w;
const arcCx = sym.x;
const arcCy = sym.y + sym.h;
const h1x = arcCx + outerR, h1y = arcCy;
if (pointInRect(lx, ly, h1x - hs / 2, h1y - hs / 2, hs, hs)) return 'right';
const h2x = arcCx + outerR * Math.cos(arcRad);
const h2y = arcCy - outerR * Math.sin(arcRad);
if (pointInRect(lx, ly, h2x - hs / 2, h2y - hs / 2, hs, hs)) return 'left';
return null;
}
if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
// Top-right handle (controls w2)
if (pointInRect(lx, ly, sym.x + w2 - hs / 2, sym.y - hs / 2, hs, hs)) return 'spur-top';
// Bottom-right handle (controls w)
if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, sym.y + sym.h - hs / 2, hs, hs)) return 'spur-bottom';
return null;
}
if (isInductionType(sym.symbolId)) {
// Only right handle — arrow head is fixed width
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC;
const stripMidY = (stripTopY + stripBottomY) / 2 - hs / 2;
if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, stripMidY, hs, hs)) return 'right';
return null;
}
const midY = sym.y + sym.h / 2 - hs / 2;
if (pointInRect(lx, ly, sym.x - hs / 2, midY, hs, hs)) return 'left';
if (pointInRect(lx, ly, sym.x + sym.w - hs / 2, midY, hs, hs)) return 'right';
return null;
}
function onCanvasMousedown(e: MouseEvent) {
if (e.button !== 0) return;
e.preventDefault();
const pos = screenToCanvas(e.clientX, e.clientY);
// PDF editing mode: drag the PDF background
if (layout.editingBackground) {
dragState = {
type: 'pdf',
startX: pos.x,
startY: pos.y,
origPdfOffsetX: layout.pdfOffsetX,
origPdfOffsetY: layout.pdfOffsetY,
};
return;
}
// Check EPC waypoint handles and resize handles (only for single selection)
if (layout.selectedIds.size === 1) {
const selId = [...layout.selectedIds][0];
const sel = layout.symbols.find(s => s.id === selId);
if (sel && isEpcType(sel.symbolId)) {
// Double-click on existing waypoint to remove it (if >2 waypoints)
if (e.detail === 2) {
const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel);
const wps = sel.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
if (wpIdx >= 0 && wps.length > 2) {
layout.pushUndo();
if (!sel.epcWaypoints) {
sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp }));
}
sel.epcWaypoints.splice(wpIdx, 1);
recalcEpcBounds(sel);
layout.markDirty();
layout.saveMcmState();
return;
}
// Double-click on "+" midpoint to add waypoint
const insertIdx = hitEpcSegmentMidpoint(pos.x, pos.y, sel);
if (insertIdx >= 0) {
layout.pushUndo();
if (!sel.epcWaypoints) {
sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp }));
}
const prev = sel.epcWaypoints[insertIdx - 1];
const next = sel.epcWaypoints[insertIdx];
sel.epcWaypoints.splice(insertIdx, 0, {
x: (prev.x + next.x) / 2,
y: (prev.y + next.y) / 2,
});
layout.markDirty();
layout.saveMcmState();
return;
}
}
// Check waypoint handles for drag
const wpIdx = hitEpcWaypoint(pos.x, pos.y, sel);
if (wpIdx >= 0) {
layout.pushUndo();
if (!sel.epcWaypoints) {
sel.epcWaypoints = EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp }));
}
dragState = {
type: 'epc-waypoint',
placedId: sel.id,
waypointIndex: wpIdx,
startX: pos.x,
startY: pos.y,
dragActivated: false,
};
return;
}
}
if (sel) {
const handle = hitResizeHandle(pos.x, pos.y, sel);
if (handle) {
layout.pushUndo();
if (handle === 'spur-top' || handle === 'spur-bottom') {
dragState = {
type: handle === 'spur-top' ? 'resize-spur-top' : 'resize-spur-bottom',
placedId: sel.id,
startX: pos.x,
startY: pos.y,
origW: sel.w,
origW2: sel.w2 ?? sel.w,
origX: sel.x,
origY: sel.y,
};
} else {
dragState = {
type: handle === 'left' ? 'resize-left' : 'resize-right',
placedId: sel.id,
startX: pos.x,
startY: pos.y,
origW: sel.w,
origX: sel.x,
origY: sel.y,
};
}
return;
}
}
}
const hitId = hitTest(pos.x, pos.y);
if (hitId !== null) {
if (e.shiftKey) {
// Shift+Click: toggle symbol in/out of selection
const newSet = new Set(layout.selectedIds);
if (newSet.has(hitId)) {
newSet.delete(hitId);
} else {
newSet.add(hitId);
}
layout.selectedIds = newSet;
layout.markDirty();
return; // Don't start drag on shift+click
}
// No shift: if symbol not already selected, make it the only selection
if (!layout.selectedIds.has(hitId)) {
layout.selectedIds = new Set([hitId]);
}
layout.pushUndo();
// Start drag for selected symbols (with threshold — won't move until mouse travels DRAG_THRESHOLD px)
if (layout.selectedIds.size > 1) {
const multiOffsets = [...layout.selectedIds].map(id => {
const sym = layout.symbols.find(s => s.id === id)!;
return { id, offsetX: pos.x - sym.x, offsetY: pos.y - sym.y };
});
dragState = { type: 'multi-move', multiOffsets, startX: pos.x, startY: pos.y, dragActivated: false };
} else {
const sym = layout.symbols.find(s => s.id === hitId)!;
dragState = {
type: 'move',
placedId: hitId,
offsetX: pos.x - sym.x,
offsetY: pos.y - sym.y,
startX: pos.x,
startY: pos.y,
dragActivated: false,
};
}
layout.markDirty();
} else {
// Click on empty space
if (!e.shiftKey) {
layout.selectedIds = new Set();
layout.markDirty();
}
}
}
function onContextMenu(e: MouseEvent) {
e.preventDefault();
const pos = screenToCanvas(e.clientX, e.clientY);
const hitId = hitTest(pos.x, pos.y);
if (hitId !== null && contextMenuCallback) {
contextMenuCallback(e.clientX, e.clientY, hitId);
}
}
function onWheel(e: WheelEvent) {
e.preventDefault();
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const cx = (mx - layout.panX) / layout.zoomLevel;
const cy = (my - layout.panY) / layout.zoomLevel;
const factor = e.deltaY > 0 ? ZOOM_OUT_FACTOR : ZOOM_IN_FACTOR;
layout.zoomLevel = clamp(layout.zoomLevel * factor, ZOOM_MIN, ZOOM_MAX);
layout.panX = mx - cx * layout.zoomLevel;
layout.panY = my - cy * layout.zoomLevel;
}
function onWrapperMousedown(e: MouseEvent) {
if (e.button === 1) {
e.preventDefault();
isPanning = true;
panStartX = e.clientX - layout.panX;
panStartY = e.clientY - layout.panY;
document.body.style.cursor = 'grabbing';
}
}
const SPUR_MIN_W = 10;
// orientedBoxCorners imported from geometry.ts
/** Recalculate EPC symbol w/h to encompass all waypoints + oriented boxes.
* Symbol origin stays fixed only w/h expand. Negative local coords are allowed. */
function recalcEpcBounds(sym: typeof layout.symbols[0]) {
const wps = sym.epcWaypoints;
if (!wps || wps.length < 2) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
// Include all waypoints
for (const wp of wps) {
minX = Math.min(minX, wp.x);
minY = Math.min(minY, wp.y);
maxX = Math.max(maxX, wp.x);
maxY = Math.max(maxY, wp.y);
}
// Left box corners (oriented along first segment)
const lbDir = { x: wps[1].x - wps[0].x, y: wps[1].y - wps[0].y };
const lbCorners = orientedBoxCorners(wps[0].x, wps[0].y, lbDir.x, lbDir.y, EPC_LEFT_BOX.w, EPC_LEFT_BOX.h, 'right');
for (const [bx, by] of lbCorners) {
minX = Math.min(minX, bx); minY = Math.min(minY, by);
maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by);
}
// Right box corners (oriented along last segment)
const last = wps[wps.length - 1];
const prev = wps[wps.length - 2];
const rbDir = { x: last.x - prev.x, y: last.y - prev.y };
const rbCorners = orientedBoxCorners(last.x, last.y, rbDir.x, rbDir.y, EPC_RIGHT_BOX.w, EPC_RIGHT_BOX.h, 'left');
for (const [bx, by] of rbCorners) {
minX = Math.min(minX, bx); minY = Math.min(minY, by);
maxX = Math.max(maxX, bx); maxY = Math.max(maxY, by);
}
// Add small padding
minX -= 1; minY -= 1; maxX += 1; maxY += 1;
// Set w/h to cover full extent from origin (allow negative local coords)
sym.w = Math.max(maxX, 1);
sym.h = Math.max(maxY, 1);
}
function snapWidth(w: number, ctrlKey: boolean, minW: number = CONVEYOR_MIN_W): number {
if (layout.snapEnabled && !ctrlKey) {
w = Math.round(w / layout.gridSize) * layout.gridSize;
}
return Math.max(minW, Math.round(w));
}
function resizeCurved(sym: typeof layout.symbols[0], dx: number, dy: number, isRight: boolean, ctrlKey: boolean) {
const arcAngle = sym.curveAngle || 90;
let delta: number;
if (isRight) {
delta = dx;
} else {
const arcRad = (arcAngle * Math.PI) / 180;
delta = dx * Math.cos(arcRad) - dy * Math.sin(arcRad);
}
const newR = snapWidth(dragState!.origW! + delta, ctrlKey);
const arcCenterY = dragState!.origY! + dragState!.origW!;
sym.w = newR;
sym.h = newR;
sym.x = dragState!.origX!;
sym.y = arcCenterY - newR;
}
function resizeSpur(sym: typeof layout.symbols[0], dx: number, dy: number, isTop: boolean, ctrlKey: boolean) {
const rad = (sym.rotation || 0) * Math.PI / 180;
const projectedDelta = dx * Math.cos(rad) + dy * Math.sin(rad);
if (isTop) {
sym.w2 = snapWidth(dragState!.origW2! + projectedDelta, ctrlKey, SPUR_MIN_W);
} else {
sym.w = snapWidth(dragState!.origW! + projectedDelta, ctrlKey, SPUR_MIN_W);
}
}
function resizeStraight(sym: typeof layout.symbols[0], dx: number, dy: number, isRight: boolean, ctrlKey: boolean) {
const rad = (sym.rotation || 0) * Math.PI / 180;
const projectedDelta = dx * Math.cos(rad) + dy * Math.sin(rad);
const newW = snapWidth(dragState!.origW! + (isRight ? projectedDelta : -projectedDelta), ctrlKey);
const cosR = Math.cos(rad), sinR = Math.sin(rad);
const origCx = dragState!.origX! + dragState!.origW! / 2;
const origCy = dragState!.origY! + sym.h / 2;
// Anchor the opposite edge: left edge for resize-right, right edge for resize-left
const anchorDir = isRight ? -1 : 1;
const anchorX = origCx + (anchorDir * dragState!.origW! / 2) * cosR;
const anchorY = origCy + (anchorDir * dragState!.origW! / 2) * sinR;
const newCx = anchorX + (-anchorDir * newW / 2) * cosR;
const newCy = anchorY + (-anchorDir * newW / 2) * sinR;
sym.w = newW;
sym.x = newCx - newW / 2;
sym.y = newCy - sym.h / 2;
}
function onMousemove(e: MouseEvent) {
if (isPanning) {
layout.panX = e.clientX - panStartX;
layout.panY = e.clientY - panStartY;
return;
}
if (!dragState) return;
if (dragState.type === 'pdf') {
const pos = screenToCanvas(e.clientX, e.clientY);
layout.pdfOffsetX = dragState.origPdfOffsetX! + (pos.x - dragState.startX!);
layout.pdfOffsetY = dragState.origPdfOffsetY! + (pos.y - dragState.startY!);
layout.markDirty();
return;
}
if (dragState.type === 'epc-waypoint' && dragState.placedId != null && dragState.waypointIndex != null) {
const pos = screenToCanvas(e.clientX, e.clientY);
if (!dragState.dragActivated) {
if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return;
dragState.dragActivated = true;
}
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
if (!sym || !sym.epcWaypoints) return;
const wps = sym.epcWaypoints;
const wpIdx = dragState.waypointIndex;
// Transform mouse position to local (unrotated) coords
const local = toSymbolLocal(pos.x, pos.y, sym);
let localX = local.x - sym.x;
let localY = local.y - sym.y;
// Snap to grid (in local coords, grid aligns with symbol origin in world)
if (layout.snapEnabled && !e.ctrlKey) {
// Snap the world position, then convert back to local
const worldSnappedX = Math.round((sym.x + localX) / layout.gridSize) * layout.gridSize;
const worldSnappedY = Math.round((sym.y + localY) / layout.gridSize) * layout.gridSize;
localX = worldSnappedX - sym.x;
localY = worldSnappedY - sym.y;
}
// Constrain to 0/45/90 degree angles relative to adjacent waypoint
const anchor = wpIdx > 0 ? wps[wpIdx - 1] : (wpIdx < wps.length - 1 ? wps[wpIdx + 1] : null);
if (anchor) {
const dx = localX - anchor.x;
const dy = localY - anchor.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0.1) {
// Snap angle to nearest 15-degree increment
const rawAngle = Math.atan2(dy, dx);
const snappedAngle = Math.round(rawAngle / (Math.PI / 12)) * (Math.PI / 12);
localX = anchor.x + dist * Math.cos(snappedAngle);
localY = anchor.y + dist * Math.sin(snappedAngle);
// Re-snap to grid after angle constraint
if (layout.snapEnabled && !e.ctrlKey) {
const worldSnappedX = Math.round((sym.x + localX) / layout.gridSize) * layout.gridSize;
const worldSnappedY = Math.round((sym.y + localY) / layout.gridSize) * layout.gridSize;
localX = worldSnappedX - sym.x;
localY = worldSnappedY - sym.y;
}
}
}
wps[wpIdx] = { x: localX, y: localY };
// Recalculate bounding box
recalcEpcBounds(sym);
layout.markDirty();
}
if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) {
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' && dragState.placedId != null) {
const pos = screenToCanvas(e.clientX, e.clientY);
if (!dragState.dragActivated) {
if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return;
dragState.dragActivated = true;
}
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
if (!sym) return;
const bb = getAABB(0, 0, sym.w, sym.h, sym.rotation);
const newX = clamp(pos.x - dragState.offsetX!, -bb.x, layout.canvasW - bb.w - bb.x);
const newY = clamp(pos.y - dragState.offsetY!, -bb.y, layout.canvasH - bb.h - bb.y);
// Hold Ctrl for pixel-precise positioning (bypass grid snap)
const snapped = e.ctrlKey ? { x: Math.round(newX), y: Math.round(newY) } : snapToGrid(newX, newY, sym.w, sym.h, sym.rotation);
sym.x = snapped.x;
sym.y = snapped.y;
layout.markDirty();
}
if (dragState.type === 'multi-move' && dragState.multiOffsets) {
const pos = screenToCanvas(e.clientX, e.clientY);
if (!dragState.dragActivated) {
if (!pastDragThreshold(pos.x, pos.y, dragState.startX!, dragState.startY!, DRAG_THRESHOLD)) return;
dragState.dragActivated = true;
}
for (const { id, offsetX, offsetY } of dragState.multiOffsets) {
const sym = layout.symbols.find(s => s.id === id);
if (!sym) continue;
const bb = getAABB(0, 0, sym.w, sym.h, sym.rotation);
const rawX = clamp(pos.x - offsetX, -bb.x, layout.canvasW - bb.w - bb.x);
const rawY = clamp(pos.y - offsetY, -bb.y, layout.canvasH - bb.h - bb.y);
const snapped = e.ctrlKey
? { x: Math.round(rawX), y: Math.round(rawY) }
: snapToGrid(rawX, rawY, sym.w, sym.h, sym.rotation);
sym.x = snapped.x;
sym.y = snapped.y;
}
layout.markDirty();
}
if ((dragState.type === 'resize-spur-top' || dragState.type === 'resize-spur-bottom') && dragState.placedId != null) {
const sym = layout.symbols.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!;
resizeSpur(sym, dx, dy, dragState.type === 'resize-spur-top', e.ctrlKey);
layout.markDirty();
}
if ((dragState.type === 'resize-left' || dragState.type === 'resize-right') && dragState.placedId != null) {
const sym = layout.symbols.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 isRight = dragState.type === 'resize-right';
if (isCurvedType(sym.symbolId)) {
resizeCurved(sym, dx, dy, isRight, e.ctrlKey);
} else {
resizeStraight(sym, dx, dy, isRight, e.ctrlKey);
}
layout.markDirty();
}
}
function onMouseup(e: MouseEvent) {
if (e.button === 1 && isPanning) {
isPanning = false;
document.body.style.cursor = '';
return;
}
if (!dragState) return;
if (dragState.type === 'pdf') {
layout.saveMcmState();
dragState = null;
return;
}
if (dragState.type === 'palette' && dragState.ghost && dragState.symbolDef) {
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 <= layout.canvasW && dropY >= -sym.h && dropY <= layout.canvasH) {
dropX = clamp(dropX, 0, layout.canvasW - sym.w);
dropY = clamp(dropY, 0, layout.canvasH - sym.h);
const valid = findValidPosition(-1, dropX, dropY, sym.w, sym.h, sym.id, rot, sym.curveAngle, sym.w2);
layout.addSymbol({
symbolId: sym.id,
file: sym.file,
name: sym.name,
label: dragState.deviceLabel || '',
x: valid.x,
y: valid.y,
w: sym.w,
h: sym.h,
w2: sym.w2,
rotation: rot,
curveAngle: sym.curveAngle,
epcWaypoints: isEpcType(sym.id) ? EPC_DEFAULT_WAYPOINTS.map(wp => ({ ...wp })) : undefined,
pdpCBs: undefined,
});
}
}
if (dragState.type === 'move' && dragState.placedId != null) {
if (dragState.dragActivated) {
const sym = layout.symbols.find(s => s.id === dragState!.placedId);
if (sym) {
if (!e.ctrlKey) {
const valid = findValidPosition(sym.id, sym.x, sym.y, sym.w, sym.h, sym.symbolId, sym.rotation, sym.curveAngle, sym.w2);
sym.x = valid.x;
sym.y = valid.y;
}
}
layout.markDirty();
layout.saveMcmState();
}
}
if (dragState.type === 'multi-move' && dragState.dragActivated) {
layout.markDirty();
layout.saveMcmState();
}
if (dragState.type === 'resize-left' || dragState.type === 'resize-right' || dragState.type === 'resize-spur-top' || dragState.type === 'resize-spur-bottom') {
layout.markDirty();
layout.saveMcmState();
}
if (dragState.type === 'epc-waypoint' && dragState.dragActivated) {
layout.markDirty();
layout.saveMcmState();
}
dragState = null;
}
function rotateSelected(delta: number) {
if (layout.selectedIds.size === 0) return;
layout.pushUndo();
for (const id of layout.selectedIds) {
const sym = layout.symbols.find(s => s.id === id);
if (!sym) continue;
sym.rotation = ((sym.rotation || 0) + delta + 360) % 360;
}
layout.markDirty();
layout.saveMcmState();
}
function onKeydown(e: KeyboardEvent) {
// Ctrl+Z: undo
if (e.ctrlKey && e.key === 'z') {
e.preventDefault();
layout.undo();
return;
}
// Ctrl+D: duplicate selected
if (e.ctrlKey && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
layout.duplicateSelected();
return;
}
// Delete: remove all selected
if (e.key === 'Delete' && layout.selectedIds.size > 0) {
layout.removeSelected();
return;
}
// E/Q: rotate all selected
if (layout.selectedIds.size > 0 && (e.key === 'e' || e.key === 'E')) {
rotateSelected(ROTATION_STEP);
}
if (layout.selectedIds.size > 0 && (e.key === 'q' || e.key === 'Q')) {
rotateSelected(-ROTATION_STEP);
}
}
// Called from Palette component to initiate a palette drag
export function startPaletteDrag(e: MouseEvent, symbolDef: (typeof SYMBOLS)[number], deviceLabel?: string) {
const ghost = document.createElement('div');
ghost.style.cssText = `
position: fixed;
pointer-events: none;
opacity: 0.6;
z-index: 9999;
width: ${symbolDef.w}px;
height: ${symbolDef.h}px;
`;
if (symbolDef.defaultRotation) {
ghost.style.transform = `rotate(${symbolDef.defaultRotation}deg)`;
}
const img = document.createElement('img');
img.src = symbolDef.file;
img.style.width = '100%';
img.style.height = '100%';
ghost.appendChild(img);
ghost.style.left = (e.clientX - symbolDef.w / 2) + 'px';
ghost.style.top = (e.clientY - symbolDef.h / 2) + 'px';
document.body.appendChild(ghost);
dragState = { type: 'palette', symbolDef, ghost, deviceLabel };
}

View File

@ -0,0 +1,477 @@
import { layout } from '../stores/layout.svelte.js';
import { getSymbolImage, isResizable, isCurvedType, isSpurType, isEpcType, isInductionType, isPhotoeyeType, getCurveBandWidth, SPACING_EXEMPT, EPC_LEFT_BOX, EPC_RIGHT_BOX, EPC_DEFAULT_WAYPOINTS, EPC_LINE_WIDTH, EPC_ICON_FILE, INDUCTION_HEAD_W, INDUCTION_ARROW, INDUCTION_STRIP_TOP_FRAC, INDUCTION_STRIP_BOTTOM_FRAC, PHOTOEYE_CONFIG } from '../symbols.js';
import { checkSpacingViolation } from './collision.js';
import type { PlacedSymbol } from '../types.js';
let ctx: CanvasRenderingContext2D | null = null;
let canvas: HTMLCanvasElement | null = null;
let animFrameId: number | null = null;
let lastDirty = -1;
const MAX_RENDER_SCALE = 4;
let currentRenderScale = 0;
let lastCanvasW = 0;
let lastCanvasH = 0;
export function setCanvas(c: HTMLCanvasElement) {
canvas = c;
ctx = c.getContext('2d')!;
currentRenderScale = 0; // force resolution update on first render
}
function updateCanvasResolution() {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE);
const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH;
if (targetScale === currentRenderScale && !sizeChanged) return;
currentRenderScale = targetScale;
lastCanvasW = layout.canvasW;
lastCanvasH = layout.canvasH;
canvas.width = layout.canvasW * currentRenderScale;
canvas.height = layout.canvasH * currentRenderScale;
canvas.style.width = layout.canvasW + 'px';
canvas.style.height = layout.canvasH + 'px';
}
export function startRenderLoop() {
function loop() {
animFrameId = requestAnimationFrame(loop);
const dpr = window.devicePixelRatio || 1;
const targetScale = Math.min(Math.ceil(dpr * layout.zoomLevel), MAX_RENDER_SCALE);
const sizeChanged = layout.canvasW !== lastCanvasW || layout.canvasH !== lastCanvasH;
if (layout.dirty !== lastDirty || targetScale !== currentRenderScale || sizeChanged) {
lastDirty = layout.dirty;
render();
}
}
loop();
}
export function stopRenderLoop() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId);
animFrameId = null;
}
}
export function render() {
if (!ctx || !canvas) return;
updateCanvasResolution();
ctx.save();
ctx.setTransform(currentRenderScale, 0, 0, currentRenderScale, 0, 0);
ctx.clearRect(0, 0, layout.canvasW, layout.canvasH);
if (layout.showGrid) {
drawGrid(ctx);
}
// Draw non-overlay symbols first, then overlay symbols (photoeyes) on top
for (const sym of layout.symbols) {
if (!SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
}
for (const sym of layout.symbols) {
if (SPACING_EXEMPT.has(sym.symbolId)) drawSymbol(ctx, sym as PlacedSymbol);
}
ctx.restore();
}
function drawGrid(ctx: CanvasRenderingContext2D) {
const size = layout.gridSize;
ctx.strokeStyle = '#333';
ctx.lineWidth = 0.5;
ctx.beginPath();
for (let x = 0; x <= layout.canvasW; x += size) {
ctx.moveTo(x, 0);
ctx.lineTo(x, layout.canvasH);
}
for (let y = 0; y <= layout.canvasH; y += size) {
ctx.moveTo(0, y);
ctx.lineTo(layout.canvasW, y);
}
ctx.stroke();
}
/** Trace the arc band outline path (for selection/collision/hover strokes on curved types) */
function traceArcBandPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) {
const angle = sym.curveAngle || 90;
const outerR = sym.w + pad;
const bandW = getCurveBandWidth(sym.symbolId);
const innerR = Math.max(0, sym.w - bandW - pad);
const sweepRad = (angle * Math.PI) / 180;
const arcCx = sym.x;
const arcCy = sym.y + sym.h;
ctx.beginPath();
ctx.arc(arcCx, arcCy, outerR, 0, -sweepRad, true);
ctx.arc(arcCx, arcCy, innerR, -sweepRad, 0, false);
ctx.closePath();
}
/** Trace the spur trapezoid path (for selection/collision strokes) */
function traceSpurPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number = 0) {
const w2 = sym.w2 ?? sym.w;
// Expand vertices outward by pad
ctx.beginPath();
ctx.moveTo(sym.x - pad, sym.y - pad);
ctx.lineTo(sym.x + w2 + pad, sym.y - pad);
ctx.lineTo(sym.x + sym.w + pad, sym.y + sym.h + pad);
ctx.lineTo(sym.x - pad, sym.y + sym.h + pad);
ctx.closePath();
}
/** Draw the EPC symbol: SVG image for left icon, programmatic polyline + right box.
* Both boxes auto-orient along the line direction at their respective ends. */
function drawEpcSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
const ox = sym.x; // origin x
const oy = sym.y; // origin y
// Draw polyline connecting waypoints
if (waypoints.length >= 2) {
ctx.beginPath();
ctx.moveTo(ox + waypoints[0].x, oy + waypoints[0].y);
for (let i = 1; i < waypoints.length; i++) {
ctx.lineTo(ox + waypoints[i].x, oy + waypoints[i].y);
}
ctx.strokeStyle = '#000000';
ctx.lineWidth = EPC_LINE_WIDTH;
ctx.stroke();
}
// --- Left icon: use actual SVG image, oriented along first segment ---
if (waypoints.length >= 2) {
const lb = EPC_LEFT_BOX;
const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
const angle = Math.atan2(p1y - p0y, p1x - p0x);
const iconImg = getSymbolImage(EPC_ICON_FILE);
if (iconImg) {
ctx.save();
ctx.translate(p0x, p0y);
ctx.rotate(angle);
ctx.drawImage(iconImg, -lb.w, -lb.h / 2, lb.w, lb.h);
ctx.restore();
}
}
// --- Right box: oriented along direction from wp[n-2] to wp[n-1] ---
if (waypoints.length >= 2) {
const last = waypoints[waypoints.length - 1];
const prev = waypoints[waypoints.length - 2];
const plx = ox + last.x, ply = oy + last.y;
const ppx = ox + prev.x, ppy = oy + prev.y;
const angle = Math.atan2(ply - ppy, plx - ppx);
ctx.save();
ctx.translate(plx, ply);
ctx.rotate(angle);
const rb = EPC_RIGHT_BOX;
ctx.fillStyle = '#aaaaaa';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 0.3;
ctx.fillRect(0, -rb.h / 2, rb.w, rb.h);
ctx.strokeRect(0, -rb.h / 2, rb.w, rb.h);
ctx.restore();
}
}
/** Draw EPC waypoint handles when selected */
function drawEpcWaypointHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
const hs = 6;
ctx.fillStyle = '#00ccff';
ctx.strokeStyle = '#0088aa';
ctx.lineWidth = 1;
for (const wp of waypoints) {
const hx = sym.x + wp.x;
const hy = sym.y + wp.y;
ctx.beginPath();
ctx.arc(hx, hy, hs / 2, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// Draw "+" at midpoints of segments to hint add-waypoint
if (waypoints.length >= 2) {
ctx.fillStyle = '#00ccff';
ctx.font = '6px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < waypoints.length - 1; i++) {
const mx = sym.x + (waypoints[i].x + waypoints[i + 1].x) / 2;
const my = sym.y + (waypoints[i].y + waypoints[i + 1].y) / 2;
ctx.fillText('+', mx, my - 4);
}
}
}
/** Trace the EPC outline path: left box + segments + right box */
function traceEpcOutlinePath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
if (waypoints.length < 2) return;
const ox = sym.x, oy = sym.y;
// Draw left box outline
const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
const lAngle = Math.atan2(p1y - p0y, p1x - p0x);
const lb = EPC_LEFT_BOX;
ctx.save();
ctx.translate(p0x, p0y);
ctx.rotate(lAngle);
ctx.beginPath();
ctx.rect(-lb.w - pad, -lb.h / 2 - pad, lb.w + pad * 2, lb.h + pad * 2);
ctx.stroke();
ctx.restore();
// Draw line segments outline (thickened)
for (let i = 0; i < waypoints.length - 1; i++) {
const ax = ox + waypoints[i].x, ay = oy + waypoints[i].y;
const bx = ox + waypoints[i + 1].x, by = oy + waypoints[i + 1].y;
const segAngle = Math.atan2(by - ay, bx - ax);
const segLen = Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
ctx.save();
ctx.translate(ax, ay);
ctx.rotate(segAngle);
ctx.beginPath();
ctx.rect(-pad, -pad - EPC_LINE_WIDTH / 2, segLen + pad * 2, EPC_LINE_WIDTH + pad * 2);
ctx.stroke();
ctx.restore();
}
// Draw right box outline
const last = waypoints[waypoints.length - 1];
const prev = waypoints[waypoints.length - 2];
const plx = ox + last.x, ply = oy + last.y;
const ppx = ox + prev.x, ppy = oy + prev.y;
const rAngle = Math.atan2(ply - ppy, plx - ppx);
const rb = EPC_RIGHT_BOX;
ctx.save();
ctx.translate(plx, ply);
ctx.rotate(rAngle);
ctx.beginPath();
ctx.rect(-pad, -rb.h / 2 - pad, rb.w + pad * 2, rb.h + pad * 2);
ctx.stroke();
ctx.restore();
}
/** Trace the induction outline path (arrow head + strip) */
function traceInductionPath(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
const hw = INDUCTION_HEAD_W;
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC;
const pts = INDUCTION_ARROW.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
ctx.beginPath();
ctx.moveTo(sym.x + sym.w + pad, stripTopY - pad);
ctx.lineTo(pts[0][0], stripTopY - pad);
// Arrow outline with padding
for (let i = 0; i < pts.length; i++) {
const [px, py] = pts[i];
// Simple approach: offset each point outward by pad
ctx.lineTo(px + (i <= 2 ? -pad : pad), py + (i <= 1 ? -pad : pad));
}
ctx.lineTo(pts[5][0], stripBottomY + pad);
ctx.lineTo(sym.x + sym.w + pad, stripBottomY + pad);
ctx.closePath();
}
/** Stroke an outline around a symbol — uses arc path for curved, trapezoid for spur, EPC shape for EPC, induction shape for induction, rect for straight */
function strokeOutline(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, pad: number) {
if (isCurvedType(sym.symbolId)) {
traceArcBandPath(ctx, sym, pad);
ctx.stroke();
} else if (isSpurType(sym.symbolId)) {
traceSpurPath(ctx, sym, pad);
ctx.stroke();
} else if (isEpcType(sym.symbolId)) {
traceEpcOutlinePath(ctx, sym, pad);
} else if (isInductionType(sym.symbolId)) {
traceInductionPath(ctx, sym, pad);
ctx.stroke();
} else {
ctx.strokeRect(sym.x - pad, sym.y - pad, sym.w + pad * 2, sym.h + pad * 2);
}
}
/** Draw a filled+stroked resize handle at (x, y) */
function drawHandle(ctx: CanvasRenderingContext2D, x: number, y: number, size: number) {
const half = size / 2;
ctx.fillRect(x - half, y - half, size, size);
ctx.strokeRect(x - half, y - half, size, size);
}
function drawResizeHandles(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
if (!isResizable(sym.symbolId)) return;
const hs = 10;
ctx.fillStyle = '#00ff88';
ctx.strokeStyle = '#009955';
ctx.lineWidth = 1;
if (isCurvedType(sym.symbolId)) {
const arcAngle = sym.curveAngle || 90;
const arcRad = (arcAngle * Math.PI) / 180;
const outerR = sym.w;
const arcCx = sym.x;
const arcCy = sym.y + sym.h;
drawHandle(ctx, arcCx + outerR, arcCy, hs);
drawHandle(ctx, arcCx + outerR * Math.cos(arcRad), arcCy - outerR * Math.sin(arcRad), hs);
} else if (isSpurType(sym.symbolId)) {
const w2 = sym.w2 ?? sym.w;
// Right handle on top base (controls w2)
drawHandle(ctx, sym.x + w2, sym.y, hs);
// Right handle on bottom base (controls w)
drawHandle(ctx, sym.x + sym.w, sym.y + sym.h, hs);
} else if (isInductionType(sym.symbolId)) {
// Only right handle — arrow head is fixed width
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC;
const stripMidY = (stripTopY + stripBottomY) / 2;
drawHandle(ctx, sym.x + sym.w, stripMidY, hs);
} else {
const midY = sym.y + sym.h / 2;
drawHandle(ctx, sym.x, midY, hs);
drawHandle(ctx, sym.x + sym.w, midY, hs);
}
}
/** Draw induction programmatically: fixed arrow head + variable strip, as one path */
function drawInductionSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const hw = INDUCTION_HEAD_W;
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC;
// Arrow points in display coords
const pts = INDUCTION_ARROW.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
ctx.beginPath();
// Top-right of strip
ctx.moveTo(sym.x + sym.w, stripTopY);
// Top-left junction (arrow meets strip)
ctx.lineTo(pts[0][0], stripTopY);
// Arrow outline
for (const [px, py] of pts) {
ctx.lineTo(px, py);
}
// Bottom-left junction to strip bottom
ctx.lineTo(pts[5][0], stripBottomY);
// Bottom-right of strip
ctx.lineTo(sym.x + sym.w, stripBottomY);
ctx.closePath();
ctx.fillStyle = '#000000';
ctx.fill();
}
/** Draw photoeye with 3-slice: fixed left cap, stretched middle beam, fixed right cap */
function drawPhotoeye3Slice(ctx: CanvasRenderingContext2D, sym: PlacedSymbol, img: HTMLImageElement) {
const { leftCap, rightCap, defaultWidth } = PHOTOEYE_CONFIG;
const srcW = img.naturalWidth;
const srcH = img.naturalHeight;
const scale = srcW / defaultWidth;
const srcLeftW = leftCap * scale;
const srcRightW = rightCap * scale;
const srcMiddleW = srcW - srcLeftW - srcRightW;
const dstMiddleW = sym.w - leftCap - rightCap;
// Left cap (fixed)
ctx.drawImage(img, 0, 0, srcLeftW, srcH, sym.x, sym.y, leftCap, sym.h);
// Middle beam (stretched)
ctx.drawImage(img, srcLeftW, 0, srcMiddleW, srcH, sym.x + leftCap, sym.y, dstMiddleW, sym.h);
// Right cap (fixed)
ctx.drawImage(img, srcW - srcRightW, 0, srcRightW, srcH, sym.x + sym.w - rightCap, sym.y, rightCap, sym.h);
}
function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boolean {
if (isEpcType(sym.symbolId)) {
drawEpcSymbol(ctx, sym);
} else if (isInductionType(sym.symbolId)) {
drawInductionSymbol(ctx, sym);
} else {
const img = getSymbolImage(sym.file);
if (!img) return false;
if (isPhotoeyeType(sym.symbolId)) {
drawPhotoeye3Slice(ctx, sym, img);
} else {
ctx.drawImage(img, sym.x, sym.y, sym.w, sym.h);
}
}
return true;
}
function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const cx = sym.x + sym.w / 2;
const isSelected = layout.selectedIds.has(sym.id);
// Selection highlight
if (isSelected) {
ctx.strokeStyle = '#00ff88';
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(0, 255, 136, 0.4)';
ctx.shadowBlur = 6;
strokeOutline(ctx, sym, 2);
ctx.shadowBlur = 0;
if (layout.selectedIds.size === 1) {
if (isEpcType(sym.symbolId)) {
drawEpcWaypointHandles(ctx, sym);
} else {
drawResizeHandles(ctx, sym);
}
}
}
// Collision highlight
if (checkSpacingViolation(sym.id, sym.x, sym.y, sym.w, sym.h, sym.rotation, sym.symbolId, sym.curveAngle, sym.w2)) {
ctx.strokeStyle = '#ff0000';
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(255, 0, 0, 0.6)';
ctx.shadowBlur = 8;
strokeOutline(ctx, sym, 2);
ctx.shadowBlur = 0;
}
// Hover border (non-selected)
if (!isSelected) {
ctx.strokeStyle = 'rgba(233, 69, 96, 0.3)';
ctx.lineWidth = 1;
strokeOutline(ctx, sym, 0);
}
// Label above symbol
if (sym.label) {
ctx.fillStyle = '#e94560';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText(sym.label, cx, sym.y - 3);
}
}
function drawSymbol(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
ctx.save();
// Apply rotation once for all symbol types
if (sym.rotation) {
const cx = sym.x + sym.w / 2;
const cy = sym.y + sym.h / 2;
ctx.translate(cx, cy);
ctx.rotate((sym.rotation * Math.PI) / 180);
ctx.translate(-cx, -cy);
}
if (!drawSymbolBody(ctx, sym)) {
ctx.restore();
return;
}
drawSymbolOverlays(ctx, sym);
ctx.restore();
}

View File

@ -0,0 +1,152 @@
import { layout } from './stores/layout.svelte.js';
import { isEpcType, isInductionType, EPC_LEFT_BOX, EPC_RIGHT_BOX, EPC_LINE_WIDTH, EPC_DEFAULT_WAYPOINTS, EPC_ICON_FILE, INDUCTION_HEAD_W, INDUCTION_ARROW, INDUCTION_STRIP_TOP_FRAC, INDUCTION_STRIP_BOTTOM_FRAC } from './symbols.js';
import { deserializeSymbol } from './serialization.js';
import type { PlacedSymbol } from './types.js';
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function buildEpcSvgElements(sym: PlacedSymbol): Promise<string> {
const waypoints = sym.epcWaypoints || EPC_DEFAULT_WAYPOINTS;
const ox = sym.x;
const oy = sym.y;
const parts: string[] = [];
// Polyline
if (waypoints.length >= 2) {
const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' ');
parts.push(` <polyline points="${points}" fill="none" stroke="#000000" stroke-width="${EPC_LINE_WIDTH}" />`);
}
if (waypoints.length >= 2) {
// Left icon — embed actual SVG, oriented along first segment
const lb = EPC_LEFT_BOX;
const p0x = ox + waypoints[0].x, p0y = oy + waypoints[0].y;
const p1x = ox + waypoints[1].x, p1y = oy + waypoints[1].y;
const lAngle = Math.atan2(p1y - p0y, p1x - p0x) * 180 / Math.PI;
try {
const svgText = await (await fetch(EPC_ICON_FILE)).text();
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
const svgEl = doc.documentElement;
const vb = svgEl.getAttribute('viewBox');
const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h];
const sx = lb.w / vbW;
const sy = lb.h / vbH;
const transform = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
parts.push(` <g transform="${transform}">`);
parts.push(` ${svgEl.innerHTML}`);
parts.push(` </g>`);
} catch {
// Fallback: plain rect
parts.push(` <rect x="${-lb.w}" y="${-lb.h / 2}" width="${lb.w}" height="${lb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})" />`);
}
// Right box — oriented along last segment, left-center at wp[last]
const last = waypoints[waypoints.length - 1];
const prev = waypoints[waypoints.length - 2];
const plx = ox + last.x, ply = oy + last.y;
const ppx = ox + prev.x, ppy = oy + prev.y;
const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI;
const rb = EPC_RIGHT_BOX;
parts.push(` <rect x="0" y="${-rb.h / 2}" width="${rb.w}" height="${rb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})" />`);
}
return parts.join('\n');
}
export async function exportSVG() {
const lines: string[] = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
`<svg width="${layout.canvasW}" height="${layout.canvasH}" viewBox="0 0 ${layout.canvasW} ${layout.canvasH}" version="1.1"`,
' xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"',
' xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">',
` <rect width="${layout.canvasW}" height="${layout.canvasH}" fill="#ffffff" />`,
];
for (const sym of layout.symbols) {
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})"` : '';
const label = sym.label || sym.name;
const idAttr = ` id="${label}" inkscape:label="${label}"`;
if (isEpcType(sym.symbolId)) {
lines.push(` <g${idAttr}${rotAttr}>`);
lines.push(await buildEpcSvgElements(sym as PlacedSymbol));
lines.push(' </g>');
} else if (isInductionType(sym.symbolId)) {
const hw = INDUCTION_HEAD_W;
const stripTopY = sym.y + sym.h * INDUCTION_STRIP_TOP_FRAC;
const stripBottomY = sym.y + sym.h * INDUCTION_STRIP_BOTTOM_FRAC;
const pts = INDUCTION_ARROW.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const);
const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`;
lines.push(` <g${idAttr}>`);
lines.push(` <path d="${d}" fill="#000000"${rotAttr} />`);
lines.push(' </g>');
} else {
try {
const svgText = await (await fetch(sym.file)).text();
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml');
const svgEl = doc.documentElement;
// Skip if DOMParser returned an error (e.g., 404 or invalid SVG)
if (svgEl.querySelector('parsererror')) {
console.error('SVG parse error for:', sym.file);
continue;
}
const vb = svgEl.getAttribute('viewBox');
const [vbX, vbY, vbW, vbH] = vb
? vb.split(/[\s,]+/).map(Number)
: [0, 0, sym.w, sym.h];
const sx = sym.w / vbW;
const sy = sym.h / vbH;
let transform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`;
if (rot) {
transform = `rotate(${rot},${cx},${cy}) ${transform}`;
}
lines.push(` <g${idAttr} transform="${transform}">`);
lines.push(` ${svgEl.innerHTML}`);
lines.push(' </g>');
} catch (err) {
console.error('Failed to embed symbol:', sym.name, err);
}
}
}
lines.push('</svg>');
downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), 'test_view.svg');
}
export function loadLayoutJSON(file: File): Promise<void> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target!.result as string);
layout.pushUndo();
if (data.gridSize) layout.gridSize = data.gridSize;
if (data.minSpacing) layout.minSpacing = data.minSpacing;
layout.symbols = [];
layout.nextId = 1;
for (const s of data.symbols) {
layout.symbols.push(deserializeSymbol(s, layout.nextId++));
}
layout.markDirty();
layout.saveMcmState();
resolve();
} catch (err) {
reject(err);
}
};
reader.readAsText(file);
});
}

View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

215
svelte-app/src/lib/pdf.ts Normal file
View File

@ -0,0 +1,215 @@
import { layout } from './stores/layout.svelte.js';
import type { PDFPageProxy } from 'pdfjs-dist';
let pdfPage: PDFPageProxy | null = null;
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfNatWidth = 0;
let pdfNatHeight = 0;
// High-res raster rendered once at load time
let hiResCanvas: HTMLCanvasElement | null = null;
// Callback to pass the image to Canvas component
let onImageUpdate: ((img: HTMLCanvasElement | null, natW: number, natH: number) => void) | null = null;
export function setImageUpdateCallback(cb: (img: HTMLCanvasElement | null, natW: number, natH: number) => void) {
onImageUpdate = cb;
if (hiResCanvas) {
cb(hiResCanvas, pdfNatWidth, pdfNatHeight);
}
}
// --- IndexedDB for PDF persistence ---
const DB_NAME = 'scada_pdf_store';
const DB_VERSION = 1;
const STORE_NAME = 'pdfs';
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE_NAME);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function getPdfKey(): string {
return `${layout.currentProject}_${layout.currentMcm}`;
}
async function savePdfData(data: ArrayBuffer) {
try {
const db = await openDb();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(data, getPdfKey());
await new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (e) {
console.warn('Failed to save PDF to IndexedDB:', e);
}
}
async function loadPdfData(): Promise<ArrayBuffer | null> {
try {
const db = await openDb();
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(getPdfKey());
const result = await new Promise<ArrayBuffer | null>((resolve, reject) => {
req.onsuccess = () => resolve(req.result ?? null);
req.onerror = () => reject(req.error);
});
db.close();
return result;
} catch (e) {
console.warn('Failed to load PDF from IndexedDB:', e);
return null;
}
}
async function deletePdfData() {
try {
const db = await openDb();
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(getPdfKey());
db.close();
} catch (e) {
console.warn('Failed to delete PDF from IndexedDB:', e);
}
}
// --- PDF loading ---
async function ensurePdfjs() {
if (!pdfjsLib) {
pdfjsLib = await import('pdfjs-dist');
// @ts-expect-error Vite handles new URL(..., import.meta.url) at build time
const workerUrl = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url);
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl.href;
}
return pdfjsLib;
}
/**
* Render the PDF page once at high resolution (HI_RES_SCALE x native).
* This gives crisp quality when zooming in up to ~5x without re-rendering.
* The canvas is stored as a DOM element and scaled via CSS (instant, no wait).
*/
const HI_RES_SCALE = 5;
async function renderHiRes() {
if (!pdfPage) return;
const viewport = pdfPage.getViewport({ scale: HI_RES_SCALE });
if (!hiResCanvas) {
hiResCanvas = document.createElement('canvas');
}
hiResCanvas.width = viewport.width;
hiResCanvas.height = viewport.height;
const ctx = hiResCanvas.getContext('2d');
if (!ctx) return;
await pdfPage.render({ canvasContext: ctx, viewport }).promise;
notifyImage();
}
function notifyImage() {
if (onImageUpdate) {
onImageUpdate(hiResCanvas, pdfNatWidth, pdfNatHeight);
}
}
async function loadPdfFromBuffer(data: ArrayBuffer, resetTransform: boolean) {
const lib = await ensurePdfjs();
const pdf = await lib.getDocument({ data }).promise;
pdfPage = await pdf.getPage(1);
const vp = pdfPage.getViewport({ scale: 1 });
pdfNatWidth = vp.width;
pdfNatHeight = vp.height;
if (resetTransform) {
layout.pdfScale = Math.min(layout.canvasW / pdfNatWidth, layout.canvasH / pdfNatHeight);
layout.pdfOffsetX = 0;
layout.pdfOffsetY = 0;
}
layout.pdfLoaded = true;
await renderHiRes();
layout.markDirty();
}
/** Load PDF from raw data, persist to IndexedDB, and save layout state */
async function loadAndPersistPdf(data: ArrayBuffer) {
const copy = data.slice(0);
await loadPdfFromBuffer(data, true);
await savePdfData(copy);
layout.saveMcmState();
}
// --- Public API ---
export async function loadPdfFile(file: File) {
await loadAndPersistPdf(await file.arrayBuffer());
}
export async function loadPdfFromPath(path: string) {
try {
const resp = await fetch(path);
if (!resp.ok) return;
await loadAndPersistPdf(await resp.arrayBuffer());
} catch (e) {
console.warn('Could not load PDF from path:', e);
}
}
export async function restorePdf() {
const data = await loadPdfData();
if (!data) return;
await loadPdfFromBuffer(data, false);
}
export function removePdf() {
pdfPage = null;
hiResCanvas = null;
pdfNatWidth = 0;
pdfNatHeight = 0;
layout.pdfScale = 1.0;
layout.pdfOffsetX = 0;
layout.pdfOffsetY = 0;
layout.pdfLoaded = false;
layout.editingBackground = false;
notifyImage();
layout.markDirty();
deletePdfData();
layout.saveMcmState();
}
const PDF_ZOOM_STEP = 1.1;
const PDF_MIN_SCALE = 0.05;
export function pdfZoomIn() {
if (!layout.pdfLoaded) return;
layout.pdfScale *= PDF_ZOOM_STEP;
layout.markDirty();
layout.saveMcmState();
}
export function pdfZoomOut() {
if (!layout.pdfLoaded) return;
layout.pdfScale = Math.max(PDF_MIN_SCALE, layout.pdfScale / PDF_ZOOM_STEP);
layout.markDirty();
layout.saveMcmState();
}
export function toggleEditBackground() {
if (!layout.pdfLoaded) return;
layout.editingBackground = !layout.editingBackground;
}

View File

@ -0,0 +1,83 @@
import type { ProjectInfo, McmInfo } from './types.js';
/**
* Scans the static/projectes/ directory structure to discover available projects and MCMs.
*
* Expected structure:
* projectes/{PROJECT}/excel/{PROJECT}_SYSDL_{MCM}*.xlsx
* projectes/{PROJECT}/pdf/{PROJECT}_SYSDL_{MCM}*-SYSDL.pdf
*
* Since we're in a static SPA, we use a manifest approach:
* At build time or at runtime, we fetch a directory listing.
* For simplicity, we'll use a manifest file that can be auto-generated.
*/
// We'll scan using a known project list fetched from a manifest
// For the static SPA, we generate a manifest at build time via a vite plugin,
// or we hardcode discovery by trying known paths.
const MCM_REGEX = /SYSDL[_ ]+(MCM\d+)/i;
export async function discoverProjects(): Promise<ProjectInfo[]> {
try {
const resp = await fetch('/projectes/manifest.json');
if (resp.ok) {
return await resp.json();
}
} catch {
// manifest doesn't exist, fall through
}
// Fallback: try to discover from known project names
// This won't work in pure static hosting without a manifest
// So we provide a way to scan from the file input
return [];
}
export function parseProjectFiles(files: FileList): ProjectInfo[] {
const projectMap = new Map<string, Map<string, { excel: string; pdf: string | null }>>();
for (const file of files) {
const parts = file.webkitRelativePath?.split('/') || file.name.split('/');
if (parts.length < 4) continue;
const projectName = parts[1] || parts[0];
const folder = parts[2]; // 'excel' or 'pdf'
const fileName = parts[parts.length - 1];
if (!projectMap.has(projectName)) {
projectMap.set(projectName, new Map());
}
const mcmMatch = fileName.match(MCM_REGEX);
if (!mcmMatch) continue;
const mcmName = mcmMatch[1];
const mcms = projectMap.get(projectName)!;
if (!mcms.has(mcmName)) {
mcms.set(mcmName, { excel: '', pdf: null });
}
if (folder === 'excel' && fileName.endsWith('.xlsx')) {
mcms.get(mcmName)!.excel = file.webkitRelativePath || file.name;
} else if (folder === 'pdf' && fileName.endsWith('.pdf')) {
mcms.get(mcmName)!.pdf = file.webkitRelativePath || file.name;
}
}
const projects: ProjectInfo[] = [];
for (const [name, mcms] of projectMap) {
const mcmList: McmInfo[] = [];
for (const [mcmName, paths] of mcms) {
mcmList.push({
name: mcmName,
excelPath: paths.excel,
pdfPath: paths.pdf,
});
}
mcmList.sort((a, b) => a.name.localeCompare(b.name));
projects.push({ name, mcms: mcmList });
}
projects.sort((a, b) => a.name.localeCompare(b.name));
return projects;
}

View File

@ -0,0 +1,55 @@
import type { PlacedSymbol, EpcWaypoint } from './types.js';
/** JSON shape stored in localStorage / exported JSON files */
export interface SerializedSymbol {
symbolId: string;
name: string;
label?: string;
file: string;
x: number;
y: number;
w: number;
h: number;
w2?: number;
rotation?: number;
curveAngle?: number;
epcWaypoints?: EpcWaypoint[];
pdpCBs?: number[];
}
export function serializeSymbol(sym: PlacedSymbol): SerializedSymbol {
return {
symbolId: sym.symbolId,
name: sym.name,
label: sym.label || undefined,
file: sym.file,
x: sym.x,
y: sym.y,
w: sym.w,
h: sym.h,
w2: sym.w2,
rotation: sym.rotation || undefined,
curveAngle: sym.curveAngle,
epcWaypoints: sym.epcWaypoints,
pdpCBs: sym.pdpCBs,
};
}
export function deserializeSymbol(data: SerializedSymbol, id: number): PlacedSymbol {
return {
id,
symbolId: data.symbolId,
name: data.name,
label: data.label || '',
file: data.file?.replace('_no_comm.svg', '.svg') || data.file,
x: data.x,
y: data.y,
w: data.w,
h: data.h,
w2: data.w2,
rotation: data.rotation || 0,
curveAngle: data.curveAngle,
epcWaypoints: data.epcWaypoints,
pdpCBs: data.pdpCBs,
};
}

View File

@ -0,0 +1,194 @@
import type { PlacedSymbol, ProjectInfo } from '../types.js';
import { serializeSymbol, deserializeSymbol } from '../serialization.js';
export const DEFAULT_CANVAS_W = 1920;
export const DEFAULT_CANVAS_H = 1080;
const MAX_UNDO = 50;
class LayoutStore {
symbols = $state<PlacedSymbol[]>([]);
nextId = $state(1);
selectedIds = $state<Set<number>>(new Set());
gridSize = $state(20);
minSpacing = $state(10);
snapEnabled = $state(true);
showGrid = $state(true);
zoomLevel = $state(1);
panX = $state(0);
panY = $state(0);
canvasW = $state(DEFAULT_CANVAS_W);
canvasH = $state(DEFAULT_CANVAS_H);
// Project/MCM
projects = $state<ProjectInfo[]>([]);
currentProject = $state<string>('');
currentMcm = $state<string>('');
// PDF state
pdfScale = $state(1.0);
pdfOffsetX = $state(0);
pdfOffsetY = $state(0);
pdfLoaded = $state(false);
editingBackground = $state(false);
// Dirty flag for canvas re-render
dirty = $state(0);
// Undo stack (not reactive — internal only)
private undoStack: { symbols: PlacedSymbol[]; nextId: number }[] = [];
markDirty() {
this.dirty++;
}
pushUndo() {
this.undoStack.push({
symbols: this.symbols.map(s => ({
...s,
epcWaypoints: s.epcWaypoints?.map(wp => ({ ...wp })),
})),
nextId: this.nextId,
});
if (this.undoStack.length > MAX_UNDO) this.undoStack.shift();
}
undo() {
const prev = this.undoStack.pop();
if (!prev) return;
this.symbols = prev.symbols;
this.nextId = prev.nextId;
this.selectedIds = new Set();
this.markDirty();
this.saveMcmState();
}
getMcmStorageKey(): string {
return `scada_${this.currentProject}_${this.currentMcm}`;
}
saveMcmState() {
if (!this.currentProject || !this.currentMcm) return;
const state = {
symbols: this.symbols.map(s => serializeSymbol(s)),
nextId: this.nextId,
gridSize: this.gridSize,
minSpacing: this.minSpacing,
pdfScale: this.pdfScale,
pdfOffsetX: this.pdfOffsetX,
pdfOffsetY: this.pdfOffsetY,
canvasW: this.canvasW,
canvasH: this.canvasH,
};
localStorage.setItem(this.getMcmStorageKey(), JSON.stringify(state));
}
loadMcmState() {
this.undoStack = [];
const raw = localStorage.getItem(this.getMcmStorageKey());
if (!raw) {
this.symbols = [];
this.nextId = 1;
this.selectedIds = new Set();
this.markDirty();
return;
}
try {
const state = JSON.parse(raw);
this.symbols = [];
this.nextId = 1;
this.selectedIds = new Set();
if (state.gridSize) this.gridSize = state.gridSize;
if (state.minSpacing) this.minSpacing = state.minSpacing;
if (state.pdfScale) this.pdfScale = state.pdfScale;
if (state.pdfOffsetX !== undefined) this.pdfOffsetX = state.pdfOffsetX;
if (state.pdfOffsetY !== undefined) this.pdfOffsetY = state.pdfOffsetY;
if (state.canvasW) this.canvasW = state.canvasW;
if (state.canvasH) this.canvasH = state.canvasH;
for (const s of state.symbols || []) {
this.symbols.push(deserializeSymbol(s, this.nextId++));
}
if (state.nextId && state.nextId > this.nextId) this.nextId = state.nextId;
this.markDirty();
} catch (e) {
console.error('Failed to load MCM state:', e);
}
}
addSymbol(sym: Omit<PlacedSymbol, 'id'>): PlacedSymbol {
this.pushUndo();
const placed: PlacedSymbol = { ...sym, id: this.nextId++ };
this.symbols.push(placed);
this.selectedIds = new Set([placed.id]);
this.markDirty();
this.saveMcmState();
return placed;
}
removeSymbol(id: number) {
this.pushUndo();
this.symbols = this.symbols.filter(s => s.id !== id);
const newSet = new Set(this.selectedIds);
newSet.delete(id);
this.selectedIds = newSet;
this.markDirty();
this.saveMcmState();
}
removeSelected() {
if (this.selectedIds.size === 0) return;
this.pushUndo();
this.symbols = this.symbols.filter(s => !this.selectedIds.has(s.id));
this.selectedIds = new Set();
this.markDirty();
this.saveMcmState();
}
duplicateSymbol(id: number) {
const orig = this.symbols.find(s => s.id === id);
if (!orig) return;
this.pushUndo();
const newSym: PlacedSymbol = {
...orig,
id: this.nextId++,
x: orig.x + 20,
y: orig.y + 20,
};
this.symbols.push(newSym);
this.selectedIds = new Set([newSym.id]);
this.markDirty();
this.saveMcmState();
}
duplicateSelected() {
if (this.selectedIds.size === 0) return;
this.pushUndo();
const newIds: number[] = [];
for (const id of this.selectedIds) {
const orig = this.symbols.find(s => s.id === id);
if (!orig) continue;
const newId = this.nextId++;
this.symbols.push({
...orig,
id: newId,
x: orig.x + 20,
y: orig.y + 20,
});
newIds.push(newId);
}
this.selectedIds = new Set(newIds);
this.markDirty();
this.saveMcmState();
}
clearAll() {
this.pushUndo();
this.symbols = [];
this.selectedIds = new Set();
this.nextId = 1;
this.markDirty();
this.saveMcmState();
}
}
export const layout = new LayoutStore();

View File

@ -0,0 +1,38 @@
/** Centralized geometry config for symbol types with programmatic rendering */
export const EPC_CONFIG = {
iconFile: '/symbols/epc_icon.svg',
leftBox: { x: 0, y: 0, w: 26, h: 20 },
rightBox: { w: 10, h: 20 },
lineWidth: 0.4,
defaultWaypoints: [
{ x: 26, y: 10 }, // exit from left box center-right
{ x: 57, y: 10 }, // entry to right box center-left
],
} as const;
export const INDUCTION_CONFIG = {
headWidth: 78, // fixed display width of arrow head (px)
// Arrow shape points as [xFrac of headW, yFrac of h] — derived from original SVG path
arrowPoints: [
[0.5552, 0.2832], // top junction with strip
[0.2946, 0.0106], // top of arrow
[0.0096, 0.3088], // left point
[0.6602, 0.9890], // bottom of arrow
[0.9331, 0.7035], // small step
[0.9154, 0.6849], // bottom junction with strip
] as [number, number][],
stripTopFrac: 0.2832,
stripBottomFrac: 0.6849,
} as const;
export const PHOTOEYE_CONFIG = {
leftCap: 8.2, // fixed display px — left transmitter arrow
rightCap: 4.2, // fixed display px — right receiver cap
defaultWidth: 56, // original PE width
} as const;
export const CURVE_CONFIG = {
convBand: 30, // matches conveyor height
chuteBand: 30, // matches chute height
} as const;

View File

@ -0,0 +1,179 @@
import type { SymbolDef } from './types.js';
import { EPC_CONFIG, INDUCTION_CONFIG, CURVE_CONFIG, PHOTOEYE_CONFIG } from './symbol-config.js';
export { EPC_CONFIG, INDUCTION_CONFIG, CURVE_CONFIG, PHOTOEYE_CONFIG };
export const SYMBOLS: SymbolDef[] = [
// --- Conveyance > Conveyor ---
{ id: 'conveyor', name: 'Conveyor', file: '/symbols/conveyor.svg', w: 154, h: 30, group: 'Conveyance', subgroup: 'Conveyor' },
{ id: 'conveyor_v', name: 'Conveyor (V)', file: '/symbols/conveyor.svg', w: 154, h: 30, defaultRotation: 90, group: 'Conveyance', subgroup: 'Conveyor' },
{ id: 'curved_conv_30', name: 'Curve 30\u00B0', file: '/symbols/curved_conveyor_30.svg', w: 154, h: 154, group: 'Conveyance', subgroup: 'Conveyor', curveAngle: 30 },
{ id: 'curved_conv_45', name: 'Curve 45\u00B0', file: '/symbols/curved_conveyor_45.svg', w: 154, h: 154, group: 'Conveyance', subgroup: 'Conveyor', curveAngle: 45 },
{ id: 'curved_conv_60', name: 'Curve 60\u00B0', file: '/symbols/curved_conveyor_60.svg', w: 154, h: 154, group: 'Conveyance', subgroup: 'Conveyor', curveAngle: 60 },
{ id: 'curved_conv_90', name: 'Curve 90\u00B0', file: '/symbols/curved_conveyor_90.svg', w: 154, h: 154, group: 'Conveyance', subgroup: 'Conveyor', curveAngle: 90 },
// --- Conveyance > Chute ---
{ id: 'chute', name: 'Chute', file: '/symbols/chute.svg', w: 68, h: 30, group: 'Conveyance', subgroup: 'Chute' },
{ id: 'chute_v', name: 'Chute (V)', file: '/symbols/chute.svg', w: 68, h: 30, defaultRotation: 90, group: 'Conveyance', subgroup: 'Chute' },
{ id: 'tipper', name: 'Tipper', file: '/symbols/tipper.svg', w: 68, h: 30, group: 'Conveyance', subgroup: 'Chute' },
{ id: 'tipper_v', name: 'Tipper (V)', file: '/symbols/tipper.svg', w: 68, h: 30, defaultRotation: 90, group: 'Conveyance', subgroup: 'Chute' },
{ id: 'curved_chute_30', name: 'C.Chute 30\u00B0', file: '/symbols/curved_chute_30.svg', w: 100, h: 100, group: 'Conveyance', subgroup: 'Chute', curveAngle: 30 },
{ id: 'curved_chute_45', name: 'C.Chute 45\u00B0', file: '/symbols/curved_chute_45.svg', w: 100, h: 100, group: 'Conveyance', subgroup: 'Chute', curveAngle: 45 },
{ id: 'curved_chute_60', name: 'C.Chute 60\u00B0', file: '/symbols/curved_chute_60.svg', w: 100, h: 100, group: 'Conveyance', subgroup: 'Chute', curveAngle: 60 },
{ id: 'curved_chute_90', name: 'C.Chute 90\u00B0', file: '/symbols/curved_chute_90.svg', w: 100, h: 100, group: 'Conveyance', subgroup: 'Chute', curveAngle: 90 },
// --- Conveyance > Other ---
{ id: 'spur', name: 'Spur', file: '/symbols/spur.svg', w: 80, h: 30, w2: 40, group: 'Conveyance', subgroup: 'Other' },
{ id: 'spur_v', name: 'Spur (V)', file: '/symbols/spur.svg', w: 80, h: 30, w2: 40, defaultRotation: 90, group: 'Conveyance', subgroup: 'Other' },
{ id: 'extendo', name: 'Extendo', file: '/symbols/extendo.svg', w: 73, h: 54, group: 'Conveyance', subgroup: 'Other' },
{ id: 'extendo_v', name: 'Extendo (V)', file: '/symbols/extendo.svg', w: 73, h: 54, defaultRotation: 90, group: 'Conveyance', subgroup: 'Other' },
{ id: 'induction', name: 'Induction', file: '/symbols/induction.svg', w: 154, h: 75, group: 'Conveyance', subgroup: 'Other' },
{ id: 'induction_v', name: 'Induction (V)', file: '/symbols/induction.svg', w: 154, h: 75, defaultRotation: 90, group: 'Conveyance', subgroup: 'Other' },
{ id: 'diverter', name: 'Diverter', file: '/symbols/diverter.svg', w: 31, h: 20, group: 'Conveyance', subgroup: 'Other' },
{ id: 'diverter_v', name: 'Diverter (V)', file: '/symbols/diverter.svg', w: 31, h: 20, defaultRotation: 90, group: 'Conveyance', subgroup: 'Other' },
// --- I/O Modules ---
{ id: 'fio_sio_fioh', name: 'FIO/SIO/FIOH', file: '/symbols/fio_sio_fioh.svg', w: 14, h: 20, group: 'I/O Modules' },
{ id: 'fio_sio_fioh_v', name: 'FIO/SIO/FIOH (V)', file: '/symbols/fio_sio_fioh.svg', w: 14, h: 20, defaultRotation: 90, group: 'I/O Modules' },
// --- Sensors ---
{ id: 'photoeye', name: 'Photoeye', file: '/symbols/photoeye.svg', w: 56, h: 20, group: 'Sensors' },
{ id: 'photoeye_v', name: 'Photoeye (V)', file: '/symbols/photoeye.svg', w: 56, h: 20, defaultRotation: 90, group: 'Sensors' },
{ id: 'pressure_sensor', name: 'Pressure Sensor', file: '/symbols/pressure_sensor.svg', w: 20, h: 20, group: 'Sensors' },
{ id: 'pressure_sensor_v', name: 'Pressure Sensor (V)', file: '/symbols/pressure_sensor.svg', w: 20, h: 20, defaultRotation: 90, group: 'Sensors' },
// --- Controls ---
{ id: 'jam_reset', name: 'Jam Reset (JR)', file: '/symbols/jam_reset.svg', w: 20, h: 20, group: 'Controls' },
{ id: 'jam_reset_v', name: 'Jam Reset (V)', file: '/symbols/jam_reset.svg', w: 20, h: 20, defaultRotation: 90, group: 'Controls' },
{ id: 'start', name: 'Start (S)', file: '/symbols/start.svg', w: 20, h: 20, group: 'Controls' },
{ id: 'start_v', name: 'Start (V)', file: '/symbols/start.svg', w: 20, h: 20, defaultRotation: 90, group: 'Controls' },
{ id: 'start_stop', name: 'Start Stop (SS)', file: '/symbols/start_stop.svg', w: 40, h: 20, group: 'Controls' },
{ id: 'start_stop_v', name: 'Start Stop (V)', file: '/symbols/start_stop.svg', w: 40, h: 20, defaultRotation: 90, group: 'Controls' },
{ id: 'chute_enable', name: 'Chute Enable', file: '/symbols/chute_enable.svg', w: 20, h: 20, group: 'Controls' },
{ id: 'chute_enable_v', name: 'Chute Enable (V)', file: '/symbols/chute_enable.svg', w: 20, h: 20, defaultRotation: 90, group: 'Controls' },
{ id: 'package_release', name: 'Package Release', file: '/symbols/package_release.svg', w: 20, h: 20, group: 'Controls' },
{ id: 'package_release_v', name: 'Package Release (V)', file: '/symbols/package_release.svg', w: 20, h: 20, defaultRotation: 90, group: 'Controls' },
{ id: 'beacon', name: 'Beacon', file: '/symbols/beacon.svg', w: 20, h: 20, group: 'Controls' },
{ id: 'beacon_v', name: 'Beacon (V)', file: '/symbols/beacon.svg', w: 20, h: 20, defaultRotation: 90, group: 'Controls' },
{ id: 'solenoid', name: '[SOL]', file: '/symbols/solenoid.svg', w: 30, h: 20, group: 'Controls' },
{ id: 'solenoid_v', name: '[SOL] (V)', file: '/symbols/solenoid.svg', w: 30, h: 20, defaultRotation: 90, group: 'Controls' },
// --- Other ---
{ id: 'pdp', name: 'PDP', file: '/symbols/pdp.svg', w: 30, h: 20, group: 'Other' },
{ id: 'pdp_v', name: 'PDP (V)', file: '/symbols/pdp.svg', w: 30, h: 20, defaultRotation: 90, group: 'Other' },
{ id: 'dpm', name: 'DPM', file: '/symbols/dpm.svg', w: 35, h: 20, group: 'Other' },
{ id: 'dpm_v', name: 'DPM (V)', file: '/symbols/dpm.svg', w: 35, h: 20, defaultRotation: 90, group: 'Other' },
{ id: 'mcm', name: 'MCM', file: '/symbols/mcm.svg', w: 60, h: 20, group: 'Other' },
{ id: 'mcm_v', name: 'MCM (V)', file: '/symbols/mcm.svg', w: 60, h: 20, defaultRotation: 90, group: 'Other' },
{ id: 'epc', name: 'EPC', file: '/symbols/epc.svg', w: 67, h: 20, group: 'Other' },
{ id: 'epc_v', name: 'EPC (V)', file: '/symbols/epc.svg', w: 67, h: 20, defaultRotation: 90, group: 'Other' },
{ id: 'ip_camera', name: 'IP Camera', file: '/symbols/ip_camera.svg', w: 20, h: 20, group: 'Other' },
{ id: 'ip_camera_v', name: 'IP Camera (V)', file: '/symbols/ip_camera.svg', w: 20, h: 20, defaultRotation: 90, group: 'Other' },
];
export const SYMBOL_GROUPS = [...new Set(SYMBOLS.map(s => s.group))];
export const PRIORITY_TYPES = new Set([
'conveyor', 'conveyor_v', 'chute', 'chute_v',
'tipper', 'tipper_v', 'extendo', 'extendo_v',
'induction', 'induction_v',
'curved_conv_30', 'curved_conv_45', 'curved_conv_60', 'curved_conv_90',
'curved_chute_30', 'curved_chute_45', 'curved_chute_60', 'curved_chute_90',
'spur', 'spur_v',
'photoeye', 'photoeye_v',
]);
// Photoeyes are exempt from spacing — can be placed freely on top of anything
export const SPACING_EXEMPT = new Set([
'photoeye', 'photoeye_v',
]);
// Re-export config as legacy names for backward compatibility
export const EPC_ICON_FILE = EPC_CONFIG.iconFile;
export const INDUCTION_HEAD_W = INDUCTION_CONFIG.headWidth;
export const INDUCTION_ARROW: [number, number][] = [...INDUCTION_CONFIG.arrowPoints];
export const INDUCTION_STRIP_TOP_FRAC = INDUCTION_CONFIG.stripTopFrac;
export const INDUCTION_STRIP_BOTTOM_FRAC = INDUCTION_CONFIG.stripBottomFrac;
export const CURVE_CONV_BAND = CURVE_CONFIG.convBand;
export const CURVE_CHUTE_BAND = CURVE_CONFIG.chuteBand;
export function getCurveBandWidth(symbolId: string): number {
if (symbolId.startsWith('curved_chute')) return CURVE_CONFIG.chuteBand;
return CURVE_CONFIG.convBand;
}
const imageCache = new Map<string, HTMLImageElement>();
const SVG_SCALE = 10; // Rasterize SVGs at 10x for crisp canvas rendering
export function preloadSymbolImages(): Promise<void[]> {
const uniqueFiles = [...new Set(SYMBOLS.map(s => s.file)), EPC_ICON_FILE];
return Promise.all(
uniqueFiles.map(
(file) =>
new Promise<void>(async (resolve) => {
if (imageCache.has(file)) {
resolve();
return;
}
try {
const resp = await fetch(file);
const svgText = await resp.text();
// Scale up width/height so the browser rasterizes at higher resolution
const scaled = svgText.replace(
/<svg([^>]*)\bwidth="([^"]*)"([^>]*)\bheight="([^"]*)"/,
(_, before, w, mid, h) =>
`<svg${before}width="${parseFloat(w) * SVG_SCALE}"${mid}height="${parseFloat(h) * SVG_SCALE}"`
);
const blob = new Blob([scaled], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
imageCache.set(file, img);
resolve();
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve();
};
img.src = url;
} catch {
resolve();
}
})
)
);
}
export function getSymbolImage(file: string): HTMLImageElement | undefined {
return imageCache.get(file);
}
export function isCurvedType(symbolId: string): boolean {
return symbolId.startsWith('curved_');
}
export function isSpurType(symbolId: string): boolean {
return symbolId === 'spur' || symbolId === 'spur_v';
}
export function isEpcType(symbolId: string): boolean {
return symbolId === 'epc' || symbolId === 'epc_v';
}
export function isInductionType(symbolId: string): boolean {
return symbolId === 'induction' || symbolId === 'induction_v';
}
export function isPhotoeyeType(symbolId: string): boolean {
return symbolId === 'photoeye' || symbolId === 'photoeye_v';
}
// EPC box dimensions — derived from symbol-config
export const EPC_LEFT_BOX = EPC_CONFIG.leftBox;
export const EPC_RIGHT_BOX = EPC_CONFIG.rightBox;
export const EPC_LINE_WIDTH = EPC_CONFIG.lineWidth;
export const EPC_DEFAULT_WAYPOINTS = EPC_CONFIG.defaultWaypoints.map(wp => ({ ...wp }));
export function isResizable(symbolId: string): boolean {
return PRIORITY_TYPES.has(symbolId);
}

View File

@ -0,0 +1,52 @@
export interface SymbolDef {
id: string;
name: string;
file: string;
w: number;
h: number;
w2?: number; // Spur: top base width
defaultRotation?: number;
group: string;
subgroup?: string;
curveAngle?: number; // For curved conveyors/chutes: arc angle in degrees
}
export interface EpcWaypoint {
x: number; // local coords relative to symbol origin
y: number;
}
export interface PlacedSymbol {
id: number;
symbolId: string;
name: string;
label: string; // User-assigned ID, exported as id and inkscape:label
file: string;
x: number;
y: number;
w: number;
h: number;
w2?: number; // Spur: top base width
rotation: number;
curveAngle?: number; // For curved conveyors/chutes
epcWaypoints?: EpcWaypoint[]; // EPC editable line waypoints (local coords)
pdpCBs?: number[]; // PDP visible circuit breaker numbers (1-26)
}
export interface AABB {
x: number;
y: number;
w: number;
h: number;
}
export interface ProjectInfo {
name: string;
mcms: McmInfo[];
}
export interface McmInfo {
name: string;
excelPath: string;
pdfPath: string | null;
}

View File

@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View File

@ -0,0 +1,2 @@
export const prerender = true;
export const ssr = false;

View File

@ -0,0 +1,63 @@
<script lang="ts">
import { onMount } from 'svelte';
import { preloadSymbolImages } from '$lib/symbols.js';
import Toolbar from '../components/Toolbar.svelte';
import Canvas from '../components/Canvas.svelte';
import DeviceDock from '../components/DeviceDock.svelte';
let ready = $state(false);
onMount(async () => {
await preloadSymbolImages();
ready = true;
});
</script>
<svelte:head>
<title>SCADA Device Layout Tool</title>
</svelte:head>
{#if ready}
<div class="app">
<Toolbar />
<Canvas />
<DeviceDock />
</div>
{:else}
<div class="loading">
<div class="spinner"></div>
<p>Loading symbols...</p>
</div>
{/if}
<style>
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #0f3460;
border-top-color: #e94560;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,102 @@
[
{
"name": "CDW5",
"mcms": [
{
"name": "MCM01",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM01.xlsx",
"pdfPath": null
},
{
"name": "MCM02",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM02.xlsx",
"pdfPath": null
},
{
"name": "MCM03",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM03.xlsx",
"pdfPath": null
},
{
"name": "MCM04",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM04.xlsx",
"pdfPath": null
},
{
"name": "MCM05",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM05.xlsx",
"pdfPath": null
},
{
"name": "MCM06",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM06.xlsx",
"pdfPath": null
},
{
"name": "MCM07",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM07.xlsx",
"pdfPath": null
},
{
"name": "MCM08",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM08.xlsx",
"pdfPath": null
},
{
"name": "MCM09",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM09 Non Con PH1.xlsx",
"pdfPath": "/projectes/CDW5/pdf/CDW5_SYSDL_MCM09 Non Con PH1-SYSDL.pdf"
},
{
"name": "MCM10",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM10.xlsx",
"pdfPath": null
},
{
"name": "MCM11",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM11.xlsx",
"pdfPath": null
},
{
"name": "MCM12",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM12.xlsx",
"pdfPath": null
},
{
"name": "MCM13",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM13.xlsx",
"pdfPath": null
},
{
"name": "MCM14",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM14.xlsx",
"pdfPath": null
},
{
"name": "MCM15",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM15.xlsx",
"pdfPath": null
},
{
"name": "MCM16",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM16.xlsx",
"pdfPath": null
},
{
"name": "MCM17",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM17.xlsx",
"pdfPath": null
},
{
"name": "MCM18",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM18.xlsx",
"pdfPath": null
},
{
"name": "MCM19",
"excelPath": "/projectes/CDW5/excel/CDW5_SYSDL_MCM19.xlsx",
"pdfPath": null
}
]
}
]

View File

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

View File

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
fallback: '200.html'
})
}
};
export default config;

20
svelte-app/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

253
svelte-app/vite.config.ts Normal file
View File

@ -0,0 +1,253 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import fs from 'fs';
import path from 'path';
import XLSX from 'xlsx';
function generateProjectManifest() {
const staticDir = path.resolve('static');
const projectesDir = path.join(staticDir, 'projectes');
if (!fs.existsSync(projectesDir)) return;
const MCM_REGEX = /SYSDL[_ ]+(MCM\d+)/i;
const projects: { name: string; mcms: { name: string; excelPath: string; pdfPath: string | null }[] }[] = [];
for (const projectName of fs.readdirSync(projectesDir)) {
const projectDir = path.join(projectesDir, projectName);
if (!fs.statSync(projectDir).isDirectory()) continue;
const excelDir = path.join(projectDir, 'excel');
const pdfDir = path.join(projectDir, 'pdf');
const mcmMap = new Map<string, { excelPath: string; pdfPath: string | null }>();
if (fs.existsSync(excelDir)) {
for (const f of fs.readdirSync(excelDir)) {
if (!f.endsWith('.xlsx')) continue;
const match = f.match(MCM_REGEX);
if (!match) continue;
const mcm = match[1];
mcmMap.set(mcm, {
excelPath: `/projectes/${projectName}/excel/${f}`,
pdfPath: null,
});
}
}
if (fs.existsSync(pdfDir)) {
for (const f of fs.readdirSync(pdfDir)) {
if (!f.endsWith('.pdf')) continue;
const match = f.match(MCM_REGEX);
if (!match) continue;
const mcm = match[1];
if (mcmMap.has(mcm)) {
mcmMap.get(mcm)!.pdfPath = `/projectes/${projectName}/pdf/${f}`;
} else {
mcmMap.set(mcm, {
excelPath: '',
pdfPath: `/projectes/${projectName}/pdf/${f}`,
});
}
}
}
if (mcmMap.size > 0) {
const mcms = [...mcmMap.entries()]
.map(([name, paths]) => ({ name, ...paths }))
.sort((a, b) => a.name.localeCompare(b.name));
projects.push({ name: projectName, mcms });
}
}
projects.sort((a, b) => a.name.localeCompare(b.name));
fs.writeFileSync(
path.join(projectesDir, 'manifest.json'),
JSON.stringify(projects, null, 2)
);
}
/** Classify a device name to a symbol ID, or null to skip */
function classifyDevice(dev: string): string | null {
// Skip
if (/ENSH|ENW/.test(dev)) return null;
if (/VFD_DI?SC/.test(dev)) return null;
if (/_LT$/.test(dev)) return null;
if (/EPC\d+_\d+/.test(dev)) return null;
if (/BCN\d+_[HBWG]$/.test(dev)) return null;
if (dev === 'SPARE') return null;
if (/ESTOP/.test(dev)) return null;
if (/SCALE/.test(dev)) return null;
if (/_STO\d*$/.test(dev)) return null;
if (/PRX\d*$/.test(dev)) return null;
if (/LRPE/.test(dev)) return null;
// Match
if (/EPC\d*$/.test(dev)) return 'epc';
if (/[TLJF]PE\d*$/.test(dev)) return 'photoeye';
if (/BDS\d+_[RS]$/.test(dev)) return 'photoeye';
if (/TS\d+_[RS]$/.test(dev)) return 'photoeye';
if (/JR\d*_PB$/.test(dev)) return 'jam_reset';
if (/SS\d+_S[TP]*PB$/.test(dev)) return 'start_stop';
if (/S\d+_PB$/.test(dev) && !/^SS/.test(dev)) return 'start';
if (/EN\d+_PB$/.test(dev)) return 'chute_enable';
if (/PR\d+_PB$/.test(dev)) return 'package_release';
if (/SOL\d*$/.test(dev)) return 'solenoid';
if (/DIV\d+_LS/.test(dev)) return 'diverter';
if (/BCN\d*$/.test(dev) || /BCN\d+_[AR]$/.test(dev)) return 'beacon';
if (/^PDP\d+_CB/.test(dev)) return 'pdp';
if (/^PDP\d+_PMM/.test(dev)) return 'pdp';
if (/FIO[HM]?\d*$/.test(dev) || /SIO\d*$/.test(dev)) return 'fio_sio_fioh';
if (/PS\d*$/.test(dev) && !/^PS\d/.test(dev)) return 'pressure_sensor';
return null;
}
/** Extract zone prefix from device name, e.g. "NCP1_1_TPE1" -> "NCP1_1" */
function extractZone(dev: string): string {
// PDP devices: PDP04_CB1 -> PDP04
const pdpM = dev.match(/^(PDP\d+)/);
if (pdpM) return pdpM[1];
// Standard: ZONE_NUM_REST
const m = dev.match(/^([A-Z]+\d*_\d+[A-Z]?)/);
return m ? m[1] : 'OTHER';
}
function generateDeviceManifest() {
const projectesDir = path.resolve('..', 'projectes');
const staticDir = path.resolve('static', 'projectes');
if (!fs.existsSync(projectesDir)) return;
const manifest: Record<string, { id: string; svg: string; zone: string }[]> = {};
for (const projectName of fs.readdirSync(projectesDir)) {
const projectDir = path.join(projectesDir, projectName);
if (!fs.statSync(projectDir).isDirectory()) continue;
const excelDir = path.join(projectDir, 'excel');
if (!fs.existsSync(excelDir)) continue;
// Find device IO file and IP addresses file
const files = fs.readdirSync(excelDir).filter(f => f.endsWith('.xlsx'));
const ioFile = files.find(f => /Devices?\s*IO/i.test(f));
const ipFile = files.find(f => /IP\s*Address/i.test(f));
if (!ioFile) continue;
const ioWb = XLSX.readFile(path.join(excelDir, ioFile));
// Also load IP addresses for VFDs, FIOMs, DPMs
let ipWb: XLSX.WorkBook | null = null;
if (ipFile) {
try { ipWb = XLSX.readFile(path.join(excelDir, ipFile)); } catch {}
}
for (const sheetName of ioWb.SheetNames) {
const mcmMatch = sheetName.match(/^(MCM\d+)$/);
if (!mcmMatch) continue;
const mcm = mcmMatch[1];
const seen = new Set<string>();
const devices: { id: string; svg: string; zone: string }[] = [];
// Parse IO sheet
const ws = ioWb.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) as string[][];
// Extract FIO/SIO controllers from column 0 (controller name)
for (let i = 1; i < rows.length; i++) {
const ctrl = String(rows[i][0] || '').trim();
if (!ctrl || seen.has(ctrl)) continue;
if (/FIO|SIO/i.test(ctrl)) {
seen.add(ctrl);
devices.push({ id: ctrl, svg: 'fio_sio_fioh', zone: extractZone(ctrl) });
}
}
// Extract assigned devices from column 3
for (let i = 1; i < rows.length; i++) {
const dev = String(rows[i][3] || '').trim();
if (!dev || seen.has(dev)) continue;
seen.add(dev);
const svg = classifyDevice(dev);
if (!svg) continue;
const zone = extractZone(dev);
// Consolidate PDP: only add once per PDP number
if (svg === 'pdp') {
const pdpId = zone; // e.g. PDP04
if (devices.some(d => d.id === pdpId)) continue;
devices.push({ id: pdpId, svg, zone });
continue;
}
// Consolidate beacon: BCN1_A + BCN1_H -> one BCN1 per zone
if (svg === 'beacon') {
const bcnBase = dev.replace(/_[ARHBWG]$/, '');
if (devices.some(d => d.id === bcnBase)) continue;
devices.push({ id: bcnBase, svg, zone });
continue;
}
devices.push({ id: dev, svg, zone });
}
// Parse IP/network sheet for VFDs, FIOMs, DPMs
if (ipWb) {
const ipSheets = ipWb.SheetNames.filter(s => s.toUpperCase().includes(mcm));
for (const ipSheet of ipSheets) {
const ipWs = ipWb.Sheets[ipSheet];
const ipRows = XLSX.utils.sheet_to_json(ipWs, { header: 1, defval: '' }) as string[][];
for (const row of ipRows) {
for (const cell of row) {
const val = String(cell || '').trim();
if (!val || val.startsWith('11.') || seen.has(val)) continue;
if (/^[A-Z]+\d*_\d+.*VFD$/.test(val)) {
seen.add(val);
devices.push({ id: val, svg: 'conveyor', zone: extractZone(val) });
} else if (/^[A-Z]+\d*_\d+.*FIOM\d*$/.test(val)) {
seen.add(val);
devices.push({ id: val, svg: 'fio_sio_fioh', zone: extractZone(val) });
} else if (/^[A-Z]+\d*_\d+.*DPM\d*$/.test(val) && !/_P\d+$/.test(val)) {
seen.add(val);
devices.push({ id: val, svg: 'dpm', zone: extractZone(val) });
}
}
}
}
}
// Sort by zone then id
devices.sort((a, b) => a.zone.localeCompare(b.zone) || a.id.localeCompare(b.id));
manifest[mcm] = devices;
}
}
fs.writeFileSync(
path.join(staticDir, 'devices-manifest.json'),
JSON.stringify(manifest, null, 2)
);
}
export default defineConfig({
plugins: [
{
name: 'generate-project-manifest',
buildStart() {
generateProjectManifest();
generateDeviceManifest();
},
configureServer(server) {
generateProjectManifest();
generateDeviceManifest();
// Re-generate on file changes in static/projectes
server.watcher.add(path.resolve('static/projectes'));
server.watcher.on('add', (p) => {
if (p.includes('projectes') && !p.endsWith('manifest.json')) {
generateProjectManifest();
}
});
},
},
sveltekit(),
],
});

BIN
symbols/chute.svg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

BIN
symbols/chute_enable.svg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 B

BIN
symbols/conveyor.svg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

BIN
symbols/diverter.svg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 B

BIN
symbols/dpm.svg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Some files were not shown because too many files have changed in this diff Show More