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 ### Productivity
- [ ] 21. **Find / search** — toolbar search box filters devices by reference/label and highlights matches on canvas; Ctrl+F shortcut - [ ] 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 - [x] 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 - [x] 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] 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 - [ ] 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) - [x] 1. **Right-click context menu** — quick actions on devices (duplicate, delete, add pin, save to library) and wires (delete, clear waypoints)
+11 -7
View File
@@ -7,7 +7,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy import text, inspect as sa_inspect from sqlalchemy import text, inspect as sa_inspect
from .database import Base, engine, SessionLocal from .database import Base, engine, SessionLocal
from .routers import bundles, connectors, diagrams, devices, export, octopart, wires from .routers import bundles, connectors, diagrams, devices, export, octopart, views, wires
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -45,6 +45,9 @@ def _migrate():
if "show_size_label" not in _cols("wires"): if "show_size_label" not in _cols("wires"):
conn.execute(text("ALTER TABLE wires ADD COLUMN show_size_label INTEGER DEFAULT 0")) conn.execute(text("ALTER TABLE wires ADD COLUMN show_size_label INTEGER DEFAULT 0"))
conn.commit() 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: except Exception as e:
print(f"[migrate] column error: {e}", file=sys.stderr) print(f"[migrate] column error: {e}", file=sys.stderr)
@@ -59,12 +62,13 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(bundles.router, prefix="/api/bundles") app.include_router(bundles.router, prefix="/api/bundles")
app.include_router(diagrams.router, prefix="/api/diagrams") app.include_router(diagrams.router, prefix="/api/diagrams")
app.include_router(devices.router, prefix="/api/devices") app.include_router(devices.router, prefix="/api/devices")
app.include_router(wires.router, prefix="/api/wires") app.include_router(wires.router, prefix="/api/wires")
app.include_router(octopart.router, prefix="/api/octopart") app.include_router(views.router, prefix="/api/views")
app.include_router(export.router, prefix="/api/export") 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(connectors.router, prefix="/api/connectors")
FRONTEND_DIR = os.path.normpath( FRONTEND_DIR = os.path.normpath(
+35
View File
@@ -16,6 +16,7 @@ class Diagram(Base):
devices = relationship("Device", back_populates="diagram", cascade="all, delete-orphan") devices = relationship("Device", back_populates="diagram", cascade="all, delete-orphan")
wires = relationship("Wire", 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") wire_bundles = relationship("WireBundle", back_populates="diagram", cascade="all, delete-orphan")
views = relationship("DiagramView", back_populates="diagram", cascade="all, delete-orphan")
class WireBundle(Base): class WireBundle(Base):
@@ -89,6 +90,40 @@ class Wire(Base):
bundle = relationship("WireBundle", back_populates="wires", foreign_keys=[bundle_id]) 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): class CustomConnector(Base):
__tablename__ = "custom_connectors" __tablename__ = "custom_connectors"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
+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 sqlalchemy.orm import Session
from typing import List from typing import List
from .. import models, schemas from .. import models, schemas
@@ -51,3 +51,115 @@ def delete_diagram(diagram_id: int, db: Session = Depends(get_db)):
db.delete(diagram) db.delete(diagram)
db.commit() db.commit()
return {"ok": True} 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] = [] 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 ─────────────────────────────────────────────────────────── # ── CustomConnector ───────────────────────────────────────────────────────────
class CustomConnectorBase(BaseModel): class CustomConnectorBase(BaseModel):
+57 -3
View File
@@ -74,6 +74,38 @@ body { background: #12121c; color: #c8ccee; }
} }
.diagram-item:hover .di-del-btn { opacity: 1; } .diagram-item:hover .di-del-btn { opacity: 1; }
.di-del-btn:hover { background: #3a1414; border-color: #aa3333; } .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 ───────────────────────────────────────────────────────── */
#device-library { padding: 6px; display: flex; flex-direction: column; gap: 3px; } #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; } .conn-meta { font-size: 10px; color: #556688; margin-top: 1px; }
/* ── Canvas area ──────────────────────────────────────────────────────────── */ /* ── Canvas area ──────────────────────────────────────────────────────────── */
#canvas-area { flex: 1; position: relative; overflow: hidden; } #canvas-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
#canvas-container { #canvas-container {
width: 100%; height: 100%; flex: 1; width: 100%;
background-color: #131320; background-color: #131320;
background-image: radial-gradient(circle, #2e2e50 1px, transparent 1px); background-image: radial-gradient(circle, #2e2e50 1px, transparent 1px);
background-size: 20px 20px; background-size: 20px 20px;
@@ -228,7 +260,7 @@ button:active { background: #1a1a3a; }
.conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; } .conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; }
/* ── Modal ───────────────────────────────────────────────────────────────── */ /* ── Modal ───────────────────────────────────────────────────────────────── */
#connector-modal { #connector-modal, #drc-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000;
align-items: center; justify-content: center; align-items: center; justify-content: center;
} }
@@ -250,6 +282,28 @@ button:active { background: #1a1a3a; }
display: flex; align-items: center; gap: 8px; flex-shrink: 0; 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 { .cm-label {
display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; display: block; font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: .06em; color: #555577; margin-bottom: 4px; 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> <button id="btn-route-curved" class="route-btn" title="Curved (smooth bezier) routing">∿ Curved</button>
<div class="sep"></div> <div class="sep"></div>
<!-- Actions --> <!-- 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-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-harness" title="Toggle harness view (H)">⌇ Harness</button>
<button id="btn-fit" title="Fit canvas to devices (F)">⊞ Fit</button> <button id="btn-fit" title="Fit canvas to devices (F)">⊞ Fit</button>
@@ -37,10 +38,12 @@
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<button id="btn-drc" title="Run design-rule check">⚠ DRC</button>
<div id="saved-indicator">✓ Saved</div> <div id="saved-indicator">✓ Saved</div>
<div class="export-wrap"> <div class="export-wrap">
<button id="export-toggle">Export ▾</button> <button id="export-toggle">Export ▾</button>
<div id="export-menu"> <div id="export-menu">
<button id="btn-import">📂 Import JSON</button>
<button id="btn-bom">📋 BOM (CSV)</button> <button id="btn-bom">📋 BOM (CSV)</button>
<button id="btn-assembly">📄 Assembly (TXT)</button> <button id="btn-assembly">📄 Assembly (TXT)</button>
<button id="btn-json">{ } JSON</button> <button id="btn-json">{ } JSON</button>
@@ -49,6 +52,7 @@
</div> </div>
</div> </div>
</div> </div>
<input id="import-file-input" type="file" accept=".json" style="display:none">
</header> </header>
<!-- ── Mode bar ─────────────────────────────────────────────────────────── --> <!-- ── Mode bar ─────────────────────────────────────────────────────────── -->
@@ -96,6 +100,11 @@
<!-- Canvas --> <!-- Canvas -->
<main id="canvas-area"> <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-container"></div>
<div id="canvas-placeholder"> <div id="canvas-placeholder">
<div class="placeholder-content"> <div class="placeholder-content">
@@ -395,6 +404,21 @@
<!-- populated dynamically by app.js --> <!-- populated dynamically by app.js -->
</div> </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="https://unpkg.com/konva@9/konva.min.js"></script>
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/deviceTypes.js"></script> <script src="/js/deviceTypes.js"></script>
+11
View File
@@ -16,6 +16,17 @@ const api = {
get: (id) => apiFetch(`/diagrams/${id}`), get: (id) => apiFetch(`/diagrams/${id}`),
update: (id, data) => apiFetch(`/diagrams/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), update: (id, data) => apiFetch(`/diagrams/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }),
delete: (id) => apiFetch(`/diagrams/${id}`, { method: "DELETE" }), 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: { devices: {
create: (data) => apiFetch("/devices/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), 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 { class WiringApp {
constructor() { constructor() {
this.diagramId = null; this.diagramId = null;
this.currentViewId = null; // null = main layout, number = view id
this._views = []; // view objects for current diagram
this._savedTimer = null; this._savedTimer = null;
this._customConnectors = []; // loaded from backend this._customConnectors = []; // loaded from backend
this._editingConnId = null; // id of custom connector being edited in modal this._editingConnId = null; // id of custom connector being edited in modal
@@ -18,15 +20,16 @@ class WiringApp {
onWireSelected: (w) => this._showWireProps(w), onWireSelected: (w) => this._showWireProps(w),
onSelectionCleared: () => this._clearProps(), onSelectionCleared: () => this._clearProps(),
onMultipleSelected: (count) => this._showMultiProps(count), 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), onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy),
onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy), onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy),
onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin), 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; }, onDragStarted: (snap) => { this._preDrag = snap; },
onDragEnded: (ids) => { this._pushMoveUndo(ids); }, onDragEnded: (ids) => { this._pushMoveUndo(ids); },
onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y), onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y),
onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y), onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y),
onMultiWireSelected: (ids) => this._onMultiWireSelected(ids),
onDeviceResized: async (id, x, y, w, h) => { onDeviceResized: async (id, x, y, w, h) => {
const device = this.canvas.deviceData.get(id); const device = this.canvas.deviceData.get(id);
if (!device) return; if (!device) return;
@@ -52,6 +55,24 @@ class WiringApp {
.then(() => this._checkOctopartStatus()); .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 ──────────────────────────────────────────── // ── Auto-reopen & saved indicator ────────────────────────────────────────────
_autoOpenLast() { _autoOpenLast() {
@@ -167,14 +188,14 @@ class WiringApp {
const d = this.canvas.deviceData.get(id); const d = this.canvas.deviceData.get(id);
if (g) { g.x(x); g.y(y); } if (g) { g.x(x); g.y(y); }
if (d) { d.x = x; d.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); this.canvas._redrawWiresFor(id);
} }
for (const [wId, wps] of waypoints) { for (const [wId, wps] of waypoints) {
const wire = this.canvas.wireData.get(wId); const wire = this.canvas.wireData.get(wId);
if (wire) { if (wire) {
wire.waypoints = wps.map(wp => ({ ...wp })); 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(); this.canvas.deviceLayer.batchDraw();
@@ -434,6 +455,30 @@ class WiringApp {
on("btn-json", () => this._export("json")); on("btn-json", () => this._export("json"));
on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); }); on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); });
on("btn-formboard", () => this._export("formboard")); 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", () => { on("btn-snap", () => {
const btn = document.getElementById("btn-snap"); const btn = document.getElementById("btn-snap");
const enabled = !this.canvas.snapEnabled; const enabled = !this.canvas.snapEnabled;
@@ -552,6 +597,41 @@ class WiringApp {
} }
_showCtxWire(id, x, y) { _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 wire = this.canvas.wireData.get(id);
const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0; const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0;
const items = [ const items = [
@@ -559,7 +639,8 @@ class WiringApp {
...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []), ...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []),
...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []), ...(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" }] : []), ...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []),
"---", "---",
{ action: "del", icon: "✕", label: "Delete wire", danger: true }, { action: "del", icon: "✕", label: "Delete wire", danger: true },
@@ -570,6 +651,13 @@ class WiringApp {
this._closeCtx(); this._closeCtx();
if (!action) return; if (!action) return;
if (action === "del") this.deleteSelected(); 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) { if ((action === "clearwp" || action === "straighten") && wire) {
wire.waypoints = []; wire.waypoints = [];
this.canvas.updateWire(wire); 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) { _setRouteMode(mode) {
this.canvas.setRouteMode(mode); this.canvas.setRouteMode(mode);
document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active")); 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 === "v") { e.preventDefault(); this.pasteClipboard(); }
if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); } if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); } 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.dataset.id = d.id;
el.innerHTML = `<span class="di-name">${d.name}</span> el.innerHTML = `<span class="di-name">${d.name}</span>
<span class="di-date">${new Date(d.updated_at + "Z").toLocaleDateString()}</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>`; <button class="di-del-btn" title="Delete diagram">×</button>`;
el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id)); el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id));
el.querySelector(".di-date").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) => { el.querySelector(".di-del-btn").addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
this.deleteDiagram(d.id, d.name); this.deleteDiagram(d.id, d.name);
@@ -908,10 +1036,13 @@ class WiringApp {
await api.diagrams.delete(id); await api.diagrams.delete(id);
if (this.diagramId === id) { if (this.diagramId === id) {
this.diagramId = null; this.diagramId = null;
this.currentViewId = null;
this._views = [];
localStorage.removeItem("lastDiagramId"); localStorage.removeItem("lastDiagramId");
this.canvas.loadDiagram({ devices: [], wires: [] }); this.canvas.loadDiagram({ devices: [], wires: [] });
document.getElementById("diagram-name").value = ""; document.getElementById("diagram-name").value = "";
document.getElementById("canvas-placeholder").style.display = ""; document.getElementById("canvas-placeholder").style.display = "";
document.getElementById("view-tab-bar").style.display = "none";
this._clearProps(); this._clearProps();
} }
await this.loadDiagramList(); await this.loadDiagramList();
@@ -932,6 +1063,7 @@ class WiringApp {
try { try {
const diagram = await api.diagrams.get(id); const diagram = await api.diagrams.get(id);
this.diagramId = id; this.diagramId = id;
this.currentViewId = null;
localStorage.setItem("lastDiagramId", id); localStorage.setItem("lastDiagramId", id);
document.getElementById("diagram-name").value = diagram.name; document.getElementById("diagram-name").value = diagram.name;
document.getElementById("canvas-placeholder").style.display = "none"; document.getElementById("canvas-placeholder").style.display = "none";
@@ -942,12 +1074,263 @@ class WiringApp {
this.setMode("select"); this.setMode("select");
this._clearProps(); this._clearProps();
await this.loadDiagramList(); await this.loadDiagramList();
await this._loadViews();
} catch (e) { } catch (e) {
localStorage.removeItem("lastDiagramId"); localStorage.removeItem("lastDiagramId");
console.error("Failed to open diagram:", e); 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 ─────────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────────
setMode(mode) { setMode(mode) {
+66 -6
View File
@@ -33,6 +33,8 @@ class DiagramCanvas {
this._bundleData = []; // array of bundle objects this._bundleData = []; // array of bundle objects
this._bundleNodes = []; // Konva shapes for bundle overlays (on groupLayer) this._bundleNodes = []; // Konva shapes for bundle overlays (on groupLayer)
this._netHighlightIds = new Set(); // wire IDs currently net-highlighted 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._initStage(containerId);
this._initZoomPan(); this._initZoomPan();
@@ -639,6 +641,8 @@ class DiagramCanvas {
clearSelection() { clearSelection() {
this._netHighlightIds.forEach(id => this._wireHighlight(id, false)); this._netHighlightIds.forEach(id => this._wireHighlight(id, false));
this._netHighlightIds.clear(); this._netHighlightIds.clear();
this.selectedWireIds.forEach(id => this._wireHighlight(id, false));
this.selectedWireIds.clear();
this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false)); this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false));
this.selectedDeviceIds.clear(); this.selectedDeviceIds.clear();
this._clearResizeHandles(); this._clearResizeHandles();
@@ -966,7 +970,38 @@ class DiagramCanvas {
_makeSvgPath(pts, crossings) { _makeSvgPath(pts, crossings) {
if (this.routeMode === "curved") return this._ptsToSvgPathCurved(pts); 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() { _recomputeJumps() {
@@ -1630,24 +1665,49 @@ class DiagramCanvas {
hit.on("click", (e) => { hit.on("click", (e) => {
if (this.mode !== "select") return; if (this.mode !== "select") return;
e.cancelBubble = true; e.cancelBubble = true;
this.selectWire(wire.id); 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", () => { 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._wireHighlight(wire.id, true);
this.stage.container().style.cursor = "pointer"; this.stage.container().style.cursor = "pointer";
} }
}); });
hit.on("mouseleave", () => { 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 = ""; this.stage.container().style.cursor = "";
}); });
hit.on("contextmenu", (e) => { hit.on("contextmenu", (e) => {
e.evt.preventDefault(); e.evt.preventDefault();
e.cancelBubble = true; e.cancelBubble = true;
if (this.selectedId !== wire.id) this.selectWire(wire.id); if (this.selectedWireIds.size > 1 && this.selectedWireIds.has(wire.id)) {
this.cb.onWireContextMenu?.(wire.id, e.evt.clientX, e.evt.clientY); // 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 // Wire body drag (selected wire only) → inserts a waypoint at drag start