diff --git a/backend/app/main.py b/backend/app/main.py index eff35bc..d9ecf6b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles from sqlalchemy import text, inspect as sa_inspect 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) @@ -70,6 +70,7 @@ app.include_router(views.router, prefix="/api/views") app.include_router(octopart.router, prefix="/api/octopart") app.include_router(export.router, prefix="/api/export") 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") FRONTEND_DIR = os.path.normpath( diff --git a/backend/app/models.py b/backend/app/models.py index a85855b..b3c0ede 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -124,6 +124,45 @@ class ViewWireWaypoints(Base): 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): __tablename__ = "custom_connectors" id = Column(Integer, primary_key=True, index=True) diff --git a/backend/app/routers/formboard.py b/backend/app/routers/formboard.py new file mode 100644 index 0000000..9398945 --- /dev/null +++ b/backend/app/routers/formboard.py @@ -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() diff --git a/frontend/css/main.css b/frontend/css/main.css index 7c58792..3757172 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -101,6 +101,26 @@ body { background: #12121c; color: #c8ccee; } border-radius: 3px; } .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 { padding: 2px 8px; font-size: 14px; background: transparent; border: 1px dashed #333355; border-radius: 4px; color: #556; cursor: pointer; diff --git a/frontend/index.html b/frontend/index.html index 2d25f91..f6823eb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -105,8 +105,24 @@ + +
+

⚡ WireDraw

@@ -346,6 +362,49 @@
+ + @@ -472,6 +531,7 @@ + diff --git a/frontend/js/api.js b/frontend/js/api.js index d4af115..27c575e 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -60,6 +60,16 @@ const api = { jsonUrl: (id) => `${API_BASE}/export/${id}/json`, 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: { status: () => apiFetch("/git/status"), log: (diagram_id) => apiFetch(diagram_id != null ? `/git/log?diagram_id=${diagram_id}` : "/git/log"), diff --git a/frontend/js/app.js b/frontend/js/app.js index a206e17..428239f 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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,">"); } + + // ── 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 = ` + + + ${w.label || "(unlabeled)"} + ${w.gauge || ""} + `; + 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(); }); diff --git a/frontend/js/formboard.js b/frontend/js/formboard.js new file mode 100644 index 0000000..f31babb --- /dev/null +++ b/frontend/js/formboard.js @@ -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(); } +}