Files
Wiring-Designer/frontend/js/canvas.js
T
Kyle f32203b630 Initial commit — wiring diagram maker with full feature set
- FastAPI + SQLite backend with routers for diagrams, devices, wires,
  bundles, connectors, export, and Octopart/Nexar search
- Konva.js canvas: ortho/direct/curved routing, wire crossings, harness
  mode, snap-to-grid, multi-select, drag, resize, undo
- Wire features: color/stripe, twisted pair helix, shielded glow,
  gauge size label, multi-core bundle grouping, length/unit tracking
- Device library: connector, terminal block, cable, splice, label, group,
  relay, fuse, switch, component with custom connector support
- Exports: BOM CSV, assembly TXT, JSON, formboard layout, PNG image
- Auto-migration for schema changes via ALTER TABLE at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:51:21 -04:00

1774 lines
67 KiB
JavaScript

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 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) {
stripe = new Konva.Path({
data: svgPath, stroke: wire.color_stripe,
strokeWidth: 1.2, lineCap: "round", dash: [7, 7], listening: false, fill: null,
});
}
// 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();
}
}