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
+2 -1
View File
@@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy import text, inspect as sa_inspect from sqlalchemy import text, inspect as sa_inspect
from .database import Base, engine, SessionLocal from .database import Base, engine, SessionLocal
from .routers import bundles, connectors, diagrams, devices, export, git_mgr, octopart, views, wires from .routers import bundles, connectors, diagrams, devices, export, formboard, git_mgr, octopart, views, wires
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -70,6 +70,7 @@ app.include_router(views.router, prefix="/api/views")
app.include_router(octopart.router, prefix="/api/octopart") app.include_router(octopart.router, prefix="/api/octopart")
app.include_router(export.router, prefix="/api/export") app.include_router(export.router, prefix="/api/export")
app.include_router(connectors.router, prefix="/api/connectors") app.include_router(connectors.router, prefix="/api/connectors")
app.include_router(formboard.router, prefix="/api/formboard")
app.include_router(git_mgr.router, prefix="/api/git") app.include_router(git_mgr.router, prefix="/api/git")
FRONTEND_DIR = os.path.normpath( FRONTEND_DIR = os.path.normpath(
+39
View File
@@ -124,6 +124,45 @@ class ViewWireWaypoints(Base):
view = relationship("DiagramView", back_populates="wire_waypoints") view = relationship("DiagramView", back_populates="wire_waypoints")
class FormboardLayout(Base):
__tablename__ = "formboard_layouts"
id = Column(Integer, primary_key=True, index=True)
diagram_id = Column(Integer, ForeignKey("diagrams.id", ondelete="CASCADE"), nullable=False, unique=True)
nodes = relationship("FormboardNode", back_populates="layout", cascade="all, delete-orphan")
segments = relationship("FormboardSegment", back_populates="layout", cascade="all, delete-orphan")
class FormboardNode(Base):
__tablename__ = "formboard_nodes"
id = Column(Integer, primary_key=True, index=True)
layout_id = Column(Integer, ForeignKey("formboard_layouts.id", ondelete="CASCADE"), nullable=False)
node_type = Column(String, default="branch") # connector | branch | splice
device_id = Column(Integer, ForeignKey("devices.id", ondelete="SET NULL"), nullable=True)
x = Column(Float, default=100)
y = Column(Float, default=100)
label = Column(String, default="")
notes = Column(Text, default="")
layout = relationship("FormboardLayout", back_populates="nodes")
class FormboardSegment(Base):
__tablename__ = "formboard_segments"
id = Column(Integer, primary_key=True, index=True)
layout_id = Column(Integer, ForeignKey("formboard_layouts.id", ondelete="CASCADE"), nullable=False)
from_node_id = Column(Integer, ForeignKey("formboard_nodes.id", ondelete="CASCADE"), nullable=False)
to_node_id = Column(Integer, ForeignKey("formboard_nodes.id", ondelete="CASCADE"), nullable=False)
waypoints = Column(JSON, default=[])
wire_ids = Column(JSON, default=[]) # wire IDs from main diagram
fitting_type = Column(String, default="open") # open | loom | conduit | heat_shrink | tape
fitting_color = Column(String, default="#888888")
label = Column(String, default="")
length_mm = Column(Float, nullable=True)
layout = relationship("FormboardLayout", back_populates="segments")
class CustomConnector(Base): class CustomConnector(Base):
__tablename__ = "custom_connectors" __tablename__ = "custom_connectors"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
+215
View File
@@ -0,0 +1,215 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from .. import models
from ..database import get_db
router = APIRouter(tags=["formboard"])
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_or_create(diagram_id: int, db: Session) -> models.FormboardLayout:
layout = db.query(models.FormboardLayout).filter_by(diagram_id=diagram_id).first()
if not layout:
layout = models.FormboardLayout(diagram_id=diagram_id)
db.add(layout)
db.commit()
db.refresh(layout)
return layout
def _nd(n: models.FormboardNode) -> dict:
return {
"id": n.id, "layout_id": n.layout_id, "node_type": n.node_type,
"device_id": n.device_id, "x": n.x, "y": n.y,
"label": n.label, "notes": n.notes,
}
def _sd(s: models.FormboardSegment) -> dict:
return {
"id": s.id, "layout_id": s.layout_id,
"from_node_id": s.from_node_id, "to_node_id": s.to_node_id,
"waypoints": s.waypoints or [], "wire_ids": s.wire_ids or [],
"fitting_type": s.fitting_type, "fitting_color": s.fitting_color,
"label": s.label, "length_mm": s.length_mm,
}
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/{diagram_id}")
def get_layout(diagram_id: int, db: Session = Depends(get_db)):
layout = _get_or_create(diagram_id, db)
return {
"id": layout.id, "diagram_id": layout.diagram_id,
"nodes": [_nd(n) for n in layout.nodes],
"segments": [_sd(s) for s in layout.segments],
}
@router.post("/{diagram_id}/sync")
def sync_from_diagram(diagram_id: int, db: Session = Depends(get_db)):
"""Import all devices from the main diagram as connector nodes.
Existing nodes (matched by device_id) are label-synced but not moved."""
diagram = db.query(models.Diagram).filter_by(id=diagram_id).first()
if not diagram:
raise HTTPException(404, "Diagram not found")
layout = _get_or_create(diagram_id, db)
existing_dev_ids = {n.device_id for n in layout.nodes if n.device_id is not None}
added = 0
x = 80
for dev in diagram.devices:
if dev.id in existing_dev_ids:
# Keep label in sync if the node label is still the default
node = next((n for n in layout.nodes if n.device_id == dev.id), None)
if node:
new_label = dev.reference or dev.label or f"Dev {dev.id}"
if not node.label or node.label == f"Dev {dev.id}":
node.label = new_label
continue
node = models.FormboardNode(
layout_id=layout.id,
node_type="connector",
device_id=dev.id,
x=x, y=180,
label=dev.reference or dev.label or f"Dev {dev.id}",
)
db.add(node)
x += 200
added += 1
db.commit()
db.refresh(layout)
return {
"added": added,
"nodes": [_nd(n) for n in layout.nodes],
"segments": [_sd(s) for s in layout.segments],
}
# ── Nodes ─────────────────────────────────────────────────────────────────────
class NodeCreate(BaseModel):
node_type: str = "branch"
x: float = 100
y: float = 100
label: str = ""
notes: str = ""
device_id: Optional[int] = None
class NodeUpdate(BaseModel):
x: Optional[float] = None
y: Optional[float] = None
label: Optional[str] = None
notes: Optional[str] = None
@router.post("/{diagram_id}/nodes", status_code=201)
def create_node(diagram_id: int, body: NodeCreate, db: Session = Depends(get_db)):
layout = _get_or_create(diagram_id, db)
node = models.FormboardNode(
layout_id=layout.id, node_type=body.node_type,
device_id=body.device_id,
x=body.x, y=body.y, label=body.label, notes=body.notes,
)
db.add(node)
db.commit()
db.refresh(node)
return _nd(node)
@router.patch("/nodes/{node_id}")
def update_node(node_id: int, body: NodeUpdate, db: Session = Depends(get_db)):
node = db.query(models.FormboardNode).filter_by(id=node_id).first()
if not node:
raise HTTPException(404)
if body.x is not None: node.x = body.x
if body.y is not None: node.y = body.y
if body.label is not None: node.label = body.label
if body.notes is not None: node.notes = body.notes
db.commit()
db.refresh(node)
return _nd(node)
@router.delete("/nodes/{node_id}", status_code=204)
def delete_node(node_id: int, db: Session = Depends(get_db)):
node = db.query(models.FormboardNode).filter_by(id=node_id).first()
if not node:
raise HTTPException(404)
# Cascade-delete connected segments
db.query(models.FormboardSegment).filter(
(models.FormboardSegment.from_node_id == node_id) |
(models.FormboardSegment.to_node_id == node_id)
).delete(synchronize_session=False)
db.delete(node)
db.commit()
# ── Segments ──────────────────────────────────────────────────────────────────
class SegCreate(BaseModel):
from_node_id: int
to_node_id: int
wire_ids: List[int] = []
fitting_type: str = "open"
fitting_color: str = "#888888"
label: str = ""
length_mm: Optional[float] = None
class SegUpdate(BaseModel):
waypoints: Optional[List] = None
wire_ids: Optional[List[int]] = None
fitting_type: Optional[str] = None
fitting_color: Optional[str] = None
label: Optional[str] = None
length_mm: Optional[float] = None
@router.post("/{diagram_id}/segments", status_code=201)
def create_segment(diagram_id: int, body: SegCreate, db: Session = Depends(get_db)):
layout = _get_or_create(diagram_id, db)
seg = models.FormboardSegment(
layout_id=layout.id,
from_node_id=body.from_node_id, to_node_id=body.to_node_id,
wire_ids=body.wire_ids, fitting_type=body.fitting_type,
fitting_color=body.fitting_color, label=body.label, length_mm=body.length_mm,
)
db.add(seg)
db.commit()
db.refresh(seg)
return _sd(seg)
@router.patch("/segments/{seg_id}")
def update_segment(seg_id: int, body: SegUpdate, db: Session = Depends(get_db)):
seg = db.query(models.FormboardSegment).filter_by(id=seg_id).first()
if not seg:
raise HTTPException(404)
if body.waypoints is not None: seg.waypoints = body.waypoints
if body.wire_ids is not None: seg.wire_ids = body.wire_ids
if body.fitting_type is not None: seg.fitting_type = body.fitting_type
if body.fitting_color is not None: seg.fitting_color = body.fitting_color
if body.label is not None: seg.label = body.label
if body.length_mm is not None: seg.length_mm = body.length_mm
db.commit()
db.refresh(seg)
return _sd(seg)
@router.delete("/segments/{seg_id}", status_code=204)
def delete_segment(seg_id: int, db: Session = Depends(get_db)):
seg = db.query(models.FormboardSegment).filter_by(id=seg_id).first()
if not seg:
raise HTTPException(404)
db.delete(seg)
db.commit()
+20
View File
@@ -101,6 +101,26 @@ body { background: #12121c; color: #c8ccee; }
border-radius: 3px; border-radius: 3px;
} }
.view-tab-close:hover { opacity: 1; background: rgba(255,80,80,0.25); color: #ff8888; } .view-tab-close:hover { opacity: 1; background: rgba(255,80,80,0.25); color: #ff8888; }
.view-tab-sep { width: 1px; background: #2a2a44; height: 18px; margin: 0 4px; }
.fb-tab { color: #6688aa; border-color: #1e3355; }
.fb-tab.active { background: #0d2235; border-color: #336699; color: #88bbdd; }
.fb-tab:hover:not(.active) { background: #0d1a28; color: #aaccee; }
#fb-toolbar {
display: flex; align-items: center; gap: 4px; flex-wrap: wrap;
padding: 4px 8px; background: #090914; border-bottom: 1px solid #1e3355;
flex-shrink: 0;
}
.fb-mode-btn {
padding: 3px 10px; font-size: 11px; border-radius: 4px;
background: #0d1a28; border: 1px solid #1e3355; color: #5577aa; cursor: pointer;
}
.fb-mode-btn.active { background: #0d2235; border-color: #336699; color: #88bbdd; }
.fb-mode-btn:hover:not(.active) { background: #111e30; color: #aaccee; }
#fb-container { flex: 1; overflow: hidden; }
#fb-canvas { width: 100%; height: 100%; }
#btn-add-view { #btn-add-view {
padding: 2px 8px; font-size: 14px; background: transparent; padding: 2px 8px; font-size: 14px; background: transparent;
border: 1px dashed #333355; border-radius: 4px; color: #556; cursor: pointer; border: 1px dashed #333355; border-radius: 4px; color: #556; cursor: pointer;
+60
View File
@@ -105,8 +105,24 @@
<div id="view-tab-bar" style="display:none"> <div id="view-tab-bar" style="display:none">
<button class="view-tab active" data-view-id="main">Main</button> <button class="view-tab active" data-view-id="main">Main</button>
<button id="btn-add-view" title="Add view">+</button> <button id="btn-add-view" title="Add view">+</button>
<div class="view-tab-sep"></div>
<button id="btn-formboard-tab" class="view-tab fb-tab" title="Formboard harness layout">⊞ Formboard</button>
</div>
<!-- Formboard sub-toolbar (visible only in formboard mode) -->
<div id="fb-toolbar" style="display:none">
<button id="fb-btn-select" class="fb-mode-btn active" title="Select / move">▸ Select</button>
<button id="fb-btn-branch" class="fb-mode-btn" title="Click canvas to add a branch point">⬤ Branch</button>
<button id="fb-btn-splice" class="fb-mode-btn" title="Click canvas to add a splice/joint">◆ Splice</button>
<button id="fb-btn-connect" class="fb-mode-btn" title="Click two nodes to draw a segment">⌒ Connect</button>
<div class="sep"></div>
<button id="fb-btn-sync" title="Import all connectors from main diagram">↻ Sync connectors</button>
<button id="fb-btn-fit" title="Fit view">⊞ Fit</button>
<button id="fb-btn-del" class="danger-btn" title="Delete selected (Del)">✕ Delete</button>
</div> </div>
<div id="canvas-container"></div> <div id="canvas-container"></div>
<div id="fb-container" style="display:none;flex:1;width:100%;position:relative">
<div id="fb-canvas" style="width:100%;height:100%"></div>
</div>
<div id="canvas-placeholder"> <div id="canvas-placeholder">
<div class="placeholder-content"> <div class="placeholder-content">
<h2>⚡ WireDraw</h2> <h2>⚡ WireDraw</h2>
@@ -346,6 +362,49 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Formboard properties (shown when formboard is active) -->
<div id="props-formboard" style="display:none">
<div id="fb-props-empty" style="padding:20px 12px;color:#444466;font-size:12px;text-align:center">
Select a node or segment.
</div>
<!-- Node props -->
<div id="fb-node-props" style="display:none;padding:8px 10px;display:none">
<div class="cm-label" style="margin-top:4px">Node type</div>
<div id="fb-node-type-badge" style="font-size:11px;color:#8899bb;margin-bottom:8px"></div>
<div class="cm-label">Label</div>
<input id="fb-node-label" class="cm-input" type="text" placeholder="Label…">
<div class="cm-label" style="margin-top:6px">Notes</div>
<textarea id="fb-node-notes" class="cm-input" rows="2" style="resize:vertical" placeholder="Notes…"></textarea>
<div id="fb-connector-info" style="display:none;margin-top:8px;font-size:11px;color:#5566aa;padding:5px 7px;background:#0d0d1e;border-radius:4px;border:1px solid #1a1a33">
<div class="cm-label" style="margin-bottom:2px">Linked device</div>
<div id="fb-connector-dev-label"></div>
</div>
</div>
<!-- Segment props -->
<div id="fb-seg-props" style="display:none;padding:8px 10px">
<div class="cm-label" style="margin-top:4px">Fitting / covering</div>
<select id="fb-seg-fitting" class="cm-input">
<option value="open">Open (no covering)</option>
<option value="loom">Split loom</option>
<option value="conduit">Rigid conduit</option>
<option value="heat_shrink">Heat shrink</option>
<option value="tape">Tape wrap</option>
</select>
<div id="fb-seg-color-row" style="display:none;margin-top:4px">
<div class="cm-label">Fitting color</div>
<input id="fb-seg-fitting-color" type="color" value="#884444" style="width:100%;height:28px;padding:0;border:1px solid #2a2a44;border-radius:4px;background:none;cursor:pointer">
</div>
<div class="cm-label" style="margin-top:6px">Label / notes</div>
<input id="fb-seg-label" class="cm-input" type="text" placeholder="e.g. engine bay trunk">
<div class="cm-label" style="margin-top:6px">Length (mm)</div>
<input id="fb-seg-length" class="cm-input" type="number" min="0" placeholder="e.g. 350">
<div class="cm-label" style="margin-top:10px">Wires through this segment</div>
<div id="fb-wire-list" style="max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;font-size:11px"></div>
</div>
</div>
</aside> </aside>
</div><!-- #main --> </div><!-- #main -->
@@ -472,6 +531,7 @@
<script src="/js/deviceTypes.js"></script> <script src="/js/deviceTypes.js"></script>
<script src="/js/connectorLibrary.js"></script> <script src="/js/connectorLibrary.js"></script>
<script src="/js/canvas.js"></script> <script src="/js/canvas.js"></script>
<script src="/js/formboard.js"></script>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>
</html> </html>
+10
View File
@@ -60,6 +60,16 @@ const api = {
jsonUrl: (id) => `${API_BASE}/export/${id}/json`, jsonUrl: (id) => `${API_BASE}/export/${id}/json`,
formboardUrl: (id) => `${API_BASE}/export/${id}/formboard`, formboardUrl: (id) => `${API_BASE}/export/${id}/formboard`,
}, },
formboard: {
get: (diagramId) => apiFetch(`/formboard/${diagramId}`),
sync: (diagramId) => apiFetch(`/formboard/${diagramId}/sync`, { method: "POST" }),
createNode: (diagramId, data) => apiFetch(`/formboard/${diagramId}/nodes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
updateNode: (nodeId, data) => apiFetch(`/formboard/nodes/${nodeId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
deleteNode: (nodeId) => apiFetch(`/formboard/nodes/${nodeId}`, { method: "DELETE" }),
createSegment: (diagramId, data) => apiFetch(`/formboard/${diagramId}/segments`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
updateSegment: (segId, data) => apiFetch(`/formboard/segments/${segId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
deleteSegment: (segId) => apiFetch(`/formboard/segments/${segId}`, { method: "DELETE" }),
},
git: { git: {
status: () => apiFetch("/git/status"), status: () => apiFetch("/git/status"),
log: (diagram_id) => apiFetch(diagram_id != null ? `/git/log?diagram_id=${diagram_id}` : "/git/log"), log: (diagram_id) => apiFetch(diagram_id != null ? `/git/log?diagram_id=${diagram_id}` : "/git/log"),
+250
View File
@@ -41,6 +41,9 @@ class WiringApp {
}, },
}); });
this._fb = null; // FormboardCanvas instance (lazy)
this._fbActive = false;
this._buildDeviceLibrary(); this._buildDeviceLibrary();
this._bindToolbar(); this._bindToolbar();
this._bindKeyboard(); this._bindKeyboard();
@@ -48,6 +51,7 @@ class WiringApp {
this._bindTabs(); this._bindTabs();
this._bindConnectorModal(); this._bindConnectorModal();
this._bindLibrarySearch(); this._bindLibrarySearch();
this._bindFormboard();
this.loadDiagramList() this.loadDiagramList()
.then(() => this._autoOpenLast()) .then(() => this._autoOpenLast())
@@ -2164,6 +2168,252 @@ class WiringApp {
} }
_esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); } _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(); }); window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });
+389
View File
@@ -0,0 +1,389 @@
"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(); }
}