commit f32203b6301ae9a26991898c9845ca19cd61ae0a Author: Kyle Date: Fri Apr 24 12:51:21 2026 -0400 Initial commit — wiring diagram maker with full feature set - FastAPI + SQLite backend with routers for diagrams, devices, wires, bundles, connectors, export, and Octopart/Nexar search - Konva.js canvas: ortho/direct/curved routing, wire crossings, harness mode, snap-to-grid, multi-select, drag, resize, undo - Wire features: color/stripe, twisted pair helix, shielded glow, gauge size label, multi-core bundle grouping, length/unit tracking - Device library: connector, terminal block, cable, splice, label, group, relay, fuse, switch, component with custom connector support - Exports: BOM CSV, assembly TXT, JSON, formboard layout, PNG image - Auto-migration for schema changes via ALTER TABLE at startup Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76e2610 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +env/ + +# Database (contains user data — don't version control) +*.db +*.sqlite +*.sqlite3 + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/ +*.swp diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..8667592 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,57 @@ +# WireDraw Feature Roadmap + +## Backlog (work top-to-bottom) + +### Routing & Canvas +- [x] 11. **Curved / arc routing** — third route mode (∿ Curved) that draws smooth bezier curves instead of right-angle or straight; toggle alongside Ortho/Direct +- [x] 12. **Grid snap** — optional snap-to-grid (10px); toggle button in toolbar + G shortcut +- [x] 13. **Straighten wire** — right-click a wire → "Straighten" clears all saved waypoints and re-routes from scratch +- [x] 14. **Zoom to selection** — right-click multi-select → "Zoom to selection"; Shift+F shortcut +- [x] 15. **Wire labels on canvas** — wire label + length rendered mid-wire as a small tag + +### Right-Click Menu Additions +- [ ] 16. **Align & distribute** — right-click multi-select → Align Left / Align Right / Align Top / Align Bottom / Distribute Horizontally / Distribute Vertically +- [ ] 17. **Send to back / Bring to front** — right-click any device → layer order shortcuts (especially useful for Section Boxes) +- [ ] 18. **Copy wire properties** — right-click a wire → "Copy style"; then right-click another wire → "Paste style" to apply color/gauge/stripe +- [ ] 19. **Select all wires on net** — right-click a wire → "Select all on net" highlights every wire sharing the same wire label/net name +- [ ] 20. **Lock device** — right-click → "Lock position" prevents accidental moves; locked devices show a padlock badge + +### Productivity +- [ ] 21. **Find / search** — toolbar search box filters devices by reference/label and highlights matches on canvas; Ctrl+F shortcut +- [ ] 22. **Import JSON** — drag-and-drop or File→Import a previously exported JSON to restore a diagram +- [ ] 23. **Duplicate diagram** — diagram list context menu → "Duplicate" clones all devices and wires under a new name +- [ ] 24. **DRC / design-rule check** — one-click check that flags: unconnected pins, duplicate references, wires with no length set, missing part numbers; results shown in a panel +- [ ] 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] 2. **Multiple sheets per project** — each diagram can have named pages/sheets; off-page connector stubs link nets across sheets +- [x] 3. **Grouping / section box** — draw a labeled rectangle around any area of the canvas for visual organization +- [ ] 4. **Pin part numbers** — each pin on a device has its own contact/terminal part number; shows up correctly in BOM export +- [ ] 5. **Signal names** — net-level labels independent of wire labels; a signal name can span multiple wires; clicking a signal highlights all wires on that net +- [x] 6. **Shielded cable marker** — flag on a wire/cable indicating shielding; renders as a dashed outer outline; shows in BOM and cutlist +- [x] 7. **Twisted pair marker** — flag + twist pitch field for CAN-bus and differential signal pairs; renders as a helical decoration; shows in cutlist +- [x] 8. **Multi-conductor cables** — a cable object that groups N wires under one jacket; single cable part number in BOM; conductors shown as child wires +- [x] 9. **1:1 formboard view** — a separate view/export where wire routing lengths are true scale so the diagram can be printed and used as a build board +- [x] 10. **Octopart API integration** — part number lookup & validation against live catalog; auto-fill manufacturer, description, and datasheet link + +## Completed +- [x] Octopart/Nexar integration (🔍 button on Part Number; auto-fills manufacturer, description, datasheet; BOM export includes description+datasheet columns) +- [x] Multi-conductor cables (cable device type; jacket + conductor row rendering; conductor color/name editor; cable section in BOM & assembly) +- [x] Twisted pair marker (checkbox + pitch field; white helix overlay on wire; stored in DB) +- [x] Multiple sheets per project (tab bar, add/rename/delete, per-sheet devices & wires) +- [x] Connector / device library with drag-to-canvas +- [x] Custom connector CRUD (modal + backend) +- [x] Wire drawing with ortho/direct routing and waypoints +- [x] Wire jump arcs at crossings +- [x] Multi-select + rubber-band selection +- [x] Multi-device drag (wires follow) +- [x] Resize handles on devices +- [x] Text wrapping + font size per device +- [x] Copy / paste / duplicate +- [x] Undo (Ctrl+Z / button) +- [x] BOM CSV export +- [x] Assembly TXT export +- [x] JSON export +- [x] PNG export +- [x] Wire properties: color, stripe, gauge, length, notes +- [x] Pin side switching + add/remove pins diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..ba28fbb --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, DeclarativeBase +import os + +DB_PATH = os.path.join(os.path.dirname(__file__), "..", "wiring.db") +DATABASE_URL = f"sqlite:///{os.path.normpath(DB_PATH)}" + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..3c1ef65 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,90 @@ +import os + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from sqlalchemy import text, inspect as sa_inspect + +from .database import Base, engine, SessionLocal +from .routers import bundles, connectors, diagrams, devices, export, octopart, wires + +Base.metadata.create_all(bind=engine) + +# Lightweight column migrations (SQLite ALTER TABLE IF NOT EXISTS unsupported) +def _migrate(): + import sys + + # ── Column additions ──────────────────────────────────────────────────────── + try: + with engine.connect() as conn: + def _cols(table): + return {c["name"] for c in sa_inspect(engine).get_columns(table)} + + if "waypoints" not in _cols("wires"): + conn.execute(text("ALTER TABLE wires ADD COLUMN waypoints TEXT DEFAULT '[]'")) + conn.commit() + if "sheet_id" not in _cols("devices"): + conn.execute(text("ALTER TABLE devices ADD COLUMN sheet_id INTEGER REFERENCES sheets(id)")) + conn.commit() + if "sheet_id" not in _cols("wires"): + conn.execute(text("ALTER TABLE wires ADD COLUMN sheet_id INTEGER REFERENCES sheets(id)")) + conn.commit() + if "twisted_pair" not in _cols("wires"): + conn.execute(text("ALTER TABLE wires ADD COLUMN twisted_pair INTEGER DEFAULT 0")) + conn.commit() + if "twist_pitch" not in _cols("wires"): + conn.execute(text("ALTER TABLE wires ADD COLUMN twist_pitch REAL DEFAULT 16.0")) + conn.commit() + if "shielded" not in _cols("wires"): + conn.execute(text("ALTER TABLE wires ADD COLUMN shielded INTEGER DEFAULT 0")) + conn.commit() + if "bundle_id" not in _cols("wires"): + conn.execute(text("ALTER TABLE wires ADD COLUMN bundle_id INTEGER REFERENCES wire_bundles(id)")) + conn.commit() + if "show_size_label" not in _cols("wires"): + conn.execute(text("ALTER TABLE wires ADD COLUMN show_size_label INTEGER DEFAULT 0")) + conn.commit() + except Exception as e: + print(f"[migrate] column error: {e}", file=sys.stderr) + +_migrate() + +app = FastAPI(title="Wiring Diagram Maker", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(bundles.router, prefix="/api/bundles") +app.include_router(diagrams.router, prefix="/api/diagrams") +app.include_router(devices.router, prefix="/api/devices") +app.include_router(wires.router, prefix="/api/wires") +app.include_router(octopart.router, prefix="/api/octopart") +app.include_router(export.router, prefix="/api/export") +app.include_router(connectors.router, prefix="/api/connectors") + +FRONTEND_DIR = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..", "frontend") +) + +if os.path.isdir(FRONTEND_DIR): + for _sub in ("css", "js"): + _d = os.path.join(FRONTEND_DIR, _sub) + if os.path.isdir(_d): + app.mount(f"/{_sub}", StaticFiles(directory=_d), name=_sub) + + _index = os.path.join(FRONTEND_DIR, "index.html") + + @app.get("/") + async def _root(): + return FileResponse(_index) + + @app.exception_handler(404) + async def _spa_fallback(request: Request, exc): + if request.url.path.startswith("/api"): + return JSONResponse(status_code=404, content={"detail": "Not Found"}) + return FileResponse(_index) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..559bdf8 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,103 @@ +from sqlalchemy import Column, Integer, String, Float, JSON, ForeignKey, DateTime, Text, Boolean +from sqlalchemy.orm import relationship +from .database import Base +import datetime + + +class Diagram(Base): + __tablename__ = "diagrams" + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False, default="Untitled Diagram") + description = Column(Text, default="") + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column(DateTime, default=datetime.datetime.utcnow) + + sheets = relationship("Sheet", back_populates="diagram", cascade="all, delete-orphan", order_by="Sheet.position") + devices = relationship("Device", back_populates="diagram", cascade="all, delete-orphan") + wires = relationship("Wire", back_populates="diagram", cascade="all, delete-orphan") + wire_bundles = relationship("WireBundle", back_populates="diagram", cascade="all, delete-orphan") + + +class WireBundle(Base): + __tablename__ = "wire_bundles" + id = Column(Integer, primary_key=True, index=True) + diagram_id = Column(Integer, ForeignKey("diagrams.id"), nullable=False) + label = Column(String, default="") + jacket_color = Column(String, default="#2a2a2a") + + diagram = relationship("Diagram", back_populates="wire_bundles") + wires = relationship("Wire", back_populates="bundle") + + +class Sheet(Base): + __tablename__ = "sheets" + 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="Sheet 1") + position = Column(Integer, default=0) + + diagram = relationship("Diagram", back_populates="sheets") + devices = relationship("Device", back_populates="sheet", cascade="all, delete-orphan") + wires = relationship("Wire", back_populates="sheet", cascade="all, delete-orphan") + + +class Device(Base): + __tablename__ = "devices" + id = Column(Integer, primary_key=True, index=True) + diagram_id = Column(Integer, ForeignKey("diagrams.id"), nullable=False) + sheet_id = Column(Integer, ForeignKey("sheets.id", ondelete="CASCADE"), nullable=True) + device_type = Column(String, nullable=False) + label = Column(String, default="") + reference = Column(String, default="") + x = Column(Float, default=100) + y = Column(Float, default=100) + width = Column(Float, default=120) + height = Column(Float, default=60) + properties = Column(JSON, default={}) + pins = Column(JSON, default=[]) + + diagram = relationship("Diagram", back_populates="devices") + sheet = relationship("Sheet", back_populates="devices") + + +class Wire(Base): + __tablename__ = "wires" + id = Column(Integer, primary_key=True, index=True) + diagram_id = Column(Integer, ForeignKey("diagrams.id"), nullable=False) + sheet_id = Column(Integer, ForeignKey("sheets.id", ondelete="CASCADE"), nullable=True) + label = Column(String, default="") + from_device_id = Column(Integer, ForeignKey("devices.id"), nullable=True) + from_pin = Column(String, default="") + to_device_id = Column(Integer, ForeignKey("devices.id"), nullable=True) + to_pin = Column(String, default="") + color_primary = Column(String, default="#FF0000") + color_stripe = Column(String, nullable=True) + gauge = Column(String, default="18 AWG") + length = Column(Float, nullable=True) + length_unit = Column(String, default="in") + notes = Column(Text, default="") + waypoints = Column(JSON, default=[]) + twisted_pair = Column(Boolean, default=False) + twist_pitch = Column(Float, default=16.0) + shielded = Column(Boolean, default=False) + + bundle_id = Column(Integer, ForeignKey("wire_bundles.id"), nullable=True) + show_size_label = Column(Boolean, default=False) + + diagram = relationship("Diagram", back_populates="wires") + sheet = relationship("Sheet", back_populates="wires") + bundle = relationship("WireBundle", back_populates="wires", foreign_keys=[bundle_id]) + + +class CustomConnector(Base): + __tablename__ = "custom_connectors" + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + manufacturer = Column(String, default="") + part_number = Column(String, default="") + category = Column(String, default="Custom") + description = Column(String, default="") + pin_count = Column(Integer, nullable=False, default=2) + pin_labels = Column(JSON, default=[]) + created_at = Column(DateTime, default=datetime.datetime.utcnow) + updated_at = Column(DateTime, default=datetime.datetime.utcnow) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/bundles.py b/backend/app/routers/bundles.py new file mode 100644 index 0000000..9091a6e --- /dev/null +++ b/backend/app/routers/bundles.py @@ -0,0 +1,45 @@ +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=["bundles"]) + + +@router.get("/diagram/{diagram_id}", response_model=List[schemas.WireBundleOut]) +def list_bundles(diagram_id: int, db: Session = Depends(get_db)): + return db.query(models.WireBundle).filter(models.WireBundle.diagram_id == diagram_id).all() + + +@router.post("/", response_model=schemas.WireBundleOut, status_code=201) +def create_bundle(payload: schemas.WireBundleCreate, db: Session = Depends(get_db)): + bundle = models.WireBundle(**payload.model_dump()) + db.add(bundle) + db.commit() + db.refresh(bundle) + return bundle + + +@router.patch("/{bundle_id}", response_model=schemas.WireBundleOut) +def update_bundle(bundle_id: int, payload: schemas.WireBundleUpdate, db: Session = Depends(get_db)): + bundle = db.get(models.WireBundle, bundle_id) + if not bundle: + raise HTTPException(404, "Bundle not found") + for k, v in payload.model_dump(exclude_unset=True).items(): + setattr(bundle, k, v) + db.commit() + db.refresh(bundle) + return bundle + + +@router.delete("/{bundle_id}", status_code=204) +def delete_bundle(bundle_id: int, db: Session = Depends(get_db)): + bundle = db.get(models.WireBundle, bundle_id) + if not bundle: + raise HTTPException(404, "Bundle not found") + # Detach wires before deleting + for wire in bundle.wires: + wire.bundle_id = None + db.delete(bundle) + db.commit() diff --git a/backend/app/routers/connectors.py b/backend/app/routers/connectors.py new file mode 100644 index 0000000..1f6d70a --- /dev/null +++ b/backend/app/routers/connectors.py @@ -0,0 +1,47 @@ +import datetime +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=["connectors"]) + + +@router.get("/", response_model=List[schemas.CustomConnector]) +def list_connectors(db: Session = Depends(get_db)): + return db.query(models.CustomConnector).order_by(models.CustomConnector.category, models.CustomConnector.name).all() + + +@router.post("/", response_model=schemas.CustomConnector) +def create_connector(connector: schemas.CustomConnectorCreate, db: Session = Depends(get_db)): + db_conn = models.CustomConnector(**connector.model_dump()) + db.add(db_conn) + db.commit() + db.refresh(db_conn) + return db_conn + + +@router.patch("/{connector_id}", response_model=schemas.CustomConnector) +def update_connector(connector_id: int, update: schemas.CustomConnectorUpdate, db: Session = Depends(get_db)): + conn = db.query(models.CustomConnector).filter(models.CustomConnector.id == connector_id).first() + if not conn: + raise HTTPException(status_code=404, detail="Connector not found") + for key, value in update.model_dump(exclude_none=True).items(): + setattr(conn, key, value) + conn.updated_at = datetime.datetime.utcnow() + db.commit() + db.refresh(conn) + return conn + + +@router.delete("/{connector_id}") +def delete_connector(connector_id: int, db: Session = Depends(get_db)): + conn = db.query(models.CustomConnector).filter(models.CustomConnector.id == connector_id).first() + if not conn: + raise HTTPException(status_code=404, detail="Connector not found") + db.delete(conn) + db.commit() + return {"ok": True} diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py new file mode 100644 index 0000000..ba1c232 --- /dev/null +++ b/backend/app/routers/devices.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from .. import models, schemas +from ..database import get_db +import datetime + +router = APIRouter(tags=["devices"]) + + +@router.post("/", response_model=schemas.Device) +def create_device(device: schemas.DeviceCreate, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == device.diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + db_device = models.Device(**device.model_dump()) + db.add(db_device) + diagram.updated_at = datetime.datetime.utcnow() + db.commit() + db.refresh(db_device) + return db_device + + +@router.patch("/{device_id}", response_model=schemas.Device) +def update_device(device_id: int, update: schemas.DeviceUpdate, db: Session = Depends(get_db)): + device = db.query(models.Device).filter(models.Device.id == device_id).first() + if not device: + raise HTTPException(status_code=404, detail="Device not found") + for key, value in update.model_dump(exclude_none=True).items(): + setattr(device, key, value) + diagram = db.query(models.Diagram).filter(models.Diagram.id == device.diagram_id).first() + if diagram: + diagram.updated_at = datetime.datetime.utcnow() + db.commit() + db.refresh(device) + return device + + +@router.delete("/{device_id}") +def delete_device(device_id: int, db: Session = Depends(get_db)): + device = db.query(models.Device).filter(models.Device.id == device_id).first() + if not device: + raise HTTPException(status_code=404, detail="Device not found") + db.delete(device) + db.commit() + return {"ok": True} diff --git a/backend/app/routers/diagrams.py b/backend/app/routers/diagrams.py new file mode 100644 index 0000000..c781d9d --- /dev/null +++ b/backend/app/routers/diagrams.py @@ -0,0 +1,53 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from .. import models, schemas +from ..database import get_db +import datetime + +router = APIRouter(tags=["diagrams"]) + + +@router.get("/", response_model=List[schemas.DiagramSummary]) +def list_diagrams(db: Session = Depends(get_db)): + return db.query(models.Diagram).order_by(models.Diagram.updated_at.desc()).all() + + +@router.post("/", response_model=schemas.DiagramSummary) +def create_diagram(diagram: schemas.DiagramCreate, db: Session = Depends(get_db)): + db_diagram = models.Diagram(**diagram.model_dump()) + db.add(db_diagram) + db.commit() + db.refresh(db_diagram) + return db_diagram + + +@router.get("/{diagram_id}", response_model=schemas.DiagramFull) +def get_diagram(diagram_id: int, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + return diagram + + +@router.patch("/{diagram_id}", response_model=schemas.DiagramSummary) +def update_diagram(diagram_id: int, update: schemas.DiagramUpdate, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + for key, value in update.model_dump(exclude_none=True).items(): + setattr(diagram, key, value) + diagram.updated_at = datetime.datetime.utcnow() + db.commit() + db.refresh(diagram) + return diagram + + +@router.delete("/{diagram_id}") +def delete_diagram(diagram_id: int, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + db.delete(diagram) + db.commit() + return {"ok": True} diff --git a/backend/app/routers/export.py b/backend/app/routers/export.py new file mode 100644 index 0000000..7344e89 --- /dev/null +++ b/backend/app/routers/export.py @@ -0,0 +1,316 @@ +import csv +import io +import json + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse, StreamingResponse +from sqlalchemy.orm import Session + +from .. import models +from ..database import get_db + +router = APIRouter(tags=["export"]) + + +@router.get("/{diagram_id}/bom/csv") +def export_bom_csv(diagram_id: int, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(["=== DEVICES ==="]) + writer.writerow(["Reference", "Type", "Label", "Part Number", "Manufacturer", "Description", "Datasheet"]) + for device in diagram.devices: + props = device.properties or {} + desc = props.get("description", "") + if device.device_type == "cable" and not desc: + desc = f"{props.get('conductorCount', len(props.get('conductors', [])))} conductors" + writer.writerow([ + device.reference, device.device_type, device.label, + props.get("partNumber", ""), props.get("manufacturer", ""), + desc, props.get("datasheetUrl", ""), + ]) + + writer.writerow([]) + + writer.writerow(["=== WIRES ==="]) + writer.writerow(["ID", "Label", "From Device", "From Pin", "To Device", "To Pin", + "Primary Color", "Stripe Color", "Gauge", "Length", "Unit", "Shielded", "Notes"]) + device_map = {d.id: d for d in diagram.devices} + for wire in diagram.wires: + fd = device_map.get(wire.from_device_id) + td = device_map.get(wire.to_device_id) + writer.writerow([ + wire.id, wire.label, + f"{fd.reference} ({fd.label})" if fd else "", + wire.from_pin, + f"{td.reference} ({td.label})" if td else "", + wire.to_pin, + wire.color_primary, wire.color_stripe or "", + wire.gauge, wire.length or "", wire.length_unit if wire.length else "", + "YES" if wire.shielded else "", wire.notes, + ]) + + output.seek(0) + filename = f"{diagram.name.replace(' ', '_')}_BOM.csv" + return StreamingResponse( + io.BytesIO(output.getvalue().encode()), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get("/{diagram_id}/assembly/txt") +def export_assembly_txt(diagram_id: int, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + + device_map = {d.id: d for d in diagram.devices} + lines = [ + f"ASSEMBLY INSTRUCTIONS: {diagram.name}", + "=" * 60, "", + ] + if diagram.description: + lines += [f"Description: {diagram.description}", ""] + + lines += ["STEP 1: PREPARE DEVICES", "-" * 40] + for i, d in enumerate(diagram.devices, 1): + props = d.properties or {} + lines.append(f" {i}. {d.reference or '?'} — {d.label} " + f"(Type: {d.device_type}, P/N: {props.get('partNumber', 'N/A')})") + lines.append("") + + # Group wires by which cable device (if any) they pass through + cable_devices = {d.id: d for d in diagram.devices if d.device_type == "cable"} + wire_cable_map = {} # wire_id -> cable_device_id + for wire in diagram.wires: + if wire.from_device_id in cable_devices or wire.to_device_id in cable_devices: + cable_id = wire.from_device_id if wire.from_device_id in cable_devices else wire.to_device_id + wire_cable_map[wire.id] = cable_id + + lines += ["STEP 2: WIRE CONNECTIONS", "-" * 40] + step = 1 + for wire in diagram.wires: + if wire.id in wire_cable_map: + continue # handled in cables step + fd = device_map.get(wire.from_device_id) + td = device_map.get(wire.to_device_id) + from_str = f"{fd.reference or fd.label} Pin {wire.from_pin}" if fd else "unconnected" + to_str = f"{td.reference or td.label} Pin {wire.to_pin}" if td else "unconnected" + color_str = wire.color_primary + if wire.color_stripe: + color_str += f" / {wire.color_stripe} stripe" + length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD" + twisted = " [TWISTED PAIR]" if wire.twisted_pair else "" + shielded = " [SHIELDED]" if wire.shielded else "" + lines += [ + f" {step}. {color_str} {wire.gauge} {length_str}{twisted}{shielded}", + f" From: {from_str}", + f" To: {to_str}", + ] + if wire.label: + lines.append(f" Label: {wire.label}") + if wire.notes: + lines.append(f" Notes: {wire.notes}") + lines.append("") + step += 1 + + if cable_devices: + lines += ["", "STEP 3: CABLE ASSEMBLIES", "-" * 40] + for cable_dev in cable_devices.values(): + props = cable_dev.properties or {} + conductors = props.get("conductors", []) + pn = props.get("partNumber", "N/A") + lines += [ + f" Cable: {cable_dev.reference or '?'} — {cable_dev.label} (P/N: {pn}, {len(conductors)} conductors)", + ] + cable_wires = [w for w in diagram.wires if wire_cable_map.get(w.id) == cable_dev.id] + for wire in cable_wires: + fd = device_map.get(wire.from_device_id) + td = device_map.get(wire.to_device_id) + other_dev = td if wire.from_device_id == cable_dev.id else fd + other_pin = wire.to_pin if wire.from_device_id == cable_dev.id else wire.from_pin + cable_pin = wire.from_pin if wire.from_device_id == cable_dev.id else wire.to_pin + length_str = f"{wire.length} {wire.length_unit}" if wire.length else "TBD" + lines.append( + f" Pin {cable_pin} ({wire.color_primary}) → " + f"{other_dev.reference or other_dev.label if other_dev else '?'} Pin {other_pin} {length_str}" + ) + lines.append("") + + content = "\n".join(lines) + filename = f"{diagram.name.replace(' ', '_')}_Assembly.txt" + return StreamingResponse( + io.BytesIO(content.encode()), + media_type="text/plain", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get("/{diagram_id}/formboard", response_class=HTMLResponse) +def export_formboard(diagram_id: int, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + + device_map = {d.id: d for d in diagram.devices} + + def to_mm(length, unit): + factors = {"mm": 1.0, "cm": 10.0, "in": 25.4, "ft": 304.8} + return length * factors.get(unit or "in", 25.4) + + wires_with_length = [] + wires_no_length = [] + for wire in diagram.wires: + if wire.length and wire.length > 0: + mm = to_mm(wire.length, wire.length_unit) + wires_with_length.append((wire, mm)) + else: + wires_no_length.append(wire) + + wires_with_length.sort(key=lambda t: t[1], reverse=True) + + def wire_label(wire): + fd = device_map.get(wire.from_device_id) + td = device_map.get(wire.to_device_id) + frm = f"{fd.reference or fd.label}/{wire.from_pin}" if fd else "?" + to = f"{td.reference or td.label}/{wire.to_pin}" if td else "?" + return f"{frm} → {to}" + (f" [{wire.label}]" if wire.label else "") + + def stripe_style(wire): + if wire.color_stripe: + c1, c2 = wire.color_primary or "#cc0000", wire.color_stripe + return f"background: repeating-linear-gradient(45deg, {c1}, {c1} 4mm, {c2} 4mm, {c2} 8mm);" + return f"background: {wire.color_primary or '#cc0000'};" + + rows_html = "" + for wire, mm in wires_with_length: + badges = "" + if wire.twisted_pair: + badges += 'TWISTED' + if wire.shielded: + badges += 'SHIELDED' + txt_color = "#000" if (wire.color_primary or "#000") in ("#FFD700","#DDDDDD","#FF8C00","#dddddd") else "#fff" + rows_html += f""" + + {wire_label(wire)} + {wire.gauge or ""} + {wire.length:.1f} {wire.length_unit or "in"} ({mm:.0f} mm) + +
+ {mm:.0f} mm +
+ + {badges} + """ + + no_len_rows = "" + for wire in wires_no_length: + no_len_rows += f"
  • {wire_label(wire)} — {wire.gauge or 'gauge?'}
  • " + + no_len_section = "" + if wires_no_length: + no_len_section = f""" +

    Wires without length

    + """ + + html = f""" + + + +Formboard — {diagram.name} + + + +

    📐 Formboard — {diagram.name}

    +

    Wire bars are printed at true 1:1 scale. Print at 100% (no scaling) for accurate lengths.

    +
    + 100 mm scale reference:
    +
    ← this bar is exactly 100 mm when printed at 100% +
    + + + + + {rows_html} +
    WireGaugeLengthScale (1:1)Flags
    +{no_len_section} +

    Print → More settings → Scale: 100% → No headers/footers

    + +""" + + return HTMLResponse(content=html) + + +@router.get("/{diagram_id}/json") +def export_json(diagram_id: int, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + + data = { + "id": diagram.id, + "name": diagram.name, + "description": diagram.description, + "devices": [ + { + "id": d.id, "device_type": d.device_type, "label": d.label, + "reference": d.reference, "x": d.x, "y": d.y, + "width": d.width, "height": d.height, + "properties": d.properties, "pins": d.pins, + } + for d in diagram.devices + ], + "wires": [ + { + "id": w.id, "label": w.label, + "from_device_id": w.from_device_id, "from_pin": w.from_pin, + "to_device_id": w.to_device_id, "to_pin": w.to_pin, + "color_primary": w.color_primary, "color_stripe": w.color_stripe, + "gauge": w.gauge, "length": w.length, "length_unit": w.length_unit, + "notes": w.notes, + } + for w in diagram.wires + ], + } + filename = f"{diagram.name.replace(' ', '_')}.json" + return StreamingResponse( + io.BytesIO(json.dumps(data, indent=2).encode()), + media_type="application/json", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) diff --git a/backend/app/routers/octopart.py b/backend/app/routers/octopart.py new file mode 100644 index 0000000..648a345 --- /dev/null +++ b/backend/app/routers/octopart.py @@ -0,0 +1,109 @@ +import os +import time +from typing import Tuple + +import httpx +from fastapi import APIRouter, HTTPException, Query + +router = APIRouter(tags=["octopart"]) + +NEXAR_TOKEN_URL = "https://identity.nexar.com/connect/token" +NEXAR_API_URL = "https://api.nexar.com/graphql" + +_token_cache: dict = {"token": None, "expires_at": 0.0} + +_SEARCH_GQL = """ +query PartSearch($q: String!, $limit: Int!) { + supSearchMpn(q: $q, limit: $limit) { + results { + part { + mpn + manufacturer { name } + shortDescription + bestDatasheet { url } + } + } + } +} +""" + + +def _credentials() -> Tuple[str, str]: + return ( + os.getenv("NEXAR_CLIENT_ID", ""), + os.getenv("NEXAR_CLIENT_SECRET", ""), + ) + + +async def _get_token() -> str: + client_id, client_secret = _credentials() + if not client_id or not client_secret: + raise HTTPException( + status_code=503, + detail="Nexar/Octopart credentials not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET environment variables", + ) + + now = time.time() + if _token_cache["token"] and now < _token_cache["expires_at"] - 60: + return _token_cache["token"] # type: ignore[return-value] + + async with httpx.AsyncClient() as client: + resp = await client.post( + NEXAR_TOKEN_URL, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + timeout=10, + ) + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"Nexar auth failed: {resp.text[:200]}") + + body = resp.json() + _token_cache["token"] = body["access_token"] + _token_cache["expires_at"] = now + body.get("expires_in", 3600) + return _token_cache["token"] # type: ignore[return-value] + + +@router.get("/status") +async def octopart_status(): + client_id, client_secret = _credentials() + return {"configured": bool(client_id and client_secret)} + + +@router.get("/search") +async def octopart_search( + q: str = Query(..., min_length=1), + limit: int = Query(8, ge=1, le=20), +): + token = await _get_token() + + async with httpx.AsyncClient() as client: + resp = await client.post( + NEXAR_API_URL, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={"query": _SEARCH_GQL, "variables": {"q": q, "limit": limit}}, + timeout=12, + ) + + if resp.status_code != 200: + raise HTTPException(status_code=502, detail=f"Nexar API error {resp.status_code}") + + body = resp.json() + if "errors" in body: + raise HTTPException(status_code=502, detail=str(body["errors"])[:300]) + + results = body.get("data", {}).get("supSearchMpn", {}).get("results", []) + out = [] + for r in results: + part = r.get("part") + if not part: + continue + out.append({ + "mpn": part.get("mpn", ""), + "manufacturer": (part.get("manufacturer") or {}).get("name", ""), + "description": part.get("shortDescription") or "", + "datasheet_url": (part.get("bestDatasheet") or {}).get("url"), + }) + return out diff --git a/backend/app/routers/sheets.py b/backend/app/routers/sheets.py new file mode 100644 index 0000000..89c24f3 --- /dev/null +++ b/backend/app/routers/sheets.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from .. import models, schemas +from ..database import get_db + +router = APIRouter(tags=["sheets"]) + + +@router.post("/", response_model=schemas.SheetOut) +def create_sheet(sheet: schemas.SheetCreate, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == sheet.diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + db_sheet = models.Sheet(**sheet.model_dump()) + db.add(db_sheet) + db.commit() + db.refresh(db_sheet) + return db_sheet + + +@router.get("/{sheet_id}", response_model=schemas.SheetOut) +def get_sheet(sheet_id: int, db: Session = Depends(get_db)): + sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first() + if not sheet: + raise HTTPException(status_code=404, detail="Sheet not found") + return sheet + + +@router.patch("/{sheet_id}", response_model=schemas.SheetOut) +def update_sheet(sheet_id: int, update: schemas.SheetUpdate, db: Session = Depends(get_db)): + sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first() + if not sheet: + raise HTTPException(status_code=404, detail="Sheet not found") + for key, value in update.model_dump(exclude_none=True).items(): + setattr(sheet, key, value) + db.commit() + db.refresh(sheet) + return sheet + + +@router.delete("/{sheet_id}") +def delete_sheet(sheet_id: int, db: Session = Depends(get_db)): + sheet = db.query(models.Sheet).filter(models.Sheet.id == sheet_id).first() + if not sheet: + raise HTTPException(status_code=404, detail="Sheet not found") + sheet_count = db.query(models.Sheet).filter(models.Sheet.diagram_id == sheet.diagram_id).count() + if sheet_count <= 1: + raise HTTPException(status_code=400, detail="Cannot delete the last sheet") + db.delete(sheet) + db.commit() + return {"ok": True} diff --git a/backend/app/routers/wires.py b/backend/app/routers/wires.py new file mode 100644 index 0000000..b8e09c9 --- /dev/null +++ b/backend/app/routers/wires.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from .. import models, schemas +from ..database import get_db +import datetime + +router = APIRouter(tags=["wires"]) + + +@router.post("/", response_model=schemas.Wire) +def create_wire(wire: schemas.WireCreate, db: Session = Depends(get_db)): + diagram = db.query(models.Diagram).filter(models.Diagram.id == wire.diagram_id).first() + if not diagram: + raise HTTPException(status_code=404, detail="Diagram not found") + db_wire = models.Wire(**wire.model_dump()) + db.add(db_wire) + diagram.updated_at = datetime.datetime.utcnow() + db.commit() + db.refresh(db_wire) + return db_wire + + +@router.patch("/{wire_id}", response_model=schemas.Wire) +def update_wire(wire_id: int, update: schemas.WireUpdate, db: Session = Depends(get_db)): + wire = db.query(models.Wire).filter(models.Wire.id == wire_id).first() + if not wire: + raise HTTPException(status_code=404, detail="Wire not found") + for key, value in update.model_dump(exclude_unset=True).items(): + setattr(wire, key, value) + diagram = db.query(models.Diagram).filter(models.Diagram.id == wire.diagram_id).first() + if diagram: + diagram.updated_at = datetime.datetime.utcnow() + db.commit() + db.refresh(wire) + return wire + + +@router.delete("/{wire_id}") +def delete_wire(wire_id: int, db: Session = Depends(get_db)): + wire = db.query(models.Wire).filter(models.Wire.id == wire_id).first() + if not wire: + raise HTTPException(status_code=404, detail="Wire not found") + db.delete(wire) + db.commit() + return {"ok": True} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..6214989 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,180 @@ +from pydantic import BaseModel, field_validator +from typing import Optional, List, Any, Dict +from datetime import datetime + + +class DeviceBase(BaseModel): + device_type: str + label: str = "" + reference: str = "" + x: float = 100 + y: float = 100 + width: float = 120 + height: float = 60 + properties: Dict[str, Any] = {} + pins: List[Dict[str, Any]] = [] + + +class DeviceCreate(DeviceBase): + diagram_id: int + + +class DeviceUpdate(BaseModel): + label: Optional[str] = None + reference: Optional[str] = None + x: Optional[float] = None + y: Optional[float] = None + width: Optional[float] = None + height: Optional[float] = None + properties: Optional[Dict[str, Any]] = None + pins: Optional[List[Dict[str, Any]]] = None + + +class Device(DeviceBase): + id: int + diagram_id: int + + model_config = {"from_attributes": True} + + +class WireBase(BaseModel): + label: str = "" + from_device_id: Optional[int] = None + from_pin: str = "" + to_device_id: Optional[int] = None + to_pin: str = "" + color_primary: str = "#FF0000" + color_stripe: Optional[str] = None + gauge: str = "18 AWG" + length: Optional[float] = None + length_unit: str = "in" + notes: str = "" + waypoints: List[Dict[str, Any]] = [] + twisted_pair: bool = False + twist_pitch: float = 16.0 + shielded: bool = False + bundle_id: Optional[int] = None + show_size_label: bool = False + + @field_validator("waypoints", mode="before") + @classmethod + def _coerce_waypoints(cls, v): + return v if v is not None else [] + + +class WireCreate(WireBase): + diagram_id: int + + +class WireUpdate(BaseModel): + label: Optional[str] = None + from_device_id: Optional[int] = None + from_pin: Optional[str] = None + to_device_id: Optional[int] = None + to_pin: Optional[str] = None + color_primary: Optional[str] = None + color_stripe: Optional[str] = None + gauge: Optional[str] = None + length: Optional[float] = None + length_unit: Optional[str] = None + notes: Optional[str] = None + waypoints: Optional[List[Dict[str, Any]]] = None + twisted_pair: Optional[bool] = None + twist_pitch: Optional[float] = None + shielded: Optional[bool] = None + bundle_id: Optional[int] = None + show_size_label: Optional[bool] = None + + +class Wire(WireBase): + id: int + diagram_id: int + + model_config = {"from_attributes": True} + + +# ── WireBundle ──────────────────────────────────────────────────────────────── + +class WireBundleBase(BaseModel): + label: str = "" + jacket_color: str = "#2a2a2a" + + +class WireBundleCreate(WireBundleBase): + diagram_id: int + + +class WireBundleUpdate(BaseModel): + label: Optional[str] = None + jacket_color: Optional[str] = None + + +class WireBundleOut(WireBundleBase): + id: int + diagram_id: int + + model_config = {"from_attributes": True} + + +# ── Diagram ─────────────────────────────────────────────────────────────────── + +class DiagramBase(BaseModel): + name: str = "Untitled Diagram" + description: str = "" + + +class DiagramCreate(DiagramBase): + pass + + +class DiagramUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +class DiagramSummary(DiagramBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class DiagramFull(DiagramSummary): + devices: List[Device] = [] + wires: List[Wire] = [] + wire_bundles: List[WireBundleOut] = [] + + +# ── CustomConnector ─────────────────────────────────────────────────────────── + +class CustomConnectorBase(BaseModel): + name: str + manufacturer: str = "" + part_number: str = "" + category: str = "Custom" + description: str = "" + pin_count: int + pin_labels: List[str] = [] + + +class CustomConnectorCreate(CustomConnectorBase): + pass + + +class CustomConnectorUpdate(BaseModel): + name: Optional[str] = None + manufacturer: Optional[str] = None + part_number: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + pin_count: Optional[int] = None + pin_labels: Optional[List[str]] = None + + +class CustomConnector(CustomConnectorBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1a032d0 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +sqlalchemy>=2.0.0 +pydantic>=2.0.0 +aiofiles>=23.0.0 +openpyxl>=3.1.0 +httpx>=0.27.0 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..655973b --- /dev/null +++ b/backend/run.py @@ -0,0 +1,4 @@ +import uvicorn + +if __name__ == "__main__": + uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=False) diff --git a/frontend/css/main.css b/frontend/css/main.css new file mode 100644 index 0000000..44213cb --- /dev/null +++ b/frontend/css/main.css @@ -0,0 +1,323 @@ +/* ── Reset & base ─────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #app { height: 100%; overflow: hidden; font-family: "Segoe UI", system-ui, sans-serif; font-size: 13px; } +body { background: #12121c; color: #c8ccee; } + +/* ── Layout ───────────────────────────────────────────────────────────────── */ +#app { display: flex; flex-direction: column; } + +#toolbar { + display: flex; align-items: center; gap: 8px; padding: 0 12px; + height: 48px; background: #0e0e1a; border-bottom: 1px solid #2a2a44; + flex-shrink: 0; z-index: 10; +} +.toolbar-left { display: flex; align-items: center; gap: 10px; min-width: 220px; } +.toolbar-center { display: flex; align-items: center; gap: 5px; flex: 1; justify-content: center; flex-wrap: wrap; } +.toolbar-right { display: flex; align-items: center; gap: 8px; min-width: 180px; justify-content: flex-end; } +.toolbar-label { font-size: 11px; color: #555577; white-space: nowrap; } + +#mode-indicator { + text-align: center; font-size: 11px; color: #00cc88; background: #0a1a14; + border-bottom: 1px solid #1a3a2a; height: 22px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; +} + +#main { display: flex; flex: 1; overflow: hidden; } + +/* ── Sidebars ─────────────────────────────────────────────────────────────── */ +#left-sidebar { + width: 240px; flex-shrink: 0; background: #14142a; + border-right: 1px solid #2a2a44; display: flex; flex-direction: column; overflow: hidden; +} +#right-sidebar { + width: 248px; flex-shrink: 0; background: #14142a; + border-left: 1px solid #2a2a44; overflow-y: auto; +} + +.sidebar-section { display: flex; flex-direction: column; flex-shrink: 0; } +.section-header { + padding: 7px 10px; font-size: 11px; font-weight: 600; letter-spacing: .05em; + text-transform: uppercase; color: #6666aa; background: #10101e; + border-bottom: 1px solid #222238; display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; +} + +/* ── Sidebar tabs ─────────────────────────────────────────────────────────── */ +.tab-bar { + display: flex; border-bottom: 1px solid #222238; background: #10101e; flex-shrink: 0; +} +.sidebar-tab { + flex: 1; padding: 6px 4px; font-size: 11px; font-weight: 500; text-align: center; + background: transparent; border: none; border-radius: 0; color: #555577; + border-bottom: 2px solid transparent; cursor: pointer; transition: color .15s; +} +.sidebar-tab:hover { color: #9999cc; background: #18182e; } +.sidebar-tab.active { color: #88aaff; border-bottom-color: #5577dd; } + +.tab-panel { display: flex; flex-direction: column; } + +/* ── Diagram list ─────────────────────────────────────────────────────────── */ +#diagram-list { padding: 4px; } +.diagram-item { + padding: 6px 8px; border-radius: 5px; cursor: pointer; + display: flex; justify-content: space-between; align-items: baseline; + border: 1px solid transparent; margin-bottom: 2px; +} +.diagram-item:hover { background: #1e1e3a; border-color: #3a3a5a; } +.diagram-item.active { background: #1a2a4a; border-color: #4466aa; } +.di-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; font-size: 12px; cursor: pointer; } +.di-date { font-size: 10px; color: #555577; margin-left: 6px; white-space: nowrap; cursor: pointer; } +.di-del-btn { + flex-shrink: 0; opacity: 0; padding: 1px 5px; font-size: 13px; line-height: 1; + background: transparent; border-color: transparent; color: #ff6666; + transition: opacity .15s; margin-left: 4px; +} +.diagram-item:hover .di-del-btn { opacity: 1; } +.di-del-btn:hover { background: #3a1414; border-color: #aa3333; } + +/* ── Device library ───────────────────────────────────────────────────────── */ +#device-library { padding: 6px; display: flex; flex-direction: column; gap: 3px; } +.lib-item { + display: flex; align-items: center; gap: 8px; padding: 7px 8px; + border: 1px solid #2a2a44; border-radius: 5px; cursor: grab; user-select: none; + background: #18182e; transition: background .1s, border-color .1s; +} +.lib-item:hover { background: #22224a; border-color: #5555aa; } +.lib-item:active { cursor: grabbing; } +.lib-icon { font-size: 16px; width: 22px; text-align: center; } +.lib-label { font-size: 12px; } + +/* ── Connector library ────────────────────────────────────────────────────── */ +.lib-conn-item { + padding: 7px 8px; border: 1px solid #1e1e38; border-radius: 5px; cursor: grab; + background: #141428; margin-bottom: 3px; user-select: none; transition: background .1s, border-color .1s; +} +.lib-conn-item:hover { background: #1e1e40; border-color: #4444aa; } +.lib-conn-item:active { cursor: grabbing; } +.conn-name { font-size: 12px; font-weight: 500; color: #bbc0ee; } +.conn-meta { font-size: 10px; color: #556688; margin-top: 1px; } + +/* ── Canvas area ──────────────────────────────────────────────────────────── */ +#canvas-area { flex: 1; position: relative; overflow: hidden; } +#canvas-container { + width: 100%; height: 100%; + background-color: #131320; + background-image: radial-gradient(circle, #2e2e50 1px, transparent 1px); + background-size: 20px 20px; +} +#canvas-container.wire-mode { cursor: crosshair; } + +#canvas-placeholder { + position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; + pointer-events: none; z-index: 5; +} +.placeholder-content { text-align: center; color: #44446a; pointer-events: all; } +.placeholder-content h2 { font-size: 28px; margin-bottom: 10px; } +.placeholder-content p { margin-bottom: 16px; } + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ +button { + background: #22223a; border: 1px solid #3a3a5a; color: #c0c4e8; + padding: 5px 11px; border-radius: 5px; cursor: pointer; font-size: 12px; + transition: background .12s, border-color .12s; white-space: nowrap; +} +button:hover { background: #2e2e54; border-color: #5555aa; } +button:active { background: #1a1a3a; } + +.mode-btn { padding: 5px 12px; } +.mode-btn.active { background: #1e3a6a; border-color: #4488dd; color: #88bbff; } + +.route-btn { padding: 4px 10px; font-size: 11px; } +.route-btn.active { background: #1a2a4a; border-color: #336699; color: #77aaee; } + +#btn-harness.active { background: #1a2a1a; border-color: #336633; color: #88ee88; } + +.danger-btn { color: #ff6666; border-color: #662222; } +.danger-btn:hover { background: #3a1414; border-color: #aa3333; } + +.app-title { font-weight: 700; font-size: 15px; color: #88aaff; white-space: nowrap; } + +.diagram-name-input { + background: #1a1a2e; border: 1px solid #2a2a44; color: #c8ccee; + padding: 4px 8px; border-radius: 4px; font-size: 13px; width: 180px; +} +.diagram-name-input:focus { outline: none; border-color: #5566aa; } + +/* Export dropdown */ +.export-wrap { position: relative; } +#export-menu { + display: none; position: absolute; right: 0; top: calc(100% + 4px); + background: #1a1a2e; border: 1px solid #3a3a5a; border-radius: 6px; + min-width: 170px; z-index: 100; overflow: hidden; flex-direction: column; +} +#export-menu.open { display: flex; } +#export-menu button { border: none; border-radius: 0; text-align: left; padding: 8px 14px; border-bottom: 1px solid #222238; } +#export-menu button:last-child { border-bottom: none; } + +/* Saved indicator */ +#saved-indicator { + font-size: 11px; color: #4caa77; background: #0f2a1a; + border: 1px solid #1e4a2a; padding: 4px 10px; border-radius: 4px; + opacity: 0; transition: opacity 0.3s; pointer-events: none; white-space: nowrap; +} +#saved-indicator.show { opacity: 1; } + +/* ── Octopart search results ──────────────────────────────────────────────── */ +#octopart-results { + margin-top: 5px; border: 1px solid #2a2a44; border-radius: 4px; + background: #0e0e1e; max-height: 220px; overflow-y: auto; +} +.op-result { + padding: 7px 9px; cursor: pointer; border-bottom: 1px solid #1a1a30; + transition: background 0.1s; +} +.op-result:last-child { border-bottom: none; } +.op-result:hover { background: #1a1a38; } +.op-mpn { font-size: 11px; font-weight: 600; color: #c8ccee; } +.op-mfr { font-size: 10px; color: #7788aa; margin-top: 1px; } +.op-desc { font-size: 10px; color: #4a5566; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.op-ds { font-size: 10px; color: #4466aa; margin-top: 1px; } +.op-no-results { padding: 10px; font-size: 11px; color: #444466; text-align: center; } +#btn-octopart-search.searching { opacity: 0.5; pointer-events: none; } + +/* ── Properties panel ─────────────────────────────────────────────────────── */ +.prop-row { padding: 7px 10px; border-bottom: 1px solid #1e1e36; } +.prop-row label { + display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: .06em; color: #555577; margin-bottom: 4px; +} +.prop-row input[type="text"], .prop-row input[type="number"], +.prop-row select, .prop-row textarea { + width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; + color: #c0c4e8; padding: 5px 7px; border-radius: 4px; font-size: 12px; font-family: inherit; +} +.prop-row input:focus, .prop-row select:focus, .prop-row textarea:focus { outline: none; border-color: #5566aa; } +.prop-value { font-size: 12px; color: #99aacc; } +.prop-value.mono { font-family: monospace; } + +.color-swatch { width: 36px; height: 28px; padding: 2px; cursor: pointer; border-radius: 4px; flex-shrink: 0; border: 1px solid #3a3a5a; } + +/* Pin table */ +.pin-table { width: 100%; border-collapse: collapse; font-size: 11px; } +.pin-table th { text-align: left; padding: 3px 4px; color: #555577; font-weight: 600; border-bottom: 1px solid #222238; } +.pin-table td { padding: 2px 4px; border-bottom: 1px solid #1a1a30; vertical-align: middle; } +.pin-table .pin-side { color: #444466; font-size: 10px; } +.pin-input { width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; color: #c0c4e8; padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 11px; } +.pin-side-select { width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; color: #c0c4e8; padding: 2px 3px; border-radius: 3px; font-size: 11px; cursor: pointer; } + +/* ── Connector library extras ─────────────────────────────────────────────── */ +.lib-section-header { + font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; + color: #555577; padding: 8px 8px 3px; border-top: 1px solid #1a1a30; margin-top: 4px; +} +.lib-section-header:first-child { border-top: none; margin-top: 0; } + +.conn-item-body { flex: 1; min-width: 0; } +.conn-custom-badge { + display: inline-block; font-size: 9px; font-weight: 600; letter-spacing: .06em; + text-transform: uppercase; background: #2a1a4a; color: #9966ee; + border: 1px solid #4422aa; border-radius: 3px; padding: 0 4px; vertical-align: middle; + margin-left: 4px; +} +.lib-conn-item { display: flex; align-items: center; gap: 6px; } +.conn-edit-btn { + flex-shrink: 0; opacity: 0; padding: 3px 6px; font-size: 12px; + background: #1e1e3a; border-color: #3a3a5a; transition: opacity .15s; +} +.lib-conn-item:hover .conn-edit-btn { opacity: 1; } +.conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; } + +/* ── Modal ───────────────────────────────────────────────────────────────── */ +#connector-modal { + position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; + align-items: center; justify-content: center; +} +.modal-box { + background: #16162a; border: 1px solid #3a3a5a; border-radius: 8px; + width: 480px; max-width: 94vw; max-height: 86vh; + display: flex; flex-direction: column; box-shadow: 0 8px 40px rgba(0,0,0,0.7); +} +.modal-header { + padding: 14px 18px 12px; border-bottom: 1px solid #222238; flex-shrink: 0; +} +.modal-header h3 { font-size: 15px; font-weight: 600; color: #c8ccee; } +.modal-body { + padding: 14px 18px; overflow-y: auto; flex: 1; + display: flex; flex-direction: column; gap: 10px; +} +.modal-footer { + padding: 10px 18px; border-top: 1px solid #222238; + display: flex; align-items: center; gap: 8px; flex-shrink: 0; +} + +.cm-label { + display: block; font-size: 10px; font-weight: 600; text-transform: uppercase; + letter-spacing: .06em; color: #555577; margin-bottom: 4px; +} +.cm-input { + width: 100%; background: #0e0e1e; border: 1px solid #2a2a44; + color: #c0c4e8; padding: 6px 8px; border-radius: 4px; font-size: 12px; font-family: inherit; +} +.cm-input:focus { outline: none; border-color: #5566aa; } +.cm-row { display: flex; flex-direction: column; } +.cm-row-2col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + +#cm-pin-labels { + display: grid; grid-template-columns: 1fr 1fr; gap: 4px; + max-height: 220px; overflow-y: auto; padding: 4px 0; +} +.pin-label-row { display: flex; align-items: center; gap: 5px; } +.pin-idx { + width: 22px; text-align: right; flex-shrink: 0; + font-size: 10px; font-weight: 600; color: #555577; font-family: monospace; +} +.pin-label-input { + flex: 1; background: #0e0e1e; border: 1px solid #222238; + color: #c0c4e8; padding: 4px 6px; border-radius: 3px; font-size: 11px; font-family: monospace; +} +.pin-label-input:focus { outline: none; border-color: #5566aa; } + +.btn-primary { + background: #1e3a6a; border-color: #4488dd; color: #88bbff; font-weight: 600; +} +.btn-primary:hover { background: #264a8a; border-color: #66aaff; } +.btn-primary:disabled { opacity: 0.5; cursor: default; } + +/* Pin remove button */ +.pin-del-btn { + padding: 0 4px; font-size: 12px; line-height: 1.4; + background: transparent; border-color: transparent; color: #ff6666; + opacity: 0; transition: opacity .15s; +} +.pin-table tr:hover .pin-del-btn { opacity: 1; } +.pin-del-btn:hover { background: #3a1414; border-color: #aa3333; } + +/* Wire drag cursor hint */ +#canvas-container.wire-drag { cursor: grabbing; } + +/* ── Context menu ─────────────────────────────────────────────────────────── */ +#ctx-menu { + position: fixed; z-index: 9000; display: none; + background: #1a1a2e; border: 1px solid #3a3a66; border-radius: 6px; + box-shadow: 0 6px 24px rgba(0,0,0,.55); padding: 4px 0; min-width: 170px; + user-select: none; +} +#ctx-menu.open { display: block; } +.ctx-item { + padding: 7px 14px; font-size: 12px; color: #c0c4e8; cursor: pointer; + display: flex; align-items: center; gap: 8px; white-space: nowrap; +} +.ctx-item:hover { background: #2a2a50; color: #fff; } +.ctx-item.danger { color: #dd6666; } +.ctx-item.danger:hover { background: #3a1414; color: #ff8888; } +.ctx-sep { height: 1px; background: #2a2a44; margin: 4px 0; } + +/* Misc */ +input[type="checkbox"] { accent-color: #5588dd; width: 14px; height: 14px; cursor: pointer; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #2a2a44; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #3a3a66; } +.muted { color: #444466; } +.small { font-size: 11px; } +.sep { width: 1px; height: 22px; background: #2a2a44; margin: 0 3px; } diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e35f00d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,405 @@ + + + + + + WireDraw — Wiring Diagram Maker + + + +
    + + +
    +
    + ⚡ WireDraw + +
    + +
    + + + +
    + + Route: + + + +
    + + + + + + + +
    + +
    +
    ✓ Saved
    +
    + +
    + + + + + +
    +
    +
    +
    + + +
    + + +
    + + + + + +
    +
    +
    +
    +

    ⚡ WireDraw

    +

    Create or open a diagram to begin

    + +
    +
    +
    + + + + +
    +
    + + + + + +
    + +
    + + + + + + + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..0ff166b --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,52 @@ +const API_BASE = "/api"; + +async function apiFetch(path, options = {}) { + const res = await fetch(`${API_BASE}${path}`, options); + if (!res.ok) { + const text = await res.text(); + throw new Error(`API ${res.status}: ${text}`); + } + return res.json(); +} + +const api = { + diagrams: { + list: () => apiFetch("/diagrams/"), + create: (data) => apiFetch("/diagrams/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + get: (id) => apiFetch(`/diagrams/${id}`), + update: (id, data) => apiFetch(`/diagrams/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/diagrams/${id}`, { method: "DELETE" }), + }, + devices: { + create: (data) => apiFetch("/devices/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + update: (id, data) => apiFetch(`/devices/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/devices/${id}`, { method: "DELETE" }), + }, + wires: { + create: (data) => apiFetch("/wires/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + update: (id, data) => apiFetch(`/wires/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/wires/${id}`, { method: "DELETE" }), + }, + bundles: { + listForDiagram: (diagramId) => apiFetch(`/bundles/diagram/${diagramId}`), + create: (data) => apiFetch("/bundles/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + update: (id, data) => apiFetch(`/bundles/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/bundles/${id}`, { method: "DELETE" }), + }, + connectors: { + list: () => apiFetch("/connectors/"), + create: (data) => apiFetch("/connectors/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + update: (id, data) => apiFetch(`/connectors/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) }), + delete: (id) => apiFetch(`/connectors/${id}`, { method: "DELETE" }), + }, + octopart: { + status: () => apiFetch("/octopart/status"), + search: (q, n) => apiFetch(`/octopart/search?q=${encodeURIComponent(q)}&limit=${n || 8}`), + }, + export: { + bomUrl: (id) => `${API_BASE}/export/${id}/bom/csv`, + assemblyUrl: (id) => `${API_BASE}/export/${id}/assembly/txt`, + jsonUrl: (id) => `${API_BASE}/export/${id}/json`, + formboardUrl: (id) => `${API_BASE}/export/${id}/formboard`, + }, +}; diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..b6cd3ed --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,1449 @@ +class WiringApp { + constructor() { + this.diagramId = null; + this._savedTimer = null; + this._customConnectors = []; // loaded from backend + this._editingConnId = null; // id of custom connector being edited in modal + this._clipboard = null; // copied device data + this._pasteCount = 0; // paste offset counter + this._undoStack = []; // array of async undo functions + this._preDrag = null; // snapshot captured at dragstart for undo + this._octopartReady = null; // null=unknown, true/false after status check + this._bundles = []; // wire bundles for current diagram + + this.canvas = new DiagramCanvas("canvas-container", { + onDeviceSelected: (d) => this._showDeviceProps(d), + onWireSelected: (w) => this._showWireProps(w), + onSelectionCleared: () => this._clearProps(), + onMultipleSelected: (count) => this._showMultiProps(count), + onDeviceMoved: (id, x, y) => { api.devices.update(id, { x, y }).catch(console.error); this._flashSaved(); }, + onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy), + onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy), + onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin), + onWaypointsChanged: (id, wps) => { api.wires.update(id, { waypoints: wps }).catch(console.error); this._flashSaved(); }, + onDragStarted: (snap) => { this._preDrag = snap; }, + onDragEnded: (ids) => { this._pushMoveUndo(ids); }, + onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y), + onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y), + onDeviceResized: async (id, x, y, w, h) => { + const device = this.canvas.deviceData.get(id); + if (!device) return; + this._recalcPinOffsets(device.pins || [], w, h); + await api.devices.update(id, { x, y, width: w, height: h, pins: device.pins }).catch(console.error); + this.canvas.updateDevice(device); + this.canvas.selectDevice(id); + this._flashSaved(); + }, + }); + + this._buildDeviceLibrary(); + this._bindToolbar(); + this._bindKeyboard(); + this._bindProps(); + this._bindTabs(); + this._bindConnectorModal(); + this._bindLibrarySearch(); + + this.loadDiagramList() + .then(() => this._autoOpenLast()) + .then(() => this.loadCustomConnectors()) + .then(() => this._checkOctopartStatus()); + } + + // ── Auto-reopen & saved indicator ──────────────────────────────────────────── + + _autoOpenLast() { + const lastId = localStorage.getItem("lastDiagramId"); + if (lastId) return this.openDiagram(parseInt(lastId)); + } + + _flashSaved() { + clearTimeout(this._savedTimer); + this._savedTimer = setTimeout(() => { + const el = document.getElementById("saved-indicator"); + if (!el) return; + el.classList.add("show"); + setTimeout(() => el.classList.remove("show"), 1400); + }, 600); + } + + // ── Undo ────────────────────────────────────────────────────────────────────── + + _pushUndo(fn) { + this._undoStack.push(fn); + if (this._undoStack.length > 50) this._undoStack.shift(); + } + + async _undo() { + const fn = this._undoStack.pop(); + if (fn) await fn(); + } + + _pushMoveUndo(movedIds) { + if (!this._preDrag) return; + const { positions, waypoints } = this._preDrag; + this._preDrag = null; + // Snapshot post-drag state for redo (not needed — single-level undo) + this._pushUndo(async () => { + for (const [id, { x, y }] of positions) { + const g = this.canvas.deviceNodes.get(id); + const d = this.canvas.deviceData.get(id); + if (g) { g.x(x); g.y(y); } + if (d) { d.x = x; d.y = y; } + await api.devices.update(id, { x, y }).catch(console.error); + this.canvas._redrawWiresFor(id); + } + for (const [wId, wps] of waypoints) { + const wire = this.canvas.wireData.get(wId); + if (wire) { + wire.waypoints = wps.map(wp => ({ ...wp })); + await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error); + } + } + this.canvas.wireLayer.batchDraw(); + this._flashSaved(); + }); + } + + // ── Device library ──────────────────────────────────────────────────────────── + + _buildDeviceLibrary() { + const container = document.getElementById("device-library"); + container.innerHTML = ""; + Object.entries(DEVICE_TYPES).forEach(([key, type]) => { + const item = document.createElement("div"); + item.className = "lib-item"; + item.draggable = true; + item.dataset.type = key; + item.innerHTML = `${type.icon}${type.label}`; + item.title = type.description; + item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("device_type", key); e.dataTransfer.effectAllowed = "copy"; }); + item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addDevice(key, 200 + Math.random() * 100, 150 + Math.random() * 100); }); + container.appendChild(item); + }); + } + + // ── Connector library ───────────────────────────────────────────────────────── + + async loadCustomConnectors() { + try { this._customConnectors = await api.connectors.list(); } + catch { this._customConnectors = []; } + this._filterConnectors(); + } + + _bindLibrarySearch() { + document.getElementById("lib-search")?.addEventListener("input", () => this._filterConnectors()); + document.getElementById("lib-category")?.addEventListener("change", () => this._filterConnectors()); + } + + _buildConnectorLibrary() { + const catSelect = document.getElementById("lib-category"); + catSelect.innerHTML = ''; + const cats = new Set(Object.keys(CONNECTOR_LIBRARY)); + this._customConnectors.forEach(c => cats.add(c.category || "Custom")); + cats.forEach(cat => { + const opt = document.createElement("option"); + opt.value = cat; opt.textContent = cat; + catSelect.appendChild(opt); + }); + } + + _filterConnectors() { + this._buildConnectorLibrary(); // refresh category list + const query = (document.getElementById("lib-search")?.value || "").toLowerCase(); + const catFilter = document.getElementById("lib-category")?.value || ""; + const list = document.getElementById("lib-list"); + list.innerHTML = ""; + + // ── Custom connectors (from DB) ────────────────────────────────────────── + const customs = this._customConnectors.filter(conn => { + if (catFilter && (conn.category || "Custom") !== catFilter) return false; + if (query && !`${conn.name} ${conn.manufacturer} ${conn.part_number} ${conn.description}`.toLowerCase().includes(query)) return false; + return true; + }); + + if (customs.length) { + const header = document.createElement("div"); + header.className = "lib-section-header"; + header.textContent = "Custom Connectors"; + list.appendChild(header); + customs.forEach(conn => list.appendChild(this._makeCustomItem(conn))); + } + + // ── Built-in library ───────────────────────────────────────────────────── + Object.entries(CONNECTOR_LIBRARY).forEach(([cat, conns]) => { + if (catFilter && cat !== catFilter) return; + const matched = conns.filter(conn => !query || + `${conn.name} ${conn.manufacturer} ${conn.partNumber} ${conn.description}`.toLowerCase().includes(query)); + if (!matched.length) return; + + const header = document.createElement("div"); + header.className = "lib-section-header"; + header.textContent = cat; + list.appendChild(header); + matched.forEach(conn => list.appendChild(this._makeBuiltinItem(conn))); + }); + + if (!list.children.length) { + list.innerHTML = '

    No connectors match

    '; + } + } + + _makeCustomItem(conn) { + const item = document.createElement("div"); + item.className = "lib-conn-item"; + item.draggable = true; + item.title = conn.description || conn.name; + item.innerHTML = ` +
    +
    ${conn.name} custom
    +
    ${conn.manufacturer || "—"} · ${conn.pin_count}p${conn.part_number ? " · " + conn.part_number : ""}
    +
    + `; + item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", "custom_" + conn.id); e.dataTransfer.effectAllowed = "copy"; }); + item.addEventListener("dblclick", (e) => { if (e.target.classList.contains("conn-edit-btn")) return; if (!this.diagramId) return this._needDiagram(); this.addConnector("custom_" + conn.id, 220, 160); }); + item.querySelector(".conn-edit-btn").addEventListener("click", (e) => { e.stopPropagation(); this.openConnectorModal(conn); }); + return item; + } + + _makeBuiltinItem(conn) { + const item = document.createElement("div"); + item.className = "lib-conn-item"; + item.draggable = true; + item.title = conn.description || conn.name; + item.innerHTML = ` +
    +
    ${conn.name}
    +
    ${conn.manufacturer} · ${conn.pinCount}p · ${conn.partNumber}
    +
    `; + item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", conn.id); e.dataTransfer.effectAllowed = "copy"; }); + item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addConnector(conn.id, 220 + Math.random() * 80, 160 + Math.random() * 80); }); + return item; + } + + // ── Custom connector modal ──────────────────────────────────────────────────── + + _bindConnectorModal() { + document.getElementById("btn-new-connector")?.addEventListener("click", () => this.openConnectorModal(null)); + document.getElementById("cm-save")?.addEventListener("click", () => this._saveConnectorModal()); + document.getElementById("cm-delete")?.addEventListener("click", () => this._deleteConnectorModal()); + document.getElementById("cm-cancel")?.addEventListener("click", () => this.closeConnectorModal()); + + // Close on backdrop click + document.getElementById("connector-modal")?.addEventListener("click", (e) => { + if (e.target.id === "connector-modal") this.closeConnectorModal(); + }); + + // Escape key + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") { + this.closeConnectorModal(); + } + }); + + // Update pin labels when pin count changes + document.getElementById("cm-pincount")?.addEventListener("input", (e) => { + const count = Math.max(1, Math.min(64, parseInt(e.target.value) || 1)); + const existing = [...document.querySelectorAll(".pin-label-input")].map(i => i.value); + this._buildPinLabelInputs(count, existing); + }); + } + + openConnectorModal(conn = null) { + this._editingConnId = conn?.id ?? null; + document.getElementById("cm-modal-title").textContent = conn ? "Edit Custom Connector" : "New Custom Connector"; + document.getElementById("cm-name").value = conn?.name || ""; + document.getElementById("cm-category").value = conn?.category || "Custom"; + document.getElementById("cm-manufacturer").value = conn?.manufacturer || ""; + document.getElementById("cm-partnumber").value = conn?.part_number || ""; + document.getElementById("cm-description").value = conn?.description || ""; + document.getElementById("cm-pincount").value = conn?.pin_count || 4; + document.getElementById("cm-delete").style.display = (conn?.id != null) ? "" : "none"; + this._buildPinLabelInputs(conn?.pin_count || 4, conn?.pin_labels || []); + document.getElementById("connector-modal").style.display = "flex"; + document.getElementById("cm-name").focus(); + } + + closeConnectorModal() { + document.getElementById("connector-modal").style.display = "none"; + this._editingConnId = null; + } + + _buildPinLabelInputs(count, existingLabels = []) { + const container = document.getElementById("cm-pin-labels"); + container.innerHTML = ""; + for (let i = 0; i < count; i++) { + const row = document.createElement("div"); + row.className = "pin-label-row"; + row.innerHTML = `${i + 1} + `; + container.appendChild(row); + } + } + + async _saveConnectorModal() { + const name = document.getElementById("cm-name").value.trim(); + const pinCount = parseInt(document.getElementById("cm-pincount").value); + if (!name) { document.getElementById("cm-name").focus(); return; } + if (!pinCount || pinCount < 1 || pinCount > 64) { alert("Pin count must be 1–64"); return; } + + const data = { + name, + category: document.getElementById("cm-category").value.trim() || "Custom", + manufacturer: document.getElementById("cm-manufacturer").value.trim(), + part_number: document.getElementById("cm-partnumber").value.trim(), + description: document.getElementById("cm-description").value.trim(), + pin_count: pinCount, + pin_labels: [...document.querySelectorAll(".pin-label-input")].map(i => i.value.trim()), + }; + + const btn = document.getElementById("cm-save"); + btn.disabled = true; btn.textContent = "Saving…"; + try { + if (this._editingConnId != null) { + await api.connectors.update(this._editingConnId, data); + } else { + await api.connectors.create(data); + } + this.closeConnectorModal(); + await this.loadCustomConnectors(); + this._flashSaved(); + } catch (e) { + alert("Failed to save connector: " + e.message); + } finally { + btn.disabled = false; btn.textContent = "Save"; + } + } + + async _deleteConnectorModal() { + if (this._editingConnId == null) return; + if (!confirm(`Delete "${document.getElementById("cm-name").value}"? This cannot be undone.`)) return; + try { + await api.connectors.delete(this._editingConnId); + this.closeConnectorModal(); + await this.loadCustomConnectors(); + this._flashSaved(); + } catch (e) { alert("Failed to delete: " + e.message); } + } + + // ── Tabs ────────────────────────────────────────────────────────────────────── + + _bindTabs() { + document.querySelectorAll(".sidebar-tab").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".sidebar-tab").forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + document.querySelectorAll(".tab-panel").forEach(p => { + p.style.display = p.dataset.panel === btn.dataset.tab ? "" : "none"; + }); + }); + }); + } + + // ── Toolbar ─────────────────────────────────────────────────────────────────── + + _bindToolbar() { + const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn); + on("btn-select", () => this.setMode("select")); + on("btn-wire", () => this.setMode("wire")); + on("btn-delete", () => this.deleteSelected()); + on("btn-duplicate", () => this.duplicateSelected()); + on("btn-undo", () => this._undo()); + on("btn-fit", () => this.canvas.fitView()); + on("btn-new", () => this.newDiagram()); + on("btn-bom", () => this._export("bom")); + on("btn-assembly", () => this._export("assembly")); + on("btn-json", () => this._export("json")); + on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); }); + on("btn-formboard", () => this._export("formboard")); + on("btn-snap", () => { + const btn = document.getElementById("btn-snap"); + const enabled = !this.canvas.snapEnabled; + this.canvas.toggleSnap(enabled); + btn?.classList.toggle("active", enabled); + }); + on("btn-harness", () => { + const btn = document.getElementById("btn-harness"); + const enabled = !this.canvas.harnessMode; + this.canvas.setHarnessMode(enabled); + btn?.classList.toggle("active", enabled); + }); + on("btn-route-ortho", () => this._setRouteMode("ortho")); + on("btn-route-direct", () => this._setRouteMode("direct")); + on("btn-route-curved", () => this._setRouteMode("curved")); + + document.getElementById("export-toggle")?.addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("export-menu").classList.toggle("open"); }); + document.addEventListener("click", (e) => { + document.getElementById("export-menu")?.classList.remove("open"); + this._closeCtx(); + if (!e.target.closest("#octopart-results") && !e.target.closest("#btn-octopart-search")) { + const res = document.getElementById("octopart-results"); + if (res) res.style.display = "none"; + } + }); + document.addEventListener("contextmenu", (e) => { + // Close ctx menu if right-clicking outside canvas (browser default handles the rest) + if (!e.target.closest("#canvas-container")) this._closeCtx(); + }); + + document.getElementById("diagram-name")?.addEventListener("change", async (e) => { + if (this.diagramId) { await api.diagrams.update(this.diagramId, { name: e.target.value }).catch(console.error); this._flashSaved(); } + }); + } + + // ── Context menu ────────────────────────────────────────────────────────────── + + _ctxMenu() { return document.getElementById("ctx-menu"); } + + _openCtx(x, y, items) { + const menu = this._ctxMenu(); + menu.innerHTML = items.map(item => { + if (item === "---") return `
    `; + return `
    + ${item.icon ? `${item.icon}` : ""}${item.label} +
    `; + }).join(""); + menu.classList.add("open"); + // Position, keeping within viewport + const vw = window.innerWidth, vh = window.innerHeight; + const mw = 180, mh = menu.childElementCount * 32; + menu.style.left = (x + mw > vw ? x - mw : x) + "px"; + menu.style.top = (y + mh > vh ? y - mh : y) + "px"; + } + + _closeCtx() { this._ctxMenu().classList.remove("open"); } + + _showCtxDevice(id, x, y) { + const d = this.canvas.deviceData.get(id); + const isMulti = this.canvas.selectedDeviceIds.size > 1; + const isGroup = d?.device_type === "group"; + const items = isMulti ? [ + { action: "dup", icon: "⊕", label: `Duplicate ${this.canvas.selectedDeviceIds.size} items` }, + { action: "zoom", icon: "⊞", label: "Zoom to selection" }, + "---", + { action: "del", icon: "✕", label: `Delete ${this.canvas.selectedDeviceIds.size} items`, danger: true }, + ] : isGroup ? [ + { action: "dup", icon: "⊕", label: "Duplicate" }, + "---", + { action: "del", icon: "✕", label: "Delete", danger: true }, + ] : [ + { action: "dup", icon: "⊕", label: "Duplicate" }, + { action: "addpin", icon: "+", label: "Add Pin" }, + { action: "savelb", icon: "⬆", label: "Save to Library" }, + "---", + { action: "del", icon: "✕", label: "Delete", danger: true }, + ]; + this._openCtx(x, y, items); + this._ctxMenu().onclick = (e) => { + const action = e.target.closest(".ctx-item")?.dataset.action; + this._closeCtx(); + if (!action) return; + if (action === "dup") this.duplicateSelected(); + if (action === "del") this.deleteSelected(); + if (action === "zoom") this.canvas.zoomToSelection(); + if (action === "addpin" && d) this._addDevicePin(d); + if (action === "savelb" && d) this._saveDeviceAsConnector(d); + }; + } + + _showCtxWire(id, x, y) { + const wire = this.canvas.wireData.get(id); + const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0; + const items = [ + { action: "straighten", icon: "⤢", label: "Straighten wire" }, + ...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []), + "---", + { action: "del", icon: "✕", label: "Delete wire", danger: true }, + ]; + this._openCtx(x, y, items); + this._ctxMenu().onclick = (e) => { + const action = e.target.closest(".ctx-item")?.dataset.action; + this._closeCtx(); + if (!action) return; + if (action === "del") this.deleteSelected(); + if ((action === "clearwp" || action === "straighten") && wire) { + wire.waypoints = []; + this.canvas.updateWire(wire); + api.wires.update(id, { waypoints: [] }).catch(console.error); + this._flashSaved(); + } + }; + } + + _setRouteMode(mode) { + this.canvas.setRouteMode(mode); + document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active")); + document.getElementById(`btn-route-${mode}`)?.classList.add("active"); + } + + _bindKeyboard() { + document.addEventListener("keydown", (e) => { + if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return; + if (e.key === "Delete" || e.key === "Backspace") this.deleteSelected(); + if (e.key === "Escape") { this.setMode("select"); this._closeCtx(); } + if (e.key === "w") this.setMode("wire"); + if (e.key === "s") this.setMode("select"); + if (e.key === "f" && !e.shiftKey) this.canvas.fitView(); + if (e.key === "F" && e.shiftKey) this.canvas.zoomToSelection(); + if (e.key === "g") { const btn = document.getElementById("btn-snap"); const en = !this.canvas.snapEnabled; this.canvas.toggleSnap(en); btn?.classList.toggle("active", en); } + if (e.key === "h") { const btn = document.getElementById("btn-harness"); const en = !this.canvas.harnessMode; this.canvas.setHarnessMode(en); btn?.classList.toggle("active", en); } + if ((e.ctrlKey || e.metaKey) && e.key === "c" && this.canvas.selectedType === "device") { e.preventDefault(); this.copySelected(); } + if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); } + if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); } + if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); } + }); + } + + // ── Properties panel ────────────────────────────────────────────────────────── + + _bindProps() { + this._onPropChange("prop-reference", (v) => this._patchDevice({ reference: v }, true)); + this._onPropChange("prop-label", (v) => this._patchDevice({ label: v }, true)); + this._onPropChange("prop-partnumber", (v) => this._patchDeviceProp("partNumber", v)); + this._onPropChange("prop-manufacturer", (v) => this._patchDeviceProp("manufacturer", v)); + this._onPropChange("prop-fontsize", (v) => this._patchDeviceProp("fontSize", Math.max(6, Math.min(72, parseInt(v) || 12)), true)); + + this._onPropChange("wire-label", (v) => this._patchWire({ label: v })); + this._onPropChange("wire-gauge", (v) => this._patchWire({ gauge: v })); + this._onPropChange("wire-length", (v) => this._patchWire({ length: v ? parseFloat(v) : null }, true)); + this._onPropChange("wire-unit", (v) => this._patchWire({ length_unit: v }, true)); + this._onPropChange("wire-notes", (v) => this._patchWire({ notes: v })); + + document.getElementById("wire-color")?.addEventListener("input", (e) => this._patchWire({ color_primary: e.target.value }, true)); + + document.getElementById("wire-stripe-enabled")?.addEventListener("change", (e) => { + const picker = document.getElementById("wire-stripe"); + picker.disabled = !e.target.checked; + picker.style.opacity = e.target.checked ? "1" : "0.3"; + this._patchWire({ color_stripe: e.target.checked ? picker.value : null }, true); + }); + document.getElementById("wire-stripe")?.addEventListener("input", (e) => { + const isTwisted = document.getElementById("wire-twisted")?.checked; + if (isTwisted || document.getElementById("wire-stripe-enabled")?.checked) { + this._patchWire({ color_stripe: e.target.value }, true); + } + }); + + document.getElementById("btn-octopart-search")?.addEventListener("click", () => this._octopartSearch()); + + document.getElementById("group-fill-color")?.addEventListener("input", async (e) => { + if (this.canvas.selectedType !== "device") return; + const d = this.canvas.deviceData.get(this.canvas.selectedId); + if (!d || d.device_type !== "group") return; + d.properties = { ...d.properties, fillColor: e.target.value }; + await api.devices.update(d.id, { properties: d.properties }).catch(console.error); + this.canvas.updateDevice(d); + this._flashSaved(); + }); + document.getElementById("group-fill-opacity")?.addEventListener("input", async (e) => { + if (this.canvas.selectedType !== "device") return; + const d = this.canvas.deviceData.get(this.canvas.selectedId); + if (!d || d.device_type !== "group") return; + const opacity = parseFloat(e.target.value); + document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%"; + d.properties = { ...d.properties, fillOpacity: opacity }; + await api.devices.update(d.id, { properties: d.properties }).catch(console.error); + this.canvas.updateDevice(d); + this._flashSaved(); + }); + + document.getElementById("cable-jacket-color")?.addEventListener("input", async (e) => { + if (this.canvas.selectedType !== "device") return; + const d = this.canvas.deviceData.get(this.canvas.selectedId); + if (!d || d.device_type !== "cable") return; + d.properties = { ...d.properties, jacketColor: e.target.value }; + await api.devices.update(d.id, { properties: d.properties }).catch(console.error); + this.canvas.updateDevice(d); + this._flashSaved(); + }); + document.getElementById("cable-sleeve-length")?.addEventListener("input", async (e) => { + if (this.canvas.selectedType !== "device") return; + const d = this.canvas.deviceData.get(this.canvas.selectedId); + if (!d || d.device_type !== "cable") return; + const val = parseInt(e.target.value); + document.getElementById("cable-sleeve-length-val").textContent = val + " px"; + d.properties = { ...d.properties, sleeveLength: val }; + await api.devices.update(d.id, { properties: d.properties }).catch(console.error); + this.canvas.updateDevice(d); + this._flashSaved(); + }); + document.getElementById("prop-add-conductor")?.addEventListener("click", () => { + if (this.canvas.selectedType !== "device") return; + const d = this.canvas.deviceData.get(this.canvas.selectedId); + if (d?.device_type === "cable") this._addConductor(d); + }); + + document.getElementById("wire-twisted")?.addEventListener("change", (e) => { + const pitchEl = document.getElementById("wire-twist-pitch"); + pitchEl.disabled = !e.target.checked; + pitchEl.style.opacity = e.target.checked ? "1" : "0.3"; + const updates = { twisted_pair: e.target.checked }; + if (e.target.checked) { + const stripeEl = document.getElementById("wire-stripe"); + if (!document.getElementById("wire-stripe-enabled").checked) { + stripeEl.value = "#cccccc"; + updates.color_stripe = "#cccccc"; + } + } + this._patchWire(updates, true); + this._updateWireTwistedUI(e.target.checked); + }); + document.getElementById("wire-twist-pitch")?.addEventListener("change", (e) => { + this._patchWire({ twist_pitch: Math.max(8, Math.min(64, parseFloat(e.target.value) || 16)) }, true); + }); + document.getElementById("wire-shielded")?.addEventListener("change", (e) => { + this._patchWire({ shielded: e.target.checked }, true); + }); + document.getElementById("wire-show-size-label")?.addEventListener("change", (e) => { + this._patchWire({ show_size_label: e.target.checked }, true); + }); + + document.getElementById("wire-bundle-select")?.addEventListener("change", (e) => { + const bundleId = e.target.value ? parseInt(e.target.value) : null; + this._patchWire({ bundle_id: bundleId }, true); + this._refreshBundleEditPanel(bundleId); + }); + document.getElementById("wire-bundle-new")?.addEventListener("click", async () => { + if (!this.diagramId) return; + try { + const b = await api.bundles.create({ diagram_id: this.diagramId, label: "New Bundle", jacket_color: "#2a2a2a" }); + this._bundles.push(b); + this.canvas.loadBundles(this._bundles); + this._refreshBundleDropdown(b.id); + this._patchWire({ bundle_id: b.id }, true); + this._refreshBundleEditPanel(b.id); + } catch (e) { console.error("Create bundle failed:", e); } + }); + document.getElementById("wire-bundle-label")?.addEventListener("change", async (e) => { + const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value); + if (!bundleId) return; + try { + const updated = await api.bundles.update(bundleId, { label: e.target.value }); + const idx = this._bundles.findIndex(b => b.id === bundleId); + if (idx >= 0) this._bundles[idx] = updated; + this.canvas.loadBundles(this._bundles); + this._refreshBundleDropdown(bundleId); + } catch (ex) { console.error(ex); } + }); + document.getElementById("wire-bundle-color")?.addEventListener("input", async (e) => { + const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value); + if (!bundleId) return; + try { + const updated = await api.bundles.update(bundleId, { jacket_color: e.target.value }); + const idx = this._bundles.findIndex(b => b.id === bundleId); + if (idx >= 0) this._bundles[idx] = updated; + this.canvas.loadBundles(this._bundles); + } catch (ex) { console.error(ex); } + }); + document.getElementById("wire-bundle-delete")?.addEventListener("click", async () => { + const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value); + if (!bundleId || !confirm("Delete this bundle? Wires will be unassigned.")) return; + try { + await api.bundles.delete(bundleId); + this._bundles = this._bundles.filter(b => b.id !== bundleId); + this.canvas.wireData.forEach((w, wId) => { + if (w.bundle_id === bundleId) { w.bundle_id = null; api.wires.update(wId, { bundle_id: null }).catch(console.error); } + }); + this.canvas.loadBundles(this._bundles); // also calls _renderBundles + const selWire = this.canvas.wireData.get(this.canvas.selectedId); + if (selWire?.bundle_id === null) this._refreshBundleDropdown(null); + else this._refreshBundleDropdown(selWire?.bundle_id || null); + this._refreshBundleEditPanel(selWire?.bundle_id || null); + } catch (ex) { console.error(ex); } + }); + + document.getElementById("prop-add-pin")?.addEventListener("click", () => { + if (this.canvas.selectedType !== "device") return; + const d = this.canvas.deviceData.get(this.canvas.selectedId); + if (d) this._addDevicePin(d); + }); + document.getElementById("prop-save-connector")?.addEventListener("click", () => { + if (this.canvas.selectedType !== "device") return; + const d = this.canvas.deviceData.get(this.canvas.selectedId); + if (d) this._saveDeviceAsConnector(d); + }); + + const presetSelect = document.getElementById("wire-color-preset"); + if (presetSelect) { + WIRE_COLORS.forEach(c => { + const opt = document.createElement("option"); + opt.value = c.hex; opt.textContent = c.name; + presetSelect.appendChild(opt); + }); + presetSelect.addEventListener("change", (e) => { + if (!e.target.value) return; + document.getElementById("wire-color").value = e.target.value; + this._patchWire({ color_primary: e.target.value }, true); + presetSelect.value = ""; + }); + } + } + + _onPropChange(id, fn) { document.getElementById(id)?.addEventListener("change", (e) => fn(e.target.value)); } + + async _patchDevice(data, redraw = false) { + if (this.canvas.selectedType !== "device") return; + const id = this.canvas.selectedId; + await api.devices.update(id, data).catch(console.error); + this._flashSaved(); + if (redraw) { const d = this.canvas.deviceData.get(id); if (d) { Object.assign(d, data); this.canvas.updateDevice(d); } } + } + + async _patchDeviceProp(key, value, redraw = false) { + if (this.canvas.selectedType !== "device") return; + const id = this.canvas.selectedId; + const d = this.canvas.deviceData.get(id); + if (!d) return; + d.properties = { ...(d.properties || {}), [key]: value }; + await api.devices.update(id, { properties: d.properties }).catch(console.error); + this._flashSaved(); + if (redraw) { this.canvas.updateDevice(d); this.canvas.selectDevice(id); } + } + + async _patchWire(data, redraw = false) { + if (this.canvas.selectedType !== "wire") return; + const id = this.canvas.selectedId; + const w = this.canvas.wireData.get(id); + if (w) { + Object.assign(w, data); + if (redraw) this.canvas.updateWire(w); + } + await api.wires.update(id, data).catch(e => console.error("Wire save failed:", e)); + this._flashSaved(); + } + + // ── Diagram management ──────────────────────────────────────────────────────── + + async loadDiagramList() { + const list = document.getElementById("diagram-list"); + try { + const diagrams = await api.diagrams.list(); + if (!diagrams.length) { + list.innerHTML = '

    No diagrams yet

    '; + return; + } + list.innerHTML = ""; + diagrams.forEach(d => { + const el = document.createElement("div"); + el.className = "diagram-item" + (d.id === this.diagramId ? " active" : ""); + el.dataset.id = d.id; + el.innerHTML = `${d.name} + ${new Date(d.updated_at + "Z").toLocaleDateString()} + `; + el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id)); + el.querySelector(".di-date").addEventListener("click", () => this.openDiagram(d.id)); + el.querySelector(".di-del-btn").addEventListener("click", (e) => { + e.stopPropagation(); + this.deleteDiagram(d.id, d.name); + }); + list.appendChild(el); + }); + } catch { list.innerHTML = '

    Failed to load

    '; } + } + + async deleteDiagram(id, name) { + if (!confirm(`Delete "${name}" and all its contents? This cannot be undone.`)) return; + try { + await api.diagrams.delete(id); + if (this.diagramId === id) { + this.diagramId = null; + localStorage.removeItem("lastDiagramId"); + this.canvas.loadDiagram({ devices: [], wires: [] }); + document.getElementById("diagram-name").value = ""; + document.getElementById("canvas-placeholder").style.display = ""; + this._clearProps(); + } + await this.loadDiagramList(); + } catch (e) { alert("Failed to delete: " + e.message); } + } + + async newDiagram() { + const name = prompt("Diagram name:", "New Diagram"); + if (!name) return; + try { + const d = await api.diagrams.create({ name }); + await this.loadDiagramList(); + await this.openDiagram(d.id); + } catch (e) { alert("Failed to create: " + e.message); } + } + + async openDiagram(id) { + try { + const diagram = await api.diagrams.get(id); + this.diagramId = id; + localStorage.setItem("lastDiagramId", id); + document.getElementById("diagram-name").value = diagram.name; + document.getElementById("canvas-placeholder").style.display = "none"; + this._bundles = diagram.wire_bundles || []; + this.canvas.loadDiagram(diagram); + this.canvas.loadBundles(this._bundles); + if (diagram.devices?.length) setTimeout(() => this.canvas.fitView(), 50); + this.setMode("select"); + this._clearProps(); + await this.loadDiagramList(); + } catch (e) { + localStorage.removeItem("lastDiagramId"); + console.error("Failed to open diagram:", e); + } + } + + // ── Actions ─────────────────────────────────────────────────────────────────── + + setMode(mode) { + this.canvas.setMode(mode); + document.querySelectorAll(".mode-btn").forEach(b => b.classList.remove("active")); + document.getElementById(`btn-${mode}`)?.classList.add("active"); + const ind = document.getElementById("mode-indicator"); + if (ind) ind.textContent = mode === "wire" + ? "Wire Mode — drag from a pin to another pin to connect (Esc to cancel)" + : ""; + } + + async addDevice(typeKey, wx, wy) { + if (!this.diagramId) return this._needDiagram(); + const def = buildDefaultDevice(typeKey, this.diagramId); + if (!def) return; + def.x = wx - def.width / 2; + def.y = wy - def.height / 2; + try { + const device = await api.devices.create(def); + this.canvas.addDevice(device); + this._flashSaved(); + this._pushUndo(async () => { + this.canvas.removeDevice(device.id); + await api.devices.delete(device.id).catch(console.error); + this._flashSaved(); + }); + } catch (e) { console.error("Create device failed:", e); } + } + + async addConnector(connId, wx, wy) { + if (!this.diagramId) return this._needDiagram(); + let def; + if (connId.startsWith("custom_")) { + const id = parseInt(connId.replace("custom_", "")); + const conn = this._customConnectors.find(c => c.id === id); + if (!conn) return; + def = this._customConnectorToDevice(conn, this.diagramId); + } else { + def = connectorToDevice(connId, this.diagramId); + } + if (!def) return; + def.x = wx - def.width / 2; + def.y = wy - def.height / 2; + try { + const device = await api.devices.create(def); + this.canvas.addDevice(device); + this._flashSaved(); + this._pushUndo(async () => { + this.canvas.removeDevice(device.id); + await api.devices.delete(device.id).catch(console.error); + this._flashSaved(); + }); + } catch (e) { console.error("Create connector failed:", e); } + } + + _customConnectorToDevice(conn, diagramId) { + const pinCount = conn.pin_count; + const w = 120; + const h = Math.max(60, pinCount * 18 + 20); + const pins = Array.from({ length: pinCount }, (_, i) => ({ + id: `pin_${i + 1}`, + name: (conn.pin_labels && conn.pin_labels[i]) || String(i + 1), + side: "right", + x_offset: w, + y_offset: ((i + 1) / (pinCount + 1)) * h, + })); + return { + diagram_id: diagramId, + device_type: "connector", + label: conn.name, + reference: "", + x: 200, y: 200, + width: w, height: h, + properties: { pinCount, orientation: "right", partNumber: conn.part_number || "", manufacturer: conn.manufacturer || "", customConnectorId: conn.id }, + pins, + }; + } + + _conductorColorForPin(deviceId, pinId) { + const d = this.canvas.deviceData.get(deviceId); + if (d?.device_type !== "cable") return null; + const m = pinId.match(/^[LR](\d+)$/); + if (!m) return null; + return d.properties?.conductors?.[parseInt(m[1]) - 1]?.color || null; + } + + async createWire(fromDev, fromPin, toDev, toPin) { + if (!this.diagramId) return; + try { + const autoColor = this._conductorColorForPin(fromDev, fromPin) + || this._conductorColorForPin(toDev, toPin) + || "#CC0000"; + const wire = await api.wires.create({ + diagram_id: this.diagramId, + from_device_id: fromDev, from_pin: fromPin, + to_device_id: toDev, to_pin: toPin, + color_primary: autoColor, gauge: "18 AWG", + }); + this.canvas.addWire(wire); + this.setMode("select"); + this.canvas.selectWire(wire.id); + this._showWireProps(wire); + this._flashSaved(); + this._pushUndo(async () => { + this.canvas.clearSelection(); + this._clearProps(); + this.canvas.removeWire(wire.id); + await api.wires.delete(wire.id).catch(console.error); + this._flashSaved(); + }); + } catch (e) { console.error("Create wire failed:", e); } + } + + async deleteSelected() { + const deviceIds = [...this.canvas.selectedDeviceIds]; + if (deviceIds.length) { + // Snapshot data for undo before clearing + const deviceSnaps = deviceIds.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); + const wireIds = new Set(); + deviceIds.forEach(id => { + this.canvas.wireData.forEach((w, wId) => { + if (w.from_device_id === id || w.to_device_id === id) wireIds.add(wId); + }); + }); + const wireSnaps = [...wireIds].map(wId => JSON.parse(JSON.stringify(this.canvas.wireData.get(wId)))).filter(Boolean); + + this.canvas.clearSelection(); + this._clearProps(); + try { + for (const wId of wireIds) { await api.wires.delete(wId); this.canvas.removeWire(wId); } + for (const id of deviceIds) { await api.devices.delete(id); this.canvas.removeDevice(id); } + this._flashSaved(); + this._pushUndo(async () => { + for (const snap of deviceSnaps) { + const restored = await api.devices.create(snap).catch(console.error); + if (restored) this.canvas.addDevice(restored); + } + for (const snap of wireSnaps) { + const restored = await api.wires.create(snap).catch(console.error); + if (restored) this.canvas.addWire(restored); + } + this._flashSaved(); + }); + } catch (e) { console.error("Delete failed:", e); } + return; + } + if (this.canvas.selectedType === "wire" && this.canvas.selectedId) { + const id = this.canvas.selectedId; + const wireSnap = JSON.parse(JSON.stringify(this.canvas.wireData.get(id))); + this.canvas.clearSelection(); + this._clearProps(); + try { + await api.wires.delete(id); this.canvas.removeWire(id); this._flashSaved(); + this._pushUndo(async () => { + const restored = await api.wires.create(wireSnap).catch(console.error); + if (restored) { this.canvas.addWire(restored); this._flashSaved(); } + }); + } + catch (e) { console.error("Delete failed:", e); } + } + } + + copySelected() { + const ids = [...this.canvas.selectedDeviceIds]; + if (!ids.length) return; + this._clipboard = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); + this._pasteCount = 0; + } + + async pasteClipboard() { + if (!this._clipboard?.length || !this.diagramId) return; + this._pasteCount++; + await this._cloneDevices(this._clipboard, this._pasteCount * 30); + } + + async duplicateSelected() { + const ids = [...this.canvas.selectedDeviceIds]; + if (!ids.length || !this.diagramId) return; + const sources = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); + this._clipboard = sources; + this._pasteCount = 1; + await this._cloneDevices(sources, 30); + } + + async _cloneDevices(sources, offset) { + this.canvas.clearSelection(); + for (const source of sources) { + const def = { + diagram_id: this.diagramId, + device_type: source.device_type, + label: source.label, + reference: "", + x: source.x + offset, + y: source.y + offset, + width: source.width, + height: source.height, + properties: JSON.parse(JSON.stringify(source.properties || {})), + pins: JSON.parse(JSON.stringify(source.pins || [])), + }; + try { + const device = await api.devices.create(def); + this.canvas.addDevice(device); + this.canvas.addToSelection(device.id); + } catch (e) { console.error("Clone device failed:", e); } + } + this._flashSaved(); + if (this.canvas.selectedDeviceIds.size === 1) { + const [id] = this.canvas.selectedDeviceIds; + this.canvas.selectedId = id; + this._showDeviceProps(this.canvas.deviceData.get(id)); + } else if (this.canvas.selectedDeviceIds.size > 1) { + this._showMultiProps(this.canvas.selectedDeviceIds.size); + } + } + + _export(type) { + if (!this.diagramId) return this._needDiagram(); + document.getElementById("export-menu")?.classList.remove("open"); + const url = type === "bom" ? api.export.bomUrl(this.diagramId) + : type === "assembly" ? api.export.assemblyUrl(this.diagramId) + : type === "formboard" ? api.export.formboardUrl(this.diagramId) + : api.export.jsonUrl(this.diagramId); + window.open(url, "_blank"); + } + + _needDiagram() { alert("Please open or create a diagram first."); } + + // ── Octopart / Nexar part search ────────────────────────────────────────────── + + async _checkOctopartStatus() { + try { + const { configured } = await api.octopart.status(); + this._octopartReady = configured; + } catch { this._octopartReady = false; } + } + + async _octopartSearch() { + const pn = document.getElementById("prop-partnumber")?.value?.trim(); + const resultsEl = document.getElementById("octopart-results"); + const statusEl = document.getElementById("octopart-status-msg"); + const btn = document.getElementById("btn-octopart-search"); + if (!resultsEl || !statusEl) return; + + resultsEl.style.display = "none"; + resultsEl.innerHTML = ""; + statusEl.style.display = "none"; + + if (this._octopartReady === false) { + statusEl.textContent = "Nexar API not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET on the server."; + statusEl.style.display = ""; + return; + } + + if (!pn) { + statusEl.textContent = "Enter a part number first."; + statusEl.style.display = ""; + return; + } + + btn?.classList.add("searching"); + try { + const results = await api.octopart.search(pn, 8); + btn?.classList.remove("searching"); + + if (!results.length) { + resultsEl.innerHTML = `
    No parts found for "${pn}"
    `; + resultsEl.style.display = ""; + return; + } + + resultsEl.innerHTML = results.map((r, i) => ` +
    +
    ${r.mpn}
    +
    ${r.manufacturer || "—"}
    + ${r.description ? `
    ${r.description}
    ` : ""} + ${r.datasheet_url ? `
    📄 datasheet available
    ` : ""} +
    `).join(""); + + resultsEl.style.display = ""; + + resultsEl.querySelectorAll(".op-result").forEach((el, i) => { + el.addEventListener("click", () => { + this._applyOctopartResult(results[i]); + resultsEl.style.display = "none"; + }); + }); + } catch (e) { + btn?.classList.remove("searching"); + statusEl.textContent = `Search failed: ${e.message}`; + statusEl.style.display = ""; + } + } + + async _applyOctopartResult(result) { + if (this.canvas.selectedType !== "device") return; + const id = this.canvas.selectedId; + const d = this.canvas.deviceData.get(id); + if (!d) return; + + document.getElementById("prop-partnumber").value = result.mpn; + document.getElementById("prop-manufacturer").value = result.manufacturer || ""; + + d.properties = { + ...(d.properties || {}), + partNumber: result.mpn, + manufacturer: result.manufacturer || "", + description: result.description || "", + datasheetUrl: result.datasheet_url || "", + }; + + await api.devices.update(id, { + properties: d.properties, + reference: d.reference, + }).catch(console.error); + + this._showDatasheetLink(result.datasheet_url); + this._flashSaved(); + } + + _showDatasheetLink(url) { + const row = document.getElementById("prop-datasheet-row"); + const link = document.getElementById("prop-datasheet-link"); + if (!row || !link) return; + if (url) { + link.href = url; + row.style.display = ""; + } else { + row.style.display = "none"; + } + } + + // ── Cable management ────────────────────────────────────────────────────────── + + _refreshGroupSection(device) { + const section = document.getElementById("group-section"); + if (!section) return; + if (device.device_type !== "group") { section.style.display = "none"; return; } + section.style.display = ""; + const color = device.properties?.fillColor || "#2828a0"; + const opacity = device.properties?.fillOpacity ?? 0.15; + document.getElementById("group-fill-color").value = color; + document.getElementById("group-fill-opacity").value = opacity; + document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%"; + } + + _refreshCableSection(device) { + const section = document.getElementById("cable-section"); + if (!section) return; + if (device.device_type !== "cable") { section.style.display = "none"; return; } + section.style.display = ""; + document.getElementById("cable-jacket-color").value = device.properties?.jacketColor || "#2a2a2a"; + const sl = device.properties?.sleeveLength ?? 60; + document.getElementById("cable-sleeve-length").value = sl; + document.getElementById("cable-sleeve-length-val").textContent = sl + " px"; + + const tbody = document.getElementById("conductor-table-body"); + tbody.innerHTML = ""; + (device.properties?.conductors || []).forEach((cond, i) => { + const tr = document.createElement("tr"); + tr.innerHTML = `${i + 1} + + + `; + tr.querySelector(".conductor-name-input").addEventListener("change", async (e) => { + const d = this.canvas.deviceData.get(device.id); + if (!d?.properties?.conductors) return; + const idx = parseInt(e.target.dataset.idx); + d.properties.conductors[idx].name = e.target.value; + const lp = d.pins.find(p => p.id === `L${idx + 1}`); + const rp = d.pins.find(p => p.id === `R${idx + 1}`); + if (lp) lp.name = e.target.value; + if (rp) rp.name = e.target.value; + await api.devices.update(d.id, { properties: d.properties, pins: d.pins }).catch(console.error); + this.canvas.updateDevice(d); + this._flashSaved(); + }); + tr.querySelector(".conductor-color-input").addEventListener("input", async (e) => { + const d = this.canvas.deviceData.get(device.id); + if (!d?.properties?.conductors) return; + d.properties.conductors[parseInt(e.target.dataset.idx)].color = e.target.value; + await api.devices.update(d.id, { properties: d.properties }).catch(console.error); + this.canvas.updateDevice(d); + this._flashSaved(); + }); + tr.querySelector(".conductor-del-btn").addEventListener("click", () => { + const d = this.canvas.deviceData.get(device.id); + if (d) this._removeConductor(d, parseInt(tr.querySelector(".conductor-del-btn").dataset.idx)); + }); + tbody.appendChild(tr); + }); + } + + async _addConductor(device) { + const d = this.canvas.deviceData.get(device.id); + if (!d) return; + const conductors = [...(d.properties?.conductors || [])]; + const color = CABLE_DEFAULT_COLORS[conductors.length % CABLE_DEFAULT_COLORS.length]; + const i = conductors.length; + conductors.push({ name: String(i + 1), color }); + const newH = conductors.length * 24 + 40; + d.properties = { ...d.properties, conductors, conductorCount: conductors.length }; + d.height = newH; + d.pins.push( + { id: `L${i + 1}`, name: String(i + 1), side: "left", x_offset: 0, y_offset: 26 + i * 24 + 12 }, + { id: `R${i + 1}`, name: String(i + 1), side: "right", x_offset: d.width, y_offset: 26 + i * 24 + 12 }, + ); + await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error); + this.canvas.updateDevice(d); + this.canvas.selectDevice(d.id); + this._flashSaved(); + } + + async _removeConductor(device, idx) { + const d = this.canvas.deviceData.get(device.id); + if (!d) return; + const conductors = [...(d.properties?.conductors || [])]; + if (conductors.length <= 1) { alert("Cable must have at least one conductor."); return; } + conductors.splice(idx, 1); + d.pins = d.pins.filter(p => p.id !== `L${idx + 1}` && p.id !== `R${idx + 1}`); + // Renumber remaining pins + let li = 0, ri = 0; + d.pins = d.pins.map(p => { + if (p.side === "left") { const n = li++; return { ...p, id: `L${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; } + if (p.side === "right") { const n = ri++; return { ...p, id: `R${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; } + return p; + }); + const newH = conductors.length * 24 + 40; + d.height = newH; + d.properties = { ...d.properties, conductors, conductorCount: conductors.length }; + await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error); + this.canvas.updateDevice(d); + this.canvas.selectDevice(d.id); + this._flashSaved(); + } + + _updateWireTwistedUI(isTwisted) { + document.getElementById("wire-stripe-lbl").textContent = isTwisted ? "Wire 2 Color" : "Stripe Color"; + document.getElementById("wire-stripe-toggle-lbl").style.display = isTwisted ? "none" : ""; + const picker = document.getElementById("wire-stripe"); + if (isTwisted) { + picker.disabled = false; + picker.style.opacity = "1"; + } else { + const hasStripe = document.getElementById("wire-stripe-enabled").checked; + picker.disabled = !hasStripe; + picker.style.opacity = hasStripe ? "1" : "0.3"; + } + } + + // ── Properties panel ────────────────────────────────────────────────────────── + + _showDeviceProps(device) { + this._setPropVisible("props-device"); + document.getElementById("prop-type").textContent = DEVICE_TYPES[device.device_type]?.label || device.device_type; + document.getElementById("prop-reference").value = device.reference || ""; + document.getElementById("prop-label").value = device.label || ""; + document.getElementById("prop-partnumber").value = device.properties?.partNumber || ""; + document.getElementById("prop-manufacturer").value = device.properties?.manufacturer || ""; + document.getElementById("prop-fontsize").value = device.properties?.fontSize || 12; + + const isGroup = device.device_type === "group"; + if (!isGroup) this._showDatasheetLink(device.properties?.datasheetUrl || ""); + else { const dr = document.getElementById("prop-datasheet-row"); if (dr) dr.style.display = "none"; } + document.getElementById("prop-save-connector").style.display = isGroup ? "none" : ""; + document.getElementById("prop-add-pin").style.display = isGroup ? "none" : ""; + document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : ""; + // Reset any open search results + const res = document.getElementById("octopart-results"); + const msg = document.getElementById("octopart-status-msg"); + if (res) { res.style.display = "none"; res.innerHTML = ""; } + if (msg) msg.style.display = "none"; + + this._refreshCableSection(device); + this._refreshGroupSection(device); + + const tbody = document.getElementById("pin-table-body"); + tbody.innerHTML = ""; + (device.pins || []).forEach(pin => { + const tr = document.createElement("tr"); + const sides = ["left", "right", "top", "bottom"]; + const sideOpts = sides.map(s => ``).join(""); + tr.innerHTML = `${pin.id} + + + `; + tr.querySelector(".pin-side-select").addEventListener("change", async (e) => { + const d = this.canvas.deviceData.get(device.id); + if (!d) return; + const p = d.pins.find(p => p.id === pin.id); + if (!p) return; + p.side = e.target.value; + this._recalcPinOffsets(d.pins, d.width, d.height); + await api.devices.update(d.id, { pins: d.pins }).catch(console.error); + this.canvas.updateDevice(d); + this._showDeviceProps(d); + this._flashSaved(); + }); + tr.querySelector(".pin-del-btn").addEventListener("click", () => { + const d = this.canvas.deviceData.get(device.id); + if (d) this._removeDevicePin(d, pin.id); + }); + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".pin-input").forEach(inp => { + inp.addEventListener("change", async (e) => { + const devId = parseInt(e.target.dataset.dev); + const pinId = e.target.dataset.pin; + const d = this.canvas.deviceData.get(devId); + if (!d) return; + const pin = d.pins.find(p => p.id === pinId); + if (pin) pin.name = e.target.value; + await api.devices.update(devId, { pins: d.pins }).catch(console.error); + this.canvas.updateDevice(d); + this._flashSaved(); + }); + }); + } + + _showWireProps(wire) { + this._setPropVisible("props-wire"); + const fd = this.canvas.deviceData.get(wire.from_device_id); + const td = this.canvas.deviceData.get(wire.to_device_id); + document.getElementById("wire-from").textContent = fd ? `${fd.reference || fd.label} : ${wire.from_pin}` : "—"; + document.getElementById("wire-to").textContent = td ? `${td.reference || td.label} : ${wire.to_pin}` : "—"; + document.getElementById("wire-label").value = wire.label || ""; + document.getElementById("wire-color").value = wire.color_primary || "#cc0000"; + const hasStripe = !!wire.color_stripe; + document.getElementById("wire-stripe-enabled").checked = hasStripe; + document.getElementById("wire-stripe").value = wire.color_stripe || "#cccccc"; + document.getElementById("wire-stripe").disabled = !hasStripe; + document.getElementById("wire-stripe").style.opacity = hasStripe ? "1" : "0.3"; + document.getElementById("wire-gauge").value = wire.gauge || "18 AWG"; + document.getElementById("wire-length").value = wire.length != null ? wire.length : ""; + document.getElementById("wire-unit").value = wire.length_unit || "in"; + const isTwisted = !!wire.twisted_pair; + document.getElementById("wire-twisted").checked = isTwisted; + document.getElementById("wire-twist-pitch").value = wire.twist_pitch || 16; + document.getElementById("wire-twist-pitch").disabled = !isTwisted; + document.getElementById("wire-twist-pitch").style.opacity = isTwisted ? "1" : "0.3"; + document.getElementById("wire-shielded").checked = !!wire.shielded; + document.getElementById("wire-show-size-label").checked = !!wire.show_size_label; + document.getElementById("wire-notes").value = wire.notes || ""; + this._updateWireTwistedUI(isTwisted); + this._refreshBundleDropdown(wire.bundle_id || null); + this._refreshBundleEditPanel(wire.bundle_id || null); + } + + _refreshBundleDropdown(selectedId) { + const sel = document.getElementById("wire-bundle-select"); + if (!sel) return; + sel.innerHTML = ''; + this._bundles.forEach(b => { + const opt = document.createElement("option"); + opt.value = b.id; + opt.textContent = b.label || `Bundle #${b.id}`; + if (b.id === selectedId) opt.selected = true; + sel.appendChild(opt); + }); + } + + _refreshBundleEditPanel(bundleId) { + const panel = document.getElementById("wire-bundle-edit"); + if (!panel) return; + if (!bundleId) { panel.style.display = "none"; return; } + const b = this._bundles.find(x => x.id === bundleId); + if (!b) { panel.style.display = "none"; return; } + panel.style.display = ""; + document.getElementById("wire-bundle-label").value = b.label || ""; + document.getElementById("wire-bundle-color").value = b.jacket_color || "#2a2a2a"; + } + + _clearProps() { this._setPropVisible("props-empty"); } + + _showMultiProps(count) { + this._setPropVisible("props-multi"); + const el = document.getElementById("props-multi-count"); + if (el) el.textContent = `${count} items selected`; + } + + _setPropVisible(activeId) { + ["props-empty", "props-device", "props-wire", "props-multi"].forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = id === activeId ? "" : "none"; + }); + } + + // ── Pin management ──────────────────────────────────────────────────────────── + + _recalcPinOffsets(pins, width, height) { + ["left", "right", "top", "bottom"].forEach(side => { + const sp = pins.filter(p => p.side === side); + sp.forEach((p, i) => { + const t = (i + 1) / (sp.length + 1); + if (side === "left") { p.x_offset = 0; p.y_offset = t * height; } + if (side === "right") { p.x_offset = width; p.y_offset = t * height; } + if (side === "top") { p.x_offset = t * width; p.y_offset = 0; } + if (side === "bottom") { p.x_offset = t * width; p.y_offset = height; } + }); + }); + } + + async _addDevicePin(device) { + const pins = device.pins || []; + const lastSide = pins[pins.length - 1]?.side || "right"; + const maxNum = pins.reduce((m, p) => Math.max(m, parseInt(p.id.replace(/\D/g, "")) || 0), 0); + const newPins = [...pins, { id: `pin_${maxNum + 1}`, name: String(maxNum + 1), side: lastSide, x_offset: 0, y_offset: 0 }]; + const newHeight = Math.max(device.height || 60, newPins.length * 18 + 20); + this._recalcPinOffsets(newPins, device.width, newHeight); + device.pins = newPins; + device.height = newHeight; + await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error); + this.canvas.updateDevice(device); + this._showDeviceProps(device); + this._flashSaved(); + } + + async _removeDevicePin(device, pinId) { + const connectedWireIds = []; + this.canvas.wireData.forEach((w, wId) => { + if ((w.from_device_id === device.id && w.from_pin === pinId) || + (w.to_device_id === device.id && w.to_pin === pinId)) connectedWireIds.push(wId); + }); + if (connectedWireIds.length && !confirm(`This pin has ${connectedWireIds.length} connected wire(s). Remove pin and its wires?`)) return; + for (const wId of connectedWireIds) { + await api.wires.delete(wId).catch(console.error); + this.canvas.removeWire(wId); + } + const newPins = (device.pins || []).filter(p => p.id !== pinId); + const newHeight = Math.max(60, newPins.length * 18 + 20); + this._recalcPinOffsets(newPins, device.width, newHeight); + device.pins = newPins; + device.height = newHeight; + await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error); + this.canvas.updateDevice(device); + this._showDeviceProps(device); + this._flashSaved(); + } + + _saveDeviceAsConnector(device) { + const pins = device.pins || []; + const connData = { + name: device.label || "From Canvas", + manufacturer: device.properties?.manufacturer || "", + part_number: device.properties?.partNumber || "", + category: "Custom", + description: "", + pin_count: pins.length, + pin_labels: pins.map(p => p.name || ""), + }; + const customConnId = device.properties?.customConnectorId; + if (customConnId != null) { + const existing = this._customConnectors.find(c => c.id === customConnId); + if (existing) { this.openConnectorModal({ ...existing, ...connData }); return; } + } + this.openConnectorModal(connData); + } +} + +window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); }); diff --git a/frontend/js/canvas.js b/frontend/js/canvas.js new file mode 100644 index 0000000..27f97ca --- /dev/null +++ b/frontend/js/canvas.js @@ -0,0 +1,1773 @@ +class DiagramCanvas { + constructor(containerId, callbacks) { + this.cb = callbacks; + this.mode = "select"; + this.routeMode = "ortho"; + this.scale = 1; + this.offsetX = 0; + this.offsetY = 0; + + this.deviceNodes = new Map(); // id -> Konva.Group + this.deviceData = new Map(); // id -> device object + this.wireNodes = new Map(); // id -> { main, stripe, hit, lbl } + this.wireData = new Map(); // id -> wire object + + this.selectedId = null; + this.selectedType = null; + this.selectedDeviceIds = new Set(); + this.wireStart = null; + this.previewLine = null; + + this._handleNodes = []; + this._resizeHandles = []; + this._wireCrossings = new Map(); // wireId -> [{x,y,segBase,t,isBelow}] + this._activeDrag = null; + this.snapEnabled = false; + this.snapSize = 10; + this.harnessMode = false; + this._multiDragAnchorId = null; + this._multiDragAnchorStartX = 0; + this._multiDragAnchorStartY = 0; + this._multiDragStartPos = null; + this._multiDragWireWaypoints = null; // wireId -> [{x,y}] snapshot at dragstart + this._bundleData = []; // array of bundle objects + this._bundleNodes = []; // Konva shapes for bundle overlays (on groupLayer) + + this._initStage(containerId); + this._initZoomPan(); + this._updateGrid(); + this._initStageEvents(); + this._initDropZone(); + } + + _snap(v) { return this.snapEnabled ? Math.round(v / this.snapSize) * this.snapSize : v; } + + toggleSnap(enabled) { + this.snapEnabled = enabled; + this._updateGrid(); + } + + setHarnessMode(enabled) { + this.harnessMode = enabled; + this.wireLayer.opacity(enabled ? 0.18 : 1); + if (enabled) this._renderHarness(); + else this._clearHarness(); + this.stage.batchDraw(); + } + + _clearHarness() { + this.harnessLayer.destroyChildren(); + this.harnessLayer.batchDraw(); + } + + loadBundles(bundles) { + this._bundleData = bundles || []; + this._renderBundles(); + } + + _renderBundles() { + this._bundleNodes.forEach(n => n.destroy()); + this._bundleNodes = []; + + this._bundleData.forEach(bundle => { + const wires = []; + this.wireData.forEach(w => { if (w.bundle_id === bundle.id) wires.push(w); }); + if (!wires.length) return; + + const color = bundle.jacket_color || "#2a2a2a"; + + wires.forEach(wire => { + const pts = this._routePoints(wire); + if (pts.length < 4) return; + const bg = new Konva.Line({ + points: pts, stroke: color, strokeWidth: 14, + lineCap: "round", lineJoin: "round", listening: false, + opacity: 0.55, + }); + this.groupLayer.add(bg); + this._bundleNodes.push(bg); + }); + + // Label at midpoint of first wire + const firstPts = this._routePoints(wires[0]); + if (firstPts.length >= 2) { + const [mx, my] = this._midPoint(firstPts); + const lbl = new Konva.Text({ + x: mx - 40, y: my - 8, width: 80, align: "center", + text: bundle.label || `Bundle #${bundle.id}`, + fontSize: 9, fontFamily: "monospace", + fill: "#ffffff", listening: false, + shadowColor: "#000", shadowBlur: 3, shadowOpacity: 0.9, + }); + this.groupLayer.add(lbl); + this._bundleNodes.push(lbl); + } + }); + + this.groupLayer.batchDraw(); + } + + _renderHarness() { + this._clearHarness(); + + // ── 1. Group wires by sorted device-pair key ───────────────────────────── + const pairMap = new Map(); // "aId_bId" → { aId, bId, wires[] } + this.wireData.forEach(wire => { + const aId = Math.min(wire.from_device_id, wire.to_device_id); + const bId = Math.max(wire.from_device_id, wire.to_device_id); + const key = `${aId}_${bId}`; + if (!pairMap.has(key)) pairMap.set(key, { aId, bId, wires: [] }); + pairMap.get(key).wires.push(wire); + }); + + // ── 2. Also build per-device departure clusters (for split-off loom) ───── + const deviceSideMap = new Map(); // "devId_side" → wires[] + this.wireData.forEach(wire => { + const fd = this.deviceData.get(wire.from_device_id); + const td = this.deviceData.get(wire.to_device_id); + const fp = (fd?.pins || []).find(p => p.id === wire.from_pin); + const tp = (td?.pins || []).find(p => p.id === wire.to_pin); + if (fd && fp) { + const k = `${wire.from_device_id}_${fp.side}`; + if (!deviceSideMap.has(k)) deviceSideMap.set(k, []); + deviceSideMap.get(k).push(wire); + } + if (td && tp) { + const k = `${wire.to_device_id}_${tp.side}`; + if (!deviceSideMap.has(k)) deviceSideMap.set(k, []); + deviceSideMap.get(k).push(wire); + } + }); + + const drawn = new Set(); // track already-rendered pair trunks + + // ── 3. Render departure looms (loom exit per device side) ───────────────── + const LOOM_LEN = 44; + deviceSideMap.forEach((wires, key) => { + if (wires.length < 2) return; + const [devId, side] = key.split('_'); + const dev = this.deviceData.get(parseInt(devId)); + if (!dev || dev.device_type === 'group') return; + + // Collect pin positions on this side of this device + const pts = wires.map(wire => { + const isFrom = wire.from_device_id === dev.id; + const d = this.deviceData.get(isFrom ? wire.from_device_id : wire.to_device_id); + const pin = (d?.pins || []).find(p => p.id === (isFrom ? wire.from_pin : wire.to_pin)); + return pin ? { x: d.x + pin.x_offset, y: d.y + pin.y_offset } : null; + }).filter(Boolean); + + if (!pts.length) return; + const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; + const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; + + const dir = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] }[side] || [1, 0]; + const ex = cx + dir[0] * LOOM_LEN; + const ey = cy + dir[1] * LOOM_LEN; + const n = wires.length; + const trunkW = Math.min(n * 3 + 5, 32); + + // Loom background + this.harnessLayer.add(new Konva.Line({ + points: [cx, cy, ex, ey], + stroke: '#0a0a18', strokeWidth: trunkW + 4, + lineCap: 'round', listening: false, + })); + // Colored wire stripes + const perp = [-dir[1], dir[0]]; + const spacing = trunkW / Math.max(n, 1); + const stripeW = Math.min(spacing * 0.7, 3.5); + wires.forEach((wire, i) => { + const off = (i - (n - 1) / 2) * spacing; + const ox = perp[0] * off, oy = perp[1] * off; + this.harnessLayer.add(new Konva.Line({ + points: [cx + ox, cy + oy, ex + ox, ey + oy], + stroke: wire.color_primary || '#cc0000', + strokeWidth: stripeW, lineCap: 'round', listening: false, + })); + }); + // Split-off circle at loom end + this.harnessLayer.add(new Konva.Circle({ + x: ex, y: ey, radius: trunkW / 2 + 4, + fill: '#12122a', stroke: '#4466cc', strokeWidth: 2, listening: false, + })); + }); + + // ── 4. Render bundle trunks between device pairs ─────────────────────────── + pairMap.forEach(({ aId, bId, wires }) => { + const dA = this.deviceData.get(aId); + const dB = this.deviceData.get(bId); + if (!dA || !dB) return; + if (dA.device_type === 'group' || dB.device_type === 'group') return; + + const aPts = [], bPts = []; + wires.forEach(wire => { + const fd = this.deviceData.get(wire.from_device_id); + const td = this.deviceData.get(wire.to_device_id); + const fp = (fd?.pins || []).find(p => p.id === wire.from_pin); + const tp = (td?.pins || []).find(p => p.id === wire.to_pin); + if (fp && fd) { + const pt = { x: fd.x + fp.x_offset, y: fd.y + fp.y_offset }; + (wire.from_device_id === aId ? aPts : bPts).push(pt); + } + if (tp && td) { + const pt = { x: td.x + tp.x_offset, y: td.y + tp.y_offset }; + (wire.to_device_id === bId ? bPts : aPts).push(pt); + } + }); + if (!aPts.length || !bPts.length) return; + + const ax = aPts.reduce((s, p) => s + p.x, 0) / aPts.length; + const ay = aPts.reduce((s, p) => s + p.y, 0) / aPts.length; + const bx = bPts.reduce((s, p) => s + p.x, 0) / bPts.length; + const by = bPts.reduce((s, p) => s + p.y, 0) / bPts.length; + + const dx = bx - ax, dy = by - ay, len = Math.hypot(dx, dy); + if (len < 2) return; + const ux = dx / len, uy = dy / len; + const perp = [-uy, ux]; + + const n = wires.length; + const trunkW = Math.min(n * 3 + 5, 32); + const spacing = trunkW / Math.max(n, 1); + const stripeW = Math.min(spacing * 0.7, 3.5); + + // Trunk background + this.harnessLayer.add(new Konva.Line({ + points: [ax, ay, bx, by], + stroke: '#0a0a18', strokeWidth: trunkW + 4, + lineCap: 'round', lineJoin: 'round', listening: false, + })); + // Colored wire stripes + wires.forEach((wire, i) => { + const off = (i - (n - 1) / 2) * spacing; + const ox = perp[0] * off, oy = perp[1] * off; + this.harnessLayer.add(new Konva.Line({ + points: [ax + ox, ay + oy, bx + ox, by + oy], + stroke: wire.color_primary || '#cc0000', + strokeWidth: stripeW, lineCap: 'round', listening: false, + })); + }); + + // Wire count + gauge badge at midpoint (only for multi-wire bundles) + if (n >= 2) { + const mx = (ax + bx) / 2, my = (ay + by) / 2; + const gauges = [...new Set(wires.map(w => w.gauge).filter(Boolean))]; + const badgeText = gauges.length === 1 ? `${n}W · ${gauges[0]}` : `${n}W`; + this.harnessLayer.add(new Konva.Rect({ + x: mx - 22, y: my - 9, + width: 44, height: 18, cornerRadius: 4, + fill: '#12122a', stroke: '#4466cc', strokeWidth: 1.5, listening: false, + })); + this.harnessLayer.add(new Konva.Text({ + x: mx - 22, y: my - 6, + text: badgeText, width: 44, align: 'center', + fontSize: 9, fontFamily: 'monospace', fill: '#99bbff', listening: false, + })); + } + }); + + this.harnessLayer.batchDraw(); + } + + // ── Stage setup ────────────────────────────────────────────────────────────── + + _initStage(containerId) { + const el = document.getElementById(containerId); + this.stage = new Konva.Stage({ container: containerId, width: el.clientWidth, height: el.clientHeight }); + this.groupLayer = new Konva.Layer(); // section boxes — bottommost + this.wireLayer = new Konva.Layer(); + this.harnessLayer = new Konva.Layer({ listening: false }); + this.deviceLayer = new Konva.Layer(); + this.uiLayer = new Konva.Layer(); + this.stage.add(this.groupLayer, this.wireLayer, this.harnessLayer, this.deviceLayer, this.uiLayer); + new ResizeObserver(() => { + this.stage.width(el.clientWidth); + this.stage.height(el.clientHeight); + this._updateGrid(); + }).observe(el); + } + + _initZoomPan() { + this.stage.on("wheel", (e) => { + e.evt.preventDefault(); + const oldScale = this.scale; + const factor = e.evt.deltaY < 0 ? 1.08 : 1 / 1.08; + this.scale = Math.max(0.08, Math.min(10, oldScale * factor)); + const ptr = this.stage.getPointerPosition(); + this.offsetX = ptr.x - ((ptr.x - this.offsetX) / oldScale) * this.scale; + this.offsetY = ptr.y - ((ptr.y - this.offsetY) / oldScale) * this.scale; + this._applyTransform(); + }); + + let panning = false, panLast = null; + this.stage.on("mousedown", (e) => { + if (e.evt.button === 1 || (e.evt.button === 0 && e.evt.altKey)) { + panning = true; + panLast = { x: e.evt.clientX, y: e.evt.clientY }; + this.stage.container().style.cursor = "grabbing"; + e.evt.preventDefault(); + } + }); + this.stage.on("mousemove", (e) => { + if (panning && panLast) { + this.offsetX += e.evt.clientX - panLast.x; + this.offsetY += e.evt.clientY - panLast.y; + panLast = { x: e.evt.clientX, y: e.evt.clientY }; + this._applyTransform(); + } + + if (this.wireStart && this.previewLine) { + const pos = this.stage.getPointerPosition(); + if (pos) { + const wp = this._s2w(pos.x, pos.y); + this.previewLine.points( + this._previewRoute(this.wireStart.wx, this.wireStart.wy, this.wireStart.side, wp.x, wp.y) + ); + this.uiLayer.batchDraw(); + } + } + + // Manual waypoint drag (wire body drag) + if (this._activeDrag) { + const pos = this.stage.getPointerPosition(); + if (pos) { + let { x, y } = this._s2w(pos.x, pos.y); + x = this._snap(x); y = this._snap(y); + const wire = this.wireData.get(this._activeDrag.wireId); + if (wire && wire.waypoints) { + wire.waypoints[this._activeDrag.idx] = { x, y }; + this._destroyWireVisuals(this._activeDrag.wireId); + this._renderWire(wire); + this.wireLayer.batchDraw(); + // Update handles to follow (preserves handle indices) + this._updateHandlePositions(wire); + } + } + } + }); + window.addEventListener("mouseup", () => { + if (panning) { panning = false; panLast = null; this.stage.container().style.cursor = ""; } + if (this._activeDrag) { + const wire = this.wireData.get(this._activeDrag.wireId); + this.cb.onWaypointsChanged?.(this._activeDrag.wireId, wire?.waypoints || []); + this._activeDrag = null; + this._recomputeJumps(); // waypoints changed → re-check crossings + } + }); + } + + _applyTransform() { + [this.groupLayer, this.wireLayer, this.harnessLayer, this.deviceLayer, this.uiLayer].forEach(l => { + l.position({ x: this.offsetX, y: this.offsetY }); + l.scale({ x: this.scale, y: this.scale }); + }); + this.stage.batchDraw(); + this._updateGrid(); + } + + _updateGrid() { + const gs = 20 * this.scale; + const ox = ((this.offsetX % gs) + gs) % gs; + const oy = ((this.offsetY % gs) + gs) % gs; + const c = this.stage.container(); + c.style.backgroundSize = `${gs}px ${gs}px`; + c.style.backgroundPosition = `${ox}px ${oy}px`; + } + + _s2w(sx, sy) { + return { x: (sx - this.offsetX) / this.scale, y: (sy - this.offsetY) / this.scale }; + } + + _initStageEvents() { + let rbStart = null; // screen coords where rubber-band began + let rbRect = null; // Konva.Rect drawn on uiLayer + let rbMoved = false; // true once mouse moved past threshold + + this.stage.on("mousedown", (e) => { + if (e.target !== this.stage) return; + if (e.evt.button !== 0 || e.evt.altKey) return; + if (this.mode !== "select" || this.wireStart) return; + rbStart = this.stage.getPointerPosition(); + rbMoved = false; + }); + + this.stage.on("mousemove", () => { + if (!rbStart) return; + const pos = this.stage.getPointerPosition(); + const dx = pos.x - rbStart.x, dy = pos.y - rbStart.y; + + if (!rbMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) { + rbMoved = true; + const wp = this._s2w(rbStart.x, rbStart.y); + rbRect = new Konva.Rect({ + x: wp.x, y: wp.y, width: 0, height: 0, + stroke: "#4488ff", strokeWidth: 1 / this.scale, + fill: "rgba(68,136,255,0.08)", listening: false, + }); + this.uiLayer.add(rbRect); + } + + if (rbMoved && rbRect) { + const wp1 = this._s2w(rbStart.x, rbStart.y); + const wp2 = this._s2w(pos.x, pos.y); + rbRect.setAttrs({ + x: Math.min(wp1.x, wp2.x), y: Math.min(wp1.y, wp2.y), + width: Math.abs(wp2.x - wp1.x), height: Math.abs(wp2.y - wp1.y), + }); + this.uiLayer.batchDraw(); + } + }); + + this.stage.on("mouseup", (e) => { + if (e.evt.button !== 0) return; + if (this._activeDrag) return; + + // Wire completion + if (this.wireStart) { + const pos = this.stage.getPointerPosition(); + const from = this.wireStart; + let completed = false; + if (pos) { + const shape = this.stage.getIntersection(pos); + if (shape && shape.name() === "pin-hit") { + const meta = shape._pinMeta; + if (meta && !(meta.deviceId === from.deviceId && meta.pinId === from.pinId)) { + this._cancelWire(); + this.cb.onWireCreated?.(from.deviceId, from.pinId, meta.deviceId, meta.pinId); + completed = true; + } + } + } + if (!completed) this._cancelWire(); + return; + } + + // Rubber-band or plain click on background + if (!rbStart) return; + const pos = this.stage.getPointerPosition(); + if (rbRect) { rbRect.destroy(); rbRect = null; this.uiLayer.batchDraw(); } + + if (!rbMoved) { + rbStart = null; + this.clearSelection(); + this.cb.onSelectionCleared?.(); + return; + } + + const wp1 = this._s2w(rbStart.x, rbStart.y); + const wp2 = this._s2w(pos.x, pos.y); + rbStart = null; rbMoved = false; + + const rx1 = Math.min(wp1.x, wp2.x), ry1 = Math.min(wp1.y, wp2.y); + const rx2 = Math.max(wp1.x, wp2.x), ry2 = Math.max(wp1.y, wp2.y); + + this.clearSelection(); + this.deviceData.forEach((d, id) => { + if (d.x < rx2 && d.x + d.width > rx1 && d.y < ry2 && d.y + d.height > ry1) { + this.selectedDeviceIds.add(id); + this._highlightDevice(id, true); + } + }); + + if (!this.selectedDeviceIds.size) { + this.cb.onSelectionCleared?.(); + return; + } + this.selectedType = "device"; + if (this.selectedDeviceIds.size === 1) { + const [id] = this.selectedDeviceIds; + this.selectedId = id; + this._showResizeHandles(this.deviceData.get(id)); + this.cb.onDeviceSelected?.(this.deviceData.get(id)); + } else { + this.cb.onMultipleSelected?.(this.selectedDeviceIds.size); + } + }); + } + + _initDropZone() { + const c = this.stage.container(); + c.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }); + c.addEventListener("drop", (e) => { + e.preventDefault(); + const rect = c.getBoundingClientRect(); + const wp = this._s2w(e.clientX - rect.left, e.clientY - rect.top); + const typeKey = e.dataTransfer.getData("device_type"); + const connId = e.dataTransfer.getData("connector_id"); + if (typeKey) this.cb.onDeviceDropped?.(typeKey, wp.x, wp.y); + if (connId) this.cb.onConnectorDropped?.(connId, wp.x, wp.y); + }); + } + + // ── Public API ──────────────────────────────────────────────────────────────── + + setMode(mode) { + this.mode = mode; + this.stage.container().classList.toggle("wire-mode", mode === "wire"); + this.deviceNodes.forEach(g => g.draggable(mode === "select")); + if (mode !== "wire") this._cancelWire(); + } + + setRouteMode(mode) { + this.routeMode = mode; + const wires = [...this.wireData.values()]; + wires.forEach(w => { this._destroyWireVisuals(w.id); this._renderWire(w); }); + if (this.selectedType === "wire") this._renderWireHandles(this.selectedId); + this._recomputeJumps(); + this._renderBundles(); + } + + loadDiagram(diagram) { + this.groupLayer.destroyChildren(); + this.wireLayer.destroyChildren(); + this.harnessLayer.destroyChildren(); + this.deviceLayer.destroyChildren(); + this.uiLayer.destroyChildren(); + this.deviceNodes.clear(); this.deviceData.clear(); + this.wireNodes.clear(); this.wireData.clear(); + this._handleNodes = []; + this.selectedId = null; this.selectedType = null; this.selectedDeviceIds.clear(); + this.wireStart = null; this.previewLine = null; this._activeDrag = null; + this._multiDragAnchorId = null; this._multiDragStartPos = null; + this._bundleNodes = []; this._bundleData = []; + + const sorted = [...(diagram.devices || [])].sort((a, b) => + (a.device_type === "group" ? -1 : 0) - (b.device_type === "group" ? -1 : 0) + ); + sorted.forEach(d => { + this.deviceData.set(d.id, d); + this._renderDevice(d); + }); + diagram.wires.forEach(w => { + if (!w.waypoints) w.waypoints = []; + this.wireData.set(w.id, w); + this._renderWire(w); + }); + this._recomputeJumps(); + this._renderBundles(); + if (this.harnessMode) this._renderHarness(); + this.deviceLayer.batchDraw(); + } + + addDevice(device) { + this.deviceData.set(device.id, device); + this._renderDevice(device); + this.deviceLayer.batchDraw(); + if (device.device_type === "group") this.groupLayer.batchDraw(); + } + + updateDevice(device) { + const old = this.deviceNodes.get(device.id); + if (old) { old.destroy(); this.deviceNodes.delete(device.id); } + this.deviceData.set(device.id, device); + this._renderDevice(device); + this._redrawWiresFor(device.id); + this.deviceLayer.batchDraw(); + if (device.device_type === "group") this.groupLayer.batchDraw(); + } + + removeDevice(deviceId) { + const d = this.deviceData.get(deviceId); + const g = this.deviceNodes.get(deviceId); + if (g) { g.destroy(); this.deviceNodes.delete(deviceId); this.deviceData.delete(deviceId); } + this.deviceLayer.batchDraw(); + if (d?.device_type === "group") this.groupLayer.batchDraw(); + } + + addWire(wire) { + if (!wire.waypoints) wire.waypoints = []; + this.wireData.set(wire.id, wire); + this._renderWire(wire); + this._recomputeJumps(); + this._renderBundles(); + if (this.harnessMode) this._renderHarness(); + } + + updateWire(wire) { + if (!wire.waypoints) wire.waypoints = []; + this._destroyWireVisuals(wire.id); + this.wireData.set(wire.id, wire); + this._renderWire(wire); + if (this.selectedId === wire.id) this._renderWireHandles(wire.id); + this._recomputeJumps(); + this._renderBundles(); + if (this.harnessMode) this._renderHarness(); + } + + removeWire(wireId) { + if (this.selectedId === wireId) this._clearWireHandles(); + this._destroyWireVisuals(wireId); + this.wireData.delete(wireId); + this._wireCrossings.delete(wireId); + this._recomputeJumps(); + this._renderBundles(); + if (this.harnessMode) this._renderHarness(); + } + + _highlightDevice(id, on) { + const g = this.deviceNodes.get(id); + if (!g) return; + const d = this.deviceData.get(id); + const isGroup = d?.device_type === "group"; + const offColor = isGroup ? (d.properties?.fillColor || "#2828a0") : "#5a5a8a"; + g.findOne("Rect").stroke(on ? "#4db8ff" : offColor); + (isGroup ? this.groupLayer : this.deviceLayer).batchDraw(); + } + + _wireHighlight(wireId, selected) { + const n = this.wireNodes.get(wireId); + const wire = this.wireData.get(wireId); + if (!n) return; + const w = selected ? 5 : 3; + if (wire?.twisted_pair) { + if (n.helix) n.helix.strokeWidth(w); + if (n.helix2) n.helix2.strokeWidth(w); + } else { + n.main.strokeWidth(w); + } + this.wireLayer.batchDraw(); + } + + clearSelection() { + this.selectedDeviceIds.forEach(id => this._highlightDevice(id, false)); + this.selectedDeviceIds.clear(); + this._clearResizeHandles(); + if (this.selectedType === "wire" && this.selectedId !== null) { + this._wireHighlight(this.selectedId, false); + this._clearWireHandles(); + } + this.selectedId = null; this.selectedType = null; + } + + selectDevice(deviceId) { + this.clearSelection(); + this.selectedDeviceIds.add(deviceId); + this.selectedId = deviceId; this.selectedType = "device"; + this._highlightDevice(deviceId, true); + this._showResizeHandles(this.deviceData.get(deviceId)); + this.cb.onDeviceSelected?.(this.deviceData.get(deviceId)); + } + + addToSelection(deviceId) { + this.selectedDeviceIds.add(deviceId); + this.selectedType = "device"; + this._highlightDevice(deviceId, true); + this._clearResizeHandles(); // multi-select → no resize handles + } + + selectWire(wireId) { + this.clearSelection(); + this.selectedId = wireId; this.selectedType = "wire"; + this._wireHighlight(wireId, true); + this._renderWireHandles(wireId); + this.cb.onWireSelected?.(this.wireData.get(wireId)); + } + + fitView() { + if (!this.deviceData.size) return; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + this.deviceData.forEach(d => { + minX = Math.min(minX, d.x); minY = Math.min(minY, d.y); + maxX = Math.max(maxX, d.x + d.width); maxY = Math.max(maxY, d.y + d.height); + }); + const pad = 60, sw = this.stage.width(), sh = this.stage.height(); + this.scale = Math.min(sw / (maxX - minX + pad * 2), sh / (maxY - minY + pad * 2), 2); + this.offsetX = sw / 2 - ((minX + maxX) / 2) * this.scale; + this.offsetY = sh / 2 - ((minY + maxY) / 2) * this.scale; + this._applyTransform(); + } + + zoomToSelection() { + const ids = this.selectedDeviceIds.size ? this.selectedDeviceIds + : this.selectedId && this.selectedType === "device" ? new Set([this.selectedId]) + : null; + if (!ids || !ids.size) return this.fitView(); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + ids.forEach(id => { + const d = this.deviceData.get(id); + if (!d) return; + minX = Math.min(minX, d.x); minY = Math.min(minY, d.y); + maxX = Math.max(maxX, d.x + d.width); maxY = Math.max(maxY, d.y + d.height); + }); + if (!isFinite(minX)) return; + const pad = 80, sw = this.stage.width(), sh = this.stage.height(); + this.scale = Math.min(sw / (maxX - minX + pad * 2), sh / (maxY - minY + pad * 2), 4); + this.offsetX = sw / 2 - ((minX + maxX) / 2) * this.scale; + this.offsetY = sh / 2 - ((minY + maxY) / 2) * this.scale; + this._applyTransform(); + } + + exportImage(name = "diagram") { + const dataURL = this.stage.toDataURL({ pixelRatio: 2, mimeType: "image/png" }); + const a = document.createElement("a"); + a.download = `${name}.png`; a.href = dataURL; a.click(); + } + + // ── Routing ─────────────────────────────────────────────────────────────────── + + // Main entry: returns flat [x,y,...] point array for a wire object + _routePoints(wire) { + const fd = this.deviceData.get(wire.from_device_id); + const td = this.deviceData.get(wire.to_device_id); + if (!fd || !td) return []; + 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 []; + + const x1 = fd.x + fp.x_offset, y1 = fd.y + fp.y_offset; + const x2 = td.x + tp.x_offset, y2 = td.y + tp.y_offset; + const wps = wire.waypoints || []; + + if (this.routeMode === "direct" || this.routeMode === "curved") { + return [x1, y1, ...wps.flatMap(p => [p.x, p.y]), x2, y2]; + } + + return this._orthoRoute( + x1, y1, fp.side, + x2, y2, tp.side, + wps, new Set([wire.from_device_id, wire.to_device_id]) + ); + } + + // Orthogonal route from pin1 to pin2 through optional waypoints + _orthoRoute(x1, y1, s1, x2, y2, s2, wps, excludeIds) { + const S = 24; + const ex1 = x1 + (s1 === "right" ? S : s1 === "left" ? -S : 0); + const ey1 = y1 + (s1 === "bottom" ? S : s1 === "top" ? -S : 0); + const ex2 = x2 + (s2 === "right" ? S : s2 === "left" ? -S : 0); + const ey2 = y2 + (s2 === "bottom" ? S : s2 === "top" ? -S : 0); + + let inner; + if (wps.length === 0) { + inner = this._autoOrtho(ex1, ey1, ex2, ey2, excludeIds); + } else { + inner = this._throughWaypoints(ex1, ey1, ex2, ey2, wps); + } + return [x1, y1, ...inner, x2, y2]; + } + + // Preview route during wire drawing (no target side known) + _previewRoute(x1, y1, side1, x2, y2) { + if (this.routeMode === "direct" || this.routeMode === "curved") return [x1, y1, x2, y2]; + const S = 24; + const ex1 = x1 + (side1 === "right" ? S : side1 === "left" ? -S : 0); + const ey1 = y1 + (side1 === "bottom" ? S : side1 === "top" ? -S : 0); + return [x1, y1, ex1, ey1, x2, ey1, x2, y2]; + } + + // Route through a series of waypoints using L-bends + _throughWaypoints(ex1, ey1, ex2, ey2, wps) { + const pts = [ex1, ey1]; + let px = ex1, py = ey1; + for (const wp of wps) { + pts.push(...this._lBend(px, py, wp.x, wp.y)); + px = wp.x; py = wp.y; + } + pts.push(...this._lBend(px, py, ex2, ey2)); + return pts; + } + + // L-bend: go horizontal to x2 then vertical to y2 (skips degenerate cases) + _lBend(x1, y1, x2, y2) { + if (Math.abs(x1 - x2) < 1 && Math.abs(y1 - y2) < 1) return []; + if (Math.abs(x1 - x2) < 1) return [x2, y2]; + if (Math.abs(y1 - y2) < 1) return [x2, y2]; + return [x2, y1, x2, y2]; + } + + // Auto orthogonal route with obstacle avoidance + _autoOrtho(ex1, ey1, ex2, ey2, excludeIds) { + // Build default route (horizontal if same height, L/S-bend otherwise) + const mx = (ex1 + ex2) / 2; + const defaultPts = Math.abs(ey1 - ey2) < 4 + ? [ex1, ey1, ex2, ey2] + : [ex1, ey1, mx, ey1, mx, ey2, ex2, ey2]; + + // Always check for obstacles even on straight horizontal runs + if (!this._anySegHitsBoxes(defaultPts, excludeIds)) return defaultPts; + + const hitBoxes = this._getHitBoxes(defaultPts, excludeIds); + if (!hitBoxes.length) return defaultPts; + + const margin = 22; + const topY = Math.min(...hitBoxes.map(b => b.y)) - margin; + const above = [ex1, ey1, ex1, topY, ex2, topY, ex2, ey2]; + if (!this._anySegHitsBoxes(above, excludeIds)) return above; + + const botY = Math.max(...hitBoxes.map(b => b.y + b.h)) + margin; + const below = [ex1, ey1, ex1, botY, ex2, botY, ex2, ey2]; + if (!this._anySegHitsBoxes(below, excludeIds)) return below; + + // Try routing around left or right side + const leftX = Math.min(...hitBoxes.map(b => b.x)) - margin; + const rightX = Math.max(...hitBoxes.map(b => b.x + b.w)) + margin; + const viaLeft = [ex1, ey1, ex1, ey1, leftX, ey1, leftX, ey2, ex2, ey2]; + const viaRight = [ex1, ey1, ex1, ey1, rightX, ey1, rightX, ey2, ex2, ey2]; + if (!this._anySegHitsBoxes(viaLeft, excludeIds)) return viaLeft; + if (!this._anySegHitsBoxes(viaRight, excludeIds)) return viaRight; + + return defaultPts; // fallback + } + + // ── Obstacle helpers ────────────────────────────────────────────────────────── + + _getDeviceBoxes() { + const result = []; + this.deviceData.forEach(d => { + if (d.device_type !== "group") result.push({ id: d.id, x: d.x, y: d.y, w: d.width, h: d.height }); + }); + return result; + } + + _anySegHitsBoxes(pts, excludeIds) { + const PAD = 6; + const boxes = this._getDeviceBoxes().filter(b => !excludeIds.has(b.id)); + for (let i = 0; i < pts.length - 2; i += 2) { + for (const box of boxes) { + if (this._segHitsBox(pts[i], pts[i + 1], pts[i + 2], pts[i + 3], box, PAD)) return true; + } + } + return false; + } + + _getHitBoxes(pts, excludeIds) { + const PAD = 6; + const boxes = this._getDeviceBoxes().filter(b => !excludeIds.has(b.id)); + const hits = new Set(); + for (let i = 0; i < pts.length - 2; i += 2) { + for (const box of boxes) { + if (this._segHitsBox(pts[i], pts[i + 1], pts[i + 2], pts[i + 3], box, PAD)) hits.add(box); + } + } + return [...hits]; + } + + _segHitsBox(x1, y1, x2, y2, box, pad) { + const minX = Math.min(x1, x2), maxX = Math.max(x1, x2); + const minY = Math.min(y1, y2), maxY = Math.max(y1, y2); + return maxX > box.x - pad && minX < box.x + box.w + pad && + maxY > box.y - pad && minY < box.y + box.h + pad; + } + + // ── Wire crossing / jump computation ───────────────────────────────────────── + + _segIntersect(x1, y1, x2, y2, x3, y3, x4, y4) { + const d1x = x2-x1, d1y = y2-y1, d2x = x4-x3, d2y = y4-y3; + const denom = d1x*d2y - d1y*d2x; + if (Math.abs(denom) < 1e-8) return null; // parallel + const t = ((x3-x1)*d2y - (y3-y1)*d2x) / denom; + const u = ((x3-x1)*d1y - (y3-y1)*d1x) / denom; + if (t < 0.02 || t > 0.98 || u < 0.02 || u > 0.98) return null; // no endpoint crossings + return { x: x1+t*d1x, y: y1+t*d1y, t, u }; + } + + _computeAllCrossings() { + const ids = [...this.wireData.keys()]; + const result = new Map(ids.map(id => [id, []])); + const ptCache = new Map(); + const getPts = id => { + if (!ptCache.has(id)) ptCache.set(id, this._routePoints(this.wireData.get(id))); + return ptCache.get(id); + }; + for (let i = 0; i < ids.length; i++) { + for (let j = i+1; j < ids.length; j++) { + const pA = getPts(ids[i]), pB = getPts(ids[j]); + for (let a = 0; a < pA.length-2; a += 2) { + for (let b = 0; b < pB.length-2; b += 2) { + const c = this._segIntersect(pA[a],pA[a+1],pA[a+2],pA[a+3], pB[b],pB[b+1],pB[b+2],pB[b+3]); + if (!c) continue; + result.get(ids[i]).push({ x: c.x, y: c.y, t: c.t, segBase: a, isBelow: true }); + result.get(ids[j]).push({ x: c.x, y: c.y, t: c.u, segBase: b, isBelow: false }); + } + } + } + } + this._wireCrossings = result; + } + + // Build SVG path string from flat [x,y,x,y...] points with gap/arc at crossings. + // "below" wire gets a gap; "above" wire gets a bezier bump arc. + _midPoint(pts) { + const n = pts.length / 2; + return [ + pts.reduce((s, v, i) => i % 2 === 0 ? s + v : s, 0) / n, + pts.reduce((s, v, i) => i % 2 === 1 ? s + v : s, 0) / n, + ]; + } + + _ptsToSvgPath(pts, crossings) { + const ARC_R = 8; + if (!pts || pts.length < 4) return ''; + + const bySeg = new Map(); + for (const c of (crossings || [])) { + if (!bySeg.has(c.segBase)) bySeg.set(c.segBase, []); + bySeg.get(c.segBase).push(c); + } + bySeg.forEach(arr => arr.sort((a, b) => a.t - b.t)); + + let path = `M ${pts[0]} ${pts[1]}`; + for (let s = 0; s < pts.length - 2; s += 2) { + const x1=pts[s], y1=pts[s+1], x2=pts[s+2], y2=pts[s+3]; + const dx=x2-x1, dy=y2-y1, len=Math.hypot(dx, dy); + if (len < 0.1) continue; + const ux=dx/len, uy=dy/len; + // left-hand perpendicular (screen coords, y-down): (-uy, ux) + const px=-uy, py=ux; + + for (const cross of (bySeg.get(s) || [])) { + if (cross.isBelow) continue; // below wire draws straight through — no gap + const f = (v) => v.toFixed(2); + const bx=cross.x - ux*ARC_R, by=cross.y - uy*ARC_R; + const ax=cross.x + ux*ARC_R, ay=cross.y + uy*ARC_R; + // Quadratic bezier bump perpendicular to wire direction + const cpx = cross.x + px * ARC_R * 1.8; + const cpy = cross.y + py * ARC_R * 1.8; + path += ` L ${f(bx)} ${f(by)} Q ${f(cpx)} ${f(cpy)} ${f(ax)} ${f(ay)}`; + } + path += ` L ${x2} ${y2}`; + } + return path; + } + + // Catmull-Rom spline through points — used by curved route mode + _ptsToSvgPathCurved(pts) { + if (!pts || pts.length < 4) return ''; + const P = []; + for (let i = 0; i < pts.length; i += 2) P.push({ x: pts[i], y: pts[i + 1] }); + if (P.length < 2) return ''; + if (P.length === 2) return `M ${P[0].x} ${P[0].y} L ${P[1].x} ${P[1].y}`; + const t = 0.4; + const f = v => v.toFixed(2); + let path = `M ${P[0].x} ${P[0].y}`; + for (let i = 0; i < P.length - 1; i++) { + const p0 = P[Math.max(0, i - 1)]; + const p1 = P[i]; + const p2 = P[i + 1]; + const p3 = P[Math.min(P.length - 1, i + 2)]; + const cp1x = p1.x + (p2.x - p0.x) * t; + const cp1y = p1.y + (p2.y - p0.y) * t; + const cp2x = p2.x - (p3.x - p1.x) * t; + const cp2y = p2.y - (p3.y - p1.y) * t; + path += ` C ${f(cp1x)} ${f(cp1y)} ${f(cp2x)} ${f(cp2y)} ${p2.x} ${p2.y}`; + } + return path; + } + + _makeSvgPath(pts, crossings) { + if (this.routeMode === "curved") return this._ptsToSvgPathCurved(pts); + return this._ptsToSvgPath(pts, crossings); + } + + _recomputeJumps() { + this._computeAllCrossings(); + this.wireData.forEach(wire => { + const n = this.wireNodes.get(wire.id); + if (!n) return; + const pts = this._routePoints(wire); + const svgPath = this._makeSvgPath(pts, this._wireCrossings.get(wire.id)); + if (n.shield) n.shield.data(svgPath); + if (!wire.twisted_pair) { + n.main.data(svgPath); + if (n.stripe) n.stripe.data(svgPath); + } + if (n.helix) n.helix.data(this._computeHelixPath(pts, wire.twist_pitch || 16, false)); + if (n.helix2) n.helix2.data(this._computeHelixPath(pts, wire.twist_pitch || 16, true)); + if (n.hit) n.hit.points(pts); + if (n.lbl) { const [mx, my] = this._midPoint(pts); n.lbl.setAttrs({ x: mx + 3, y: my - 10 }); } + }); + this.wireLayer.batchDraw(); + } + + // ── Device rendering ────────────────────────────────────────────────────────── + + _deviceFill(type) { + return { + connector: "#12253a", + terminal_block: "#122a1a", + component: "#1e1230", + splice: "#2a1e10", + label: "#22220e", + fuse: "#2a1c08", + relay: "#0a1628", + switch: "#0a2218", + bulb: "#24220a", + motor: "#1a0a28", + diode: "#28081a", + resistor: "#1a1a08", + capacitor: "#081a1a", + ground: "#0e140e", + power: "#1a0808", + cable: "#1a1a1a", + group: "rgba(40,40,80,0.35)", + }[type] || "#1e1e2e"; + } + + _renderDevice(device) { + const group = new Konva.Group({ x: device.x, y: device.y, draggable: this.mode === "select" }); + const fontSize = Math.max(6, Math.min(72, device.properties?.fontSize || 12)); + + if (device.device_type === "group") { + const gColor = device.properties?.fillColor || "#2828a0"; + const gOpacity = device.properties?.fillOpacity ?? 0.15; + group.add(new Konva.Rect({ + name: "device-rect", + width: device.width, height: device.height, + fill: gColor, opacity: gOpacity, + stroke: gColor, strokeWidth: 1.5, + dash: [8, 5], cornerRadius: 6, + listening: false, + })); + group.add(new Konva.Rect({ + name: "group-header", + x: 0, y: 0, width: device.width, height: 22, + fill: gColor, opacity: Math.min(1, gOpacity * 2 + 0.25), + cornerRadius: [6, 6, 0, 0], + })); + group.add(new Konva.Text({ + name: "device-label", + x: 8, y: 4, width: device.width - 16, + text: device.label, fontSize, fontFamily: "monospace", fill: "#ffffff", + fontStyle: "bold", listening: false, + })); + } else { + + const rect = new Konva.Rect({ + name: "device-rect", + width: device.width, height: device.height, + fill: this._deviceFill(device.device_type), stroke: "#5a5a8a", strokeWidth: 2, cornerRadius: 4, + }); + group.add(rect); + + if (device.device_type === "label") { + // Full-box text for note/label type + group.add(new Konva.Text({ + name: "device-label", + x: 8, y: 8, width: device.width - 16, height: device.height - 16, + text: device.label, fontSize, fontFamily: "monospace", fill: "#dde0f5", + align: "left", verticalAlign: "top", wrap: "word", + })); + } else if (device.device_type === "cable") { + const jacket = device.properties?.jacketColor || "#2a2a2a"; + const conductors = device.properties?.conductors || []; + const sleeveLen = device.properties?.sleeveLength ?? 60; + + // ── Conductor sleeves (rendered first so they sit behind device body) ── + if (sleeveLen > 0 && conductors.length > 0) { + const n = conductors.length; + // Pin y positions match deviceTypes.js: 26 + i*24 + 12 + const pinYs = conductors.map((_, i) => 26 + i * 24 + 12); + const minY = pinYs[0], maxY = pinYs[n - 1]; + const bundleCY = (minY + maxY) / 2; + const sleeveH = Math.max(maxY - minY + 20, 14); + const capR = sleeveH / 2; + + [ + { side: "left", sx: -sleeveLen, ex: 0, pinX: 0 }, + { side: "right", sx: device.width, ex: device.width + sleeveLen, pinX: device.width }, + ].forEach(({ sx, ex, pinX }) => { + const goesRight = ex > sx; + + // Jacket tube + group.add(new Konva.Rect({ + x: Math.min(sx, ex), y: bundleCY - capR, + width: sleeveLen, height: sleeveH, + fill: jacket, + cornerRadius: goesRight + ? [capR, 0, 0, capR] // left sleeve: rounded on outer (left) end + : [0, capR, capR, 0], // right sleeve: rounded on outer (right) end + listening: false, + })); + + // Conductor color stripes inside the sleeve tube + const stripeSpacing = sleeveH / n; + const stripeW = Math.min(stripeSpacing * 0.55, 4); + conductors.forEach((cond, i) => { + const sy2 = bundleCY - capR + (i + 0.5) * stripeSpacing - stripeW / 2; + group.add(new Konva.Rect({ + x: Math.min(sx, ex), y: sy2, + width: sleeveLen, height: stripeW, + fill: cond.color || "#888888", opacity: 0.55, + listening: false, + })); + }); + + // End-cap line (outer tip of sleeve) + const capX = goesRight ? sx : ex; + group.add(new Konva.Line({ + points: [capX, bundleCY - capR, capX, bundleCY + capR], + stroke: "#777", strokeWidth: 1.5, listening: false, + })); + + // Conductor fan-out stubs from sleeve tip to pin y positions + conductors.forEach((cond, i) => { + const py = pinYs[i]; + group.add(new Konva.Line({ + points: [capX, bundleCY, pinX, py], + stroke: cond.color || "#888888", + strokeWidth: 2.5, lineCap: "round", listening: false, + })); + }); + }); + } + + // ── Cable device body ── + // Header jacket bar + group.add(new Konva.Rect({ x: 2, y: 2, width: device.width - 4, height: 22, fill: jacket, cornerRadius: [3,3,0,0] })); + if (device.reference) { + group.add(new Konva.Text({ x: 6, y: 6, text: device.reference, fontSize: 9, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" })); + } + group.add(new Konva.Text({ + name: "device-label", + x: 4, y: 6, width: device.width - 8, align: "center", + text: device.label, fontSize: 10, fontFamily: "monospace", fill: "#dde0f5", + })); + // Conductor rows + conductors.forEach((cond, i) => { + const rowY = 26 + i * 24; + const color = cond.color || "#888888"; + group.add(new Konva.Rect({ x: 1, y: rowY, width: device.width - 2, height: 24, fill: "#0e0e1a" })); + group.add(new Konva.Line({ + points: [14, rowY + 12, device.width - 14, rowY + 12], + stroke: color, strokeWidth: 5, lineCap: "round", listening: false, + })); + group.add(new Konva.Text({ + x: 16, y: rowY + 4, text: cond.name || String(i + 1), + fontSize: 9, fontFamily: "monospace", fill: "#c8ccee", listening: false, + })); + }); + // Footer jacket bar + group.add(new Konva.Rect({ x: 2, y: device.height - 16, width: device.width - 4, height: 14, fill: jacket, cornerRadius: [0,0,3,3] })); + group.add(new Konva.Text({ + name: "device-type", + x: 4, y: device.height - 14, width: device.width - 8, align: "right", + text: "cable", fontSize: 8, fontFamily: "monospace", fill: "#888888", + })); + } else { + if (device.reference) { + group.add(new Konva.Text({ x: 6, y: 5, text: device.reference, fontSize: 10, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" })); + } + group.add(new Konva.Text({ + name: "device-label", + x: 4, y: device.height / 2 - fontSize / 2 - 2, width: device.width - 8, align: "center", + text: device.label, fontSize, fontFamily: "monospace", fill: "#dde0f5", wrap: "word", + })); + group.add(new Konva.Text({ + name: "device-type", + x: 4, y: device.height - 14, width: device.width - 8, align: "right", + text: device.device_type, fontSize: 8, fontFamily: "monospace", fill: "#444466", + })); + } + } // end non-group else + + (device.pins || []).forEach(pin => this._addPin(group, device, pin)); + + group.on("contextmenu", (e) => { + e.evt.preventDefault(); + e.cancelBubble = true; + if (!this.selectedDeviceIds.has(device.id)) this.selectDevice(device.id); + this.cb.onDeviceContextMenu?.(device.id, e.evt.clientX, e.evt.clientY); + }); + + group.on("click", (e) => { + if (this.mode !== "select") return; + e.cancelBubble = true; + if (e.evt.ctrlKey || e.evt.metaKey || e.evt.shiftKey) { + // Toggle membership in multi-selection + if (this.selectedDeviceIds.has(device.id)) { + this.selectedDeviceIds.delete(device.id); + this._highlightDevice(device.id, false); + if (!this.selectedDeviceIds.size) { + this.selectedId = null; this.selectedType = null; + this._clearResizeHandles(); + this.cb.onSelectionCleared?.(); + } else if (this.selectedDeviceIds.size === 1) { + const [id] = this.selectedDeviceIds; + this.selectedId = id; + this._showResizeHandles(this.deviceData.get(id)); + this.cb.onDeviceSelected?.(this.deviceData.get(id)); + } else { + this._clearResizeHandles(); + this.cb.onMultipleSelected?.(this.selectedDeviceIds.size); + } + } else { + this.selectedDeviceIds.add(device.id); + this._highlightDevice(device.id, true); + this.selectedType = "device"; + if (this.selectedDeviceIds.size === 1) { + this.selectedId = device.id; + this._showResizeHandles(this.deviceData.get(device.id)); + this.cb.onDeviceSelected?.(this.deviceData.get(device.id)); + } else { + this.selectedId = null; + this._clearResizeHandles(); + this.cb.onMultipleSelected?.(this.selectedDeviceIds.size); + } + } + } else if (this.selectedDeviceIds.size > 1 && this.selectedDeviceIds.has(device.id)) { + // Click on already-selected member: keep the group, don't collapse to single + } else { + this.selectDevice(device.id); + } + }); + group.on("dragstart", () => { + if (this.wireStart) { group.stopDrag(); return; } + if (device.device_type !== "group") group.moveToTop(); + this._clearResizeHandles(); // hide during drag + if (this.selectedDeviceIds.size > 1 && this.selectedDeviceIds.has(device.id)) { + this._multiDragAnchorId = device.id; + this._multiDragAnchorStartX = group.x(); + this._multiDragAnchorStartY = group.y(); + this._multiDragStartPos = new Map(); + this.selectedDeviceIds.forEach(id => { + if (id === device.id) return; + const g = this.deviceNodes.get(id); + if (g) this._multiDragStartPos.set(id, { x: g.x(), y: g.y() }); + }); + // Capture waypoints for wires whose both endpoints are in the selection + this._multiDragWireWaypoints = new Map(); + this.wireData.forEach((wire, wId) => { + if (this.selectedDeviceIds.has(wire.from_device_id) && + this.selectedDeviceIds.has(wire.to_device_id) && + wire.waypoints?.length) { + this._multiDragWireWaypoints.set(wId, wire.waypoints.map(wp => ({ ...wp }))); + } + }); + // Snapshot all selected positions for undo + const preDrag = new Map(); + this.selectedDeviceIds.forEach(id => { + const g = this.deviceNodes.get(id); + if (g) preDrag.set(id, { x: g.x(), y: g.y() }); + }); + const preWaypoints = new Map(); + this._multiDragWireWaypoints.forEach((wps, wId) => preWaypoints.set(wId, wps.map(wp => ({ ...wp })))); + this.cb.onDragStarted?.({ positions: preDrag, waypoints: preWaypoints }); + } else { + this._multiDragAnchorId = null; + this._multiDragWireWaypoints = null; + // Single-device undo snapshot + const preDrag = new Map([[device.id, { x: group.x(), y: group.y() }]]); + this.cb.onDragStarted?.({ positions: preDrag, waypoints: new Map() }); + } + }); + group.on("dragmove", () => { + if (this.snapEnabled) { group.x(this._snap(group.x())); group.y(this._snap(group.y())); } + const d = this.deviceData.get(device.id); + if (d) { d.x = group.x(); d.y = group.y(); } + if (this._multiDragAnchorId === device.id) { + const dx = group.x() - this._multiDragAnchorStartX; + const dy = group.y() - this._multiDragAnchorStartY; + this._multiDragStartPos.forEach(({ x, y }, id) => { + const g = this.deviceNodes.get(id); + const dd = this.deviceData.get(id); + if (g) { g.x(x + dx); g.y(y + dy); } + if (dd) { dd.x = x + dx; dd.y = y + dy; } + this._redrawWiresFor(id); + }); + // Translate captured waypoints + this._multiDragWireWaypoints?.forEach((origWps, wId) => { + const wire = this.wireData.get(wId); + if (!wire) return; + wire.waypoints = origWps.map(wp => ({ x: wp.x + dx, y: wp.y + dy })); + }); + } + this._redrawWiresFor(device.id); + }); + group.on("dragend", () => { + if (this._multiDragAnchorId === device.id) { + this.selectedDeviceIds.forEach(id => { + const g = this.deviceNodes.get(id); + if (g) this.cb.onDeviceMoved?.(id, g.x(), g.y()); + }); + // Persist translated waypoints + this._multiDragWireWaypoints?.forEach((_, wId) => { + const wire = this.wireData.get(wId); + if (wire) this.cb.onWaypointsChanged?.(wId, wire.waypoints); + }); + this.cb.onDragEnded?.([...this.selectedDeviceIds]); + this._multiDragAnchorId = null; + this._multiDragStartPos = null; + this._multiDragWireWaypoints = null; + } else { + this.cb.onDeviceMoved?.(device.id, group.x(), group.y()); + this.cb.onDragEnded?.([device.id]); + // Re-show resize handles for single selected device + if (this.selectedDeviceIds.size === 1 && this.selectedDeviceIds.has(device.id)) { + this._showResizeHandles(this.deviceData.get(device.id)); + } + } + this._recomputeJumps(); // devices moved → wire paths changed → re-check crossings + this._renderBundles(); + if (this.harnessMode) this._renderHarness(); + }); + + this.deviceNodes.set(device.id, group); + if (device.device_type === "group") this.groupLayer.add(group); + else this.deviceLayer.add(group); + } + + // ── Resize handles ─────────────────────────────────────────────────────────── + + _clearResizeHandles() { + this._resizeHandles.forEach(h => h.destroy()); + this._resizeHandles = []; + this.uiLayer.batchDraw(); + } + + _showResizeHandles(device) { + this._clearResizeHandles(); + if (!device || device.device_type === "cable") return; + + const ANCHORS = [ + { n: "nw", cur: "nw-resize" }, { n: "n", cur: "n-resize" }, + { n: "ne", cur: "ne-resize" }, { n: "e", cur: "e-resize" }, + { n: "se", cur: "se-resize" }, { n: "s", cur: "s-resize" }, + { n: "sw", cur: "sw-resize" }, { n: "w", cur: "w-resize" }, + ]; + + const anchorPos = (name, d) => ({ + x: name.includes("e") ? d.x + d.width : name.includes("w") ? d.x : d.x + d.width / 2, + y: name.includes("s") ? d.y + d.height : name.includes("n") ? d.y : d.y + d.height / 2, + }); + + ANCHORS.forEach(({ n: an, cur }) => { + const p = anchorPos(an, device); + const h = new Konva.Circle({ + x: p.x, y: p.y, radius: 5, + fill: "#ffffff", stroke: "#4488ff", strokeWidth: 1.5, + draggable: true, hitStrokeWidth: 10, + }); + h._anchorName = an; + + let startSnap = null; + + h.on("mouseenter", (e) => { e.cancelBubble = true; this.stage.container().style.cursor = cur; }); + h.on("mouseleave", () => { this.stage.container().style.cursor = ""; }); + + h.on("dragstart", (e) => { + e.cancelBubble = true; + startSnap = { x: device.x, y: device.y, w: device.width, h: device.height }; + this.deviceNodes.get(device.id)?.draggable(false); + }); + + h.on("dragmove", () => { + if (!startSnap) return; + const MIN = 30; + const re = startSnap.x + startSnap.w, be = startSnap.y + startSnap.h; + let nx = startSnap.x, ny = startSnap.y, nw = startSnap.w, nh = startSnap.h; + + if (an.includes("e")) nw = Math.max(MIN, h.x() - startSnap.x); + if (an.includes("s")) nh = Math.max(MIN, h.y() - startSnap.y); + if (an.includes("w")) { nx = Math.min(h.x(), re - MIN); nw = re - nx; } + if (an.includes("n")) { ny = Math.min(h.y(), be - MIN); nh = be - ny; } + + device.x = nx; device.y = ny; device.width = nw; device.height = nh; + + const g = this.deviceNodes.get(device.id); + if (g) { + g.x(nx); g.y(ny); + g.findOne(".device-rect")?.setAttrs({ width: nw, height: nh }); + const lbl = g.findOne(".device-label"); + if (lbl) { + if (device.device_type === "label") { + lbl.setAttrs({ width: nw - 16, height: nh - 16 }); + } else if (device.device_type === "group") { + lbl.setAttrs({ width: nw - 16 }); + g.findOne(".group-header")?.setAttrs({ width: nw }); + } else { + lbl.setAttrs({ y: nh / 2 - lbl.fontSize() / 2 - 2, width: nw - 8 }); + } + } + g.findOne(".device-type")?.setAttrs({ y: nh - 14, width: nw - 8 }); + } + + // Reposition all handles + this._resizeHandles.forEach(rh => { + const rp = anchorPos(rh._anchorName, device); + rh.setAttrs({ x: rp.x, y: rp.y }); + }); + + this._redrawWiresFor(device.id); + this.deviceLayer.batchDraw(); + this.uiLayer.batchDraw(); + }); + + h.on("dragend", (e) => { + e.cancelBubble = true; + if (this.mode === "select") this.deviceNodes.get(device.id)?.draggable(true); + this.cb.onDeviceResized?.(device.id, device.x, device.y, device.width, device.height); + this._recomputeJumps(); + this._renderBundles(); + }); + + this._resizeHandles.push(h); + this.uiLayer.add(h); + }); + this.uiLayer.batchDraw(); + } + + _addPin(group, device, pin) { + const circle = new Konva.Circle({ + x: pin.x_offset, y: pin.y_offset, radius: 5, + fill: "#0a0f1a", stroke: "#5566aa", strokeWidth: 1.5, + }); + const hit = new Konva.Circle({ + x: pin.x_offset, y: pin.y_offset, radius: 14, + fill: "rgba(0,0,0,0.01)", name: "pin-hit", + }); + hit._pinMeta = { deviceId: device.id, pinId: pin.id, side: pin.side }; + + // Position label inside the device body, clear of the pin circle (r=5, gap=3 → offset 8) + const GAP = 8; + let lx, ly, lw, la; + switch (pin.side) { + case "right": + lx = pin.x_offset - 28; ly = pin.y_offset - 5; lw = 20; la = "right"; break; + case "top": + lx = pin.x_offset - 10; ly = pin.y_offset + GAP; lw = 20; la = "center"; break; + case "bottom": + lx = pin.x_offset - 10; ly = pin.y_offset - 13; lw = 20; la = "center"; break; + default: // left + lx = pin.x_offset + GAP; ly = pin.y_offset - 5; lw = 20; la = "left"; + } + group.add(new Konva.Text({ + x: lx, y: ly, width: lw, align: la, + text: pin.name, fontSize: 8, fontFamily: "monospace", fill: "#556688", + })); + group.add(circle); + group.add(hit); + + const highlightPin = (active) => { + circle.fill(active ? "#003300" : "#0a0f1a"); + circle.stroke(active ? "#00dd00" : "#5566aa"); + circle.radius(active ? 7 : 5); + this.deviceLayer.batchDraw(); + }; + + hit.on("mouseenter", () => { highlightPin(true); this.stage.container().style.cursor = "crosshair"; }); + hit.on("mouseleave", () => { highlightPin(false); this.stage.container().style.cursor = this.wireStart ? "crosshair" : ""; }); + + hit.on("mousedown", (e) => { + if (e.evt.button !== 0) return; + e.cancelBubble = true; + group.draggable(false); + + const abs = circle.getAbsolutePosition(); + const wx = (abs.x - this.offsetX) / this.scale; + const wy = (abs.y - this.offsetY) / this.scale; + + this.wireStart = { deviceId: device.id, pinId: pin.id, wx, wy, side: pin.side }; + this.previewLine = new Konva.Line({ + points: [wx, wy, wx, wy], stroke: "#00ff88", + strokeWidth: 2, dash: [5, 4], listening: false, + }); + this.uiLayer.add(this.previewLine); + this.uiLayer.batchDraw(); + }); + } + + _cancelWire() { + const wasWiring = !!this.wireStart; + this.wireStart = null; + if (this.previewLine) { this.previewLine.destroy(); this.previewLine = null; this.uiLayer.batchDraw(); } + if (wasWiring && this.mode === "select") { + this.deviceNodes.forEach(g => g.draggable(true)); + } + } + + // ── Wire rendering ──────────────────────────────────────────────────────────── + + // Returns SVG path string of alternating bezier arcs along the polyline. + // flip=true starts arcs on the opposite side — used for the second wire of a twisted pair. + _computeHelixPath(pts, pitch = 16, flip = false) { + if (!pts || pts.length < 4) return ''; + const points = []; + let total = 0; + for (let i = 0; i < pts.length; i += 2) { + if (i === 0) { points.push({ x: pts[0], y: pts[1], d: 0 }); continue; } + const dx = pts[i] - pts[i-2], dy = pts[i+1] - pts[i-1]; + total += Math.sqrt(dx*dx + dy*dy); + points.push({ x: pts[i], y: pts[i+1], d: total }); + } + if (total < pitch) return ''; + + const interp = (d) => { + d = Math.max(0, Math.min(total, d)); + for (let i = 1; i < points.length; i++) { + if (points[i].d >= d) { + const p0 = points[i-1], p1 = points[i]; + const segLen = p1.d - p0.d; + if (segLen < 1e-6) return { x: p0.x, y: p0.y, ux: 0, uy: 1 }; + const t = (d - p0.d) / segLen; + const len = Math.sqrt((p1.x-p0.x)**2 + (p1.y-p0.y)**2); + return { x: p0.x + t*(p1.x-p0.x), y: p0.y + t*(p1.y-p0.y), + ux: (p1.x-p0.x)/len, uy: (p1.y-p0.y)/len }; + } + } + const last = points[points.length-1]; + return { x: last.x, y: last.y, ux: 0, uy: 1 }; + }; + + const amp = pitch * 0.5; + let d = '', side = flip ? -1 : 1; + const start0 = interp(0); + d += `M ${start0.x.toFixed(1)} ${start0.y.toFixed(1)} `; + for (let dist = 0; dist + pitch <= total; dist += pitch) { + const mid = interp(dist + pitch / 2); + const end = interp(dist + pitch); + const cpx = mid.x + (-mid.uy) * amp * side; + const cpy = mid.y + mid.ux * amp * side; + d += `Q ${cpx.toFixed(1)} ${cpy.toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)} `; + side *= -1; + } + return d.trim(); + } + + _renderWire(wire) { + const pts = this._routePoints(wire); + if (!pts.length) return; + + const svgPath = this._makeSvgPath(pts, this._wireCrossings.get(wire.id)); + + let shield = null; + if (wire.shielded) { + shield = new Konva.Path({ + data: svgPath, stroke: "rgba(160,200,255,0.45)", + strokeWidth: 9, lineCap: "round", lineJoin: "round", + dash: [6, 4], fill: null, listening: false, + }); + this.wireLayer.add(shield); + } + + const main = new Konva.Path({ + data: svgPath, stroke: wire.color_primary || "#cc0000", + strokeWidth: 3, lineCap: "round", lineJoin: "round", fill: null, + }); + + let stripe = null; + if (wire.color_stripe) { + stripe = new Konva.Path({ + data: svgPath, stroke: wire.color_stripe, + strokeWidth: 1.2, lineCap: "round", dash: [7, 7], listening: false, fill: null, + }); + } + + // Keep hit as Konva.Line for reliable click/drag detection over full wire + const hit = new Konva.Line({ + points: pts, stroke: "rgba(0,0,0,0.01)", + strokeWidth: 16, lineCap: "round", lineJoin: "round", + }); + + let lbl = null; + const gaugePart = wire.show_size_label && wire.gauge ? wire.gauge : ""; + const labelText = [wire.label || "", wire.length ? `${wire.length}${wire.length_unit}` : "", gaugePart].filter(Boolean).join(" "); + if (labelText) { + const [mx, my] = this._midPoint(pts); + lbl = new Konva.Text({ + x: mx + 3, y: my - 10, + text: labelText, + fontSize: 9, fontFamily: "monospace", fill: "#aabbcc", listening: false, + shadowColor: "#000", shadowBlur: 3, shadowOpacity: 0.8, + }); + } + + hit.on("click", (e) => { + if (this.mode !== "select") return; + e.cancelBubble = true; + this.selectWire(wire.id); + }); + hit.on("mouseenter", () => { + if (this.mode === "select" && this.selectedId !== wire.id) { + this._wireHighlight(wire.id, true); + this.stage.container().style.cursor = "pointer"; + } + }); + hit.on("mouseleave", () => { + if (this.selectedId !== wire.id) this._wireHighlight(wire.id, false); + this.stage.container().style.cursor = ""; + }); + + hit.on("contextmenu", (e) => { + e.evt.preventDefault(); + e.cancelBubble = true; + 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 + hit.on("mousedown", (e) => { + if (this.mode !== "select" || e.evt.button !== 0) return; + if (this.selectedId !== wire.id) return; // unselected: let click handle it + e.cancelBubble = true; + + const pos = this.stage.getPointerPosition(); + const { x, y } = this._s2w(pos.x, pos.y); + const result = this._insertWaypoint(wire, x, y); + wire.waypoints = result.waypoints; + + this._destroyWireVisuals(wire.id); + this._renderWire(wire); + this.wireLayer.batchDraw(); + this._renderWireHandles(wire.id); + this._activeDrag = { wireId: wire.id, idx: result.idx }; + }); + + let helix = null, helix2 = null; + if (wire.twisted_pair) { + const pitch = wire.twist_pitch || 16; + const h1 = this._computeHelixPath(pts, pitch, false); + const h2 = this._computeHelixPath(pts, pitch, true); + if (h1) helix = new Konva.Path({ + data: h1, stroke: wire.color_primary || "#cc0000", + strokeWidth: 3, lineCap: "round", lineJoin: "round", fill: null, listening: false, + }); + if (h2) helix2 = new Konva.Path({ + data: h2, stroke: wire.color_stripe || "#dddddd", + strokeWidth: 3, lineCap: "round", lineJoin: "round", fill: null, listening: false, + }); + } + + if (!wire.twisted_pair || (!helix && !helix2)) { + this.wireLayer.add(main); + if (stripe) this.wireLayer.add(stripe); + } + if (helix) this.wireLayer.add(helix); + if (helix2) this.wireLayer.add(helix2); + if (lbl) this.wireLayer.add(lbl); + this.wireLayer.add(hit); + + this.wireNodes.set(wire.id, { main, stripe, hit, lbl, helix, helix2, shield }); + } + + _redrawWiresFor(deviceId) { + this.wireData.forEach(wire => { + if (wire.from_device_id !== deviceId && wire.to_device_id !== deviceId) return; + const n = this.wireNodes.get(wire.id); + if (!n) return; + const pts = this._routePoints(wire); + // Use cached crossings during drag (fast); _recomputeJumps() fixes them after drag + const svgPath = this._makeSvgPath(pts, this._wireCrossings.get(wire.id)); + if (n.shield) n.shield.data(svgPath); + if (!wire.twisted_pair) { + n.main.data(svgPath); + if (n.stripe) n.stripe.data(svgPath); + } + if (n.helix) n.helix.data(this._computeHelixPath(pts, wire.twist_pitch || 16, false)); + if (n.helix2) n.helix2.data(this._computeHelixPath(pts, wire.twist_pitch || 16, true)); + if (n.hit) n.hit.points(pts); + if (n.lbl) { const [mx, my] = this._midPoint(pts); n.lbl.setAttrs({ x: mx + 3, y: my - 10 }); } + }); + // Keep handles in sync if the selected wire is affected + if (this.selectedType === "wire") { + const w = this.wireData.get(this.selectedId); + if (w && (w.from_device_id === deviceId || w.to_device_id === deviceId)) { + this._renderWireHandles(this.selectedId); + } + } + this.wireLayer.batchDraw(); + } + + _destroyWireVisuals(wireId) { + const n = this.wireNodes.get(wireId); + if (!n) return; + if (n.shield) n.shield.destroy(); + n.main.destroy(); + if (n.stripe) n.stripe.destroy(); + if (n.helix) n.helix.destroy(); + if (n.helix2) n.helix2.destroy(); + if (n.hit) n.hit.destroy(); + if (n.lbl) n.lbl.destroy(); + this.wireNodes.delete(wireId); + } + + // ── Waypoint editing ────────────────────────────────────────────────────────── + + // Insert a new waypoint at (wx,wy) in sorted order along the pin-to-pin axis + _insertWaypoint(wire, wx, wy) { + const fd = this.deviceData.get(wire.from_device_id); + const td = this.deviceData.get(wire.to_device_id); + const fp = (fd?.pins || []).find(p => p.id === wire.from_pin); + const tp = (td?.pins || []).find(p => p.id === wire.to_pin); + + const ax = (fd?.x || 0) + (fp?.x_offset || 0); + const ay = (fd?.y || 0) + (fp?.y_offset || 0); + const bx = (td?.x || 0) + (tp?.x_offset || 0); + const by = (td?.y || 0) + (tp?.y_offset || 0); + + const dx = bx - ax, dy = by - ay; + const len2 = dx * dx + dy * dy; + const tNew = len2 < 1 ? 0.5 : ((wx - ax) * dx + (wy - ay) * dy) / len2; + + const waypoints = [...(wire.waypoints || [])]; + const tArr = waypoints.map(wp => + len2 < 1 ? 0.5 : ((wp.x - ax) * dx + (wp.y - ay) * dy) / len2 + ); + + let idx = tArr.findIndex(t => t > tNew); + if (idx === -1) idx = waypoints.length; + waypoints.splice(idx, 0, { x: wx, y: wy }); + return { waypoints, idx }; + } + + // Render draggable handles at each waypoint for the selected wire + _renderWireHandles(wireId) { + this._clearWireHandles(); + const wire = this.wireData.get(wireId); + if (!wire) return; + + const waypoints = wire.waypoints || []; + + waypoints.forEach((wp, idx) => { + const handle = new Konva.Circle({ + x: wp.x, y: wp.y, radius: 5, + fill: "#00aaff", stroke: "#ffffff", strokeWidth: 1.5, + draggable: true, + }); + + handle.on("dragstart", (e) => { e.cancelBubble = true; }); + + handle.on("dragmove", () => { + if (this.snapEnabled) { handle.x(this._snap(handle.x())); handle.y(this._snap(handle.y())); } + wire.waypoints[idx] = { x: handle.x(), y: handle.y() }; + this._destroyWireVisuals(wireId); + this._renderWire(wire); + this.wireLayer.batchDraw(); + }); + + handle.on("dragend", () => { + this.cb.onWaypointsChanged?.(wireId, wire.waypoints); + }); + + // Double-click a handle to remove that waypoint + handle.on("dblclick", () => { + wire.waypoints = wire.waypoints.filter((_, i) => i !== idx); + this._destroyWireVisuals(wireId); + this._renderWire(wire); + this.wireLayer.batchDraw(); + this._renderWireHandles(wireId); + this.cb.onWaypointsChanged?.(wireId, wire.waypoints); + }); + + this.uiLayer.add(handle); + this._handleNodes.push(handle); + }); + + this.uiLayer.batchDraw(); + } + + // Update handle positions without full recreation (used during active manual drag) + _updateHandlePositions(wire) { + const wps = wire.waypoints || []; + this._handleNodes.forEach((h, i) => { + if (wps[i]) { h.x(wps[i].x); h.y(wps[i].y); } + }); + this.uiLayer.batchDraw(); + } + + _clearWireHandles() { + this._handleNodes.forEach(n => n.destroy()); + this._handleNodes = []; + this.uiLayer.batchDraw(); + } +} diff --git a/frontend/js/connectorLibrary.js b/frontend/js/connectorLibrary.js new file mode 100644 index 0000000..7c79aea --- /dev/null +++ b/frontend/js/connectorLibrary.js @@ -0,0 +1,184 @@ +// Automotive / EV connector library +// Each entry: { id, name, manufacturer, partNumber, pinCount, pinLabels?, description } + +const CONNECTOR_LIBRARY = { + "Deutsch DT": [ + { id: "dt-2p", name: "DT04-2P", manufacturer: "TE Connectivity", partNumber: "DT04-2P-P012", pinCount: 2, description: "2-pin weatherproof plug, 20–14 AWG" }, + { id: "dt-3p", name: "DT04-3P", manufacturer: "TE Connectivity", partNumber: "DT04-3P-P012", pinCount: 3, description: "3-pin weatherproof plug, 20–14 AWG" }, + { id: "dt-4p", name: "DT04-4P", manufacturer: "TE Connectivity", partNumber: "DT04-4P-P012", pinCount: 4, description: "4-pin weatherproof plug, 20–14 AWG" }, + { id: "dt-6p", name: "DT04-6P", manufacturer: "TE Connectivity", partNumber: "DT04-6P-P012", pinCount: 6, description: "6-pin weatherproof plug, 20–14 AWG" }, + { id: "dt-8p", name: "DT04-8P", manufacturer: "TE Connectivity", partNumber: "DT04-8P-P012", pinCount: 8, description: "8-pin weatherproof plug, 20–14 AWG" }, + { id: "dt-12p", name: "DT04-12P", manufacturer: "TE Connectivity", partNumber: "DT04-12P-P014", pinCount: 12, description: "12-pin weatherproof plug, 20–14 AWG" }, + { id: "dt-2s", name: "DT06-2S", manufacturer: "TE Connectivity", partNumber: "DT06-2S", pinCount: 2, description: "2-pin weatherproof socket (receptacle)" }, + { id: "dt-4s", name: "DT06-4S", manufacturer: "TE Connectivity", partNumber: "DT06-4S", pinCount: 4, description: "4-pin weatherproof socket (receptacle)" }, + { id: "dt-6s", name: "DT06-6S", manufacturer: "TE Connectivity", partNumber: "DT06-6S", pinCount: 6, description: "6-pin weatherproof socket (receptacle)" }, + { id: "dt-8s", name: "DT06-8S", manufacturer: "TE Connectivity", partNumber: "DT06-8S", pinCount: 8, description: "8-pin weatherproof socket (receptacle)" }, + ], + "Deutsch DTM (Miniature)": [ + { id: "dtm-2p", name: "DTM04-2P", manufacturer: "TE Connectivity", partNumber: "DTM04-2P", pinCount: 2, description: "2-pin miniature weatherproof, 24–20 AWG" }, + { id: "dtm-3p", name: "DTM04-3P", manufacturer: "TE Connectivity", partNumber: "DTM04-3P", pinCount: 3, description: "3-pin miniature weatherproof" }, + { id: "dtm-4p", name: "DTM04-4P", manufacturer: "TE Connectivity", partNumber: "DTM04-4P", pinCount: 4, description: "4-pin miniature weatherproof" }, + { id: "dtm-6p", name: "DTM04-6P", manufacturer: "TE Connectivity", partNumber: "DTM04-6P", pinCount: 6, description: "6-pin miniature weatherproof" }, + { id: "dtm-8p", name: "DTM04-8P", manufacturer: "TE Connectivity", partNumber: "DTM04-8P", pinCount: 8, description: "8-pin miniature weatherproof" }, + { id: "dtm-2s", name: "DTM06-2S", manufacturer: "TE Connectivity", partNumber: "DTM06-2S", pinCount: 2, description: "2-pin miniature socket" }, + { id: "dtm-4s", name: "DTM06-4S", manufacturer: "TE Connectivity", partNumber: "DTM06-4S", pinCount: 4, description: "4-pin miniature socket" }, + ], + "Deutsch DTP (High Current)": [ + { id: "dtp-2p", name: "DTP04-2P", manufacturer: "TE Connectivity", partNumber: "DTP04-2P-E003", pinCount: 2, description: "2-pin high current plug, up to 40A per pin, 12–10 AWG", pinLabels: ["+", "−"] }, + { id: "dtp-4p", name: "DTP04-4P", manufacturer: "TE Connectivity", partNumber: "DTP04-4P-E003", pinCount: 4, description: "4-pin high current plug, up to 40A per pin" }, + { id: "dtp-2s", name: "DTP06-2S", manufacturer: "TE Connectivity", partNumber: "DTP06-2S-E003", pinCount: 2, description: "2-pin high current socket" }, + { id: "dtp-4s", name: "DTP06-4S", manufacturer: "TE Connectivity", partNumber: "DTP06-4S-E003", pinCount: 4, description: "4-pin high current socket" }, + ], + "Deutsch HD (Heavy Duty)": [ + { id: "hd-9", name: "HD30-9", manufacturer: "TE Connectivity", partNumber: "HD30-9-1939P", pinCount: 9, description: "9-pin heavy duty, SAE J1939 CAN" }, + { id: "hd-18", name: "HD30-18", manufacturer: "TE Connectivity", partNumber: "HD30-18-14P", pinCount: 18, description: "18-pin heavy duty" }, + { id: "hd-24", name: "HD30-24", manufacturer: "TE Connectivity", partNumber: "HD30-24-14P", pinCount: 24, description: "24-pin heavy duty" }, + ], + "Anderson Powerpole": [ + { id: "ap-pp15", name: "PP15/30", manufacturer: "Anderson Power", partNumber: "1327G6", pinCount: 2, description: "15/30 A Powerpole, 15–12 AWG", pinLabels: ["+", "−"] }, + { id: "ap-pp45", name: "PP45", manufacturer: "Anderson Power", partNumber: "1338G6", pinCount: 2, description: "45 A Powerpole, 12–10 AWG", pinLabels: ["+", "−"] }, + { id: "ap-pp75", name: "PP75", manufacturer: "Anderson Power", partNumber: "5913G2", pinCount: 2, description: "75 A Powerpole, 8–4 AWG", pinLabels: ["+", "−"] }, + { id: "ap-sb50", name: "SB 50", manufacturer: "Anderson Power", partNumber: "6319G1", pinCount: 2, description: "50 A SB connector, 6–2 AWG", pinLabels: ["+", "−"] }, + { id: "ap-sb120", name: "SB 120", manufacturer: "Anderson Power", partNumber: "6352G1", pinCount: 2, description: "120 A SB connector, 2–4/0 AWG", pinLabels: ["+", "−"] }, + { id: "ap-sb175", name: "SB 175", manufacturer: "Anderson Power", partNumber: "6321G1", pinCount: 2, description: "175 A SB connector, 2–4/0 AWG", pinLabels: ["+", "−"] }, + { id: "ap-sb350", name: "SB 350", manufacturer: "Anderson Power", partNumber: "6348G1", pinCount: 2, description: "350 A SB connector, 4/0 AWG", pinLabels: ["+", "−"] }, + ], + "Molex Micro-Fit 3.0": [ + { id: "mf-2", name: "Micro-Fit 2-pin", manufacturer: "Molex", partNumber: "43045-0200", pinCount: 2, description: "600 V, 5 A, 3.0 mm pitch" }, + { id: "mf-4", name: "Micro-Fit 4-pin", manufacturer: "Molex", partNumber: "43045-0400", pinCount: 4, description: "600 V, 5 A" }, + { id: "mf-6", name: "Micro-Fit 6-pin", manufacturer: "Molex", partNumber: "43045-0600", pinCount: 6, description: "600 V, 5 A" }, + { id: "mf-8", name: "Micro-Fit 8-pin", manufacturer: "Molex", partNumber: "43045-0800", pinCount: 8, description: "600 V, 5 A" }, + { id: "mf-10", name: "Micro-Fit 10-pin", manufacturer: "Molex", partNumber: "43045-1000", pinCount: 10, description: "600 V, 5 A" }, + { id: "mf-12", name: "Micro-Fit 12-pin", manufacturer: "Molex", partNumber: "43045-1200", pinCount: 12, description: "600 V, 5 A" }, + ], + "Molex Mini-Fit Jr.": [ + { id: "mfj-2", name: "Mini-Fit Jr. 2-pin", manufacturer: "Molex", partNumber: "39012020", pinCount: 2, description: "600 V, 9 A, 4.20 mm pitch" }, + { id: "mfj-4", name: "Mini-Fit Jr. 4-pin", manufacturer: "Molex", partNumber: "39012040", pinCount: 4, description: "600 V, 9 A" }, + { id: "mfj-6", name: "Mini-Fit Jr. 6-pin", manufacturer: "Molex", partNumber: "39012060", pinCount: 6, description: "600 V, 9 A" }, + { id: "mfj-8", name: "Mini-Fit Jr. 8-pin", manufacturer: "Molex", partNumber: "39012080", pinCount: 8, description: "600 V, 9 A" }, + { id: "mfj-12", name: "Mini-Fit Jr. 12-pin", manufacturer: "Molex", partNumber: "39012120", pinCount: 12, description: "600 V, 9 A" }, + ], + "Molex MX150 (Automotive)": [ + { id: "mx-2", name: "MX150 2-pin", manufacturer: "Molex", partNumber: "33472-0201", pinCount: 2, description: "Sealed automotive, 600 V, 13 A" }, + { id: "mx-4", name: "MX150 4-pin", manufacturer: "Molex", partNumber: "33472-0401", pinCount: 4, description: "Sealed automotive" }, + { id: "mx-6", name: "MX150 6-pin", manufacturer: "Molex", partNumber: "33472-0601", pinCount: 6, description: "Sealed automotive" }, + { id: "mx-8", name: "MX150 8-pin", manufacturer: "Molex", partNumber: "33472-0801", pinCount: 8, description: "Sealed automotive" }, + ], + "TE Superseal 1.5 mm (IP67)": [ + { id: "ss-1", name: "Superseal 1-pin", manufacturer: "TE Connectivity", partNumber: "1-967616-1", pinCount: 1, description: "IP67, 0.5–1.0 mm², 8.5 A" }, + { id: "ss-2", name: "Superseal 2-pin", manufacturer: "TE Connectivity", partNumber: "1-967616-2", pinCount: 2, description: "IP67, 0.5–1.0 mm²" }, + { id: "ss-3", name: "Superseal 3-pin", manufacturer: "TE Connectivity", partNumber: "1-967616-3", pinCount: 3, description: "IP67, 0.5–1.0 mm²" }, + { id: "ss-4", name: "Superseal 4-pin", manufacturer: "TE Connectivity", partNumber: "1-967616-4", pinCount: 4, description: "IP67, 0.5–1.0 mm²" }, + { id: "ss-6", name: "Superseal 6-pin", manufacturer: "TE Connectivity", partNumber: "1-967616-6", pinCount: 6, description: "IP67, 0.5–1.0 mm²" }, + ], + "Delphi / Aptiv Weatherpack": [ + { id: "wp-1", name: "Weatherpack 1-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12015793", pinCount: 1, description: "GT series, 100 °C rated, 20–18 AWG" }, + { id: "wp-2", name: "Weatherpack 2-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12010299", pinCount: 2, description: "GT series, 100 °C rated" }, + { id: "wp-3", name: "Weatherpack 3-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12010325", pinCount: 3, description: "GT series" }, + { id: "wp-4", name: "Weatherpack 4-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12010298", pinCount: 4, description: "GT series" }, + { id: "wp-6", name: "Weatherpack 6-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12010973", pinCount: 6, description: "GT series" }, + ], + "Delphi Metripack": [ + { id: "mp150-2", name: "Metripack 150 2-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12162825", pinCount: 2, description: "Metripack 150, sealed" }, + { id: "mp150-3", name: "Metripack 150 3-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12162193", pinCount: 3, description: "Metripack 150, sealed" }, + { id: "mp280-2", name: "Metripack 280 2-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12010973", pinCount: 2, description: "Metripack 280 (larger terminal, higher current)" }, + { id: "mp630-1", name: "Metripack 630 1-pin", manufacturer: "Aptiv (Delphi)", partNumber: "12110293", pinCount: 1, description: "Metripack 630, high current single contact" }, + ], + "Tesla (Proprietary / Approximate)": [ + { + id: "tesla-hv23", name: "HV Battery 23-pin", manufacturer: "Tesla", partNumber: "Tesla OEM", + pinCount: 23, description: "Model S/X HV battery main connector (proprietary pinout — verify with service manual)", + pinLabels: ["HV+","HV+","HV−","HV−","SH","SH","HVIL+","HVIL−","CAN H","CAN L","CANH2","CANL2","12V","GND","GND","TP","TP","TP","TP","TP","TP","TP","TP"], + }, + { + id: "tesla-hvil2", name: "HV Interlock 2-pin", manufacturer: "Tesla", partNumber: "Tesla OEM", + pinCount: 2, description: "High voltage interlock loop", pinLabels: ["HVIL+", "HVIL−"], + }, + { + id: "tesla-charge-m3", name: "Charge Port (Model 3/Y)", manufacturer: "Tesla", partNumber: "Tesla OEM", + pinCount: 5, description: "AC charge port, Model 3 / Y (proprietary, similar to J1772)", pinLabels: ["L1","L2/N","GND","PP","CP"], + }, + { + id: "tesla-can4", name: "CAN Bus 4-pin", manufacturer: "Tesla", partNumber: "Tesla OEM", + pinCount: 4, description: "Typical CAN bus connector (varies by model/year)", pinLabels: ["CAN H","CAN L","GND","12V"], + }, + { + id: "tesla-hv2", name: "HV 2-pin (Phase)", manufacturer: "Tesla", partNumber: "Tesla OEM", + pinCount: 2, description: "HV 2-pin power connector (motor/inverter phase, approximate)", pinLabels: ["+","−"], + }, + ], + "EV Charging Standards": [ + { + id: "ev-j1772", name: "SAE J1772 Type 1", manufacturer: "Standard", partNumber: "SAE J1772", + pinCount: 5, description: "AC Level 1/2 charging — North America", pinLabels: ["L1","N","GND","PP","CP"], + }, + { + id: "ev-type2", name: "IEC 62196 Type 2", manufacturer: "Standard", partNumber: "IEC 62196-2", + pinCount: 7, description: "AC charging — Europe / international", pinLabels: ["L1","L2","L3","N","GND","PP","CP"], + }, + { + id: "ev-chademo", name: "CHAdeMO DC Fast", manufacturer: "Standard", partNumber: "CHAdeMO", + pinCount: 10, description: "DC fast charge (up to 400 A / 500 V)", pinLabels: ["DC+","DC−","GND","CAN H","CAN L","Charge enable","Car ready","Charge stop","FG","Shield"], + }, + { + id: "ev-ccs1", name: "CCS Combo 1 (DC)", manufacturer: "Standard", partNumber: "SAE J1772 CCS", + pinCount: 2, description: "CCS DC fast charge pins (2 large pins below J1772)", pinLabels: ["DC+","DC−"], + }, + ], + "CAN / Network": [ + { id: "can-dt2", name: "CAN Deutsch 2-pin", manufacturer: "TE Connectivity", partNumber: "DT04-2P", pinCount: 2, description: "Deutsch DT 2-pin for CAN bus", pinLabels: ["CAN H","CAN L"] }, + { id: "can-j1939", name: "SAE J1939 9-pin", manufacturer: "Amphenol", partNumber: "HD10-9-1939P", pinCount: 9, description: "J1939 CAN heavy-vehicle datalink", pinLabels: ["−Battery","+Battery","GND","CAN H","Shield","GND","CAN L","NC","NC"] }, + { id: "can-obd2", name: "OBD-II 16-pin", manufacturer: "Standard", partNumber: "SAE J1962", pinCount: 16, description: "On-board diagnostics port", pinLabels: ["Mfr","Mfr","GND","Mfr","Sig GND","CAN H","K-Line","Mfr","Bat+","Mfr","Mfr","Mfr","Mfr","CAN L","L-Line","OBD+"] }, + ], + "General Automotive / EV": [ + { id: "ga-motor3", name: "Motor Phase 3-pin", manufacturer: "varies", partNumber: "varies", pinCount: 3, description: "3-phase motor connections", pinLabels: ["U","V","W"] }, + { id: "ga-res2", name: "Resolver 2-pin", manufacturer: "varies", partNumber: "varies", pinCount: 2, description: "Motor resolver position sensor", pinLabels: ["SIN","COS"] }, + { id: "ga-therm2", name: "Temperature Sensor 2-pin",manufacturer: "varies", partNumber: "varies", pinCount: 2, description: "NTC/PTC thermistor", pinLabels: ["SIG","GND"] }, + { id: "ga-hall3", name: "Hall Sensor 3-pin", manufacturer: "varies", partNumber: "varies", pinCount: 3, description: "Hall effect position sensor", pinLabels: ["VCC","GND","SIG"] }, + { id: "ga-batt2", name: "12 V Battery 2-pin", manufacturer: "varies", partNumber: "varies", pinCount: 2, description: "12 V battery / accessory supply", pinLabels: ["+12V","GND"] }, + { id: "ga-ctrlr4", name: "Controller 4-pin", manufacturer: "varies", partNumber: "varies", pinCount: 4, description: "Generic 4-pin controller interface", pinLabels: ["VCC","GND","SIG","EN"] }, + { id: "ga-fuse2", name: "Fuse Holder 2-pin", manufacturer: "varies", partNumber: "varies", pinCount: 2, description: "Inline fuse holder connections", pinLabels: ["IN","OUT"] }, + { id: "ga-relay5", name: "Relay 5-pin (SPDT)", manufacturer: "varies", partNumber: "varies", pinCount: 5, description: "SPDT automotive relay", pinLabels: ["85","86","30","87","87a"] }, + ], +}; + +// Build a flat lookup map for quick access by id +const _CONN_BY_ID = {}; +for (const conns of Object.values(CONNECTOR_LIBRARY)) { + for (const c of conns) { _CONN_BY_ID[c.id] = c; } +} + +function getConnectorById(id) { return _CONN_BY_ID[id] || null; } + +function connectorToDevice(connId, diagramId) { + const conn = getConnectorById(connId); + if (!conn) return null; + + const pinCount = conn.pinCount || 2; + const w = 120; + const h = Math.max(60, pinCount * 18 + 20); + const pins = Array.from({ length: pinCount }, (_, i) => ({ + id: `pin_${i + 1}`, + name: conn.pinLabels ? (conn.pinLabels[i] || String(i + 1)) : String(i + 1), + side: "right", + x_offset: w, + y_offset: ((i + 1) / (pinCount + 1)) * h, + })); + + return { + diagram_id: diagramId, + device_type: "connector", + label: conn.name, + reference: "", + x: 200, y: 200, + width: w, height: h, + properties: { + pinCount, + orientation: "right", + partNumber: conn.partNumber || "", + manufacturer: conn.manufacturer || "", + connectorLibraryId: connId, + }, + pins, + }; +} diff --git a/frontend/js/deviceTypes.js b/frontend/js/deviceTypes.js new file mode 100644 index 0000000..5a32da2 --- /dev/null +++ b/frontend/js/deviceTypes.js @@ -0,0 +1,272 @@ +const WIRE_COLORS = [ + { name: "Black", hex: "#000000" }, + { name: "Brown", hex: "#8B4513" }, + { name: "Red", hex: "#CC0000" }, + { name: "Orange", hex: "#FF8C00" }, + { name: "Yellow", hex: "#FFD700" }, + { name: "Green", hex: "#007700" }, + { name: "Blue", hex: "#0000CC" }, + { name: "Violet", hex: "#7B00CC" }, + { name: "Grey", hex: "#808080" }, + { name: "White", hex: "#DDDDDD" }, + { name: "Pink", hex: "#FF69B4" }, + { name: "Tan", hex: "#D2B48C" }, +]; + +const SIDE = { LEFT: "left", RIGHT: "right", TOP: "top", BOTTOM: "bottom" }; + +function makePins(side, count, w, h, prefix = "") { + return Array.from({ length: count }, (_, i) => { + const t = (i + 1) / (count + 1); + const x = side === SIDE.LEFT ? 0 : side === SIDE.RIGHT ? w : t * w; + const y = side === SIDE.TOP ? 0 : side === SIDE.BOTTOM ? h : t * h; + return { id: `${prefix}pin_${i + 1}`, name: String(i + 1), side, x_offset: x, y_offset: y }; + }); +} + +const DEVICE_TYPES = { + connector: { + label: "Connector", + description: "Multi-pin connector (inline/header)", + icon: "🔌", + defaultProps: { pinCount: 4, orientation: "right", partNumber: "", manufacturer: "" }, + defaultSize: (p) => ({ w: 100, h: Math.max(60, (p.pinCount || 4) * 20 + 20) }), + getPins: (p, w, h) => makePins(p.orientation || SIDE.RIGHT, p.pinCount || 4, w, h), + }, + terminal_block: { + label: "Terminal Block", + description: "Screw terminal block", + icon: "⬛", + defaultProps: { terminalCount: 4, partNumber: "", manufacturer: "" }, + defaultSize: (p) => ({ w: Math.max(60, (p.terminalCount || 4) * 24), h: 60 }), + getPins: (p, w, h) => { + const count = p.terminalCount || 4; + const pins = []; + for (let i = 0; i < count; i++) { + const x = ((i + 0.5) / count) * w; + pins.push({ id: `t${i + 1}`, name: String(i + 1), side: SIDE.TOP, x_offset: x, y_offset: 0 }); + pins.push({ id: `b${i + 1}`, name: String(i + 1), side: SIDE.BOTTOM, x_offset: x, y_offset: h }); + } + return pins; + }, + }, + component: { + label: "Component", + description: "Generic component / IC", + icon: "⬜", + defaultProps: { leftPins: 2, rightPins: 2, partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 120, h: 80 }), + getPins: (p, w, h) => { + const left = makePins(SIDE.LEFT, p.leftPins || 2, w, h, "L"); + const right = makePins(SIDE.RIGHT, p.rightPins || 2, w, h, "R"); + left.forEach((pin, i) => (pin.name = `L${i + 1}`)); + right.forEach((pin, i) => (pin.name = `R${i + 1}`)); + return [...left, ...right]; + }, + }, + splice: { + label: "Splice / Junction", + description: "Wire splice or junction point", + icon: "✕", + defaultProps: {}, + defaultSize: () => ({ w: 32, h: 32 }), + getPins: (p, w, h) => [ + { id: "left", name: "L", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "right", name: "R", side: SIDE.RIGHT, x_offset: w, y_offset: h / 2 }, + { id: "top", name: "T", side: SIDE.TOP, x_offset: w / 2, y_offset: 0 }, + { id: "bottom", name: "B", side: SIDE.BOTTOM, x_offset: w / 2, y_offset: h }, + ], + }, + label: { + label: "Note / Label", + description: "Text annotation on the diagram", + icon: "📝", + defaultProps: { text: "Note" }, + defaultSize: () => ({ w: 160, h: 40 }), + getPins: () => [], + }, + group: { + label: "Section Box", + description: "Labeled section rectangle for visual grouping", + icon: "⬚", + defaultProps: { fillColor: "#2828a0", fillOpacity: 0.15 }, + defaultSize: () => ({ w: 320, h: 220 }), + getPins: () => [], + }, + + // ── Electrical components ─────────────────────────────────────────────────── + fuse: { + label: "Fuse", + description: "In-line fuse or fusible link", + icon: "⚡", + defaultProps: { ampRating: "10A", fuseType: "blade", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 80, h: 44 }), + getPins: (p, w, h) => [ + { id: "A", name: "A", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "B", name: "B", side: SIDE.RIGHT, x_offset: w, y_offset: h / 2 }, + ], + }, + relay: { + label: "Relay", + description: "Electromagnetic relay — SPDT 5-pin (85/86/30/87/87a)", + icon: "🔁", + defaultProps: { coilVoltage: "12V", ampRating: "30A", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 110, h: 100 }), + getPins: (p, w, h) => [ + { id: "85", name: "85", side: SIDE.LEFT, x_offset: 0, y_offset: h * 0.30 }, + { id: "86", name: "86", side: SIDE.LEFT, x_offset: 0, y_offset: h * 0.70 }, + { id: "87a", name: "87a", side: SIDE.RIGHT, x_offset: w, y_offset: h * 0.20 }, + { id: "30", name: "30", side: SIDE.RIGHT, x_offset: w, y_offset: h * 0.50 }, + { id: "87", name: "87", side: SIDE.RIGHT, x_offset: w, y_offset: h * 0.80 }, + ], + }, + switch: { + label: "Switch", + description: "SPDT switch — common, normally-open, normally-closed", + icon: "⎘", + defaultProps: { switchType: "SPDT", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 90, h: 70 }), + getPins: (p, w, h) => [ + { id: "C", name: "C", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "NO", name: "NO", side: SIDE.RIGHT, x_offset: w, y_offset: h * 0.30 }, + { id: "NC", name: "NC", side: SIDE.RIGHT, x_offset: w, y_offset: h * 0.70 }, + ], + }, + bulb: { + label: "Bulb / Light", + description: "Indicator lamp or lighting load", + icon: "💡", + defaultProps: { voltage: "12V", wattage: "", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 80, h: 50 }), + getPins: (p, w, h) => [ + { id: "+", name: "+", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "-", name: "−", side: SIDE.RIGHT, x_offset: w, y_offset: h / 2 }, + ], + }, + motor: { + label: "Motor", + description: "DC motor or actuator", + icon: "⚙", + defaultProps: { voltage: "12V", ampRating: "", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 90, h: 60 }), + getPins: (p, w, h) => [ + { id: "+", name: "+", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "-", name: "−", side: SIDE.RIGHT, x_offset: w, y_offset: h / 2 }, + ], + }, + diode: { + label: "Diode", + description: "Diode, Schottky, or TVS — anode / cathode", + icon: "⊳", + defaultProps: { diodeType: "rectifier", voltage: "", current: "", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 80, h: 44 }), + getPins: (p, w, h) => [ + { id: "A", name: "A", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "K", name: "K", side: SIDE.RIGHT, x_offset: w, y_offset: h / 2 }, + ], + }, + resistor: { + label: "Resistor", + description: "Fixed resistor or load", + icon: "Ω", + defaultProps: { resistance: "", wattage: "", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 80, h: 44 }), + getPins: (p, w, h) => [ + { id: "1", name: "1", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "2", name: "2", side: SIDE.RIGHT, x_offset: w, y_offset: h / 2 }, + ], + }, + capacitor: { + label: "Capacitor", + description: "Electrolytic or film capacitor", + icon: "⊏", + defaultProps: { capacitance: "", voltage: "", partNumber: "", manufacturer: "" }, + defaultSize: () => ({ w: 80, h: 44 }), + getPins: (p, w, h) => [ + { id: "+", name: "+", side: SIDE.LEFT, x_offset: 0, y_offset: h / 2 }, + { id: "-", name: "−", side: SIDE.RIGHT, x_offset: w, y_offset: h / 2 }, + ], + }, + ground: { + label: "Ground", + description: "Chassis or signal ground reference", + icon: "⏚", + defaultProps: { groundType: "chassis", reference: "" }, + defaultSize: () => ({ w: 60, h: 50 }), + getPins: (p, w, h) => [ + { id: "GND", name: "GND", side: SIDE.TOP, x_offset: w / 2, y_offset: 0 }, + ], + }, + power: { + label: "Power Rail", + description: "Battery positive, ignition, or supply rail", + icon: "⊕", + defaultProps: { voltage: "12V", sourceType: "battery", reference: "" }, + defaultSize: () => ({ w: 70, h: 50 }), + getPins: (p, w, h) => [ + { id: "PWR", name: "+", side: SIDE.BOTTOM, x_offset: w / 2, y_offset: h }, + ], + }, +}; + +const CABLE_DEFAULT_COLORS = [ + "#dddddd", "#cc0000", "#111111", "#007700", + "#0000cc", "#ff8c00", "#8b4513", "#7b00cc", +]; + +const DEVICE_TYPES_EXTRA = { + cable: { + label: "Cable", + description: "Multi-conductor cable under one jacket", + icon: "🔗", + defaultProps: { + conductorCount: 4, + partNumber: "", + manufacturer: "", + jacketColor: "#2a2a2a", + sleeveLength: 60, + conductors: [ + { name: "1", color: "#dddddd" }, + { name: "2", color: "#cc0000" }, + { name: "3", color: "#111111" }, + { name: "4", color: "#007700" }, + ], + }, + defaultSize: (p) => { + const n = p.conductorCount || p.conductors?.length || 4; + return { w: 200, h: n * 24 + 40 }; + }, + getPins: (p, w, h) => { + const conductors = p.conductors || Array.from({ length: p.conductorCount || 4 }, (_, i) => ({ name: String(i + 1) })); + return conductors.flatMap((c, i) => { + const yOff = 26 + i * 24 + 12; + return [ + { id: `L${i + 1}`, name: c.name || String(i + 1), side: "left", x_offset: 0, y_offset: yOff }, + { id: `R${i + 1}`, name: c.name || String(i + 1), side: "right", x_offset: w, y_offset: yOff }, + ]; + }); + }, + }, +}; + +Object.assign(DEVICE_TYPES, DEVICE_TYPES_EXTRA); + +function buildDefaultDevice(typeKey, diagramId) { + const type = DEVICE_TYPES[typeKey]; + if (!type) return null; + const props = { ...type.defaultProps }; + const { w, h } = type.defaultSize(props); + const pins = type.getPins(props, w, h); + return { + diagram_id: diagramId, + device_type: typeKey, + label: type.label, + reference: "", + x: 200, + y: 200, + width: w, + height: h, + properties: props, + pins, + }; +} diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..1cd07de --- /dev/null +++ b/start.bat @@ -0,0 +1,11 @@ +@echo off +echo Installing dependencies... +cd /d "%~dp0backend" +py -m pip install -r requirements.txt --quiet + +echo. +echo Starting WireDraw at http://localhost:8000 +echo Press Ctrl+C to stop. +echo. +py run.py +pause