e4a80dc0e6
- Roadmap #22: File→Import JSON to restore a diagram (drag-drop or file picker) - Roadmap #23: Duplicate diagram from diagram list - Roadmap #24: DRC panel with collapsible categories (unconnected pins, dup refs, no length, no P/N) - Multi-view tabs: named views per diagram with independent device/wire layouts; snapshot-on-entry prevents bleed - Keyboard arrow nudge for selected device(s) - Undo for move, duplicate, label/property changes - Wire jump arcs toggle (⌒ Jumps button in toolbar) - Shift+click multi-wire selection; right-click bulk clear waypoints or auto-route - Auto-route: obstacle-avoiding ortho routing for single or bulk selected wires - Delete view tab with × button; right-click context menu for rename/delete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1943 lines
74 KiB
JavaScript
1943 lines
74 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._netHighlightIds = new Set(); // wire IDs currently net-highlighted
|
|
this.selectedWireIds = new Set(); // wire IDs in multi-wire selection
|
|
this.jumpsEnabled = true; // wire jump arcs at crossings
|
|
|
|
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, id) => {
|
|
const d = this.deviceData.get(id);
|
|
g.draggable(mode === "select" && !d?.properties?.locked);
|
|
});
|
|
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) => {
|
|
const aGroup = a.device_type === "group" ? 0 : 1;
|
|
const bGroup = b.device_type === "group" ? 0 : 1;
|
|
if (aGroup !== bGroup) return aGroup - bGroup;
|
|
return (a.properties?.zOrder || 0) - (b.properties?.zOrder || 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._netHighlightIds.forEach(id => this._wireHighlight(id, false));
|
|
this._netHighlightIds.clear();
|
|
this.selectedWireIds.forEach(id => this._wireHighlight(id, false));
|
|
this.selectedWireIds.clear();
|
|
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, this.jumpsEnabled ? crossings : null);
|
|
}
|
|
|
|
setJumpsEnabled(enabled) {
|
|
this.jumpsEnabled = enabled;
|
|
this._recomputeJumps();
|
|
}
|
|
|
|
autoRouteWire(wireId) {
|
|
const wire = this.wireData.get(wireId);
|
|
if (!wire) return null;
|
|
const fd = this.deviceData.get(wire.from_device_id);
|
|
const td = this.deviceData.get(wire.to_device_id);
|
|
if (!fd || !td) return null;
|
|
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 null;
|
|
const S = 24;
|
|
const ex1 = fd.x + fp.x_offset + (fp.side === "right" ? S : fp.side === "left" ? -S : 0);
|
|
const ey1 = fd.y + fp.y_offset + (fp.side === "bottom" ? S : fp.side === "top" ? -S : 0);
|
|
const ex2 = td.x + tp.x_offset + (tp.side === "right" ? S : tp.side === "left" ? -S : 0);
|
|
const ey2 = td.y + tp.y_offset + (tp.side === "bottom" ? S : tp.side === "top" ? -S : 0);
|
|
const excludeIds = new Set([wire.from_device_id, wire.to_device_id]);
|
|
const inner = this._autoOrtho(ex1, ey1, ex2, ey2, excludeIds);
|
|
// Strip the two exit points (_autoOrtho includes ex1,ey1 and ex2,ey2); store inner pairs as waypoints
|
|
const mid = inner.slice(2, inner.length - 2);
|
|
wire.waypoints = [];
|
|
for (let i = 0; i < mid.length; i += 2) wire.waypoints.push({ x: mid[i], y: mid[i + 1] });
|
|
this._destroyWireVisuals(wireId);
|
|
this._renderWire(wire);
|
|
this._recomputeJumps();
|
|
return wire.waypoints;
|
|
}
|
|
|
|
_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));
|
|
|
|
if (device.properties?.locked) {
|
|
group.add(new Konva.Text({
|
|
name: "lock-badge", text: "🔒",
|
|
x: device.width - 16, y: 3, fontSize: 11, listening: false,
|
|
}));
|
|
group.draggable(false);
|
|
}
|
|
|
|
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.properties?.locked) { 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;
|
|
if (e.evt.shiftKey) {
|
|
// Shift+click: toggle this wire in multi-wire selection
|
|
if (this.selectedWireIds.has(wire.id)) {
|
|
this.selectedWireIds.delete(wire.id);
|
|
this._wireHighlight(wire.id, false);
|
|
} else {
|
|
// Add current single-selected wire to set first
|
|
if (this.selectedId && this.selectedType === "wire" && !this.selectedWireIds.has(this.selectedId)) {
|
|
this.selectedWireIds.add(this.selectedId);
|
|
}
|
|
this.selectedWireIds.add(wire.id);
|
|
this._wireHighlight(wire.id, true);
|
|
this.selectedId = wire.id;
|
|
this.selectedType = "wire";
|
|
}
|
|
this.cb.onMultiWireSelected?.(this.selectedWireIds);
|
|
} else {
|
|
this.selectWire(wire.id);
|
|
}
|
|
});
|
|
hit.on("mouseenter", () => {
|
|
if (this.mode === "select" && this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) {
|
|
this._wireHighlight(wire.id, true);
|
|
this.stage.container().style.cursor = "pointer";
|
|
}
|
|
});
|
|
hit.on("mouseleave", () => {
|
|
if (this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) {
|
|
this._wireHighlight(wire.id, false);
|
|
}
|
|
this.stage.container().style.cursor = "";
|
|
});
|
|
|
|
hit.on("contextmenu", (e) => {
|
|
e.evt.preventDefault();
|
|
e.cancelBubble = true;
|
|
if (this.selectedWireIds.size > 1 && this.selectedWireIds.has(wire.id)) {
|
|
// Multi-wire context menu
|
|
this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY);
|
|
} else {
|
|
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();
|
|
}
|
|
|
|
// ── Layer order ───────────────────────────────────────────────────────────────
|
|
|
|
bringToFront(id) {
|
|
const g = this.deviceNodes.get(id);
|
|
const d = this.deviceData.get(id);
|
|
if (!g || !d) return;
|
|
g.moveToTop();
|
|
(d.device_type === "group" ? this.groupLayer : this.deviceLayer).batchDraw();
|
|
}
|
|
|
|
sendToBack(id) {
|
|
const g = this.deviceNodes.get(id);
|
|
const d = this.deviceData.get(id);
|
|
if (!g || !d) return;
|
|
g.moveToBottom();
|
|
(d.device_type === "group" ? this.groupLayer : this.deviceLayer).batchDraw();
|
|
}
|
|
|
|
// ── Lock / unlock ─────────────────────────────────────────────────────────────
|
|
|
|
setDeviceLocked(id, locked) {
|
|
const d = this.deviceData.get(id);
|
|
const g = this.deviceNodes.get(id);
|
|
if (!d || !g) return;
|
|
d.properties = { ...(d.properties || {}), locked };
|
|
g.draggable(!locked && this.mode === "select");
|
|
const existing = g.findOne(".lock-badge");
|
|
if (existing) existing.destroy();
|
|
if (locked) {
|
|
g.add(new Konva.Text({
|
|
name: "lock-badge", text: "🔒",
|
|
x: d.width - 16, y: 3, fontSize: 11, listening: false,
|
|
}));
|
|
}
|
|
(d.device_type === "group" ? this.groupLayer : this.deviceLayer).batchDraw();
|
|
}
|
|
|
|
// ── Net highlight ─────────────────────────────────────────────────────────────
|
|
|
|
highlightNet(label) {
|
|
this._netHighlightIds.forEach(id => {
|
|
if (id !== this.selectedId) this._wireHighlight(id, false);
|
|
});
|
|
this._netHighlightIds.clear();
|
|
if (!label) return;
|
|
this.wireData.forEach((wire, id) => {
|
|
if (wire.label === label) {
|
|
this._netHighlightIds.add(id);
|
|
this._wireHighlight(id, true);
|
|
}
|
|
});
|
|
this.wireLayer.batchDraw();
|
|
}
|
|
}
|