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:
@@ -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,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||||
|
||||
// ── 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(); });
|
||||
|
||||
Reference in New Issue
Block a user