Add views, import/duplicate, DRC, jumps toggle, multi-wire select, auto-route, delete view tabs

- Roadmap #22: File→Import JSON to restore a diagram (drag-drop or file picker)
- Roadmap #23: Duplicate diagram from diagram list
- Roadmap #24: DRC panel with collapsible categories (unconnected pins, dup refs, no length, no P/N)
- Multi-view tabs: named views per diagram with independent device/wire layouts; snapshot-on-entry prevents bleed
- Keyboard arrow nudge for selected device(s)
- Undo for move, duplicate, label/property changes
- Wire jump arcs toggle (⌒ Jumps button in toolbar)
- Shift+click multi-wire selection; right-click bulk clear waypoints or auto-route
- Auto-route: obstacle-avoiding ortho routing for single or bulk selected wires
- Delete view tab with × button; right-click context menu for rename/delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 19:18:39 -04:00
parent f43cd3409c
commit e4a80dc0e6
11 changed files with 823 additions and 25 deletions
+3 -3
View File
@@ -18,9 +18,9 @@
### Productivity
- [ ] 21. **Find / search** — toolbar search box filters devices by reference/label and highlights matches on canvas; Ctrl+F shortcut
- [ ] 22. **Import JSON** — drag-and-drop or File→Import a previously exported JSON to restore a diagram
- [ ] 23. **Duplicate diagram** — diagram list context menu → "Duplicate" clones all devices and wires under a new name
- [ ] 24. **DRC / design-rule check** — one-click check that flags: unconnected pins, duplicate references, wires with no length set, missing part numbers; results shown in a panel
- [x] 22. **Import JSON** — drag-and-drop or File→Import a previously exported JSON to restore a diagram
- [x] 23. **Duplicate diagram** — diagram list context menu → "Duplicate" clones all devices and wires under a new name
- [x] 24. **DRC / design-rule check** — one-click check that flags: unconnected pins, duplicate references, wires with no length set, missing part numbers; results shown in a panel
- [ ] 25. **Pin part numbers** — each pin row in the properties panel has a "Contact P/N" field; shows up correctly in BOM export as a separate line item
- [x] 1. **Right-click context menu** — quick actions on devices (duplicate, delete, add pin, save to library) and wires (delete, clear waypoints)
+5 -1
View File
@@ -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, octopart, wires
from .routers import bundles, connectors, diagrams, devices, export, octopart, views, wires
Base.metadata.create_all(bind=engine)
@@ -45,6 +45,9 @@ def _migrate():
if "show_size_label" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN show_size_label INTEGER DEFAULT 0"))
conn.commit()
# diagram_views / view_device_positions / view_wire_waypoints are created
# via Base.metadata.create_all — no ALTER TABLE needed for new tables
except Exception as e:
print(f"[migrate] column error: {e}", file=sys.stderr)
@@ -63,6 +66,7 @@ app.include_router(bundles.router, prefix="/api/bundles")
app.include_router(diagrams.router, prefix="/api/diagrams")
app.include_router(devices.router, prefix="/api/devices")
app.include_router(wires.router, prefix="/api/wires")
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")
+35
View File
@@ -16,6 +16,7 @@ class Diagram(Base):
devices = relationship("Device", back_populates="diagram", cascade="all, delete-orphan")
wires = relationship("Wire", back_populates="diagram", cascade="all, delete-orphan")
wire_bundles = relationship("WireBundle", back_populates="diagram", cascade="all, delete-orphan")
views = relationship("DiagramView", back_populates="diagram", cascade="all, delete-orphan")
class WireBundle(Base):
@@ -89,6 +90,40 @@ class Wire(Base):
bundle = relationship("WireBundle", back_populates="wires", foreign_keys=[bundle_id])
class DiagramView(Base):
__tablename__ = "diagram_views"
id = Column(Integer, primary_key=True, index=True)
diagram_id = Column(Integer, ForeignKey("diagrams.id", ondelete="CASCADE"), nullable=False)
name = Column(String, nullable=False, default="View")
route_mode = Column(String, default="direct")
created_at = Column(DateTime, default=datetime.datetime.utcnow)
diagram = relationship("Diagram", back_populates="views")
device_positions = relationship("ViewDevicePosition", back_populates="view", cascade="all, delete-orphan")
wire_waypoints = relationship("ViewWireWaypoints", back_populates="view", cascade="all, delete-orphan")
class ViewDevicePosition(Base):
__tablename__ = "view_device_positions"
id = Column(Integer, primary_key=True, index=True)
view_id = Column(Integer, ForeignKey("diagram_views.id", ondelete="CASCADE"), nullable=False)
device_id = Column(Integer, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False)
x = Column(Float, nullable=False, default=100)
y = Column(Float, nullable=False, default=100)
view = relationship("DiagramView", back_populates="device_positions")
class ViewWireWaypoints(Base):
__tablename__ = "view_wire_waypoints"
id = Column(Integer, primary_key=True, index=True)
view_id = Column(Integer, ForeignKey("diagram_views.id", ondelete="CASCADE"), nullable=False)
wire_id = Column(Integer, ForeignKey("wires.id", ondelete="CASCADE"), nullable=False)
waypoints = Column(JSON, default=[])
view = relationship("DiagramView", back_populates="wire_waypoints")
class CustomConnector(Base):
__tablename__ = "custom_connectors"
id = Column(Integer, primary_key=True, index=True)
+113 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
from .. import models, schemas
@@ -51,3 +51,115 @@ def delete_diagram(diagram_id: int, db: Session = Depends(get_db)):
db.delete(diagram)
db.commit()
return {"ok": True}
@router.post("/{diagram_id}/duplicate", response_model=schemas.DiagramSummary, status_code=201)
def duplicate_diagram(diagram_id: int, db: Session = Depends(get_db)):
src = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first()
if not src:
raise HTTPException(status_code=404, detail="Diagram not found")
new_diag = models.Diagram(name=f"{src.name} (copy)", description=src.description or "")
db.add(new_diag)
db.flush()
id_map = {}
for dev in src.devices:
new_dev = models.Device(
diagram_id=new_diag.id,
device_type=dev.device_type,
label=dev.label,
reference=dev.reference,
x=dev.x + 30,
y=dev.y + 30,
width=dev.width,
height=dev.height,
properties=dev.properties,
pins=dev.pins,
)
db.add(new_dev)
db.flush()
id_map[dev.id] = new_dev.id
for wire in src.wires:
new_wire = models.Wire(
diagram_id=new_diag.id,
label=wire.label,
from_device_id=id_map.get(wire.from_device_id),
from_pin=wire.from_pin,
to_device_id=id_map.get(wire.to_device_id),
to_pin=wire.to_pin,
color_primary=wire.color_primary,
color_stripe=wire.color_stripe,
gauge=wire.gauge,
length=wire.length,
length_unit=wire.length_unit,
notes=wire.notes or "",
waypoints=wire.waypoints or [],
twisted_pair=wire.twisted_pair,
twist_pitch=wire.twist_pitch,
shielded=wire.shielded,
show_size_label=wire.show_size_label,
)
db.add(new_wire)
db.commit()
db.refresh(new_diag)
return new_diag
@router.post("/import", response_model=schemas.DiagramSummary, status_code=201)
async def import_diagram(request: Request, db: Session = Depends(get_db)):
data = await request.json()
new_diag = models.Diagram(
name=data.get("name", "Imported Diagram"),
description=data.get("description", ""),
)
db.add(new_diag)
db.flush()
id_map = {}
for dev_data in data.get("devices", []):
new_dev = models.Device(
diagram_id=new_diag.id,
device_type=dev_data.get("device_type", "generic"),
label=dev_data.get("label", ""),
reference=dev_data.get("reference", ""),
x=dev_data.get("x", 100),
y=dev_data.get("y", 100),
width=dev_data.get("width", 120),
height=dev_data.get("height", 60),
properties=dev_data.get("properties", {}),
pins=dev_data.get("pins", []),
)
db.add(new_dev)
db.flush()
if "id" in dev_data:
id_map[dev_data["id"]] = new_dev.id
for wire_data in data.get("wires", []):
new_wire = models.Wire(
diagram_id=new_diag.id,
label=wire_data.get("label", ""),
from_device_id=id_map.get(wire_data.get("from_device_id")),
from_pin=wire_data.get("from_pin", ""),
to_device_id=id_map.get(wire_data.get("to_device_id")),
to_pin=wire_data.get("to_pin", ""),
color_primary=wire_data.get("color_primary", "#FF0000"),
color_stripe=wire_data.get("color_stripe"),
gauge=wire_data.get("gauge", "18 AWG"),
length=wire_data.get("length"),
length_unit=wire_data.get("length_unit", "in"),
notes=wire_data.get("notes", ""),
waypoints=wire_data.get("waypoints", []),
twisted_pair=wire_data.get("twisted_pair", False),
twist_pitch=wire_data.get("twist_pitch", 16.0),
shielded=wire_data.get("shielded", False),
show_size_label=wire_data.get("show_size_label", False),
)
db.add(new_wire)
db.commit()
db.refresh(new_diag)
return new_diag
+77
View File
@@ -0,0 +1,77 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from .. import models, schemas
from ..database import get_db
router = APIRouter(tags=["views"])
@router.get("/diagram/{diagram_id}", response_model=List[schemas.DiagramViewOut])
def list_views(diagram_id: int, db: Session = Depends(get_db)):
return db.query(models.DiagramView).filter(models.DiagramView.diagram_id == diagram_id).order_by(models.DiagramView.created_at).all()
@router.post("/", response_model=schemas.DiagramViewOut, status_code=201)
def create_view(payload: schemas.DiagramViewCreate, db: Session = Depends(get_db)):
view = models.DiagramView(**payload.model_dump())
db.add(view)
db.commit()
db.refresh(view)
return view
@router.patch("/{view_id}", response_model=schemas.DiagramViewOut)
def update_view(view_id: int, payload: schemas.DiagramViewUpdate, db: Session = Depends(get_db)):
view = db.get(models.DiagramView, view_id)
if not view:
raise HTTPException(404, "View not found")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(view, k, v)
db.commit()
db.refresh(view)
return view
@router.delete("/{view_id}", status_code=204)
def delete_view(view_id: int, db: Session = Depends(get_db)):
view = db.get(models.DiagramView, view_id)
if not view:
raise HTTPException(404, "View not found")
db.delete(view)
db.commit()
@router.get("/{view_id}/layout", response_model=schemas.ViewLayout)
def get_view_layout(view_id: int, db: Session = Depends(get_db)):
view = db.get(models.DiagramView, view_id)
if not view:
raise HTTPException(404, "View not found")
pos = {p.device_id: {"x": p.x, "y": p.y} for p in view.device_positions}
waypts = {w.wire_id: (w.waypoints or []) for w in view.wire_waypoints}
return {"device_positions": pos, "wire_waypoints": waypts}
@router.patch("/{view_id}/devices/{device_id}")
def update_view_device_position(view_id: int, device_id: int, payload: schemas.ViewDevicePositionUpdate, db: Session = Depends(get_db)):
pos = db.query(models.ViewDevicePosition).filter_by(view_id=view_id, device_id=device_id).first()
if pos:
pos.x = payload.x
pos.y = payload.y
else:
pos = models.ViewDevicePosition(view_id=view_id, device_id=device_id, x=payload.x, y=payload.y)
db.add(pos)
db.commit()
return {"ok": True}
@router.patch("/{view_id}/wires/{wire_id}")
def update_view_wire_waypoints(view_id: int, wire_id: int, payload: schemas.ViewWireWaypointsUpdate, db: Session = Depends(get_db)):
ww = db.query(models.ViewWireWaypoints).filter_by(view_id=view_id, wire_id=wire_id).first()
if ww:
ww.waypoints = payload.waypoints
else:
ww = models.ViewWireWaypoints(view_id=view_id, wire_id=wire_id, waypoints=payload.waypoints)
db.add(ww)
db.commit()
return {"ok": True}
+38
View File
@@ -146,6 +146,44 @@ class DiagramFull(DiagramSummary):
wire_bundles: List[WireBundleOut] = []
# ── DiagramView ───────────────────────────────────────────────────────────────
class DiagramViewBase(BaseModel):
name: str
route_mode: str = "direct"
class DiagramViewCreate(DiagramViewBase):
diagram_id: int
class DiagramViewUpdate(BaseModel):
name: Optional[str] = None
route_mode: Optional[str] = None
class DiagramViewOut(DiagramViewBase):
id: int
diagram_id: int
created_at: datetime
model_config = {"from_attributes": True}
class ViewDevicePositionUpdate(BaseModel):
x: float
y: float
class ViewWireWaypointsUpdate(BaseModel):
waypoints: List[Dict[str, Any]] = []
class ViewLayout(BaseModel):
device_positions: Dict[int, Dict[str, float]] = {}
wire_waypoints: Dict[int, List[Dict[str, Any]]] = {}
# ── CustomConnector ───────────────────────────────────────────────────────────
class CustomConnectorBase(BaseModel):
+57 -3
View File
@@ -74,6 +74,38 @@ body { background: #12121c; color: #c8ccee; }
}
.diagram-item:hover .di-del-btn { opacity: 1; }
.di-del-btn:hover { background: #3a1414; border-color: #aa3333; }
.di-dup-btn {
flex-shrink: 0; opacity: 0; padding: 1px 5px; font-size: 13px; line-height: 1;
background: transparent; border-color: transparent; color: #88aaff;
transition: opacity .15s; margin-left: 4px;
}
.diagram-item:hover .di-dup-btn { opacity: 1; }
.di-dup-btn:hover { background: #1a1a40; border-color: #4466aa; }
/* ── View tab bar ────────────────────────────────────────────────────────── */
#view-tab-bar {
display: flex; align-items: center; gap: 2px;
padding: 3px 8px; background: #0c0c1c; border-bottom: 1px solid #2a2a44;
flex-shrink: 0;
}
.view-tab {
padding: 3px 12px; font-size: 11px; border-radius: 4px 4px 0 0;
background: #1a1a30; border: 1px solid #2a2a44; border-bottom: none;
color: #8888aa; cursor: pointer;
}
.view-tab { display: inline-flex; align-items: center; gap: 4px; }
.view-tab.active { background: #1e2a4a; border-color: #4466aa; color: #c0c8f0; }
.view-tab:hover:not(.active) { background: #1e1e38; color: #aaa; }
.view-tab-close {
font-size: 13px; line-height: 1; opacity: 0.4; padding: 0 2px;
border-radius: 3px;
}
.view-tab-close:hover { opacity: 1; background: rgba(255,80,80,0.25); color: #ff8888; }
#btn-add-view {
padding: 2px 8px; font-size: 14px; background: transparent;
border: 1px dashed #333355; border-radius: 4px; color: #556; cursor: pointer;
}
#btn-add-view:hover { border-color: #5566aa; color: #99aadd; }
/* ── Device library ───────────────────────────────────────────────────────── */
#device-library { padding: 6px; display: flex; flex-direction: column; gap: 3px; }
@@ -98,9 +130,9 @@ body { background: #12121c; color: #c8ccee; }
.conn-meta { font-size: 10px; color: #556688; margin-top: 1px; }
/* ── Canvas area ──────────────────────────────────────────────────────────── */
#canvas-area { flex: 1; position: relative; overflow: hidden; }
#canvas-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
#canvas-container {
width: 100%; height: 100%;
flex: 1; width: 100%;
background-color: #131320;
background-image: radial-gradient(circle, #2e2e50 1px, transparent 1px);
background-size: 20px 20px;
@@ -228,7 +260,7 @@ button:active { background: #1a1a3a; }
.conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; }
/* ── Modal ───────────────────────────────────────────────────────────────── */
#connector-modal {
#connector-modal, #drc-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000;
align-items: center; justify-content: center;
}
@@ -250,6 +282,28 @@ button:active { background: #1a1a3a; }
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
/* ── DRC ────────────────────────────────────────────────────────────────── */
.drc-category { border: 1px solid #2a2a44; border-radius: 5px; overflow: hidden; }
.drc-cat-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; background: #1a1a30; cursor: pointer;
user-select: none;
}
.drc-cat-header:hover { background: #1e1e3a; }
.drc-cat-icon { font-size: 13px; }
.drc-cat-label { flex: 1; font-size: 12px; font-weight: 600; color: #c0c4e8; }
.drc-cat-count {
font-size: 11px; font-weight: 700; background: #2a2a50;
padding: 1px 7px; border-radius: 10px; color: #9090cc;
}
.drc-cat-body { padding: 4px 0; background: #0e0e1e; }
.drc-row {
padding: 5px 14px; font-size: 11px; color: #a0a4c8;
border-bottom: 1px solid #151528;
}
.drc-row:last-child { border-bottom: none; }
.drc-none { color: #444466; font-style: italic; }
.cm-label {
display: block; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: .06em; color: #555577; margin-bottom: 4px;
+24
View File
@@ -28,6 +28,7 @@
<button id="btn-route-curved" class="route-btn" title="Curved (smooth bezier) routing">∿ Curved</button>
<div class="sep"></div>
<!-- Actions -->
<button id="btn-jumps" title="Toggle wire jump arcs at crossings" class="jumps-btn active">⌒ Jumps</button>
<button id="btn-snap" title="Toggle grid snap (G)" class="snap-btn">⊹ Snap</button>
<button id="btn-harness" title="Toggle harness view (H)">⌇ Harness</button>
<button id="btn-fit" title="Fit canvas to devices (F)">⊞ Fit</button>
@@ -37,10 +38,12 @@
</div>
<div class="toolbar-right">
<button id="btn-drc" title="Run design-rule check">⚠ DRC</button>
<div id="saved-indicator">✓ Saved</div>
<div class="export-wrap">
<button id="export-toggle">Export ▾</button>
<div id="export-menu">
<button id="btn-import">📂 Import JSON</button>
<button id="btn-bom">📋 BOM (CSV)</button>
<button id="btn-assembly">📄 Assembly (TXT)</button>
<button id="btn-json">{ } JSON</button>
@@ -49,6 +52,7 @@
</div>
</div>
</div>
<input id="import-file-input" type="file" accept=".json" style="display:none">
</header>
<!-- ── Mode bar ─────────────────────────────────────────────────────────── -->
@@ -96,6 +100,11 @@
<!-- Canvas -->
<main id="canvas-area">
<!-- View tab bar -->
<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>
<div id="canvas-container"></div>
<div id="canvas-placeholder">
<div class="placeholder-content">
@@ -395,6 +404,21 @@
<!-- populated dynamically by app.js -->
</div>
<!-- ── DRC Modal ──────────────────────────────────────────────────────────────── -->
<div id="drc-modal" style="display:none">
<div class="modal-box" style="min-width:480px;max-width:640px">
<div class="modal-header">
<h3>Design Rule Check</h3>
</div>
<div class="modal-body" id="drc-results" style="max-height:400px;overflow-y:auto;font-size:12px;padding:10px;gap:6px">
</div>
<div class="modal-footer">
<div style="flex:1"></div>
<button id="drc-close" class="btn-primary">Close</button>
</div>
</div>
</div>
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/deviceTypes.js"></script>
+11
View File
@@ -16,6 +16,17 @@ const api = {
get: (id) => apiFetch(`/diagrams/${id}`),
update: (id, data) => apiFetch(`/diagrams/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/diagrams/${id}`, { method: "DELETE" }),
duplicate: (id) => apiFetch(`/diagrams/${id}/duplicate`, { method: "POST" }),
import: (data) => apiFetch("/diagrams/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
},
views: {
listForDiagram: (diagramId) => apiFetch(`/views/diagram/${diagramId}`),
create: (data) => apiFetch("/views/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
update: (id, data) => apiFetch(`/views/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/views/${id}`, { method: "DELETE" }),
getLayout: (id) => apiFetch(`/views/${id}/layout`),
updateDevicePos: (viewId, deviceId, data) => apiFetch(`/views/${viewId}/devices/${deviceId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
updateWireWaypoints: (viewId, wireId, data) => apiFetch(`/views/${viewId}/wires/${wireId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
},
devices: {
create: (data) => apiFetch("/devices/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
+388 -5
View File
@@ -1,6 +1,8 @@
class WiringApp {
constructor() {
this.diagramId = null;
this.currentViewId = null; // null = main layout, number = view id
this._views = []; // view objects for current diagram
this._savedTimer = null;
this._customConnectors = []; // loaded from backend
this._editingConnId = null; // id of custom connector being edited in modal
@@ -18,15 +20,16 @@ class WiringApp {
onWireSelected: (w) => this._showWireProps(w),
onSelectionCleared: () => this._clearProps(),
onMultipleSelected: (count) => this._showMultiProps(count),
onDeviceMoved: (id, x, y) => { api.devices.update(id, { x, y }).catch(console.error); this._flashSaved(); },
onDeviceMoved: (id, x, y) => { this._savePosForCurrentView(id, x, y); this._flashSaved(); },
onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy),
onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy),
onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin),
onWaypointsChanged: (id, wps) => { api.wires.update(id, { waypoints: wps }).catch(console.error); this._flashSaved(); },
onWaypointsChanged: (id, wps) => { this._saveWaypointsForCurrentView(id, wps); this._flashSaved(); },
onDragStarted: (snap) => { this._preDrag = snap; },
onDragEnded: (ids) => { this._pushMoveUndo(ids); },
onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y),
onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y),
onMultiWireSelected: (ids) => this._onMultiWireSelected(ids),
onDeviceResized: async (id, x, y, w, h) => {
const device = this.canvas.deviceData.get(id);
if (!device) return;
@@ -52,6 +55,24 @@ class WiringApp {
.then(() => this._checkOctopartStatus());
}
// ── View-aware position/waypoint saving ───────────────────────────────────────
_savePosForCurrentView(id, x, y) {
if (this.currentViewId) {
api.views.updateDevicePos(this.currentViewId, id, { x, y }).catch(console.error);
} else {
api.devices.update(id, { x, y }).catch(console.error);
}
}
_saveWaypointsForCurrentView(wireId, waypoints) {
if (this.currentViewId) {
api.views.updateWireWaypoints(this.currentViewId, wireId, { waypoints }).catch(console.error);
} else {
api.wires.update(wireId, { waypoints }).catch(console.error);
}
}
// ── Auto-reopen & saved indicator ────────────────────────────────────────────
_autoOpenLast() {
@@ -167,14 +188,14 @@ class WiringApp {
const d = this.canvas.deviceData.get(id);
if (g) { g.x(x); g.y(y); }
if (d) { d.x = x; d.y = y; }
await api.devices.update(id, { x, y }).catch(console.error);
this._savePosForCurrentView(id, x, y);
this.canvas._redrawWiresFor(id);
}
for (const [wId, wps] of waypoints) {
const wire = this.canvas.wireData.get(wId);
if (wire) {
wire.waypoints = wps.map(wp => ({ ...wp }));
await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error);
this._saveWaypointsForCurrentView(wId, wire.waypoints);
}
}
this.canvas.deviceLayer.batchDraw();
@@ -434,6 +455,30 @@ class WiringApp {
on("btn-json", () => this._export("json"));
on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); });
on("btn-formboard", () => this._export("formboard"));
on("btn-drc", () => this._runDrc());
on("btn-import", () => document.getElementById("import-file-input")?.click());
document.getElementById("import-file-input")?.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (ev) => {
try {
const data = JSON.parse(ev.target.result);
const diag = await api.diagrams.import(data);
await this.loadDiagramList();
await this.openDiagram(diag.id);
} catch (err) { alert("Import failed: " + err.message); }
e.target.value = "";
};
reader.readAsText(file);
});
on("btn-jumps", () => {
const btn = document.getElementById("btn-jumps");
const enabled = !this.canvas.jumpsEnabled;
this.canvas.setJumpsEnabled(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-snap", () => {
const btn = document.getElementById("btn-snap");
const enabled = !this.canvas.snapEnabled;
@@ -552,6 +597,41 @@ class WiringApp {
}
_showCtxWire(id, x, y) {
const multiIds = this.canvas.selectedWireIds;
if (multiIds.size > 1 && multiIds.has(id)) {
// Multi-wire context menu
const items = [
{ action: "multi-clearwp", icon: "⌀", label: `Clear waypoints (${multiIds.size} wires)` },
{ action: "multi-autoroute", icon: "⤢", label: `Auto-route (${multiIds.size} wires)` },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "multi-clearwp") {
for (const wid of multiIds) {
const w = this.canvas.wireData.get(wid);
if (!w) continue;
w.waypoints = [];
this.canvas.updateWire(w);
api.wires.update(wid, { waypoints: [] }).catch(console.error);
}
this._flashSaved();
}
if (action === "multi-autoroute") {
for (const wid of multiIds) {
const newWps = this.canvas.autoRouteWire(wid);
if (newWps !== null) {
api.wires.update(wid, { waypoints: newWps }).catch(console.error);
}
}
this._flashSaved();
}
};
return;
}
const wire = this.canvas.wireData.get(id);
const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0;
const items = [
@@ -559,7 +639,8 @@ class WiringApp {
...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []),
...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []),
"---",
{ action: "straighten", icon: "⤢", label: "Straighten wire" },
{ action: "autoroute", icon: "⤢", label: "Auto-route" },
{ action: "straighten", icon: "⤡", label: "Straighten wire" },
...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []),
"---",
{ action: "del", icon: "✕", label: "Delete wire", danger: true },
@@ -570,6 +651,13 @@ class WiringApp {
this._closeCtx();
if (!action) return;
if (action === "del") this.deleteSelected();
if (action === "autoroute" && wire) {
const newWps = this.canvas.autoRouteWire(id);
if (newWps !== null) {
api.wires.update(id, { waypoints: newWps }).catch(console.error);
this._flashSaved();
}
}
if ((action === "clearwp" || action === "straighten") && wire) {
wire.waypoints = [];
this.canvas.updateWire(wire);
@@ -600,6 +688,10 @@ class WiringApp {
};
}
_onMultiWireSelected(ids) {
// No panel update needed for multi-wire; the count could be shown in status bar
}
_setRouteMode(mode) {
this.canvas.setRouteMode(mode);
document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active"));
@@ -621,6 +713,33 @@ class WiringApp {
if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); }
if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); }
// Arrow key nudge
if (["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)) {
const ids = [...this.canvas.selectedDeviceIds];
if (!ids.length) return;
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0;
ids.forEach(id => {
const g = this.canvas.deviceNodes.get(id);
const d = this.canvas.deviceData.get(id);
if (!g || !d) return;
d.x += dx; d.y += dy;
g.x(d.x); g.y(d.y);
this.canvas._redrawWiresFor(id);
});
this.canvas.deviceLayer.batchDraw();
clearTimeout(this._nudgeSaveTimer);
this._nudgeSaveTimer = setTimeout(() => {
ids.forEach(id => {
const d = this.canvas.deviceData.get(id);
if (d) this._savePosForCurrentView(id, d.x, d.y);
});
this._flashSaved();
}, 300);
}
});
}
@@ -890,9 +1009,18 @@ class WiringApp {
el.dataset.id = d.id;
el.innerHTML = `<span class="di-name">${d.name}</span>
<span class="di-date">${new Date(d.updated_at + "Z").toLocaleDateString()}</span>
<button class="di-dup-btn" title="Duplicate diagram">⊕</button>
<button class="di-del-btn" title="Delete diagram">×</button>`;
el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id));
el.querySelector(".di-date").addEventListener("click", () => this.openDiagram(d.id));
el.querySelector(".di-dup-btn").addEventListener("click", async (e) => {
e.stopPropagation();
try {
const copy = await api.diagrams.duplicate(d.id);
await this.loadDiagramList();
await this.openDiagram(copy.id);
} catch (err) { alert("Duplicate failed: " + err.message); }
});
el.querySelector(".di-del-btn").addEventListener("click", (e) => {
e.stopPropagation();
this.deleteDiagram(d.id, d.name);
@@ -908,10 +1036,13 @@ class WiringApp {
await api.diagrams.delete(id);
if (this.diagramId === id) {
this.diagramId = null;
this.currentViewId = null;
this._views = [];
localStorage.removeItem("lastDiagramId");
this.canvas.loadDiagram({ devices: [], wires: [] });
document.getElementById("diagram-name").value = "";
document.getElementById("canvas-placeholder").style.display = "";
document.getElementById("view-tab-bar").style.display = "none";
this._clearProps();
}
await this.loadDiagramList();
@@ -932,6 +1063,7 @@ class WiringApp {
try {
const diagram = await api.diagrams.get(id);
this.diagramId = id;
this.currentViewId = null;
localStorage.setItem("lastDiagramId", id);
document.getElementById("diagram-name").value = diagram.name;
document.getElementById("canvas-placeholder").style.display = "none";
@@ -942,12 +1074,263 @@ class WiringApp {
this.setMode("select");
this._clearProps();
await this.loadDiagramList();
await this._loadViews();
} catch (e) {
localStorage.removeItem("lastDiagramId");
console.error("Failed to open diagram:", e);
}
}
// ── Views ─────────────────────────────────────────────────────────────────────
async _loadViews() {
if (!this.diagramId) return;
try { this._views = await api.views.listForDiagram(this.diagramId); }
catch { this._views = []; }
this._renderViewTabs();
}
_renderViewTabs() {
const bar = document.getElementById("view-tab-bar");
if (!bar) return;
// Remove all view tabs (keep add button)
bar.querySelectorAll(".view-tab").forEach(t => t.remove());
const addBtn = document.getElementById("btn-add-view");
const mainTab = document.createElement("button");
mainTab.className = "view-tab" + (this.currentViewId === null ? " active" : "");
mainTab.dataset.viewId = "main";
mainTab.textContent = "Main";
mainTab.addEventListener("click", () => this._switchToView(null));
bar.insertBefore(mainTab, addBtn);
this._views.forEach(v => {
const tab = document.createElement("button");
tab.className = "view-tab" + (this.currentViewId === v.id ? " active" : "");
tab.dataset.viewId = v.id;
const label = document.createElement("span");
label.textContent = v.name;
tab.appendChild(label);
const closeBtn = document.createElement("span");
closeBtn.textContent = "×";
closeBtn.className = "view-tab-close";
closeBtn.title = "Delete view";
closeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Delete view "${v.name}"?`)) return;
try {
await api.views.delete(v.id);
this._views = this._views.filter(x => x.id !== v.id);
if (this.currentViewId === v.id) await this._switchToView(null);
else this._renderViewTabs();
} catch (err) { alert("Failed to delete view: " + err.message); }
});
tab.appendChild(closeBtn);
tab.addEventListener("dblclick", () => this._renameView(v));
tab.addEventListener("click", () => this._switchToView(v.id));
tab.addEventListener("contextmenu", (e) => { e.preventDefault(); this._viewTabCtx(v, e.clientX, e.clientY); });
bar.insertBefore(tab, addBtn);
});
bar.style.display = this.diagramId ? "" : "none";
if (!addBtn._bound) {
addBtn._bound = true;
addBtn.addEventListener("click", () => this._addView());
}
}
async _addView() {
const name = prompt("View name (e.g. \"Curved\"):", "View " + (this._views.length + 1));
if (!name || !this.diagramId) return;
const routeMode = prompt("Default routing (direct / curved / ortho):", "direct") || "direct";
try {
const v = await api.views.create({ diagram_id: this.diagramId, name, route_mode: routeMode });
this._views.push(v);
this._renderViewTabs();
await this._switchToView(v.id);
} catch (e) { alert("Failed to create view: " + e.message); }
}
async _renameView(v) {
const name = prompt("Rename view:", v.name);
if (!name) return;
try {
const updated = await api.views.update(v.id, { name });
const idx = this._views.findIndex(x => x.id === v.id);
if (idx >= 0) this._views[idx] = updated;
this._renderViewTabs();
} catch (e) { alert("Failed to rename: " + e.message); }
}
_viewTabCtx(v, x, y) {
this._openCtx(x, y, [
{ action: "rename", icon: "✏", label: "Rename…" },
{ action: "del", icon: "✕", label: "Delete view", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "rename") this._renameView(v);
if (action === "del") {
if (!confirm(`Delete view "${v.name}"?`)) return;
try {
await api.views.delete(v.id);
this._views = this._views.filter(x => x.id !== v.id);
if (this.currentViewId === v.id) await this._switchToView(null);
else this._renderViewTabs();
} catch (err) { alert("Failed to delete view: " + err.message); }
}
};
}
async _switchToView(viewId) {
this.currentViewId = viewId;
const diagram = await api.diagrams.get(this.diagramId).catch(() => null);
if (!diagram) return;
if (viewId === null) {
// Restore main layout — reload the diagram as-is
this.canvas.loadDiagram(diagram);
this.canvas.loadBundles(this._bundles);
const routeBtn = document.querySelector(".route-btn.active");
// Keep current route mode
} else {
const view = this._views.find(v => v.id === viewId);
const layout = await api.views.getLayout(viewId).catch(() => ({ device_positions: {}, wire_waypoints: {} }));
this.canvas.loadDiagram(diagram);
this.canvas.loadBundles(this._bundles);
// Apply stored view positions
const storedIds = new Set(Object.keys(layout.device_positions).map(Number));
Object.entries(layout.device_positions).forEach(([devIdStr, pos]) => {
const devId = parseInt(devIdStr);
const d = this.canvas.deviceData.get(devId);
const g = this.canvas.deviceNodes.get(devId);
if (d && g) { d.x = pos.x; d.y = pos.y; g.x(pos.x); g.y(pos.y); }
});
// Snapshot any device NOT yet in this view's layout so main-tab moves
// can never bleed through after this point.
const toSnapshot = [];
this.canvas.deviceData.forEach((d, devId) => {
if (!storedIds.has(devId)) toSnapshot.push({ devId, x: d.x, y: d.y });
});
toSnapshot.forEach(({ devId, x, y }) =>
api.views.updateDevicePos(viewId, devId, { x, y }).catch(console.error)
);
// Apply stored wire waypoints
Object.entries(layout.wire_waypoints).forEach(([wireIdStr, wps]) => {
const wireId = parseInt(wireIdStr);
const w = this.canvas.wireData.get(wireId);
if (w) { w.waypoints = wps; }
});
this.canvas.deviceLayer.batchDraw();
this.canvas.wireLayer.batchDraw();
if (view?.route_mode) this._setRouteMode(view.route_mode);
}
this._renderViewTabs();
this.setMode("select");
this._clearProps();
}
// ── DRC ───────────────────────────────────────────────────────────────────────
_runDrc() {
if (!this.diagramId) return this._needDiagram();
const devices = [...this.canvas.deviceData.values()];
const wires = [...this.canvas.wireData.values()];
const categories = [
{ key: "unconnected", label: "Unconnected Pins", icon: "🔴", items: [] },
{ key: "dupref", label: "Duplicate References",icon: "🟠", items: [] },
{ key: "nolength", label: "Missing Wire Length", icon: "🔵", items: [] },
{ key: "nopn", label: "Missing Part Numbers", icon: "🟡", items: [] },
];
const cat = Object.fromEntries(categories.map(c => [c.key, c.items]));
// Unconnected pins
const connectedPins = new Set();
wires.forEach(w => {
if (w.from_device_id) connectedPins.add(`${w.from_device_id}::${w.from_pin}`);
if (w.to_device_id) connectedPins.add(`${w.to_device_id}::${w.to_pin}`);
});
devices.forEach(d => {
if (d.device_type === "group") return;
(d.pins || []).forEach(pin => {
if (!connectedPins.has(`${d.id}::${pin.id}`))
cat.unconnected.push(`${d.reference || d.label || "Device"} — pin "${pin.name || pin.id}"`);
});
});
// Duplicate references
const refCount = new Map();
devices.forEach(d => { if (d.reference) refCount.set(d.reference, (refCount.get(d.reference) || 0) + 1); });
refCount.forEach((count, ref) => {
if (count > 1) cat.dupref.push(`"${ref}" used on ${count} devices`);
});
// Missing wire length
wires.forEach(w => {
if (!w.length) cat.nolength.push(`Wire ${w.label ? `"${w.label}"` : `ID ${w.id}`}`);
});
// Missing part numbers
devices.forEach(d => {
if (d.device_type === "group") return;
if (!d.properties?.partNumber) cat.nopn.push(`${d.reference || d.label || "Device"}`);
});
// Render
const modal = document.getElementById("drc-modal");
const results = document.getElementById("drc-results");
if (!modal || !results) return;
const totalIssues = categories.reduce((s, c) => s + c.items.length, 0);
if (!totalIssues) {
results.innerHTML = `<div style="color:#6ec97b;padding:16px;text-align:center;font-size:14px">✓ No issues found</div>`;
} else {
results.innerHTML = categories.map(c => {
const count = c.items.length;
const catId = `drc-cat-${c.key}`;
const rows = c.items.map(msg =>
`<div class="drc-row">${msg}</div>`
).join("");
return `
<div class="drc-category">
<label class="drc-cat-header">
<input type="checkbox" class="drc-toggle" data-cat="${catId}" ${count ? "checked" : "disabled"}>
<span class="drc-cat-icon">${c.icon}</span>
<span class="drc-cat-label">${c.label}</span>
<span class="drc-cat-count">${count}</span>
</label>
<div id="${catId}" class="drc-cat-body" ${!count ? 'style="display:none"' : ""}>
${count ? rows : '<div class="drc-row drc-none">None</div>'}
</div>
</div>`;
}).join("");
results.querySelectorAll(".drc-toggle").forEach(cb => {
cb.addEventListener("change", () => {
const body = document.getElementById(cb.dataset.cat);
if (body) body.style.display = cb.checked ? "" : "none";
});
});
}
modal.style.display = "flex";
document.getElementById("drc-close")?.addEventListener("click", () => { modal.style.display = "none"; }, { once: true });
modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }, { once: true });
}
// ── Actions ───────────────────────────────────────────────────────────────────
setMode(mode) {
+63 -3
View File
@@ -33,6 +33,8 @@ class DiagramCanvas {
this._bundleData = []; // array of bundle objects
this._bundleNodes = []; // Konva shapes for bundle overlays (on groupLayer)
this._netHighlightIds = new Set(); // wire IDs currently net-highlighted
this.selectedWireIds = new Set(); // wire IDs in multi-wire selection
this.jumpsEnabled = true; // wire jump arcs at crossings
this._initStage(containerId);
this._initZoomPan();
@@ -639,6 +641,8 @@ class DiagramCanvas {
clearSelection() {
this._netHighlightIds.forEach(id => this._wireHighlight(id, false));
this._netHighlightIds.clear();
this.selectedWireIds.forEach(id => this._wireHighlight(id, false));
this.selectedWireIds.clear();
this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false));
this.selectedDeviceIds.clear();
this._clearResizeHandles();
@@ -966,7 +970,38 @@ class DiagramCanvas {
_makeSvgPath(pts, crossings) {
if (this.routeMode === "curved") return this._ptsToSvgPathCurved(pts);
return this._ptsToSvgPath(pts, crossings);
return this._ptsToSvgPath(pts, this.jumpsEnabled ? crossings : null);
}
setJumpsEnabled(enabled) {
this.jumpsEnabled = enabled;
this._recomputeJumps();
}
autoRouteWire(wireId) {
const wire = this.wireData.get(wireId);
if (!wire) return null;
const fd = this.deviceData.get(wire.from_device_id);
const td = this.deviceData.get(wire.to_device_id);
if (!fd || !td) return null;
const fp = (fd.pins || []).find(p => p.id === wire.from_pin);
const tp = (td.pins || []).find(p => p.id === wire.to_pin);
if (!fp || !tp) return null;
const S = 24;
const ex1 = fd.x + fp.x_offset + (fp.side === "right" ? S : fp.side === "left" ? -S : 0);
const ey1 = fd.y + fp.y_offset + (fp.side === "bottom" ? S : fp.side === "top" ? -S : 0);
const ex2 = td.x + tp.x_offset + (tp.side === "right" ? S : tp.side === "left" ? -S : 0);
const ey2 = td.y + tp.y_offset + (tp.side === "bottom" ? S : tp.side === "top" ? -S : 0);
const excludeIds = new Set([wire.from_device_id, wire.to_device_id]);
const inner = this._autoOrtho(ex1, ey1, ex2, ey2, excludeIds);
// Strip the two exit points (_autoOrtho includes ex1,ey1 and ex2,ey2); store inner pairs as waypoints
const mid = inner.slice(2, inner.length - 2);
wire.waypoints = [];
for (let i = 0; i < mid.length; i += 2) wire.waypoints.push({ x: mid[i], y: mid[i + 1] });
this._destroyWireVisuals(wireId);
this._renderWire(wire);
this._recomputeJumps();
return wire.waypoints;
}
_recomputeJumps() {
@@ -1630,24 +1665,49 @@ class DiagramCanvas {
hit.on("click", (e) => {
if (this.mode !== "select") return;
e.cancelBubble = true;
if (e.evt.shiftKey) {
// Shift+click: toggle this wire in multi-wire selection
if (this.selectedWireIds.has(wire.id)) {
this.selectedWireIds.delete(wire.id);
this._wireHighlight(wire.id, false);
} else {
// Add current single-selected wire to set first
if (this.selectedId && this.selectedType === "wire" && !this.selectedWireIds.has(this.selectedId)) {
this.selectedWireIds.add(this.selectedId);
}
this.selectedWireIds.add(wire.id);
this._wireHighlight(wire.id, true);
this.selectedId = wire.id;
this.selectedType = "wire";
}
this.cb.onMultiWireSelected?.(this.selectedWireIds);
} else {
this.selectWire(wire.id);
}
});
hit.on("mouseenter", () => {
if (this.mode === "select" && this.selectedId !== wire.id) {
if (this.mode === "select" && this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) {
this._wireHighlight(wire.id, true);
this.stage.container().style.cursor = "pointer";
}
});
hit.on("mouseleave", () => {
if (this.selectedId !== wire.id) this._wireHighlight(wire.id, false);
if (this.selectedId !== wire.id && !this.selectedWireIds.has(wire.id)) {
this._wireHighlight(wire.id, false);
}
this.stage.container().style.cursor = "";
});
hit.on("contextmenu", (e) => {
e.evt.preventDefault();
e.cancelBubble = true;
if (this.selectedWireIds.size > 1 && this.selectedWireIds.has(wire.id)) {
// Multi-wire context menu
this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY);
} else {
if (this.selectedId !== wire.id) this.selectWire(wire.id);
this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY);
}
});
// Wire body drag (selected wire only) → inserts a waypoint at drag start