real commit
910
app.js
Normal 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
1
dist/assets/index-D5Q66DLP.css
vendored
Normal 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
@ -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
@ -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>
|
||||||
BIN
projectes/CDW5/excel/Amazon CDW5_Devices IO.xlsx
Normal file
BIN
projectes/CDW5/excel/Amazon CDW5_IP Addresses_Local.xlsx
Normal file
267
style.css
Normal 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
@ -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
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
3
svelte-app/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
42
svelte-app/README.md
Normal 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
27
svelte-app/package.json
Normal 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
@ -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
@ -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
@ -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>
|
||||||
214
svelte-app/src/components/Canvas.svelte
Normal 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>
|
||||||
289
svelte-app/src/components/DeviceDock.svelte
Normal 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>
|
||||||
216
svelte-app/src/components/Palette.svelte
Normal 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>
|
||||||
465
svelte-app/src/components/Toolbar.svelte
Normal 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>
|
||||||
BIN
svelte-app/src/lib/assets/favicon.svg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
707
svelte-app/src/lib/canvas/collision.ts
Normal 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));
|
||||||
|
}
|
||||||
77
svelte-app/src/lib/canvas/geometry.ts
Normal 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 };
|
||||||
|
}
|
||||||
776
svelte-app/src/lib/canvas/interactions.ts
Normal 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 };
|
||||||
|
}
|
||||||
477
svelte-app/src/lib/canvas/renderer.ts
Normal 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();
|
||||||
|
}
|
||||||
152
svelte-app/src/lib/export.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
1
svelte-app/src/lib/index.ts
Normal 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
@ -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;
|
||||||
|
}
|
||||||
83
svelte-app/src/lib/projects.ts
Normal 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;
|
||||||
|
}
|
||||||
55
svelte-app/src/lib/serialization.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
194
svelte-app/src/lib/stores/layout.svelte.ts
Normal 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();
|
||||||
38
svelte-app/src/lib/symbol-config.ts
Normal 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;
|
||||||
179
svelte-app/src/lib/symbols.ts
Normal 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);
|
||||||
|
}
|
||||||
52
svelte-app/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
6
svelte-app/src/routes/+layout.svelte
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
2
svelte-app/src/routes/+layout.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const prerender = true;
|
||||||
|
export const ssr = false;
|
||||||
63
svelte-app/src/routes/+page.svelte
Normal 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>
|
||||||
33915
svelte-app/static/projectes/devices-manifest.json
Normal file
102
svelte-app/static/projectes/manifest.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
3
svelte-app/static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
BIN
svelte-app/static/symbols/beacon.svg
Normal file
|
After Width: | Height: | Size: 388 B |
BIN
svelte-app/static/symbols/chute.svg
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
svelte-app/static/symbols/chute_enable.svg
Normal file
|
After Width: | Height: | Size: 567 B |
BIN
svelte-app/static/symbols/conveyor.svg
Normal file
|
After Width: | Height: | Size: 367 B |
BIN
svelte-app/static/symbols/curved_chute_30.svg
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
svelte-app/static/symbols/curved_chute_45.svg
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
svelte-app/static/symbols/curved_chute_60.svg
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
svelte-app/static/symbols/curved_chute_90.svg
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
svelte-app/static/symbols/curved_conveyor_30.svg
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
svelte-app/static/symbols/curved_conveyor_45.svg
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
svelte-app/static/symbols/curved_conveyor_60.svg
Normal file
|
After Width: | Height: | Size: 308 B |
BIN
svelte-app/static/symbols/curved_conveyor_90.svg
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
svelte-app/static/symbols/diverter.svg
Normal file
|
After Width: | Height: | Size: 697 B |
BIN
svelte-app/static/symbols/dpm.svg
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
svelte-app/static/symbols/epc.svg
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
svelte-app/static/symbols/epc_icon.svg
Normal file
|
After Width: | Height: | Size: 865 B |
BIN
svelte-app/static/symbols/extendo.svg
Normal file
|
After Width: | Height: | Size: 587 B |
BIN
svelte-app/static/symbols/fio_sio_fioh.svg
Normal file
|
After Width: | Height: | Size: 682 B |
BIN
svelte-app/static/symbols/induction.svg
Normal file
|
After Width: | Height: | Size: 449 B |
BIN
svelte-app/static/symbols/ip_camera.svg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
svelte-app/static/symbols/jam_reset.svg
Normal file
|
After Width: | Height: | Size: 565 B |
BIN
svelte-app/static/symbols/mcm.svg
Normal file
|
After Width: | Height: | Size: 595 B |
BIN
svelte-app/static/symbols/package_release.svg
Normal file
|
After Width: | Height: | Size: 567 B |
BIN
svelte-app/static/symbols/pdp.svg
Normal file
|
After Width: | Height: | Size: 390 B |
BIN
svelte-app/static/symbols/photoeye.svg
Normal file
|
After Width: | Height: | Size: 480 B |
BIN
svelte-app/static/symbols/pressure_sensor.svg
Normal file
|
After Width: | Height: | Size: 768 B |
BIN
svelte-app/static/symbols/solenoid.svg
Normal file
|
After Width: | Height: | Size: 390 B |
BIN
svelte-app/static/symbols/spur.svg
Normal file
|
After Width: | Height: | Size: 178 B |
BIN
svelte-app/static/symbols/start.svg
Normal file
|
After Width: | Height: | Size: 566 B |
BIN
svelte-app/static/symbols/start_stop.svg
Normal file
|
After Width: | Height: | Size: 716 B |
BIN
svelte-app/static/symbols/tipper.svg
Normal file
|
After Width: | Height: | Size: 367 B |
12
svelte-app/svelte.config.js
Normal 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
@ -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
@ -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
|
After Width: | Height: | Size: 380 B |
BIN
symbols/chute_enable.svg
Normal file
|
After Width: | Height: | Size: 629 B |
BIN
symbols/conveyor.svg
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
symbols/diverter.svg
Normal file
|
After Width: | Height: | Size: 779 B |
BIN
symbols/dpm.svg
Normal file
|
After Width: | Height: | Size: 746 B |