82d8570767
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>
390 lines
16 KiB
JavaScript
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(); }
|
|
}
|