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 <noreply@anthropic.com>
This commit is contained in:
+25
@@ -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
|
||||||
+57
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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 += '<span class="badge tw">TWISTED</span>'
|
||||||
|
if wire.shielded:
|
||||||
|
badges += '<span class="badge sh">SHIELDED</span>'
|
||||||
|
txt_color = "#000" if (wire.color_primary or "#000") in ("#FFD700","#DDDDDD","#FF8C00","#dddddd") else "#fff"
|
||||||
|
rows_html += f"""
|
||||||
|
<tr>
|
||||||
|
<td class="lbl">{wire_label(wire)}</td>
|
||||||
|
<td class="gauge">{wire.gauge or ""}</td>
|
||||||
|
<td class="len-val">{wire.length:.1f} {wire.length_unit or "in"} ({mm:.0f} mm)</td>
|
||||||
|
<td class="bar-cell">
|
||||||
|
<div class="wire-bar" style="width:{mm:.1f}mm;{stripe_style(wire)}color:{txt_color}">
|
||||||
|
<span class="bar-text">{mm:.0f} mm</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{badges}</td>
|
||||||
|
</tr>"""
|
||||||
|
|
||||||
|
no_len_rows = ""
|
||||||
|
for wire in wires_no_length:
|
||||||
|
no_len_rows += f"<li>{wire_label(wire)} — {wire.gauge or 'gauge?'}</li>"
|
||||||
|
|
||||||
|
no_len_section = ""
|
||||||
|
if wires_no_length:
|
||||||
|
no_len_section = f"""
|
||||||
|
<h2>Wires without length</h2>
|
||||||
|
<ul class="no-len">{no_len_rows}</ul>"""
|
||||||
|
|
||||||
|
html = f"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Formboard — {diagram.name}</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
|
body {{ font-family: sans-serif; font-size: 11pt; padding: 10mm; background: #fff; color: #111; }}
|
||||||
|
h1 {{ font-size: 16pt; margin-bottom: 4mm; }}
|
||||||
|
.meta {{ font-size: 9pt; color: #555; margin-bottom: 6mm; }}
|
||||||
|
.scale-bar-wrap {{ margin-bottom: 8mm; }}
|
||||||
|
.scale-bar {{ display: inline-block; width: 100mm; height: 5mm; background: #333; }}
|
||||||
|
.scale-bar-lbl {{ font-size: 9pt; color: #555; margin-left: 3mm; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; margin-bottom: 8mm; }}
|
||||||
|
th {{ text-align: left; font-size: 9pt; color: #555; border-bottom: 0.5mm solid #ccc; padding: 1mm 2mm; }}
|
||||||
|
td {{ padding: 1.5mm 2mm; vertical-align: middle; border-bottom: 0.2mm solid #e8e8e8; }}
|
||||||
|
.lbl {{ font-size: 9pt; max-width: 60mm; word-break: break-all; }}
|
||||||
|
.gauge {{ font-size: 9pt; white-space: nowrap; }}
|
||||||
|
.len-val {{ font-size: 9pt; white-space: nowrap; }}
|
||||||
|
.bar-cell {{ width: 99%; }}
|
||||||
|
.wire-bar {{
|
||||||
|
height: 5mm; min-width: 5mm; border-radius: 1mm;
|
||||||
|
display: flex; align-items: center; padding: 0 2mm;
|
||||||
|
print-color-adjust: exact; -webkit-print-color-adjust: exact;
|
||||||
|
}}
|
||||||
|
.bar-text {{ font-size: 7pt; white-space: nowrap; }}
|
||||||
|
.badge {{ font-size: 7pt; padding: 0.5mm 1.5mm; border-radius: 1mm; margin-left: 1mm; white-space: nowrap; }}
|
||||||
|
.tw {{ background: #d0e8ff; color: #003; }}
|
||||||
|
.sh {{ background: #e8e8e8; color: #333; }}
|
||||||
|
.no-len {{ font-size: 9pt; padding-left: 5mm; margin-bottom: 6mm; }}
|
||||||
|
.no-len li {{ margin-bottom: 1mm; }}
|
||||||
|
h2 {{ font-size: 12pt; margin: 6mm 0 3mm; }}
|
||||||
|
@media print {{
|
||||||
|
body {{ padding: 5mm; }}
|
||||||
|
.no-print {{ display: none; }}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📐 Formboard — {diagram.name}</h1>
|
||||||
|
<p class="meta">Wire bars are printed at true 1:1 scale. Print at 100% (no scaling) for accurate lengths.</p>
|
||||||
|
<div class="scale-bar-wrap no-print">
|
||||||
|
<strong>100 mm scale reference:</strong><br>
|
||||||
|
<div class="scale-bar"></div><span class="scale-bar-lbl">← this bar is exactly 100 mm when printed at 100%</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Wire</th><th>Gauge</th><th>Length</th><th>Scale (1:1)</th><th>Flags</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{rows_html}</tbody>
|
||||||
|
</table>
|
||||||
|
{no_len_section}
|
||||||
|
<p class="meta no-print" style="margin-top:8mm">Print → More settings → Scale: 100% → No headers/footers</p>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
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}"},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import uvicorn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run("app.main:app", host="127.0.0.1", port=8000, reload=False)
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WireDraw — Wiring Diagram Maker</title>
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
|
||||||
|
<!-- ── Toolbar ──────────────────────────────────────────────────────────── -->
|
||||||
|
<header id="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="app-title">⚡ WireDraw</span>
|
||||||
|
<input id="diagram-name" type="text" class="diagram-name-input" placeholder="Diagram name…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-center">
|
||||||
|
<!-- Mode -->
|
||||||
|
<button id="btn-select" class="mode-btn active" title="Select mode (S)">▸ Select</button>
|
||||||
|
<button id="btn-wire" class="mode-btn" title="Draw wire: drag from pin to pin (W)">∿ Wire</button>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<!-- Routing -->
|
||||||
|
<span class="toolbar-label">Route:</span>
|
||||||
|
<button id="btn-route-ortho" class="route-btn active" title="Orthogonal (right-angle) routing">⌐ Ortho</button>
|
||||||
|
<button id="btn-route-direct" class="route-btn" title="Direct (straight line) routing">⤢ Direct</button>
|
||||||
|
<button id="btn-route-curved" class="route-btn" title="Curved (smooth bezier) routing">∿ Curved</button>
|
||||||
|
<div class="sep"></div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<button id="btn-snap" title="Toggle grid snap (G)" class="snap-btn">⊹ Snap</button>
|
||||||
|
<button id="btn-harness" title="Toggle harness view (H)">⌇ Harness</button>
|
||||||
|
<button id="btn-fit" title="Fit canvas to devices (F)">⊞ Fit</button>
|
||||||
|
<button id="btn-duplicate" title="Duplicate selected (Ctrl+D)">⊕ Dupe</button>
|
||||||
|
<button id="btn-undo" title="Undo (Ctrl+Z)">↩ Undo</button>
|
||||||
|
<button id="btn-delete" class="danger-btn" title="Delete selected (Del)">✕ Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<div id="saved-indicator">✓ Saved</div>
|
||||||
|
<div class="export-wrap">
|
||||||
|
<button id="export-toggle">Export ▾</button>
|
||||||
|
<div id="export-menu">
|
||||||
|
<button id="btn-bom">📋 BOM (CSV)</button>
|
||||||
|
<button id="btn-assembly">📄 Assembly (TXT)</button>
|
||||||
|
<button id="btn-json">{ } JSON</button>
|
||||||
|
<button id="btn-img">🖼 Image (PNG)</button>
|
||||||
|
<button id="btn-formboard">📐 Formboard (1:1)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Mode bar ─────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="mode-indicator"></div>
|
||||||
|
|
||||||
|
<!-- ── Main ─────────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="main">
|
||||||
|
|
||||||
|
<!-- Left sidebar -->
|
||||||
|
<aside id="left-sidebar">
|
||||||
|
<!-- Diagram list -->
|
||||||
|
<div class="sidebar-section" style="flex-shrink:0">
|
||||||
|
<div class="section-header">
|
||||||
|
Diagrams
|
||||||
|
<button id="btn-new" title="New diagram" style="padding:2px 8px;font-size:16px;line-height:1">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="diagram-list" style="overflow-y:auto;max-height:180px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs: Devices / Connectors -->
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button class="sidebar-tab active" data-tab="devices">Devices</button>
|
||||||
|
<button class="sidebar-tab" data-tab="connectors">Connector Library</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Devices panel -->
|
||||||
|
<div class="tab-panel" data-panel="devices" style="overflow-y:auto;flex:1">
|
||||||
|
<p class="muted small" style="padding:5px 10px">Drag to canvas or double-click to add</p>
|
||||||
|
<div id="device-library"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connector library panel -->
|
||||||
|
<div class="tab-panel" data-panel="connectors" style="display:none;flex-direction:column;overflow:hidden;flex:1">
|
||||||
|
<div style="padding:6px 8px;display:flex;flex-direction:column;gap:4px;flex-shrink:0">
|
||||||
|
<div style="display:flex;gap:4px;align-items:center">
|
||||||
|
<input id="lib-search" type="text" placeholder="Search connectors…" style="flex:1;background:#0e0e1e;border:1px solid #2a2a44;color:#c0c4e8;padding:5px 8px;border-radius:4px;font-size:12px">
|
||||||
|
<button id="btn-new-connector" title="Create custom connector" style="padding:4px 8px;font-size:13px;flex-shrink:0">+</button>
|
||||||
|
</div>
|
||||||
|
<select id="lib-category" style="background:#0e0e1e;border:1px solid #2a2a44;color:#c0c4e8;padding:4px 6px;border-radius:4px;font-size:11px"></select>
|
||||||
|
</div>
|
||||||
|
<p class="muted small" style="padding:0 10px 4px">Drag to canvas or double-click to add</p>
|
||||||
|
<div id="lib-list" style="overflow-y:auto;flex:1;padding:4px"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Canvas -->
|
||||||
|
<main id="canvas-area">
|
||||||
|
<div id="canvas-container"></div>
|
||||||
|
<div id="canvas-placeholder">
|
||||||
|
<div class="placeholder-content">
|
||||||
|
<h2>⚡ WireDraw</h2>
|
||||||
|
<p>Create or open a diagram to begin</p>
|
||||||
|
<button onclick="app.newDiagram()">+ New Diagram</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Right sidebar — Properties -->
|
||||||
|
<aside id="right-sidebar">
|
||||||
|
<div class="section-header">Properties</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div id="props-empty" style="padding:20px 12px;color:#444466;font-size:12px;text-align:center">
|
||||||
|
Select a device or wire to edit its properties.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multi-select state -->
|
||||||
|
<div id="props-multi" style="display:none;padding:24px 12px;text-align:center">
|
||||||
|
<div id="props-multi-count" style="font-size:13px;color:#c8ccee"></div>
|
||||||
|
<div style="margin-top:6px;font-size:11px;color:#444466">Drag any to move all · Ctrl+D to duplicate · Del to delete</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device properties -->
|
||||||
|
<div id="props-device" style="display:none">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Type</label>
|
||||||
|
<span id="prop-type" class="prop-value"></span>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Reference</label>
|
||||||
|
<input id="prop-reference" type="text" placeholder="J1, TB1, U1…">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Label</label>
|
||||||
|
<input id="prop-label" type="text" placeholder="Friendly name">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Part Number</label>
|
||||||
|
<div style="display:flex;gap:4px">
|
||||||
|
<input id="prop-partnumber" type="text" placeholder="Mfr part number" style="flex:1;min-width:0">
|
||||||
|
<button id="btn-octopart-search" title="Search Octopart / Nexar" style="padding:4px 7px;font-size:13px;flex-shrink:0">🔍</button>
|
||||||
|
</div>
|
||||||
|
<div id="octopart-results" style="display:none"></div>
|
||||||
|
<div id="octopart-status-msg" style="display:none;font-size:10px;color:#666688;margin-top:3px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Manufacturer</label>
|
||||||
|
<input id="prop-manufacturer" type="text" placeholder="Manufacturer">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row" id="prop-datasheet-row" style="display:none">
|
||||||
|
<label>Datasheet</label>
|
||||||
|
<a id="prop-datasheet-link" href="#" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="font-size:11px;color:#7788ff;word-break:break-all;display:block">📄 Open datasheet</a>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Font Size</label>
|
||||||
|
<input id="prop-fontsize" type="number" min="6" max="72" step="1" placeholder="12" style="width:64px">
|
||||||
|
</div>
|
||||||
|
<!-- Group-specific section (shown only for section box) -->
|
||||||
|
<div id="group-section" style="display:none">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Fill Color</label>
|
||||||
|
<input id="group-fill-color" type="color" value="#2828a0" class="color-swatch">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Opacity</label>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<input id="group-fill-opacity" type="range" min="0.02" max="0.6" step="0.01" value="0.15" style="flex:1">
|
||||||
|
<span id="group-fill-opacity-val" style="font-size:10px;color:#778;width:28px;text-align:right">15%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Cable-specific section (shown only for cable devices) -->
|
||||||
|
<div id="cable-section" style="display:none">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Jacket Color</label>
|
||||||
|
<input id="cable-jacket-color" type="color" value="#2a2a2a" class="color-swatch">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Sleeve Length</label>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<input id="cable-sleeve-length" type="range" min="0" max="200" step="5" value="60" style="flex:1">
|
||||||
|
<span id="cable-sleeve-length-val" style="font-size:10px;color:#778;width:32px;text-align:right">60 px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Conductors</label>
|
||||||
|
<table class="pin-table">
|
||||||
|
<thead><tr><th>#</th><th>Name</th><th>Color</th><th></th></tr></thead>
|
||||||
|
<tbody id="conductor-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<button id="prop-add-conductor" style="margin-top:5px;width:100%;font-size:11px;padding:3px 6px">+ Add Conductor</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Pins <span class="muted small">(rename inline)</span></label>
|
||||||
|
<table class="pin-table">
|
||||||
|
<thead><tr><th>ID</th><th>Name</th><th title="L=left R=right T=top B=bottom">Side</th><th></th></tr></thead>
|
||||||
|
<tbody id="pin-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top:5px;display:flex;gap:4px">
|
||||||
|
<button id="prop-add-pin" style="flex:1;font-size:11px;padding:3px 6px">+ Add Pin</button>
|
||||||
|
<button id="prop-save-connector" class="btn-primary" style="flex:1;font-size:11px;padding:3px 6px">⬆ Save to Library</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wire properties -->
|
||||||
|
<div id="props-wire" style="display:none">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>From</label>
|
||||||
|
<span id="wire-from" class="prop-value mono"></span>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>To</label>
|
||||||
|
<span id="wire-to" class="prop-value mono"></span>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Label</label>
|
||||||
|
<input id="wire-label" type="text" placeholder="Wire label / ID">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Primary Color</label>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<input id="wire-color" type="color" value="#CC0000" class="color-swatch">
|
||||||
|
<select id="wire-color-preset" style="flex:1;background:#0e0e1e;border:1px solid #2a2a44;color:#c0c4e8;padding:4px 6px;border-radius:4px;font-size:11px">
|
||||||
|
<option value="">— Preset colors —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label id="wire-stripe-lbl">Stripe Color</label>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<label id="wire-stripe-toggle-lbl" style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:11px;color:#778">
|
||||||
|
<input id="wire-stripe-enabled" type="checkbox"> stripe
|
||||||
|
</label>
|
||||||
|
<input id="wire-stripe" type="color" value="#ffffff" disabled class="color-swatch" style="opacity:0.3">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Wire Gauge</label>
|
||||||
|
<select id="wire-gauge">
|
||||||
|
<optgroup label="AWG">
|
||||||
|
<option>4 AWG</option>
|
||||||
|
<option>6 AWG</option>
|
||||||
|
<option>8 AWG</option>
|
||||||
|
<option>10 AWG</option>
|
||||||
|
<option>12 AWG</option>
|
||||||
|
<option>14 AWG</option>
|
||||||
|
<option>16 AWG</option>
|
||||||
|
<option selected>18 AWG</option>
|
||||||
|
<option>20 AWG</option>
|
||||||
|
<option>22 AWG</option>
|
||||||
|
<option>24 AWG</option>
|
||||||
|
<option>26 AWG</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Metric (mm²)">
|
||||||
|
<option>0.5 mm²</option>
|
||||||
|
<option>0.75 mm²</option>
|
||||||
|
<option>1.0 mm²</option>
|
||||||
|
<option>1.5 mm²</option>
|
||||||
|
<option>2.0 mm²</option>
|
||||||
|
<option>2.5 mm²</option>
|
||||||
|
<option>4.0 mm²</option>
|
||||||
|
<option>6.0 mm²</option>
|
||||||
|
<option>10 mm²</option>
|
||||||
|
<option>16 mm²</option>
|
||||||
|
<option>25 mm²</option>
|
||||||
|
<option>35 mm²</option>
|
||||||
|
<option>50 mm²</option>
|
||||||
|
<option>70 mm²</option>
|
||||||
|
<option>95 mm²</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row" style="display:flex;gap:6px">
|
||||||
|
<div style="flex:1">
|
||||||
|
<label>Length</label>
|
||||||
|
<input id="wire-length" type="number" min="0" step="0.5" placeholder="0.0">
|
||||||
|
</div>
|
||||||
|
<div style="width:64px">
|
||||||
|
<label>Unit</label>
|
||||||
|
<select id="wire-unit">
|
||||||
|
<option>in</option><option>cm</option><option>mm</option><option>ft</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Twisted Pair</label>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:11px;color:#778">
|
||||||
|
<input id="wire-twisted" type="checkbox"> twisted pair
|
||||||
|
</label>
|
||||||
|
<input id="wire-twist-pitch" type="number" min="8" max="64" step="2" value="16" disabled
|
||||||
|
style="width:48px;opacity:0.3">
|
||||||
|
<span id="wire-twist-pitch-label" style="font-size:10px;color:#444466">px pitch</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Shielded</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:11px;color:#778">
|
||||||
|
<input id="wire-shielded" type="checkbox"> shielded cable
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Size Label</label>
|
||||||
|
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:11px;color:#778">
|
||||||
|
<input id="wire-show-size-label" type="checkbox"> show gauge on canvas
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Notes</label>
|
||||||
|
<textarea id="wire-notes" rows="3" placeholder="Notes…" style="resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="prop-section-header" style="margin-top:10px;margin-bottom:4px;font-size:10px;color:#556;text-transform:uppercase;letter-spacing:.05em">Multi-Core Bundle</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Bundle</label>
|
||||||
|
<div style="display:flex;gap:4px;flex:1">
|
||||||
|
<select id="wire-bundle-select" style="flex:1">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
</select>
|
||||||
|
<button id="wire-bundle-new" title="New bundle" style="padding:2px 7px;font-size:13px">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="wire-bundle-edit" style="display:none">
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Label</label>
|
||||||
|
<input id="wire-bundle-label" type="text" placeholder="e.g. CAN Bus" style="flex:1">
|
||||||
|
</div>
|
||||||
|
<div class="prop-row">
|
||||||
|
<label>Jacket</label>
|
||||||
|
<input id="wire-bundle-color" type="color" value="#2a2a2a" style="width:50px">
|
||||||
|
<button id="wire-bundle-delete" style="margin-left:auto;padding:2px 7px;font-size:11px;color:#c66;background:none;border:1px solid #c66;border-radius:3px;cursor:pointer">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
</div><!-- #main -->
|
||||||
|
</div><!-- #app -->
|
||||||
|
|
||||||
|
<!-- ── Custom Connector Modal ──────────────────────────────────────────────── -->
|
||||||
|
<div id="connector-modal" style="display:none">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="cm-modal-title">New Custom Connector</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="cm-row">
|
||||||
|
<label class="cm-label" for="cm-name">Name <span style="color:#ff6666">*</span></label>
|
||||||
|
<input id="cm-name" type="text" class="cm-input" placeholder="e.g. AMP Superseal 6P">
|
||||||
|
</div>
|
||||||
|
<div class="cm-row cm-row-2col">
|
||||||
|
<div>
|
||||||
|
<label class="cm-label" for="cm-category">Category</label>
|
||||||
|
<input id="cm-category" type="text" class="cm-input" placeholder="Custom">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="cm-label" for="cm-pincount">Pin Count <span style="color:#ff6666">*</span></label>
|
||||||
|
<input id="cm-pincount" type="number" class="cm-input" min="1" max="64" value="4">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cm-row cm-row-2col">
|
||||||
|
<div>
|
||||||
|
<label class="cm-label" for="cm-manufacturer">Manufacturer</label>
|
||||||
|
<input id="cm-manufacturer" type="text" class="cm-input" placeholder="e.g. TE Connectivity">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="cm-label" for="cm-partnumber">Part Number</label>
|
||||||
|
<input id="cm-partnumber" type="text" class="cm-input" placeholder="e.g. 1-1703630-1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cm-row">
|
||||||
|
<label class="cm-label" for="cm-description">Description</label>
|
||||||
|
<input id="cm-description" type="text" class="cm-input" placeholder="Optional description">
|
||||||
|
</div>
|
||||||
|
<div class="cm-row">
|
||||||
|
<label class="cm-label">Pin Labels <span class="muted small">(optional)</span></label>
|
||||||
|
<div id="cm-pin-labels"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="cm-delete" class="danger-btn" style="display:none">Delete</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button id="cm-cancel">Cancel</button>
|
||||||
|
<button id="cm-save" class="btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Context Menu ──────────────────────────────────────────────────────────── -->
|
||||||
|
<div id="ctx-menu">
|
||||||
|
<!-- populated dynamically by app.js -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/konva@9/konva.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/deviceTypes.js"></script>
|
||||||
|
<script src="/js/connectorLibrary.js"></script>
|
||||||
|
<script src="/js/canvas.js"></script>
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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`,
|
||||||
|
},
|
||||||
|
};
|
||||||
+1449
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user