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:
+2
-1
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
|
||||
@@ -105,8 +105,24 @@
|
||||
<div id="view-tab-bar" style="display:none">
|
||||
<button class="view-tab active" data-view-id="main">Main</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 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 class="placeholder-content">
|
||||
<h2>⚡ WireDraw</h2>
|
||||
@@ -346,6 +362,49 @@
|
||||
</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>
|
||||
|
||||
</div><!-- #main -->
|
||||
@@ -472,6 +531,7 @@
|
||||
<script src="/js/deviceTypes.js"></script>
|
||||
<script src="/js/connectorLibrary.js"></script>
|
||||
<script src="/js/canvas.js"></script>
|
||||
<script src="/js/formboard.js"></script>
|
||||
<script src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(); });
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
Reference in New Issue
Block a user