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:
+3
-3
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user