Files
Wiring-Designer/frontend/js/formboard.js
T
Kyle 82d8570767 Add formboard harness layout designer
Separate read-only canvas for physically laying out wire harnesses.
Syncs connectors from the main diagram (one-way), no changes flow back.

Backend:
- FormboardLayout, FormboardNode, FormboardSegment models
- /api/formboard/ router: get/create layout, sync connectors from diagram,
  full CRUD for nodes and segments

Frontend (formboard.js):
- Konva canvas: drag nodes, click-to-connect segments, zoom/pan
- Node types: Connector (blue rect, synced from diagram), Branch (green
  circle, manual), Splice/joint (orange diamond, manual)
- Segments scale thickness by wire count; fitting overlays for open,
  split loom, conduit, heat shrink, tape wrap
- Right-click context menu for delete on nodes/segments

App integration:
- "⊞ Formboard" tab in view-tab-bar (distinct color, independent of views)
- Sub-toolbar: Select / Branch / Splice / Connect / Sync / Fit / Delete
- Right panel swaps to formboard props when active:
  - Node: label, notes, linked device info
  - Segment: fitting type, fitting color, label, length, wire checklist
    (checkboxes for every wire in the main diagram)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 07:18:43 -04:00

390 lines
16 KiB
JavaScript

"use strict";
class FormboardCanvas {
constructor(containerId, callbacks = {}) {
this.cb = callbacks;
this.nodeData = new Map(); // id → node obj
this.segData = new Map(); // id → segment obj
this.nodeKonva = new Map(); // id → {group, shape, lbl}
this.segKonva = new Map(); // id → {line, overlay, hit, lbl}
this.selectedId = null;
this.selectedType = null; // "node" | "segment"
this.mode = "select"; // select | add-branch | add-splice | connect
this.connectStart = null;
this.scale = 1;
this.offsetX = 80;
this.offsetY = 80;
this._panning = false;
this._panStart = null;
this._setup(containerId);
}
// ── Setup ─────────────────────────────────────────────────────────────────
_setup(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const w = container.clientWidth || 900;
const h = container.clientHeight || 600;
this.stage = new Konva.Stage({ container: containerId, width: w, height: h });
this.bgLayer = new Konva.Layer();
this.segLayer = new Konva.Layer();
this.nodeLayer = new Konva.Layer();
this.uiLayer = new Konva.Layer();
this.stage.add(this.bgLayer, this.segLayer, this.nodeLayer, this.uiLayer);
this._bg = new Konva.Rect({ x: 0, y: 0, width: w, height: h, fill: "#07070f" });
this.bgLayer.add(this._bg);
this._drawGrid(w, h);
this.bgLayer.batchDraw();
this._preview = new Konva.Line({
points: [], stroke: "#5577cc", strokeWidth: 4,
dash: [10, 5], listening: false, opacity: 0.6,
});
this.uiLayer.add(this._preview);
this.stage.on("click", (e) => this._onBgClick(e));
this.stage.on("mousemove", () => this._onMouseMove());
this.stage.on("wheel", (e) => this._onWheel(e));
this.stage.on("mousedown", (e) => {
if (e.evt.button === 1) {
this._panning = true;
this._panStart = { x: e.evt.clientX, y: e.evt.clientY, ox: this.offsetX, oy: this.offsetY };
e.evt.preventDefault();
}
});
this.stage.on("mousemove", (e) => {
if (!this._panning || !this._panStart) return;
this.offsetX = this._panStart.ox + (e.evt.clientX - this._panStart.x);
this.offsetY = this._panStart.oy + (e.evt.clientY - this._panStart.y);
this._applyTransform();
});
this.stage.on("mouseup", () => { this._panning = false; });
this._resizeObs = new ResizeObserver(() => this._resize());
this._resizeObs.observe(container);
}
_drawGrid(w, h) {
const spacing = 40;
for (let x = 0; x < w; x += spacing) {
for (let y = 0; y < h; y += spacing) {
this.bgLayer.add(new Konva.Circle({ x, y, radius: 1, fill: "#1a1a30", listening: false }));
}
}
}
_resize() {
const el = this.stage?.container()?.parentElement;
if (!el) return;
const w = el.clientWidth, h = el.clientHeight;
this.stage.width(w); this.stage.height(h);
this._bg.width(w); this._bg.height(h);
this.bgLayer.batchDraw();
}
_s2w(sx, sy) { return { x: (sx - this.offsetX) / this.scale, y: (sy - this.offsetY) / this.scale }; }
_applyTransform() {
[this.segLayer, this.nodeLayer].forEach(l => {
l.x(this.offsetX); l.y(this.offsetY);
l.scaleX(this.scale); l.scaleY(this.scale);
});
this.segLayer.batchDraw(); this.nodeLayer.batchDraw();
}
// ── Mode & selection ──────────────────────────────────────────────────────
setMode(mode) {
this.mode = mode;
if (mode !== "connect") this._cancelConnect();
this.stage?.container().style.cursor =
["add-branch", "add-splice", "connect"].includes(mode) ? "crosshair" : "default";
}
_cancelConnect() {
if (this.connectStart !== null) { this._highlightNode(this.connectStart, false); this.connectStart = null; }
this._preview.points([]); this.uiLayer.batchDraw();
}
clearSelection() {
if (this.selectedId !== null) {
if (this.selectedType === "node") this._highlightNode(this.selectedId, false);
if (this.selectedType === "segment") this._highlightSeg(this.selectedId, false);
}
this.selectedId = null; this.selectedType = null;
}
selectNode(id) {
this.clearSelection();
this.selectedId = id; this.selectedType = "node";
this._highlightNode(id, true);
this.cb.onNodeSelected?.(this.nodeData.get(id));
}
selectSegment(id) {
this.clearSelection();
this.selectedId = id; this.selectedType = "segment";
this._highlightSeg(id, true);
this.cb.onSegmentSelected?.(this.segData.get(id));
}
// ── Events ────────────────────────────────────────────────────────────────
_onBgClick(e) {
if (e.target !== this.stage && e.target !== this._bg) return;
if (this.mode === "add-branch" || this.mode === "add-splice") {
const pos = this.stage.getPointerPosition();
const { x, y } = this._s2w(pos.x, pos.y);
this.cb.onAddNode?.({ node_type: this.mode === "add-splice" ? "splice" : "branch", x, y, label: "" });
} else if (this.mode === "connect" && this.connectStart !== null) {
this._cancelConnect();
} else {
this.clearSelection();
this.cb.onSelectionCleared?.();
}
}
_onMouseMove() {
if (this.mode !== "connect" || this.connectStart === null) return;
const pos = this.stage.getPointerPosition();
const from = this.nodeData.get(this.connectStart);
if (!from) return;
const fx = from.x * this.scale + this.offsetX;
const fy = from.y * this.scale + this.offsetY;
this._preview.points([fx, fy, pos.x, pos.y]);
this.uiLayer.batchDraw();
}
_onWheel(e) {
e.evt.preventDefault();
const old = this.scale;
const ptr = this.stage.getPointerPosition();
const d = e.evt.deltaY > 0 ? 0.9 : 1.1;
this.scale = Math.max(0.1, Math.min(5, this.scale * d));
this.offsetX = ptr.x - (ptr.x - this.offsetX) * (this.scale / old);
this.offsetY = ptr.y - (ptr.y - this.offsetY) * (this.scale / old);
this._applyTransform();
}
// ── Highlighting ──────────────────────────────────────────────────────────
_nodeColors(type) {
return {
connector: { fill: "#0d1e38", stroke_off: "#334488", stroke_on: "#88aaff" },
branch: { fill: "#0f1f0f", stroke_off: "#2a4a2a", stroke_on: "#55ee55" },
splice: { fill: "#1f0f0f", stroke_off: "#443333", stroke_on: "#ffaa44" },
}[type] || { fill: "#1a1a2a", stroke_off: "#333355", stroke_on: "#8888ff" };
}
_highlightNode(id, on) {
const k = this.nodeKonva.get(id);
const n = this.nodeData.get(id);
if (!k || !n) return;
const c = this._nodeColors(n.node_type);
k.shape.stroke(on ? c.stroke_on : c.stroke_off);
k.shape.strokeWidth(on ? 2 : 1);
this.nodeLayer.batchDraw();
}
_highlightSeg(id, on) {
const k = this.segKonva.get(id);
if (!k) return;
k.line.stroke(on ? "#88aaff" : this._segColor(this.segData.get(id)));
this.segLayer.batchDraw();
}
_segColor(seg) {
return { open: "#3a3a5a", loom: "#252535", conduit: "#2e4055", heat_shrink: "#4a2222", tape: "#2a3a22" }[seg?.fitting_type] || "#3a3a5a";
}
_segThickness(seg) { return Math.max(8, Math.min(30, 8 + (seg.wire_ids || []).length * 1.8)); }
_midPt(pts) {
const i = Math.floor(pts.length / 4) * 2;
return [pts[i] ?? pts[0], pts[i + 1] ?? pts[1]];
}
_segPts(seg) {
const f = this.nodeData.get(seg.from_node_id);
const t = this.nodeData.get(seg.to_node_id);
if (!f || !t) return [];
return [f.x, f.y, ...(seg.waypoints || []).flatMap(p => [p.x, p.y]), t.x, t.y];
}
// ── Load / CRUD ───────────────────────────────────────────────────────────
load(data) {
this.nodeKonva.forEach(k => k.group.destroy());
this.segKonva.forEach(k => { k.line?.destroy(); k.overlay?.destroy(); k.hit?.destroy(); k.lbl?.destroy(); });
this.nodeKonva.clear(); this.nodeData.clear();
this.segKonva.clear(); this.segData.clear();
this.selectedId = null; this.selectedType = null;
(data.segments || []).forEach(s => { this.segData.set(s.id, s); this._renderSeg(s); });
(data.nodes || []).forEach(n => { this.nodeData.set(n.id, n); this._renderNode(n); });
this.segLayer.batchDraw(); this.nodeLayer.batchDraw();
}
addNode(n) {
this.nodeData.set(n.id, n); this._renderNode(n);
this.nodeLayer.batchDraw(); this.selectNode(n.id);
}
refreshNodeLabel(n) {
this.nodeData.set(n.id, n);
const k = this.nodeKonva.get(n.id);
if (k?.lbl) k.lbl.text(n.label || "");
this.nodeLayer.batchDraw();
}
deleteNode(id) {
this.nodeKonva.get(id)?.group.destroy(); this.nodeKonva.delete(id); this.nodeData.delete(id);
if (this.selectedId === id) { this.selectedId = null; this.selectedType = null; }
this.nodeLayer.batchDraw();
}
addSegment(s) {
this.segData.set(s.id, s); this._renderSeg(s);
this.segLayer.batchDraw(); this.selectSegment(s.id);
}
refreshSegment(s) {
this.segData.set(s.id, s);
const k = this.segKonva.get(s.id);
if (k) { k.line?.destroy(); k.overlay?.destroy(); k.hit?.destroy(); k.lbl?.destroy(); this.segKonva.delete(s.id); }
this._renderSeg(s); this.segLayer.batchDraw();
if (this.selectedId === s.id) this._highlightSeg(s.id, true);
}
deleteSegment(id) {
const k = this.segKonva.get(id);
if (k) { k.line?.destroy(); k.overlay?.destroy(); k.hit?.destroy(); k.lbl?.destroy(); }
this.segKonva.delete(id); this.segData.delete(id);
if (this.selectedId === id) { this.selectedId = null; this.selectedType = null; }
this.segLayer.batchDraw();
}
_redrawSegsFor(nodeId) {
this.segData.forEach((seg, id) => {
if (seg.from_node_id !== nodeId && seg.to_node_id !== nodeId) return;
const k = this.segKonva.get(id); if (!k) return;
const pts = this._segPts(seg);
k.line?.points(pts); k.overlay?.points(pts); k.hit?.points(pts);
if (k.lbl) { const m = this._midPt(pts); k.lbl.setAttrs({ x: m[0] + 5, y: m[1] - 14 }); }
});
this.segLayer.batchDraw();
}
// ── Rendering ─────────────────────────────────────────────────────────────
_renderNode(n) {
const group = new Konva.Group({ x: n.x, y: n.y, draggable: true });
const colors = this._nodeColors(n.node_type);
let shape, lbl;
if (n.node_type === "connector") {
shape = new Konva.Rect({ x: -65, y: -24, width: 130, height: 48, fill: colors.fill, stroke: colors.stroke_off, strokeWidth: 1, cornerRadius: 5 });
lbl = new Konva.Text({ x: -61, y: -8, width: 122, text: n.label || "Connector", fontSize: 11, fontFamily: "monospace", fill: "#c0c8f0", align: "center" });
} else if (n.node_type === "splice") {
shape = new Konva.RegularPolygon({ sides: 4, radius: 18, fill: colors.fill, stroke: colors.stroke_off, strokeWidth: 1, rotation: 45 });
lbl = new Konva.Text({ x: -16, y: -7, width: 32, text: n.label || "SP", fontSize: 9, fontFamily: "monospace", fill: "#cc9988", align: "center" });
} else {
shape = new Konva.Circle({ radius: 18, fill: colors.fill, stroke: colors.stroke_off, strokeWidth: 1 });
lbl = new Konva.Text({ x: -16, y: -7, width: 32, text: n.label || "BR", fontSize: 9, fontFamily: "monospace", fill: "#88cc88", align: "center" });
}
group.add(shape, lbl);
this.nodeLayer.add(group);
this.nodeKonva.set(n.id, { group, shape, lbl });
group.on("dragmove", () => {
const nd = this.nodeData.get(n.id);
if (nd) { nd.x = group.x(); nd.y = group.y(); }
this._redrawSegsFor(n.id);
});
group.on("dragend", () => {
const nd = this.nodeData.get(n.id);
if (nd) this.cb.onNodeMoved?.(n.id, nd.x, nd.y);
});
group.on("click", (e) => {
e.cancelBubble = true;
if (this.mode === "connect") {
if (this.connectStart === null) { this.connectStart = n.id; this._highlightNode(n.id, true); }
else if (this.connectStart !== n.id) { this.cb.onSegmentDrawn?.(this.connectStart, n.id); this._cancelConnect(); }
} else { this.selectNode(n.id); }
});
group.on("contextmenu", (e) => {
e.evt.preventDefault(); e.cancelBubble = true;
this.selectNode(n.id); this.cb.onNodeCtx?.(n.id, e.evt.clientX, e.evt.clientY);
});
group.on("mouseenter", () => { this.stage.container().style.cursor = this.mode === "connect" ? "crosshair" : "pointer"; });
group.on("mouseleave", () => { this.stage.container().style.cursor = this.mode === "connect" ? "crosshair" : ""; });
}
_renderSeg(seg) {
const pts = this._segPts(seg);
if (pts.length < 4) return;
const thick = this._segThickness(seg);
const color = this._segColor(seg);
const line = new Konva.Line({ points: pts, stroke: color, strokeWidth: thick, lineCap: "round", lineJoin: "round" });
let overlay = null;
if (seg.fitting_type === "loom") {
overlay = new Konva.Line({ points: pts, stroke: "#111120", strokeWidth: thick * 0.55, lineCap: "round", lineJoin: "round", dash: [7, 5], listening: false });
} else if (seg.fitting_type === "conduit") {
overlay = new Konva.Line({ points: pts, stroke: "#5577aa", strokeWidth: thick * 0.3, lineCap: "butt", lineJoin: "round", listening: false, opacity: 0.5 });
} else if (seg.fitting_type === "heat_shrink") {
overlay = new Konva.Line({ points: pts, stroke: seg.fitting_color || "#884444", strokeWidth: thick * 0.45, lineCap: "round", lineJoin: "round", listening: false, opacity: 0.55 });
} else if (seg.fitting_type === "tape") {
overlay = new Konva.Line({ points: pts, stroke: "#667744", strokeWidth: thick * 0.35, lineCap: "round", lineJoin: "round", dash: [4, 8], listening: false, opacity: 0.7 });
}
const hit = new Konva.Line({ points: pts, stroke: "rgba(0,0,0,0.01)", strokeWidth: Math.max(thick + 12, 22), lineCap: "round", lineJoin: "round" });
const mid = this._midPt(pts);
const parts = [];
const wc = (seg.wire_ids || []).length;
if (wc) parts.push(`${wc}W`);
if (seg.label) parts.push(seg.label);
if (seg.length_mm) parts.push(`${seg.length_mm}mm`);
let lbl = null;
if (parts.length) {
lbl = new Konva.Text({
x: mid[0] + 5, y: mid[1] - 14,
text: parts.join(" "), fontSize: 10, fontFamily: "monospace", fill: "#aabbcc",
listening: false, shadowColor: "#000", shadowBlur: 4, shadowOpacity: 0.95,
});
}
this.segLayer.add(line);
if (overlay) this.segLayer.add(overlay);
this.segLayer.add(hit);
if (lbl) this.segLayer.add(lbl);
this.segKonva.set(seg.id, { line, overlay, hit, lbl });
hit.on("click", (e) => { e.cancelBubble = true; this.selectSegment(seg.id); });
hit.on("contextmenu", (e) => { e.evt.preventDefault(); e.cancelBubble = true; this.selectSegment(seg.id); this.cb.onSegCtx?.(seg.id, e.evt.clientX, e.evt.clientY); });
hit.on("mouseenter", () => { if (this.selectedId !== seg.id) { line.stroke("#6677aa"); this.segLayer.batchDraw(); } this.stage.container().style.cursor = "pointer"; });
hit.on("mouseleave", () => { if (this.selectedId !== seg.id) { line.stroke(color); this.segLayer.batchDraw(); } this.stage.container().style.cursor = ""; });
}
// ── Utilities ─────────────────────────────────────────────────────────────
fitView() {
if (!this.nodeData.size) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.nodeData.forEach(n => { minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y); });
const pad = 120, 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();
}
destroy() { this._resizeObs?.disconnect(); this.stage?.destroy(); }
}