class DiagramCanvas { constructor(containerId, callbacks) { this.cb = callbacks; this.mode = "select"; this.routeMode = "ortho"; this.scale = 1; this.offsetX = 0; this.offsetY = 0; this.deviceNodes = new Map(); // id -> Konva.Group this.deviceData = new Map(); // id -> device object this.wireNodes = new Map(); // id -> { main, stripe, hit, lbl } this.wireData = new Map(); // id -> wire object this.selectedId = null; this.selectedType = null; this.selectedDeviceIds = new Set(); this.wireStart = null; this.previewLine = null; this._handleNodes = []; this._resizeHandles = []; this._wireCrossings = new Map(); // wireId -> [{x,y,segBase,t,isBelow}] this._activeDrag = null; this.snapEnabled = false; this.snapSize = 10; this.harnessMode = false; this._multiDragAnchorId = null; this._multiDragAnchorStartX = 0; this._multiDragAnchorStartY = 0; this._multiDragStartPos = null; this._multiDragWireWaypoints = null; // wireId -> [{x,y}] snapshot at dragstart this._bundleData = []; // array of bundle objects this._bundleNodes = []; // Konva shapes for bundle overlays (on groupLayer) this._initStage(containerId); this._initZoomPan(); this._updateGrid(); this._initStageEvents(); this._initDropZone(); } _snap(v) { return this.snapEnabled ? Math.round(v / this.snapSize) * this.snapSize : v; } toggleSnap(enabled) { this.snapEnabled = enabled; this._updateGrid(); } setHarnessMode(enabled) { this.harnessMode = enabled; this.wireLayer.opacity(enabled ? 0.18 : 1); if (enabled) this._renderHarness(); else this._clearHarness(); this.stage.batchDraw(); } _clearHarness() { this.harnessLayer.destroyChildren(); this.harnessLayer.batchDraw(); } loadBundles(bundles) { this._bundleData = bundles || []; this._renderBundles(); } _renderBundles() { this._bundleNodes.forEach(n => n.destroy()); this._bundleNodes = []; this._bundleData.forEach(bundle => { const wires = []; this.wireData.forEach(w => { if (w.bundle_id === bundle.id) wires.push(w); }); if (!wires.length) return; const color = bundle.jacket_color || "#2a2a2a"; wires.forEach(wire => { const pts = this._routePoints(wire); if (pts.length < 4) return; const bg = new Konva.Line({ points: pts, stroke: color, strokeWidth: 14, lineCap: "round", lineJoin: "round", listening: false, opacity: 0.55, }); this.groupLayer.add(bg); this._bundleNodes.push(bg); }); // Label at midpoint of first wire const firstPts = this._routePoints(wires[0]); if (firstPts.length >= 2) { const [mx, my] = this._midPoint(firstPts); const lbl = new Konva.Text({ x: mx - 40, y: my - 8, width: 80, align: "center", text: bundle.label || `Bundle #${bundle.id}`, fontSize: 9, fontFamily: "monospace", fill: "#ffffff", listening: false, shadowColor: "#000", shadowBlur: 3, shadowOpacity: 0.9, }); this.groupLayer.add(lbl); this._bundleNodes.push(lbl); } }); this.groupLayer.batchDraw(); } _renderHarness() { this._clearHarness(); // ── 1. Group wires by sorted device-pair key ───────────────────────────── const pairMap = new Map(); // "aId_bId" → { aId, bId, wires[] } this.wireData.forEach(wire => { const aId = Math.min(wire.from_device_id, wire.to_device_id); const bId = Math.max(wire.from_device_id, wire.to_device_id); const key = `${aId}_${bId}`; if (!pairMap.has(key)) pairMap.set(key, { aId, bId, wires: [] }); pairMap.get(key).wires.push(wire); }); // ── 2. Also build per-device departure clusters (for split-off loom) ───── const deviceSideMap = new Map(); // "devId_side" → wires[] this.wireData.forEach(wire => { const fd = this.deviceData.get(wire.from_device_id); const td = this.deviceData.get(wire.to_device_id); const fp = (fd?.pins || []).find(p => p.id === wire.from_pin); const tp = (td?.pins || []).find(p => p.id === wire.to_pin); if (fd && fp) { const k = `${wire.from_device_id}_${fp.side}`; if (!deviceSideMap.has(k)) deviceSideMap.set(k, []); deviceSideMap.get(k).push(wire); } if (td && tp) { const k = `${wire.to_device_id}_${tp.side}`; if (!deviceSideMap.has(k)) deviceSideMap.set(k, []); deviceSideMap.get(k).push(wire); } }); const drawn = new Set(); // track already-rendered pair trunks // ── 3. Render departure looms (loom exit per device side) ───────────────── const LOOM_LEN = 44; deviceSideMap.forEach((wires, key) => { if (wires.length < 2) return; const [devId, side] = key.split('_'); const dev = this.deviceData.get(parseInt(devId)); if (!dev || dev.device_type === 'group') return; // Collect pin positions on this side of this device const pts = wires.map(wire => { const isFrom = wire.from_device_id === dev.id; const d = this.deviceData.get(isFrom ? wire.from_device_id : wire.to_device_id); const pin = (d?.pins || []).find(p => p.id === (isFrom ? wire.from_pin : wire.to_pin)); return pin ? { x: d.x + pin.x_offset, y: d.y + pin.y_offset } : null; }).filter(Boolean); if (!pts.length) return; const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; const dir = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] }[side] || [1, 0]; const ex = cx + dir[0] * LOOM_LEN; const ey = cy + dir[1] * LOOM_LEN; const n = wires.length; const trunkW = Math.min(n * 3 + 5, 32); // Loom background this.harnessLayer.add(new Konva.Line({ points: [cx, cy, ex, ey], stroke: '#0a0a18', strokeWidth: trunkW + 4, lineCap: 'round', listening: false, })); // Colored wire stripes const perp = [-dir[1], dir[0]]; const spacing = trunkW / Math.max(n, 1); const stripeW = Math.min(spacing * 0.7, 3.5); wires.forEach((wire, i) => { const off = (i - (n - 1) / 2) * spacing; const ox = perp[0] * off, oy = perp[1] * off; this.harnessLayer.add(new Konva.Line({ points: [cx + ox, cy + oy, ex + ox, ey + oy], stroke: wire.color_primary || '#cc0000', strokeWidth: stripeW, lineCap: 'round', listening: false, })); }); // Split-off circle at loom end this.harnessLayer.add(new Konva.Circle({ x: ex, y: ey, radius: trunkW / 2 + 4, fill: '#12122a', stroke: '#4466cc', strokeWidth: 2, listening: false, })); }); // ── 4. Render bundle trunks between device pairs ─────────────────────────── pairMap.forEach(({ aId, bId, wires }) => { const dA = this.deviceData.get(aId); const dB = this.deviceData.get(bId); if (!dA || !dB) return; if (dA.device_type === 'group' || dB.device_type === 'group') return; const aPts = [], bPts = []; wires.forEach(wire => { const fd = this.deviceData.get(wire.from_device_id); const td = this.deviceData.get(wire.to_device_id); const fp = (fd?.pins || []).find(p => p.id === wire.from_pin); const tp = (td?.pins || []).find(p => p.id === wire.to_pin); if (fp && fd) { const pt = { x: fd.x + fp.x_offset, y: fd.y + fp.y_offset }; (wire.from_device_id === aId ? aPts : bPts).push(pt); } if (tp && td) { const pt = { x: td.x + tp.x_offset, y: td.y + tp.y_offset }; (wire.to_device_id === bId ? bPts : aPts).push(pt); } }); if (!aPts.length || !bPts.length) return; const ax = aPts.reduce((s, p) => s + p.x, 0) / aPts.length; const ay = aPts.reduce((s, p) => s + p.y, 0) / aPts.length; const bx = bPts.reduce((s, p) => s + p.x, 0) / bPts.length; const by = bPts.reduce((s, p) => s + p.y, 0) / bPts.length; const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy); if (len < 2) return; const ux = dx / len, uy = dy / len; const perp = [-uy, ux]; const n = wires.length; const trunkW = Math.min(n * 3 + 5, 32); const spacing = trunkW / Math.max(n, 1); const stripeW = Math.min(spacing * 0.7, 3.5); // Trunk background this.harnessLayer.add(new Konva.Line({ points: [ax, ay, bx, by], stroke: '#0a0a18', strokeWidth: trunkW + 4, lineCap: 'round', lineJoin: 'round', listening: false, })); // Colored wire stripes wires.forEach((wire, i) => { const off = (i - (n - 1) / 2) * spacing; const ox = perp[0] * off, oy = perp[1] * off; this.harnessLayer.add(new Konva.Line({ points: [ax + ox, ay + oy, bx + ox, by + oy], stroke: wire.color_primary || '#cc0000', strokeWidth: stripeW, lineCap: 'round', listening: false, })); }); // Wire count + gauge badge at midpoint (only for multi-wire bundles) if (n >= 2) { const mx = (ax + bx) / 2, my = (ay + by) / 2; const gauges = [...new Set(wires.map(w => w.gauge).filter(Boolean))]; const badgeText = gauges.length === 1 ? `${n}W · ${gauges[0]}` : `${n}W`; this.harnessLayer.add(new Konva.Rect({ x: mx - 22, y: my - 9, width: 44, height: 18, cornerRadius: 4, fill: '#12122a', stroke: '#4466cc', strokeWidth: 1.5, listening: false, })); this.harnessLayer.add(new Konva.Text({ x: mx - 22, y: my - 6, text: badgeText, width: 44, align: 'center', fontSize: 9, fontFamily: 'monospace', fill: '#99bbff', listening: false, })); } }); this.harnessLayer.batchDraw(); } // ── Stage setup ────────────────────────────────────────────────────────────── _initStage(containerId) { const el = document.getElementById(containerId); this.stage = new Konva.Stage({ container: containerId, width: el.clientWidth, height: el.clientHeight }); this.groupLayer = new Konva.Layer(); // section boxes — bottommost this.wireLayer = new Konva.Layer(); this.harnessLayer = new Konva.Layer({ listening: false }); this.deviceLayer = new Konva.Layer(); this.uiLayer = new Konva.Layer(); this.stage.add(this.groupLayer, this.wireLayer, this.harnessLayer, this.deviceLayer, this.uiLayer); new ResizeObserver(() => { this.stage.width(el.clientWidth); this.stage.height(el.clientHeight); this._updateGrid(); }).observe(el); } _initZoomPan() { this.stage.on("wheel", (e) => { e.evt.preventDefault(); const oldScale = this.scale; const factor = e.evt.deltaY < 0 ? 1.08 : 1 / 1.08; this.scale = Math.max(0.08, Math.min(10, oldScale * factor)); const ptr = this.stage.getPointerPosition(); this.offsetX = ptr.x - ((ptr.x - this.offsetX) / oldScale) * this.scale; this.offsetY = ptr.y - ((ptr.y - this.offsetY) / oldScale) * this.scale; this._applyTransform(); }); let panning = false, panLast = null; this.stage.on("mousedown", (e) => { if (e.evt.button === 1 || (e.evt.button === 0 && e.evt.altKey)) { panning = true; panLast = { x: e.evt.clientX, y: e.evt.clientY }; this.stage.container().style.cursor = "grabbing"; e.evt.preventDefault(); } }); this.stage.on("mousemove", (e) => { if (panning && panLast) { this.offsetX += e.evt.clientX - panLast.x; this.offsetY += e.evt.clientY - panLast.y; panLast = { x: e.evt.clientX, y: e.evt.clientY }; this._applyTransform(); } if (this.wireStart && this.previewLine) { const pos = this.stage.getPointerPosition(); if (pos) { const wp = this._s2w(pos.x, pos.y); this.previewLine.points( this._previewRoute(this.wireStart.wx, this.wireStart.wy, this.wireStart.side, wp.x, wp.y) ); this.uiLayer.batchDraw(); } } // Manual waypoint drag (wire body drag) if (this._activeDrag) { const pos = this.stage.getPointerPosition(); if (pos) { let { x, y } = this._s2w(pos.x, pos.y); x = this._snap(x); y = this._snap(y); const wire = this.wireData.get(this._activeDrag.wireId); if (wire && wire.waypoints) { wire.waypoints[this._activeDrag.idx] = { x, y }; this._destroyWireVisuals(this._activeDrag.wireId); this._renderWire(wire); this.wireLayer.batchDraw(); // Update handles to follow (preserves handle indices) this._updateHandlePositions(wire); } } } }); window.addEventListener("mouseup", () => { if (panning) { panning = false; panLast = null; this.stage.container().style.cursor = ""; } if (this._activeDrag) { const wire = this.wireData.get(this._activeDrag.wireId); this.cb.onWaypointsChanged?.(this._activeDrag.wireId, wire?.waypoints || []); this._activeDrag = null; this._recomputeJumps(); // waypoints changed → re-check crossings } }); } _applyTransform() { [this.groupLayer, this.wireLayer, this.harnessLayer, this.deviceLayer, this.uiLayer].forEach(l => { l.position({ x: this.offsetX, y: this.offsetY }); l.scale({ x: this.scale, y: this.scale }); }); this.stage.batchDraw(); this._updateGrid(); } _updateGrid() { const gs = 20 * this.scale; const ox = ((this.offsetX % gs) + gs) % gs; const oy = ((this.offsetY % gs) + gs) % gs; const c = this.stage.container(); c.style.backgroundSize = `${gs}px ${gs}px`; c.style.backgroundPosition = `${ox}px ${oy}px`; } _s2w(sx, sy) { return { x: (sx - this.offsetX) / this.scale, y: (sy - this.offsetY) / this.scale }; } _initStageEvents() { let rbStart = null; // screen coords where rubber-band began let rbRect = null; // Konva.Rect drawn on uiLayer let rbMoved = false; // true once mouse moved past threshold this.stage.on("mousedown", (e) => { if (e.target !== this.stage) return; if (e.evt.button !== 0 || e.evt.altKey) return; if (this.mode !== "select" || this.wireStart) return; rbStart = this.stage.getPointerPosition(); rbMoved = false; }); this.stage.on("mousemove", () => { if (!rbStart) return; const pos = this.stage.getPointerPosition(); const dx = pos.x - rbStart.x, dy = pos.y - rbStart.y; if (!rbMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) { rbMoved = true; const wp = this._s2w(rbStart.x, rbStart.y); rbRect = new Konva.Rect({ x: wp.x, y: wp.y, width: 0, height: 0, stroke: "#4488ff", strokeWidth: 1 / this.scale, fill: "rgba(68,136,255,0.08)", listening: false, }); this.uiLayer.add(rbRect); } if (rbMoved && rbRect) { const wp1 = this._s2w(rbStart.x, rbStart.y); const wp2 = this._s2w(pos.x, pos.y); rbRect.setAttrs({ x: Math.min(wp1.x, wp2.x), y: Math.min(wp1.y, wp2.y), width: Math.abs(wp2.x - wp1.x), height: Math.abs(wp2.y - wp1.y), }); this.uiLayer.batchDraw(); } }); this.stage.on("mouseup", (e) => { if (e.evt.button !== 0) return; if (this._activeDrag) return; // Wire completion if (this.wireStart) { const pos = this.stage.getPointerPosition(); const from = this.wireStart; let completed = false; if (pos) { const shape = this.stage.getIntersection(pos); if (shape && shape.name() === "pin-hit") { const meta = shape._pinMeta; if (meta && !(meta.deviceId === from.deviceId && meta.pinId === from.pinId)) { this._cancelWire(); this.cb.onWireCreated?.(from.deviceId, from.pinId, meta.deviceId, meta.pinId); completed = true; } } } if (!completed) this._cancelWire(); return; } // Rubber-band or plain click on background if (!rbStart) return; const pos = this.stage.getPointerPosition(); if (rbRect) { rbRect.destroy(); rbRect = null; this.uiLayer.batchDraw(); } if (!rbMoved) { rbStart = null; this.clearSelection(); this.cb.onSelectionCleared?.(); return; } const wp1 = this._s2w(rbStart.x, rbStart.y); const wp2 = this._s2w(pos.x, pos.y); rbStart = null; rbMoved = false; const rx1 = Math.min(wp1.x, wp2.x), ry1 = Math.min(wp1.y, wp2.y); const rx2 = Math.max(wp1.x, wp2.x), ry2 = Math.max(wp1.y, wp2.y); this.clearSelection(); this.deviceData.forEach((d, id) => { if (d.x < rx2 && d.x + d.width > rx1 && d.y < ry2 && d.y + d.height > ry1) { this.selectedDeviceIds.add(id); this._highlightDevice(id, true); } }); if (!this.selectedDeviceIds.size) { this.cb.onSelectionCleared?.(); return; } this.selectedType = "device"; if (this.selectedDeviceIds.size === 1) { const [id] = this.selectedDeviceIds; this.selectedId = id; this._showResizeHandles(this.deviceData.get(id)); this.cb.onDeviceSelected?.(this.deviceData.get(id)); } else { this.cb.onMultipleSelected?.(this.selectedDeviceIds.size); } }); } _initDropZone() { const c = this.stage.container(); c.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }); c.addEventListener("drop", (e) => { e.preventDefault(); const rect = c.getBoundingClientRect(); const wp = this._s2w(e.clientX - rect.left, e.clientY - rect.top); const typeKey = e.dataTransfer.getData("device_type"); const connId = e.dataTransfer.getData("connector_id"); if (typeKey) this.cb.onDeviceDropped?.(typeKey, wp.x, wp.y); if (connId) this.cb.onConnectorDropped?.(connId, wp.x, wp.y); }); } // ── Public API ──────────────────────────────────────────────────────────────── setMode(mode) { this.mode = mode; this.stage.container().classList.toggle("wire-mode", mode === "wire"); this.deviceNodes.forEach(g => g.draggable(mode === "select")); if (mode !== "wire") this._cancelWire(); } setRouteMode(mode) { this.routeMode = mode; const wires = [...this.wireData.values()]; wires.forEach(w => { this._destroyWireVisuals(w.id); this._renderWire(w); }); if (this.selectedType === "wire") this._renderWireHandles(this.selectedId); this._recomputeJumps(); this._renderBundles(); } loadDiagram(diagram) { this.groupLayer.destroyChildren(); this.wireLayer.destroyChildren(); this.harnessLayer.destroyChildren(); this.deviceLayer.destroyChildren(); this.uiLayer.destroyChildren(); this.deviceNodes.clear(); this.deviceData.clear(); this.wireNodes.clear(); this.wireData.clear(); this._handleNodes = []; this.selectedId = null; this.selectedType = null; this.selectedDeviceIds.clear(); this.wireStart = null; this.previewLine = null; this._activeDrag = null; this._multiDragAnchorId = null; this._multiDragStartPos = null; this._bundleNodes = []; this._bundleData = []; const sorted = [...(diagram.devices || [])].sort((a, b) => (a.device_type === "group" ? -1 : 0) - (b.device_type === "group" ? -1 : 0) ); sorted.forEach(d => { this.deviceData.set(d.id, d); this._renderDevice(d); }); diagram.wires.forEach(w => { if (!w.waypoints) w.waypoints = []; this.wireData.set(w.id, w); this._renderWire(w); }); this._recomputeJumps(); this._renderBundles(); if (this.harnessMode) this._renderHarness(); this.deviceLayer.batchDraw(); } addDevice(device) { this.deviceData.set(device.id, device); this._renderDevice(device); this.deviceLayer.batchDraw(); if (device.device_type === "group") this.groupLayer.batchDraw(); } updateDevice(device) { const old = this.deviceNodes.get(device.id); if (old) { old.destroy(); this.deviceNodes.delete(device.id); } this.deviceData.set(device.id, device); this._renderDevice(device); this._redrawWiresFor(device.id); this.deviceLayer.batchDraw(); if (device.device_type === "group") this.groupLayer.batchDraw(); } removeDevice(deviceId) { const d = this.deviceData.get(deviceId); const g = this.deviceNodes.get(deviceId); if (g) { g.destroy(); this.deviceNodes.delete(deviceId); this.deviceData.delete(deviceId); } this.deviceLayer.batchDraw(); if (d?.device_type === "group") this.groupLayer.batchDraw(); } addWire(wire) { if (!wire.waypoints) wire.waypoints = []; this.wireData.set(wire.id, wire); this._renderWire(wire); this._recomputeJumps(); this._renderBundles(); if (this.harnessMode) this._renderHarness(); } updateWire(wire) { if (!wire.waypoints) wire.waypoints = []; this._destroyWireVisuals(wire.id); this.wireData.set(wire.id, wire); this._renderWire(wire); if (this.selectedId === wire.id) this._renderWireHandles(wire.id); this._recomputeJumps(); this._renderBundles(); if (this.harnessMode) this._renderHarness(); } removeWire(wireId) { if (this.selectedId === wireId) this._clearWireHandles(); this._destroyWireVisuals(wireId); this.wireData.delete(wireId); this._wireCrossings.delete(wireId); this._recomputeJumps(); this._renderBundles(); if (this.harnessMode) this._renderHarness(); } _highlightDevice(id, on) { const g = this.deviceNodes.get(id); if (!g) return; const d = this.deviceData.get(id); const isGroup = d?.device_type === "group"; const offColor = isGroup ? (d.properties?.fillColor || "#2828a0") : "#5a5a8a"; g.findOne("Rect").stroke(on ? "#4db8ff" : offColor); (isGroup ? this.groupLayer : this.deviceLayer).batchDraw(); } _wireHighlight(wireId, selected) { const n = this.wireNodes.get(wireId); const wire = this.wireData.get(wireId); if (!n) return; const w = selected ? 5 : 3; if (wire?.twisted_pair) { if (n.helix) n.helix.strokeWidth(w); if (n.helix2) n.helix2.strokeWidth(w); } else { n.main.strokeWidth(w); } this.wireLayer.batchDraw(); } clearSelection() { this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false)); this.selectedDeviceIds.clear(); this._clearResizeHandles(); if (this.selectedType === "wire" && this.selectedId !== null) { this._wireHighlight(this.selectedId, false); this._clearWireHandles(); } this.selectedId = null; this.selectedType = null; } selectDevice(deviceId) { this.clearSelection(); this.selectedDeviceIds.add(deviceId); this.selectedId = deviceId; this.selectedType = "device"; this._highlightDevice(deviceId, true); this._showResizeHandles(this.deviceData.get(deviceId)); this.cb.onDeviceSelected?.(this.deviceData.get(deviceId)); } addToSelection(deviceId) { this.selectedDeviceIds.add(deviceId); this.selectedType = "device"; this._highlightDevice(deviceId, true); this._clearResizeHandles(); // multi-select → no resize handles } selectWire(wireId) { this.clearSelection(); this.selectedId = wireId; this.selectedType = "wire"; this._wireHighlight(wireId, true); this._renderWireHandles(wireId); this.cb.onWireSelected?.(this.wireData.get(wireId)); } fitView() { if (!this.deviceData.size) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; this.deviceData.forEach(d => { minX = Math.min(minX, d.x); minY = Math.min(minY, d.y); maxX = Math.max(maxX, d.x + d.width); maxY = Math.max(maxY, d.y + d.height); }); const pad = 60, sw = this.stage.width(), sh = this.stage.height(); this.scale = Math.min(sw / (maxX - minX + pad * 2), sh / (maxY - minY + pad * 2), 2); this.offsetX = sw / 2 - ((minX + maxX) / 2) * this.scale; this.offsetY = sh / 2 - ((minY + maxY) / 2) * this.scale; this._applyTransform(); } zoomToSelection() { const ids = this.selectedDeviceIds.size ? this.selectedDeviceIds : this.selectedId && this.selectedType === "device" ? new Set([this.selectedId]) : null; if (!ids || !ids.size) return this.fitView(); let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; ids.forEach(id => { const d = this.deviceData.get(id); if (!d) return; minX = Math.min(minX, d.x); minY = Math.min(minY, d.y); maxX = Math.max(maxX, d.x + d.width); maxY = Math.max(maxY, d.y + d.height); }); if (!isFinite(minX)) return; const pad = 80, sw = this.stage.width(), sh = this.stage.height(); this.scale = Math.min(sw / (maxX - minX + pad * 2), sh / (maxY - minY + pad * 2), 4); this.offsetX = sw / 2 - ((minX + maxX) / 2) * this.scale; this.offsetY = sh / 2 - ((minY + maxY) / 2) * this.scale; this._applyTransform(); } exportImage(name = "diagram") { const dataURL = this.stage.toDataURL({ pixelRatio: 2, mimeType: "image/png" }); const a = document.createElement("a"); a.download = `${name}.png`; a.href = dataURL; a.click(); } // ── Routing ─────────────────────────────────────────────────────────────────── // Main entry: returns flat [x,y,...] point array for a wire object _routePoints(wire) { const fd = this.deviceData.get(wire.from_device_id); const td = this.deviceData.get(wire.to_device_id); if (!fd || !td) return []; const fp = (fd.pins || []).find(p => p.id === wire.from_pin); const tp = (td.pins || []).find(p => p.id === wire.to_pin); if (!fp || !tp) return []; const x1 = fd.x + fp.x_offset, y1 = fd.y + fp.y_offset; const x2 = td.x + tp.x_offset, y2 = td.y + tp.y_offset; const wps = wire.waypoints || []; if (this.routeMode === "direct" || this.routeMode === "curved") { return [x1, y1, ...wps.flatMap(p => [p.x, p.y]), x2, y2]; } return this._orthoRoute( x1, y1, fp.side, x2, y2, tp.side, wps, new Set([wire.from_device_id, wire.to_device_id]) ); } // Orthogonal route from pin1 to pin2 through optional waypoints _orthoRoute(x1, y1, s1, x2, y2, s2, wps, excludeIds) { const S = 24; const ex1 = x1 + (s1 === "right" ? S : s1 === "left" ? -S : 0); const ey1 = y1 + (s1 === "bottom" ? S : s1 === "top" ? -S : 0); const ex2 = x2 + (s2 === "right" ? S : s2 === "left" ? -S : 0); const ey2 = y2 + (s2 === "bottom" ? S : s2 === "top" ? -S : 0); let inner; if (wps.length === 0) { inner = this._autoOrtho(ex1, ey1, ex2, ey2, excludeIds); } else { inner = this._throughWaypoints(ex1, ey1, ex2, ey2, wps); } return [x1, y1, ...inner, x2, y2]; } // Preview route during wire drawing (no target side known) _previewRoute(x1, y1, side1, x2, y2) { if (this.routeMode === "direct" || this.routeMode === "curved") return [x1, y1, x2, y2]; const S = 24; const ex1 = x1 + (side1 === "right" ? S : side1 === "left" ? -S : 0); const ey1 = y1 + (side1 === "bottom" ? S : side1 === "top" ? -S : 0); return [x1, y1, ex1, ey1, x2, ey1, x2, y2]; } // Route through a series of waypoints using L-bends _throughWaypoints(ex1, ey1, ex2, ey2, wps) { const pts = [ex1, ey1]; let px = ex1, py = ey1; for (const wp of wps) { pts.push(...this._lBend(px, py, wp.x, wp.y)); px = wp.x; py = wp.y; } pts.push(...this._lBend(px, py, ex2, ey2)); return pts; } // L-bend: go horizontal to x2 then vertical to y2 (skips degenerate cases) _lBend(x1, y1, x2, y2) { if (Math.abs(x1 - x2) < 1 && Math.abs(y1 - y2) < 1) return []; if (Math.abs(x1 - x2) < 1) return [x2, y2]; if (Math.abs(y1 - y2) < 1) return [x2, y2]; return [x2, y1, x2, y2]; } // Auto orthogonal route with obstacle avoidance _autoOrtho(ex1, ey1, ex2, ey2, excludeIds) { // Build default route (horizontal if same height, L/S-bend otherwise) const mx = (ex1 + ex2) / 2; const defaultPts = Math.abs(ey1 - ey2) < 4 ? [ex1, ey1, ex2, ey2] : [ex1, ey1, mx, ey1, mx, ey2, ex2, ey2]; // Always check for obstacles even on straight horizontal runs if (!this._anySegHitsBoxes(defaultPts, excludeIds)) return defaultPts; const hitBoxes = this._getHitBoxes(defaultPts, excludeIds); if (!hitBoxes.length) return defaultPts; const margin = 22; const topY = Math.min(...hitBoxes.map(b => b.y)) - margin; const above = [ex1, ey1, ex1, topY, ex2, topY, ex2, ey2]; if (!this._anySegHitsBoxes(above, excludeIds)) return above; const botY = Math.max(...hitBoxes.map(b => b.y + b.h)) + margin; const below = [ex1, ey1, ex1, botY, ex2, botY, ex2, ey2]; if (!this._anySegHitsBoxes(below, excludeIds)) return below; // Try routing around left or right side const leftX = Math.min(...hitBoxes.map(b => b.x)) - margin; const rightX = Math.max(...hitBoxes.map(b => b.x + b.w)) + margin; const viaLeft = [ex1, ey1, ex1, ey1, leftX, ey1, leftX, ey2, ex2, ey2]; const viaRight = [ex1, ey1, ex1, ey1, rightX, ey1, rightX, ey2, ex2, ey2]; if (!this._anySegHitsBoxes(viaLeft, excludeIds)) return viaLeft; if (!this._anySegHitsBoxes(viaRight, excludeIds)) return viaRight; return defaultPts; // fallback } // ── Obstacle helpers ────────────────────────────────────────────────────────── _getDeviceBoxes() { const result = []; this.deviceData.forEach(d => { if (d.device_type !== "group") result.push({ id: d.id, x: d.x, y: d.y, w: d.width, h: d.height }); }); return result; } _anySegHitsBoxes(pts, excludeIds) { const PAD = 6; const boxes = this._getDeviceBoxes().filter(b => !excludeIds.has(b.id)); for (let i = 0; i < pts.length - 2; i += 2) { for (const box of boxes) { if (this._segHitsBox(pts[i], pts[i + 1], pts[i + 2], pts[i + 3], box, PAD)) return true; } } return false; } _getHitBoxes(pts, excludeIds) { const PAD = 6; const boxes = this._getDeviceBoxes().filter(b => !excludeIds.has(b.id)); const hits = new Set(); for (let i = 0; i < pts.length - 2; i += 2) { for (const box of boxes) { if (this._segHitsBox(pts[i], pts[i + 1], pts[i + 2], pts[i + 3], box, PAD)) hits.add(box); } } return [...hits]; } _segHitsBox(x1, y1, x2, y2, box, pad) { const minX = Math.min(x1, x2), maxX = Math.max(x1, x2); const minY = Math.min(y1, y2), maxY = Math.max(y1, y2); return maxX > box.x - pad && minX < box.x + box.w + pad && maxY > box.y - pad && minY < box.y + box.h + pad; } // ── Wire crossing / jump computation ───────────────────────────────────────── _segIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { const d1x = x2-x1, d1y = y2-y1, d2x = x4-x3, d2y = y4-y3; const denom = d1x*d2y - d1y*d2x; if (Math.abs(denom) < 1e-8) return null; // parallel const t = ((x3-x1)*d2y - (y3-y1)*d2x) / denom; const u = ((x3-x1)*d1y - (y3-y1)*d1x) / denom; if (t < 0.02 || t > 0.98 || u < 0.02 || u > 0.98) return null; // no endpoint crossings return { x: x1+t*d1x, y: y1+t*d1y, t, u }; } _computeAllCrossings() { const ids = [...this.wireData.keys()]; const result = new Map(ids.map(id => [id, []])); const ptCache = new Map(); const getPts = id => { if (!ptCache.has(id)) ptCache.set(id, this._routePoints(this.wireData.get(id))); return ptCache.get(id); }; for (let i = 0; i < ids.length; i++) { for (let j = i+1; j < ids.length; j++) { const pA = getPts(ids[i]), pB = getPts(ids[j]); for (let a = 0; a < pA.length-2; a += 2) { for (let b = 0; b < pB.length-2; b += 2) { const c = this._segIntersect(pA[a],pA[a+1],pA[a+2],pA[a+3], pB[b],pB[b+1],pB[b+2],pB[b+3]); if (!c) continue; result.get(ids[i]).push({ x: c.x, y: c.y, t: c.t, segBase: a, isBelow: true }); result.get(ids[j]).push({ x: c.x, y: c.y, t: c.u, segBase: b, isBelow: false }); } } } } this._wireCrossings = result; } // Build SVG path string from flat [x,y,x,y...] points with gap/arc at crossings. // "below" wire gets a gap; "above" wire gets a bezier bump arc. _midPoint(pts) { const n = pts.length / 2; return [ pts.reduce((s, v, i) => i % 2 === 0 ? s + v : s, 0) / n, pts.reduce((s, v, i) => i % 2 === 1 ? s + v : s, 0) / n, ]; } _ptsToSvgPath(pts, crossings) { const ARC_R = 8; if (!pts || pts.length < 4) return ''; const bySeg = new Map(); for (const c of (crossings || [])) { if (!bySeg.has(c.segBase)) bySeg.set(c.segBase, []); bySeg.get(c.segBase).push(c); } bySeg.forEach(arr => arr.sort((a, b) => a.t - b.t)); let path = `M ${pts[0]} ${pts[1]}`; for (let s = 0; s < pts.length - 2; s += 2) { const x1=pts[s], y1=pts[s+1], x2=pts[s+2], y2=pts[s+3]; const dx=x2-x1, dy=y2-y1, len=Math.hypot(dx, dy); if (len < 0.1) continue; const ux=dx/len, uy=dy/len; // left-hand perpendicular (screen coords, y-down): (-uy, ux) const px=-uy, py=ux; for (const cross of (bySeg.get(s) || [])) { if (cross.isBelow) continue; // below wire draws straight through — no gap const f = (v) => v.toFixed(2); const bx=cross.x - ux*ARC_R, by=cross.y - uy*ARC_R; const ax=cross.x + ux*ARC_R, ay=cross.y + uy*ARC_R; // Quadratic bezier bump perpendicular to wire direction const cpx = cross.x + px * ARC_R * 1.8; const cpy = cross.y + py * ARC_R * 1.8; path += ` L ${f(bx)} ${f(by)} Q ${f(cpx)} ${f(cpy)} ${f(ax)} ${f(ay)}`; } path += ` L ${x2} ${y2}`; } return path; } // Catmull-Rom spline through points — used by curved route mode _ptsToSvgPathCurved(pts) { if (!pts || pts.length < 4) return ''; const P = []; for (let i = 0; i < pts.length; i += 2) P.push({ x: pts[i], y: pts[i + 1] }); if (P.length < 2) return ''; if (P.length === 2) return `M ${P[0].x} ${P[0].y} L ${P[1].x} ${P[1].y}`; const t = 0.4; const f = v => v.toFixed(2); let path = `M ${P[0].x} ${P[0].y}`; for (let i = 0; i < P.length - 1; i++) { const p0 = P[Math.max(0, i - 1)]; const p1 = P[i]; const p2 = P[i + 1]; const p3 = P[Math.min(P.length - 1, i + 2)]; const cp1x = p1.x + (p2.x - p0.x) * t; const cp1y = p1.y + (p2.y - p0.y) * t; const cp2x = p2.x - (p3.x - p1.x) * t; const cp2y = p2.y - (p3.y - p1.y) * t; path += ` C ${f(cp1x)} ${f(cp1y)} ${f(cp2x)} ${f(cp2y)} ${p2.x} ${p2.y}`; } return path; } _makeSvgPath(pts, crossings) { if (this.routeMode === "curved") return this._ptsToSvgPathCurved(pts); return this._ptsToSvgPath(pts, crossings); } _recomputeJumps() { this._computeAllCrossings(); this.wireData.forEach(wire => { const n = this.wireNodes.get(wire.id); if (!n) return; const pts = this._routePoints(wire); const svgPath = this._makeSvgPath(pts, this._wireCrossings.get(wire.id)); if (n.shield) n.shield.data(svgPath); if (!wire.twisted_pair) { n.main.data(svgPath); if (n.stripe) n.stripe.data(svgPath); } if (n.helix) n.helix.data(this._computeHelixPath(pts, wire.twist_pitch || 16, false)); if (n.helix2) n.helix2.data(this._computeHelixPath(pts, wire.twist_pitch || 16, true)); if (n.hit) n.hit.points(pts); if (n.lbl) { const [mx, my] = this._midPoint(pts); n.lbl.setAttrs({ x: mx + 3, y: my - 10 }); } }); this.wireLayer.batchDraw(); } // ── Device rendering ────────────────────────────────────────────────────────── _deviceFill(type) { return { connector: "#12253a", terminal_block: "#122a1a", component: "#1e1230", splice: "#2a1e10", label: "#22220e", fuse: "#2a1c08", relay: "#0a1628", switch: "#0a2218", bulb: "#24220a", motor: "#1a0a28", diode: "#28081a", resistor: "#1a1a08", capacitor: "#081a1a", ground: "#0e140e", power: "#1a0808", cable: "#1a1a1a", group: "rgba(40,40,80,0.35)", }[type] || "#1e1e2e"; } _renderDevice(device) { const group = new Konva.Group({ x: device.x, y: device.y, draggable: this.mode === "select" }); const fontSize = Math.max(6, Math.min(72, device.properties?.fontSize || 12)); if (device.device_type === "group") { const gColor = device.properties?.fillColor || "#2828a0"; const gOpacity = device.properties?.fillOpacity ?? 0.15; group.add(new Konva.Rect({ name: "device-rect", width: device.width, height: device.height, fill: gColor, opacity: gOpacity, stroke: gColor, strokeWidth: 1.5, dash: [8, 5], cornerRadius: 6, listening: false, })); group.add(new Konva.Rect({ name: "group-header", x: 0, y: 0, width: device.width, height: 22, fill: gColor, opacity: Math.min(1, gOpacity * 2 + 0.25), cornerRadius: [6, 6, 0, 0], })); group.add(new Konva.Text({ name: "device-label", x: 8, y: 4, width: device.width - 16, text: device.label, fontSize, fontFamily: "monospace", fill: "#ffffff", fontStyle: "bold", listening: false, })); } else { const rect = new Konva.Rect({ name: "device-rect", width: device.width, height: device.height, fill: this._deviceFill(device.device_type), stroke: "#5a5a8a", strokeWidth: 2, cornerRadius: 4, }); group.add(rect); if (device.device_type === "label") { // Full-box text for note/label type group.add(new Konva.Text({ name: "device-label", x: 8, y: 8, width: device.width - 16, height: device.height - 16, text: device.label, fontSize, fontFamily: "monospace", fill: "#dde0f5", align: "left", verticalAlign: "top", wrap: "word", })); } else if (device.device_type === "cable") { const jacket = device.properties?.jacketColor || "#2a2a2a"; const conductors = device.properties?.conductors || []; const sleeveLen = device.properties?.sleeveLength ?? 60; // ── Conductor sleeves (rendered first so they sit behind device body) ── if (sleeveLen > 0 && conductors.length > 0) { const n = conductors.length; // Pin y positions match deviceTypes.js: 26 + i*24 + 12 const pinYs = conductors.map((_, i) => 26 + i * 24 + 12); const minY = pinYs[0], maxY = pinYs[n - 1]; const bundleCY = (minY + maxY) / 2; const sleeveH = Math.max(maxY - minY + 20, 14); const capR = sleeveH / 2; [ { side: "left", sx: -sleeveLen, ex: 0, pinX: 0 }, { side: "right", sx: device.width, ex: device.width + sleeveLen, pinX: device.width }, ].forEach(({ sx, ex, pinX }) => { const goesRight = ex > sx; // Jacket tube group.add(new Konva.Rect({ x: Math.min(sx, ex), y: bundleCY - capR, width: sleeveLen, height: sleeveH, fill: jacket, cornerRadius: goesRight ? [capR, 0, 0, capR] // left sleeve: rounded on outer (left) end : [0, capR, capR, 0], // right sleeve: rounded on outer (right) end listening: false, })); // Conductor color stripes inside the sleeve tube const stripeSpacing = sleeveH / n; const stripeW = Math.min(stripeSpacing * 0.55, 4); conductors.forEach((cond, i) => { const sy2 = bundleCY - capR + (i + 0.5) * stripeSpacing - stripeW / 2; group.add(new Konva.Rect({ x: Math.min(sx, ex), y: sy2, width: sleeveLen, height: stripeW, fill: cond.color || "#888888", opacity: 0.55, listening: false, })); }); // End-cap line (outer tip of sleeve) const capX = goesRight ? sx : ex; group.add(new Konva.Line({ points: [capX, bundleCY - capR, capX, bundleCY + capR], stroke: "#777", strokeWidth: 1.5, listening: false, })); // Conductor fan-out stubs from sleeve tip to pin y positions conductors.forEach((cond, i) => { const py = pinYs[i]; group.add(new Konva.Line({ points: [capX, bundleCY, pinX, py], stroke: cond.color || "#888888", strokeWidth: 2.5, lineCap: "round", listening: false, })); }); }); } // ── Cable device body ── // Header jacket bar group.add(new Konva.Rect({ x: 2, y: 2, width: device.width - 4, height: 22, fill: jacket, cornerRadius: [3,3,0,0] })); if (device.reference) { group.add(new Konva.Text({ x: 6, y: 6, text: device.reference, fontSize: 9, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" })); } group.add(new Konva.Text({ name: "device-label", x: 4, y: 6, width: device.width - 8, align: "center", text: device.label, fontSize: 10, fontFamily: "monospace", fill: "#dde0f5", })); // Conductor rows conductors.forEach((cond, i) => { const rowY = 26 + i * 24; const color = cond.color || "#888888"; group.add(new Konva.Rect({ x: 1, y: rowY, width: device.width - 2, height: 24, fill: "#0e0e1a" })); group.add(new Konva.Line({ points: [14, rowY + 12, device.width - 14, rowY + 12], stroke: color, strokeWidth: 5, lineCap: "round", listening: false, })); group.add(new Konva.Text({ x: 16, y: rowY + 4, text: cond.name || String(i + 1), fontSize: 9, fontFamily: "monospace", fill: "#c8ccee", listening: false, })); }); // Footer jacket bar group.add(new Konva.Rect({ x: 2, y: device.height - 16, width: device.width - 4, height: 14, fill: jacket, cornerRadius: [0,0,3,3] })); group.add(new Konva.Text({ name: "device-type", x: 4, y: device.height - 14, width: device.width - 8, align: "right", text: "cable", fontSize: 8, fontFamily: "monospace", fill: "#888888", })); } else { if (device.reference) { group.add(new Konva.Text({ x: 6, y: 5, text: device.reference, fontSize: 10, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" })); } group.add(new Konva.Text({ name: "device-label", x: 4, y: device.height / 2 - fontSize / 2 - 2, width: device.width - 8, align: "center", text: device.label, fontSize, fontFamily: "monospace", fill: "#dde0f5", wrap: "word", })); group.add(new Konva.Text({ name: "device-type", x: 4, y: device.height - 14, width: device.width - 8, align: "right", text: device.device_type, fontSize: 8, fontFamily: "monospace", fill: "#444466", })); } } // end non-group else (device.pins || []).forEach(pin => this._addPin(group, device, pin)); group.on("contextmenu", (e) => { e.evt.preventDefault(); e.cancelBubble = true; if (!this.selectedDeviceIds.has(device.id)) this.selectDevice(device.id); this.cb.onDeviceContextMenu?.(device.id, e.evt.clientX, e.evt.clientY); }); group.on("click", (e) => { if (this.mode !== "select") return; e.cancelBubble = true; if (e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey) { // Toggle membership in multi-selection if (this.selectedDeviceIds.has(device.id)) { this.selectedDeviceIds.delete(device.id); this._highlightDevice(device.id, false); if (!this.selectedDeviceIds.size) { this.selectedId = null; this.selectedType = null; this._clearResizeHandles(); this.cb.onSelectionCleared?.(); } else if (this.selectedDeviceIds.size === 1) { const [id] = this.selectedDeviceIds; this.selectedId = id; this._showResizeHandles(this.deviceData.get(id)); this.cb.onDeviceSelected?.(this.deviceData.get(id)); } else { this._clearResizeHandles(); this.cb.onMultipleSelected?.(this.selectedDeviceIds.size); } } else { this.selectedDeviceIds.add(device.id); this._highlightDevice(device.id, true); this.selectedType = "device"; if (this.selectedDeviceIds.size === 1) { this.selectedId = device.id; this._showResizeHandles(this.deviceData.get(device.id)); this.cb.onDeviceSelected?.(this.deviceData.get(device.id)); } else { this.selectedId = null; this._clearResizeHandles(); this.cb.onMultipleSelected?.(this.selectedDeviceIds.size); } } } else if (this.selectedDeviceIds.size > 1 && this.selectedDeviceIds.has(device.id)) { // Click on already-selected member: keep the group, don't collapse to single } else { this.selectDevice(device.id); } }); group.on("dragstart", () => { if (this.wireStart) { group.stopDrag(); return; } if (device.device_type !== "group") group.moveToTop(); this._clearResizeHandles(); // hide during drag if (this.selectedDeviceIds.size > 1 && this.selectedDeviceIds.has(device.id)) { this._multiDragAnchorId = device.id; this._multiDragAnchorStartX = group.x(); this._multiDragAnchorStartY = group.y(); this._multiDragStartPos = new Map(); this.selectedDeviceIds.forEach(id => { if (id === device.id) return; const g = this.deviceNodes.get(id); if (g) this._multiDragStartPos.set(id, { x: g.x(), y: g.y() }); }); // Capture waypoints for wires whose both endpoints are in the selection this._multiDragWireWaypoints = new Map(); this.wireData.forEach((wire, wId) => { if (this.selectedDeviceIds.has(wire.from_device_id) && this.selectedDeviceIds.has(wire.to_device_id) && wire.waypoints?.length) { this._multiDragWireWaypoints.set(wId, wire.waypoints.map(wp => ({ ...wp }))); } }); // Snapshot all selected positions for undo const preDrag = new Map(); this.selectedDeviceIds.forEach(id => { const g = this.deviceNodes.get(id); if (g) preDrag.set(id, { x: g.x(), y: g.y() }); }); const preWaypoints = new Map(); this._multiDragWireWaypoints.forEach((wps, wId) => preWaypoints.set(wId, wps.map(wp => ({ ...wp })))); this.cb.onDragStarted?.({ positions: preDrag, waypoints: preWaypoints }); } else { this._multiDragAnchorId = null; this._multiDragWireWaypoints = null; // Single-device undo snapshot const preDrag = new Map([[device.id, { x: group.x(), y: group.y() }]]); this.cb.onDragStarted?.({ positions: preDrag, waypoints: new Map() }); } }); group.on("dragmove", () => { if (this.snapEnabled) { group.x(this._snap(group.x())); group.y(this._snap(group.y())); } const d = this.deviceData.get(device.id); if (d) { d.x = group.x(); d.y = group.y(); } if (this._multiDragAnchorId === device.id) { const dx = group.x() - this._multiDragAnchorStartX; const dy = group.y() - this._multiDragAnchorStartY; this._multiDragStartPos.forEach(({ x, y }, id) => { const g = this.deviceNodes.get(id); const dd = this.deviceData.get(id); if (g) { g.x(x + dx); g.y(y + dy); } if (dd) { dd.x = x + dx; dd.y = y + dy; } this._redrawWiresFor(id); }); // Translate captured waypoints this._multiDragWireWaypoints?.forEach((origWps, wId) => { const wire = this.wireData.get(wId); if (!wire) return; wire.waypoints = origWps.map(wp => ({ x: wp.x + dx, y: wp.y + dy })); }); } this._redrawWiresFor(device.id); }); group.on("dragend", () => { if (this._multiDragAnchorId === device.id) { this.selectedDeviceIds.forEach(id => { const g = this.deviceNodes.get(id); if (g) this.cb.onDeviceMoved?.(id, g.x(), g.y()); }); // Persist translated waypoints this._multiDragWireWaypoints?.forEach((_, wId) => { const wire = this.wireData.get(wId); if (wire) this.cb.onWaypointsChanged?.(wId, wire.waypoints); }); this.cb.onDragEnded?.([...this.selectedDeviceIds]); this._multiDragAnchorId = null; this._multiDragStartPos = null; this._multiDragWireWaypoints = null; } else { this.cb.onDeviceMoved?.(device.id, group.x(), group.y()); this.cb.onDragEnded?.([device.id]); // Re-show resize handles for single selected device if (this.selectedDeviceIds.size === 1 && this.selectedDeviceIds.has(device.id)) { this._showResizeHandles(this.deviceData.get(device.id)); } } this._recomputeJumps(); // devices moved → wire paths changed → re-check crossings this._renderBundles(); if (this.harnessMode) this._renderHarness(); }); this.deviceNodes.set(device.id, group); if (device.device_type === "group") this.groupLayer.add(group); else this.deviceLayer.add(group); } // ── Resize handles ─────────────────────────────────────────────────────────── _clearResizeHandles() { this._resizeHandles.forEach(h => h.destroy()); this._resizeHandles = []; this.uiLayer.batchDraw(); } _showResizeHandles(device) { this._clearResizeHandles(); if (!device || device.device_type === "cable") return; const ANCHORS = [ { n: "nw", cur: "nw-resize" }, { n: "n", cur: "n-resize" }, { n: "ne", cur: "ne-resize" }, { n: "e", cur: "e-resize" }, { n: "se", cur: "se-resize" }, { n: "s", cur: "s-resize" }, { n: "sw", cur: "sw-resize" }, { n: "w", cur: "w-resize" }, ]; const anchorPos = (name, d) => ({ x: name.includes("e") ? d.x + d.width : name.includes("w") ? d.x : d.x + d.width / 2, y: name.includes("s") ? d.y + d.height : name.includes("n") ? d.y : d.y + d.height / 2, }); ANCHORS.forEach(({ n: an, cur }) => { const p = anchorPos(an, device); const h = new Konva.Circle({ x: p.x, y: p.y, radius: 5, fill: "#ffffff", stroke: "#4488ff", strokeWidth: 1.5, draggable: true, hitStrokeWidth: 10, }); h._anchorName = an; let startSnap = null; h.on("mouseenter", (e) => { e.cancelBubble = true; this.stage.container().style.cursor = cur; }); h.on("mouseleave", () => { this.stage.container().style.cursor = ""; }); h.on("dragstart", (e) => { e.cancelBubble = true; startSnap = { x: device.x, y: device.y, w: device.width, h: device.height }; this.deviceNodes.get(device.id)?.draggable(false); }); h.on("dragmove", () => { if (!startSnap) return; const MIN = 30; const re = startSnap.x + startSnap.w, be = startSnap.y + startSnap.h; let nx = startSnap.x, ny = startSnap.y, nw = startSnap.w, nh = startSnap.h; if (an.includes("e")) nw = Math.max(MIN, h.x() - startSnap.x); if (an.includes("s")) nh = Math.max(MIN, h.y() - startSnap.y); if (an.includes("w")) { nx = Math.min(h.x(), re - MIN); nw = re - nx; } if (an.includes("n")) { ny = Math.min(h.y(), be - MIN); nh = be - ny; } device.x = nx; device.y = ny; device.width = nw; device.height = nh; const g = this.deviceNodes.get(device.id); if (g) { g.x(nx); g.y(ny); g.findOne(".device-rect")?.setAttrs({ width: nw, height: nh }); const lbl = g.findOne(".device-label"); if (lbl) { if (device.device_type === "label") { lbl.setAttrs({ width: nw - 16, height: nh - 16 }); } else if (device.device_type === "group") { lbl.setAttrs({ width: nw - 16 }); g.findOne(".group-header")?.setAttrs({ width: nw }); } else { lbl.setAttrs({ y: nh / 2 - lbl.fontSize() / 2 - 2, width: nw - 8 }); } } g.findOne(".device-type")?.setAttrs({ y: nh - 14, width: nw - 8 }); } // Reposition all handles this._resizeHandles.forEach(rh => { const rp = anchorPos(rh._anchorName, device); rh.setAttrs({ x: rp.x, y: rp.y }); }); this._redrawWiresFor(device.id); this.deviceLayer.batchDraw(); this.uiLayer.batchDraw(); }); h.on("dragend", (e) => { e.cancelBubble = true; if (this.mode === "select") this.deviceNodes.get(device.id)?.draggable(true); this.cb.onDeviceResized?.(device.id, device.x, device.y, device.width, device.height); this._recomputeJumps(); this._renderBundles(); }); this._resizeHandles.push(h); this.uiLayer.add(h); }); this.uiLayer.batchDraw(); } _addPin(group, device, pin) { const circle = new Konva.Circle({ x: pin.x_offset, y: pin.y_offset, radius: 5, fill: "#0a0f1a", stroke: "#5566aa", strokeWidth: 1.5, }); const hit = new Konva.Circle({ x: pin.x_offset, y: pin.y_offset, radius: 14, fill: "rgba(0,0,0,0.01)", name: "pin-hit", }); hit._pinMeta = { deviceId: device.id, pinId: pin.id, side: pin.side }; // Position label inside the device body, clear of the pin circle (r=5, gap=3 → offset 8) const GAP = 8; let lx, ly, lw, la; switch (pin.side) { case "right": lx = pin.x_offset - 28; ly = pin.y_offset - 5; lw = 20; la = "right"; break; case "top": lx = pin.x_offset - 10; ly = pin.y_offset + GAP; lw = 20; la = "center"; break; case "bottom": lx = pin.x_offset - 10; ly = pin.y_offset - 13; lw = 20; la = "center"; break; default: // left lx = pin.x_offset + GAP; ly = pin.y_offset - 5; lw = 20; la = "left"; } group.add(new Konva.Text({ x: lx, y: ly, width: lw, align: la, text: pin.name, fontSize: 8, fontFamily: "monospace", fill: "#556688", })); group.add(circle); group.add(hit); const highlightPin = (active) => { circle.fill(active ? "#003300" : "#0a0f1a"); circle.stroke(active ? "#00dd00" : "#5566aa"); circle.radius(active ? 7 : 5); this.deviceLayer.batchDraw(); }; hit.on("mouseenter", () => { highlightPin(true); this.stage.container().style.cursor = "crosshair"; }); hit.on("mouseleave", () => { highlightPin(false); this.stage.container().style.cursor = this.wireStart ? "crosshair" : ""; }); hit.on("mousedown", (e) => { if (e.evt.button !== 0) return; e.cancelBubble = true; group.draggable(false); const abs = circle.getAbsolutePosition(); const wx = (abs.x - this.offsetX) / this.scale; const wy = (abs.y - this.offsetY) / this.scale; this.wireStart = { deviceId: device.id, pinId: pin.id, wx, wy, side: pin.side }; this.previewLine = new Konva.Line({ points: [wx, wy, wx, wy], stroke: "#00ff88", strokeWidth: 2, dash: [5, 4], listening: false, }); this.uiLayer.add(this.previewLine); this.uiLayer.batchDraw(); }); } _cancelWire() { const wasWiring = !!this.wireStart; this.wireStart = null; if (this.previewLine) { this.previewLine.destroy(); this.previewLine = null; this.uiLayer.batchDraw(); } if (wasWiring && this.mode === "select") { this.deviceNodes.forEach(g => g.draggable(true)); } } // ── Wire rendering ──────────────────────────────────────────────────────────── // Returns SVG path of short perpendicular tick marks along the polyline — used for stripe rendering. _computeStripeTickPath(pts, spacing = 8, halfLen = 2) { if (!pts || pts.length < 4) return ''; const points = []; let total = 0; for (let i = 0; i < pts.length; i += 2) { if (i === 0) { points.push({ x: pts[0], y: pts[1], d: 0 }); continue; } const dx = pts[i] - pts[i-2], dy = pts[i+1] - pts[i-1]; total += Math.sqrt(dx*dx + dy*dy); points.push({ x: pts[i], y: pts[i+1], d: total }); } if (total < spacing) return ''; const interp = (d) => { d = Math.max(0, Math.min(total, d)); for (let i = 1; i < points.length; i++) { if (points[i].d >= d) { const p0 = points[i-1], p1 = points[i]; const segLen = p1.d - p0.d; if (segLen < 1e-6) return { x: p0.x, y: p0.y, nx: 0, ny: 1 }; const t = (d - p0.d) / segLen; const len = Math.sqrt((p1.x-p0.x)**2 + (p1.y-p0.y)**2); const ux = (p1.x-p0.x)/len, uy = (p1.y-p0.y)/len; return { x: p0.x + t*(p1.x-p0.x), y: p0.y + t*(p1.y-p0.y), nx: -uy, ny: ux }; } } const last = points[points.length-1]; return { x: last.x, y: last.y, nx: 0, ny: 1 }; }; let d = ''; for (let dist = spacing / 2; dist <= total - spacing / 2; dist += spacing) { const p = interp(dist); d += `M ${(p.x - p.nx * halfLen).toFixed(1)} ${(p.y - p.ny * halfLen).toFixed(1)} `; d += `L ${(p.x + p.nx * halfLen).toFixed(1)} ${(p.y + p.ny * halfLen).toFixed(1)} `; } return d.trim(); } // Returns SVG path string of alternating bezier arcs along the polyline. // flip=true starts arcs on the opposite side — used for the second wire of a twisted pair. _computeHelixPath(pts, pitch = 16, flip = false) { if (!pts || pts.length < 4) return ''; const points = []; let total = 0; for (let i = 0; i < pts.length; i += 2) { if (i === 0) { points.push({ x: pts[0], y: pts[1], d: 0 }); continue; } const dx = pts[i] - pts[i-2], dy = pts[i+1] - pts[i-1]; total += Math.sqrt(dx*dx + dy*dy); points.push({ x: pts[i], y: pts[i+1], d: total }); } if (total < pitch) return ''; const interp = (d) => { d = Math.max(0, Math.min(total, d)); for (let i = 1; i < points.length; i++) { if (points[i].d >= d) { const p0 = points[i-1], p1 = points[i]; const segLen = p1.d - p0.d; if (segLen < 1e-6) return { x: p0.x, y: p0.y, ux: 0, uy: 1 }; const t = (d - p0.d) / segLen; const len = Math.sqrt((p1.x-p0.x)**2 + (p1.y-p0.y)**2); return { x: p0.x + t*(p1.x-p0.x), y: p0.y + t*(p1.y-p0.y), ux: (p1.x-p0.x)/len, uy: (p1.y-p0.y)/len }; } } const last = points[points.length-1]; return { x: last.x, y: last.y, ux: 0, uy: 1 }; }; const amp = pitch * 0.5; let d = '', side = flip ? -1 : 1; const start0 = interp(0); d += `M ${start0.x.toFixed(1)} ${start0.y.toFixed(1)} `; for (let dist = 0; dist + pitch <= total; dist += pitch) { const mid = interp(dist + pitch / 2); const end = interp(dist + pitch); const cpx = mid.x + (-mid.uy) * amp * side; const cpy = mid.y + mid.ux * amp * side; d += `Q ${cpx.toFixed(1)} ${cpy.toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)} `; side *= -1; } return d.trim(); } _renderWire(wire) { const pts = this._routePoints(wire); if (!pts.length) return; const svgPath = this._makeSvgPath(pts, this._wireCrossings.get(wire.id)); let shield = null; if (wire.shielded) { shield = new Konva.Path({ data: svgPath, stroke: "rgba(160,200,255,0.45)", strokeWidth: 9, lineCap: "round", lineJoin: "round", dash: [6, 4], fill: null, listening: false, }); this.wireLayer.add(shield); } const main = new Konva.Path({ data: svgPath, stroke: wire.color_primary || "#cc0000", strokeWidth: 3, lineCap: "round", lineJoin: "round", fill: null, }); let stripe = null; if (wire.color_stripe && !wire.twisted_pair) { stripe = new Konva.Path({ data: svgPath, stroke: wire.color_stripe, strokeWidth: 1, lineCap: "round", lineJoin: "round", fill: null, listening: false, }); } // Keep hit as Konva.Line for reliable click/drag detection over full wire const hit = new Konva.Line({ points: pts, stroke: "rgba(0,0,0,0.01)", strokeWidth: 16, lineCap: "round", lineJoin: "round", }); let lbl = null; const gaugePart = wire.show_size_label && wire.gauge ? wire.gauge : ""; const labelText = [wire.label || "", wire.length ? `${wire.length}${wire.length_unit}` : "", gaugePart].filter(Boolean).join(" "); if (labelText) { const [mx, my] = this._midPoint(pts); lbl = new Konva.Text({ x: mx + 3, y: my - 10, text: labelText, fontSize: 9, fontFamily: "monospace", fill: "#aabbcc", listening: false, shadowColor: "#000", shadowBlur: 3, shadowOpacity: 0.8, }); } hit.on("click", (e) => { if (this.mode !== "select") return; e.cancelBubble = true; this.selectWire(wire.id); }); hit.on("mouseenter", () => { if (this.mode === "select" && this.selectedId !== wire.id) { this._wireHighlight(wire.id, true); this.stage.container().style.cursor = "pointer"; } }); hit.on("mouseleave", () => { if (this.selectedId !== wire.id) this._wireHighlight(wire.id, false); this.stage.container().style.cursor = ""; }); hit.on("contextmenu", (e) => { e.evt.preventDefault(); e.cancelBubble = true; if (this.selectedId !== wire.id) this.selectWire(wire.id); this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY); }); // Wire body drag (selected wire only) → inserts a waypoint at drag start hit.on("mousedown", (e) => { if (this.mode !== "select" || e.evt.button !== 0) return; if (this.selectedId !== wire.id) return; // unselected: let click handle it e.cancelBubble = true; const pos = this.stage.getPointerPosition(); const { x, y } = this._s2w(pos.x, pos.y); const result = this._insertWaypoint(wire, x, y); wire.waypoints = result.waypoints; this._destroyWireVisuals(wire.id); this._renderWire(wire); this.wireLayer.batchDraw(); this._renderWireHandles(wire.id); this._activeDrag = { wireId: wire.id, idx: result.idx }; }); let helix = null, helix2 = null; if (wire.twisted_pair) { const pitch = wire.twist_pitch || 16; const h1 = this._computeHelixPath(pts, pitch, false); const h2 = this._computeHelixPath(pts, pitch, true); if (h1) helix = new Konva.Path({ data: h1, stroke: wire.color_primary || "#cc0000", strokeWidth: 3, lineCap: "round", lineJoin: "round", fill: null, listening: false, }); if (h2) helix2 = new Konva.Path({ data: h2, stroke: wire.color_stripe || "#dddddd", strokeWidth: 3, lineCap: "round", lineJoin: "round", fill: null, listening: false, }); } if (!wire.twisted_pair || (!helix && !helix2)) { this.wireLayer.add(main); if (stripe) this.wireLayer.add(stripe); } if (helix) this.wireLayer.add(helix); if (helix2) this.wireLayer.add(helix2); if (lbl) this.wireLayer.add(lbl); this.wireLayer.add(hit); this.wireNodes.set(wire.id, { main, stripe, hit, lbl, helix, helix2, shield }); } _redrawWiresFor(deviceId) { this.wireData.forEach(wire => { if (wire.from_device_id !== deviceId && wire.to_device_id !== deviceId) return; const n = this.wireNodes.get(wire.id); if (!n) return; const pts = this._routePoints(wire); // Use cached crossings during drag (fast); _recomputeJumps() fixes them after drag const svgPath = this._makeSvgPath(pts, this._wireCrossings.get(wire.id)); if (n.shield) n.shield.data(svgPath); if (!wire.twisted_pair) { n.main.data(svgPath); if (n.stripe) n.stripe.data(svgPath); } if (n.helix) n.helix.data(this._computeHelixPath(pts, wire.twist_pitch || 16, false)); if (n.helix2) n.helix2.data(this._computeHelixPath(pts, wire.twist_pitch || 16, true)); if (n.hit) n.hit.points(pts); if (n.lbl) { const [mx, my] = this._midPoint(pts); n.lbl.setAttrs({ x: mx + 3, y: my - 10 }); } }); // Keep handles in sync if the selected wire is affected if (this.selectedType === "wire") { const w = this.wireData.get(this.selectedId); if (w && (w.from_device_id === deviceId || w.to_device_id === deviceId)) { this._renderWireHandles(this.selectedId); } } this.wireLayer.batchDraw(); } _destroyWireVisuals(wireId) { const n = this.wireNodes.get(wireId); if (!n) return; if (n.shield) n.shield.destroy(); n.main.destroy(); if (n.stripe) n.stripe.destroy(); if (n.helix) n.helix.destroy(); if (n.helix2) n.helix2.destroy(); if (n.hit) n.hit.destroy(); if (n.lbl) n.lbl.destroy(); this.wireNodes.delete(wireId); } // ── Waypoint editing ────────────────────────────────────────────────────────── // Insert a new waypoint at (wx,wy) in sorted order along the pin-to-pin axis _insertWaypoint(wire, wx, wy) { const fd = this.deviceData.get(wire.from_device_id); const td = this.deviceData.get(wire.to_device_id); const fp = (fd?.pins || []).find(p => p.id === wire.from_pin); const tp = (td?.pins || []).find(p => p.id === wire.to_pin); const ax = (fd?.x || 0) + (fp?.x_offset || 0); const ay = (fd?.y || 0) + (fp?.y_offset || 0); const bx = (td?.x || 0) + (tp?.x_offset || 0); const by = (td?.y || 0) + (tp?.y_offset || 0); const dx = bx - ax, dy = by - ay; const len2 = dx * dx + dy * dy; const tNew = len2 < 1 ? 0.5 : ((wx - ax) * dx + (wy - ay) * dy) / len2; const waypoints = [...(wire.waypoints || [])]; const tArr = waypoints.map(wp => len2 < 1 ? 0.5 : ((wp.x - ax) * dx + (wp.y - ay) * dy) / len2 ); let idx = tArr.findIndex(t => t > tNew); if (idx === -1) idx = waypoints.length; waypoints.splice(idx, 0, { x: wx, y: wy }); return { waypoints, idx }; } // Render draggable handles at each waypoint for the selected wire _renderWireHandles(wireId) { this._clearWireHandles(); const wire = this.wireData.get(wireId); if (!wire) return; const waypoints = wire.waypoints || []; waypoints.forEach((wp, idx) => { const handle = new Konva.Circle({ x: wp.x, y: wp.y, radius: 5, fill: "#00aaff", stroke: "#ffffff", strokeWidth: 1.5, draggable: true, }); handle.on("dragstart", (e) => { e.cancelBubble = true; }); handle.on("dragmove", () => { if (this.snapEnabled) { handle.x(this._snap(handle.x())); handle.y(this._snap(handle.y())); } wire.waypoints[idx] = { x: handle.x(), y: handle.y() }; this._destroyWireVisuals(wireId); this._renderWire(wire); this.wireLayer.batchDraw(); }); handle.on("dragend", () => { this.cb.onWaypointsChanged?.(wireId, wire.waypoints); }); // Double-click a handle to remove that waypoint handle.on("dblclick", () => { wire.waypoints = wire.waypoints.filter((_, i) => i !== idx); this._destroyWireVisuals(wireId); this._renderWire(wire); this.wireLayer.batchDraw(); this._renderWireHandles(wireId); this.cb.onWaypointsChanged?.(wireId, wire.waypoints); }); this.uiLayer.add(handle); this._handleNodes.push(handle); }); this.uiLayer.batchDraw(); } // Update handle positions without full recreation (used during active manual drag) _updateHandlePositions(wire) { const wps = wire.waypoints || []; this._handleNodes.forEach((h, i) => { if (wps[i]) { h.x(wps[i].x); h.y(wps[i].y); } }); this.uiLayer.batchDraw(); } _clearWireHandles() { this._handleNodes.forEach(n => n.destroy()); this._handleNodes = []; this.uiLayer.batchDraw(); } }