"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(); } }