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>
This commit is contained in:
2026-04-25 07:18:43 -04:00
parent 0bbcaa8ee5
commit 82d8570767
8 changed files with 985 additions and 1 deletions
+250
View File
@@ -41,6 +41,9 @@ class WiringApp {
},
});
this._fb = null; // FormboardCanvas instance (lazy)
this._fbActive = false;
this._buildDeviceLibrary();
this._bindToolbar();
this._bindKeyboard();
@@ -48,6 +51,7 @@ class WiringApp {
this._bindTabs();
this._bindConnectorModal();
this._bindLibrarySearch();
this._bindFormboard();
this.loadDiagramList()
.then(() => this._autoOpenLast())
@@ -2164,6 +2168,252 @@ class WiringApp {
}
_esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
// ── Formboard ─────────────────────────────────────────────────────────────
_bindFormboard() {
document.getElementById("btn-formboard-tab")?.addEventListener("click", () => {
if (this._fbActive) this._closeFormboard(); else this._openFormboard();
});
const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn);
on("fb-btn-select", () => this._fbSetMode("select"));
on("fb-btn-branch", () => this._fbSetMode("add-branch"));
on("fb-btn-splice", () => this._fbSetMode("add-splice"));
on("fb-btn-connect", () => this._fbSetMode("connect"));
on("fb-btn-fit", () => this._fb?.fitView());
on("fb-btn-sync", () => this._fbSync());
on("fb-btn-del", () => this._fbDeleteSelected());
// Node props
document.getElementById("fb-node-label")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "node") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchNode({ label: e.target.value }), 400);
});
document.getElementById("fb-node-notes")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "node") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchNode({ notes: e.target.value }), 400);
});
// Segment props
document.getElementById("fb-seg-fitting")?.addEventListener("change", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
const showColor = ["heat_shrink", "tape"].includes(e.target.value);
document.getElementById("fb-seg-color-row").style.display = showColor ? "" : "none";
this._fbPatchSeg({ fitting_type: e.target.value });
});
document.getElementById("fb-seg-fitting-color")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbColorTimer);
this._fbColorTimer = setTimeout(() => this._fbPatchSeg({ fitting_color: e.target.value }), 200);
});
document.getElementById("fb-seg-label")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchSeg({ label: e.target.value }), 400);
});
document.getElementById("fb-seg-length")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchSeg({ length_mm: parseFloat(e.target.value) || null }), 400);
});
// Keyboard delete in formboard
document.addEventListener("keydown", (e) => {
if (!this._fbActive) return;
if (["INPUT","TEXTAREA","SELECT"].includes(e.target.tagName)) return;
if (e.key === "Delete" || e.key === "Backspace") this._fbDeleteSelected();
});
}
async _openFormboard() {
if (!this.diagramId) return this._needDiagram();
this._fbActive = true;
// Tab styling
document.getElementById("btn-formboard-tab")?.classList.add("active");
document.querySelectorAll(".view-tab:not(.fb-tab)").forEach(t => t.classList.remove("active"));
// Show formboard, hide main canvas
document.getElementById("canvas-container").style.display = "none";
document.getElementById("fb-container").style.display = "flex";
document.getElementById("fb-toolbar").style.display = "flex";
// Swap properties panel
document.getElementById("props-empty").style.display = "none";
document.getElementById("props-device").style.display = "none";
document.getElementById("props-wire").style.display = "none";
document.getElementById("props-formboard").style.display = "";
// Create canvas if needed
if (!this._fb) {
this._fb = new FormboardCanvas("fb-canvas", {
onNodeSelected: (n) => this._fbShowNodeProps(n),
onSegmentSelected: (s) => this._fbShowSegProps(s),
onSelectionCleared:() => this._fbClearProps(),
onNodeMoved: (id, x, y) => api.formboard.updateNode(id, { x, y }).catch(console.error),
onAddNode: async (data) => {
const n = await api.formboard.createNode(this.diagramId, data).catch(console.error);
if (n) this._fb.addNode(n);
},
onSegmentDrawn: async (fromId, toId) => {
const s = await api.formboard.createSegment(this.diagramId, { from_node_id: fromId, to_node_id: toId }).catch(console.error);
if (s) this._fb.addSegment(s);
},
onNodeCtx: (id, x, y) => this._fbNodeCtx(id, x, y),
onSegCtx: (id, x, y) => this._fbSegCtx(id, x, y),
});
}
// Load data
const data = await api.formboard.get(this.diagramId).catch(() => null);
if (data) this._fb.load(data);
if (data?.nodes?.length) this._fb.fitView();
}
_closeFormboard() {
this._fbActive = false;
document.getElementById("btn-formboard-tab")?.classList.remove("active");
document.getElementById("canvas-container").style.display = "";
document.getElementById("fb-container").style.display = "none";
document.getElementById("fb-toolbar").style.display = "none";
document.getElementById("props-formboard").style.display = "none";
document.getElementById("props-empty").style.display = "";
this._renderViewTabs(); // restore active view tab highlight
}
_fbSetMode(mode) {
this._fb?.setMode(mode);
document.querySelectorAll(".fb-mode-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`fb-btn-${mode === "add-branch" ? "branch" : mode === "add-splice" ? "splice" : mode === "connect" ? "connect" : "select"}`)?.classList.add("active");
}
async _fbSync() {
if (!this.diagramId) return;
const data = await api.formboard.sync(this.diagramId).catch(e => { alert("Sync failed: " + e.message); return null; });
if (!data) return;
this._fb?.load(data);
if (data.added > 0) this._fb?.fitView();
}
async _fbDeleteSelected() {
if (!this._fb || this._fb.selectedId === null) return;
const { selectedId, selectedType } = this._fb;
if (selectedType === "node") {
await api.formboard.deleteNode(selectedId).catch(console.error);
// Also remove segments that referenced this node from the canvas
[...this._fb.segData.values()]
.filter(s => s.from_node_id === selectedId || s.to_node_id === selectedId)
.forEach(s => this._fb.deleteSegment(s.id));
this._fb.deleteNode(selectedId);
} else if (selectedType === "segment") {
await api.formboard.deleteSegment(selectedId).catch(console.error);
this._fb.deleteSegment(selectedId);
}
this._fbClearProps();
}
async _fbPatchNode(data) {
if (!this._fb || this._fb.selectedType !== "node") return;
const id = this._fb.selectedId;
const updated = await api.formboard.updateNode(id, data).catch(console.error);
if (updated) this._fb.refreshNodeLabel(updated);
}
async _fbPatchSeg(data) {
if (!this._fb || this._fb.selectedType !== "segment") return;
const id = this._fb.selectedId;
const updated = await api.formboard.updateSegment(id, data).catch(console.error);
if (updated) this._fb.refreshSegment(updated);
}
_fbShowNodeProps(node) {
document.getElementById("fb-props-empty").style.display = "none";
document.getElementById("fb-seg-props").style.display = "none";
document.getElementById("fb-node-props").style.display = "";
const typeLabels = { connector: "Connector (synced from diagram)", branch: "Branch point", splice: "Splice / joint" };
document.getElementById("fb-node-type-badge").textContent = typeLabels[node.node_type] || node.node_type;
document.getElementById("fb-node-label").value = node.label || "";
document.getElementById("fb-node-notes").value = node.notes || "";
const infoEl = document.getElementById("fb-connector-info");
if (node.node_type === "connector" && node.device_id) {
const dev = this.canvas.deviceData.get(node.device_id);
document.getElementById("fb-connector-dev-label").textContent =
dev ? `${dev.reference || ""} ${dev.label || ""}`.trim() || `Device ${dev.id}` : `Device ID ${node.device_id}`;
infoEl.style.display = "";
} else {
infoEl.style.display = "none";
}
}
_fbShowSegProps(seg) {
document.getElementById("fb-props-empty").style.display = "none";
document.getElementById("fb-node-props").style.display = "none";
document.getElementById("fb-seg-props").style.display = "";
document.getElementById("fb-seg-fitting").value = seg.fitting_type || "open";
document.getElementById("fb-seg-fitting-color").value = seg.fitting_color || "#884444";
document.getElementById("fb-seg-label").value = seg.label || "";
document.getElementById("fb-seg-length").value = seg.length_mm || "";
const showColor = ["heat_shrink", "tape"].includes(seg.fitting_type || "");
document.getElementById("fb-seg-color-row").style.display = showColor ? "" : "none";
// Build wire checklist from main diagram
const wireList = document.getElementById("fb-wire-list");
const assignedIds = new Set(seg.wire_ids || []);
wireList.innerHTML = "";
this.canvas.wireData.forEach(w => {
const row = document.createElement("label");
row.style.cssText = "display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:3px;cursor:pointer;";
row.innerHTML = `
<input type="checkbox" ${assignedIds.has(w.id) ? "checked" : ""}>
<span style="width:12px;height:12px;border-radius:2px;background:${w.color_primary || "#cc0000"};flex-shrink:0"></span>
<span style="color:#bbc">${w.label || "(unlabeled)"}</span>
<span style="color:#556;margin-left:auto">${w.gauge || ""}</span>
`;
row.querySelector("input").addEventListener("change", () => {
const checked = [];
wireList.querySelectorAll("label[data-wire-id]").forEach(lbl => {
if (lbl.querySelector("input")?.checked) checked.push(parseInt(lbl.dataset.wireId));
});
this._fbPatchSeg({ wire_ids: checked });
});
row.dataset.wireId = w.id;
wireList.appendChild(row);
});
}
_fbClearProps() {
document.getElementById("fb-props-empty").style.display = "";
document.getElementById("fb-node-props").style.display = "none";
document.getElementById("fb-seg-props").style.display = "none";
}
_fbNodeCtx(id, x, y) {
this._openCtx(x, y, [
{ action: "del", icon: "✕", label: "Delete node + segments", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "del") { this._fb.selectNode(id); await this._fbDeleteSelected(); }
};
}
_fbSegCtx(id, x, y) {
this._openCtx(x, y, [
{ action: "del", icon: "✕", label: "Delete segment", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "del") { this._fb.selectSegment(id); await this._fbDeleteSelected(); }
};
}
}
window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });