Files
Wiring-Designer/frontend/js/app.js
T
Kyle cca03cd2f3 feat: standard parts library, fuse box configurator, and theming
Standard parts (partsLibrary.js)
- ACDC TXL palette: 15 solids plus the 16 stocked stripe combinations
- 29 wire functions fix colour, stripe and gauge per signal, so a wire
  drawn from a given pin comes out identical on every build
- 16 pre-labelled parts: Skudak VCU (GRAY/BLACK), openinverter LDU
  23-pin, Dilong OBC/DCDC, BMW pedal, Honeywell CSSV1500, Bender
  ISO175, Bosch iBooster, Prius EPS, Volvo PS pump, cluster, DNR
  switch, 32-way enclosure bulkhead, contactors, VW MEB BMS
- OEM colours override the in-house standard where we splice into a
  factory loom (VW MEB LV connector, recovered from the VW bms diagram)
- Brown is ground, matching the MEB loom and European practice; orange
  is reserved for AC mains and OEM high-voltage runs

Fuse box configurator (pdmLibrary.js)
- GEP FRH-A12/A24 and the 48-way PDM as 280-footprint cavity grids
- Place, drag and rotate fuses, relays, diodes, breakers and bus bars
- Bus bars occupy their own collision layer and feed the blades they
  cross, suppressing those inputs so one wire supplies the run
- Cavity capacity is tracked; Apply is blocked while a placement
  overlaps or overhangs
- Naming a circuit renames its pins: "VCU" gives "VCU in" / "VCU out"

Theming (theme.js)
- Light and dark modes; canvas background, grid dots, part fill and
  part outline are configurable and persisted
- CSS converted to custom properties with property-aware light-mode
  pairs, since a colour used as text and as a surface must flip
  differently; no rule falls below 3:1 contrast in either theme
- Canvas devices, pins, labels, cable rows and the harness view are
  themed too, since Konva bakes colours in at draw time and cannot
  read CSS variables

Also
- Pass-through bulkheads: one device covering both connector faces,
  with a single centred label per circuit
- Pin labels scale with the device font instead of a fixed 8px
- Keyboard handling is modal-scoped, so Delete no longer removes the
  canvas device while the fuse box editor is open

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 10:54:49 -04:00

3313 lines
146 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class WiringApp {
constructor() {
this.diagramId = null;
this.currentViewId = null; // null = main layout, number = view id
this._views = []; // view objects for current diagram
this._savedTimer = null;
this._customConnectors = []; // loaded from backend
this._editingConnId = null; // id of custom connector being edited in modal
this._clipboard = null; // copied device data
this._pasteCount = 0; // paste offset counter
this._undoStack = []; // array of async undo functions
this._preDrag = null; // snapshot captured at dragstart for undo
this._octopartReady = null; // null=unknown, true/false after status check
this._bundles = []; // wire bundles for current diagram
this._updatingBundleDropdown = false;
this._copiedWireStyle = null; // clipboard for wire style copy/paste
this.canvas = new DiagramCanvas("canvas-container", {
onDeviceSelected: (d) => this._showDeviceProps(d),
onWireSelected: (w) => this._showWireProps(w),
onSelectionCleared: () => this._clearProps(),
onMultipleSelected: (count) => this._showMultiProps(count),
onDeviceMoved: (id, x, y) => { this._savePosForCurrentView(id, x, y); this._flashSaved(); },
onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy),
onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy),
onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin),
onWaypointsChanged: (id, wps) => { this._saveWaypointsForCurrentView(id, wps); this._flashSaved(); },
onDragStarted: (snap) => { this._preDrag = snap; },
onDragEnded: (ids) => { this._pushMoveUndo(ids); },
onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y),
onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y),
onMultiWireSelected: (ids) => this._onMultiWireSelected(ids),
onPinoutRequested: (d) => this._showPinoutPopup(d),
onDeviceResized: async (id, x, y, w, h) => {
const device = this.canvas.deviceData.get(id);
if (!device) return;
this._recalcPinOffsets(device.pins || [], w, h, device);
await api.devices.update(id, { x, y, width: w, height: h, pins: device.pins }).catch(console.error);
this.canvas.updateDevice(device);
this.canvas.selectDevice(id);
this._flashSaved();
},
});
this._fb = null; // FormboardCanvas instance (lazy)
this._fbActive = false;
this._buildDeviceLibrary();
this._bindToolbar();
this._bindKeyboard();
this._bindProps();
this._bindTabs();
this._bindConnectorModal();
this._bindPinoutModal();
this._bindLibrarySearch();
this._bindFormboard();
this._bindPdm();
this._bindTheme();
this.loadDiagramList()
.then(() => this._autoOpenLast())
.then(() => this.loadCustomConnectors())
.then(() => this._checkOctopartStatus());
}
// ── View-aware position/waypoint saving ───────────────────────────────────────
_savePosForCurrentView(id, x, y) {
if (this.currentViewId) {
api.views.updateDevicePos(this.currentViewId, id, { x, y }).catch(console.error);
} else {
api.devices.update(id, { x, y }).catch(console.error);
}
}
_saveWaypointsForCurrentView(wireId, waypoints) {
if (this.currentViewId) {
api.views.updateWireWaypoints(this.currentViewId, wireId, { waypoints }).catch(console.error);
} else {
api.wires.update(wireId, { waypoints }).catch(console.error);
}
}
// ── Auto-reopen & saved indicator ────────────────────────────────────────────
_autoOpenLast() {
const lastId = localStorage.getItem("lastDiagramId");
if (lastId) return this.openDiagram(parseInt(lastId));
}
_flashSaved() {
clearTimeout(this._savedTimer);
this._savedTimer = setTimeout(() => {
const el = document.getElementById("saved-indicator");
if (!el) return;
el.textContent = "✓ Saved";
el.style.color = "";
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1400);
}, 600);
}
_flashError() {
clearTimeout(this._savedTimer);
const el = document.getElementById("saved-indicator");
if (!el) return;
el.textContent = "✗ Save failed";
el.style.color = "#e06c6c";
el.classList.add("show");
this._savedTimer = setTimeout(() => {
el.classList.remove("show");
el.style.color = "";
}, 3000);
}
// ── Align & Distribute ────────────────────────────────────────────────────────
async _alignDevices(edge) {
const ids = [...this.canvas.selectedIds];
if (ids.length < 2) return;
const boxes = ids.map(id => {
const d = this.canvas.deviceData.get(id);
return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 };
});
let target;
if (edge === "left") target = Math.min(...boxes.map(b => b.x));
if (edge === "right") target = Math.max(...boxes.map(b => b.x + b.w));
if (edge === "top") target = Math.min(...boxes.map(b => b.y));
if (edge === "bottom") target = Math.max(...boxes.map(b => b.y + b.h));
for (const b of boxes) {
const d = this.canvas.deviceData.get(b.id);
if (edge === "left") d.x = target;
if (edge === "right") d.x = target - b.w;
if (edge === "top") d.y = target;
if (edge === "bottom") d.y = target - b.h;
this.canvas.updateDevice(d);
await api.devices.update(b.id, { x: d.x, y: d.y }).catch(console.error);
}
this._flashSaved();
}
async _distributeDevices(axis) {
const ids = [...this.canvas.selectedIds];
if (ids.length < 3) return;
const boxes = ids.map(id => {
const d = this.canvas.deviceData.get(id);
return { id, x: d.x, y: d.y, w: d.width || 80, h: d.height || 60 };
});
if (axis === "h") {
boxes.sort((a, b) => a.x - b.x);
const totalW = boxes.reduce((s, b) => s + b.w, 0);
const gap = (boxes[boxes.length - 1].x + boxes[boxes.length - 1].w - boxes[0].x - totalW) / (boxes.length - 1);
let cursor = boxes[0].x + boxes[0].w;
for (let i = 1; i < boxes.length - 1; i++) {
const d = this.canvas.deviceData.get(boxes[i].id);
d.x = Math.round(cursor + gap);
this.canvas.updateDevice(d);
await api.devices.update(boxes[i].id, { x: d.x }).catch(console.error);
cursor = d.x + boxes[i].w;
}
} else {
boxes.sort((a, b) => a.y - b.y);
const totalH = boxes.reduce((s, b) => s + b.h, 0);
const gap = (boxes[boxes.length - 1].y + boxes[boxes.length - 1].h - boxes[0].y - totalH) / (boxes.length - 1);
let cursor = boxes[0].y + boxes[0].h;
for (let i = 1; i < boxes.length - 1; i++) {
const d = this.canvas.deviceData.get(boxes[i].id);
d.y = Math.round(cursor + gap);
this.canvas.updateDevice(d);
await api.devices.update(boxes[i].id, { y: d.y }).catch(console.error);
cursor = d.y + boxes[i].h;
}
}
this._flashSaved();
}
// ── Undo ──────────────────────────────────────────────────────────────────────
_pushUndo(fn) {
this._undoStack.push(fn);
if (this._undoStack.length > 50) this._undoStack.shift();
}
async _undo() {
const fn = this._undoStack.pop();
if (fn) await fn();
}
_pushMoveUndo(movedIds) {
if (!this._preDrag) return;
const { positions, waypoints } = this._preDrag;
this._preDrag = null;
this._pushUndo(async () => {
for (const [id, { x, y }] of positions) {
const g = this.canvas.deviceNodes.get(id);
const d = this.canvas.deviceData.get(id);
if (g) { g.x(x); g.y(y); }
if (d) { d.x = x; d.y = y; }
this._savePosForCurrentView(id, x, y);
this.canvas._redrawWiresFor(id);
}
for (const [wId, wps] of waypoints) {
const wire = this.canvas.wireData.get(wId);
if (wire) {
wire.waypoints = wps.map(wp => ({ ...wp }));
this._saveWaypointsForCurrentView(wId, wire.waypoints);
}
}
this.canvas.deviceLayer.batchDraw();
this.canvas.wireLayer.batchDraw();
this._flashSaved();
});
}
// ── Device library ────────────────────────────────────────────────────────────
_buildDeviceLibrary() {
const container = document.getElementById("device-library");
container.innerHTML = "";
Object.entries(DEVICE_TYPES).forEach(([key, type]) => {
const item = document.createElement("div");
item.className = "lib-item";
item.draggable = true;
item.dataset.type = key;
item.innerHTML = `<span class="lib-icon">${type.icon}</span><span class="lib-label">${type.label}</span>`;
item.title = type.description;
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("device_type", key); e.dataTransfer.effectAllowed = "copy"; });
item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addDevice(key, 200 + Math.random() * 100, 150 + Math.random() * 100); });
container.appendChild(item);
});
// Standard parts (partsLibrary.js) also live here, not just under the
// Connector Library tab — they are devices, and this is where you look for
// one. They reuse the connector add path, so a dropped part arrives with
// its pins labelled and its standard wire colours attached.
if (typeof SKUDAK_PARTS !== "undefined") {
Object.entries(SKUDAK_PARTS).forEach(([category, parts]) => {
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = category;
container.appendChild(header);
parts.forEach(part => container.appendChild(this._makeBuiltinItem(part)));
});
}
}
// ── Connector library ─────────────────────────────────────────────────────────
async loadCustomConnectors() {
try { this._customConnectors = await api.connectors.list(); }
catch { this._customConnectors = []; }
this._filterConnectors();
}
_bindLibrarySearch() {
document.getElementById("lib-search")?.addEventListener("input", () => this._filterConnectors());
document.getElementById("lib-category")?.addEventListener("change", () => this._filterConnectors());
}
_buildConnectorLibrary() {
const catSelect = document.getElementById("lib-category");
catSelect.innerHTML = '<option value="">All Categories</option>';
const cats = new Set(Object.keys(CONNECTOR_LIBRARY));
this._customConnectors.forEach(c => cats.add(c.category || "Custom"));
cats.forEach(cat => {
const opt = document.createElement("option");
opt.value = cat; opt.textContent = cat;
catSelect.appendChild(opt);
});
}
_filterConnectors() {
const savedCat = document.getElementById("lib-category")?.value || "";
this._buildConnectorLibrary(); // refresh category list
const catSelect = document.getElementById("lib-category");
if (catSelect && savedCat) catSelect.value = savedCat; // restore selection after innerHTML rebuild
const query = (document.getElementById("lib-search")?.value || "").toLowerCase();
const catFilter = catSelect?.value || "";
const list = document.getElementById("lib-list");
list.innerHTML = "";
// ── Custom connectors (from DB) ──────────────────────────────────────────
const customs = this._customConnectors.filter(conn => {
if (catFilter && (conn.category || "Custom") !== catFilter) return false;
if (query && !`${conn.name} ${conn.manufacturer} ${conn.part_number} ${conn.description}`.toLowerCase().includes(query)) return false;
return true;
});
if (customs.length) {
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = "Custom Connectors";
list.appendChild(header);
customs.forEach(conn => list.appendChild(this._makeCustomItem(conn)));
}
// ── Built-in library ─────────────────────────────────────────────────────
Object.entries(CONNECTOR_LIBRARY).forEach(([cat, conns]) => {
if (catFilter && cat !== catFilter) return;
const matched = conns.filter(conn => !query ||
`${conn.name} ${conn.manufacturer} ${conn.partNumber} ${conn.description}`.toLowerCase().includes(query));
if (!matched.length) return;
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = cat;
list.appendChild(header);
matched.forEach(conn => list.appendChild(this._makeBuiltinItem(conn)));
});
if (!list.children.length) {
list.innerHTML = '<p class="muted small" style="padding:8px 10px">No connectors match</p>';
}
}
_makeCustomItem(conn) {
const item = document.createElement("div");
item.className = "lib-conn-item";
item.draggable = true;
item.title = conn.description || conn.name;
item.innerHTML = `
<div class="conn-item-body">
<div class="conn-name">${conn.name} <span class="conn-custom-badge">custom</span></div>
<div class="conn-meta">${conn.manufacturer || "—"} · ${conn.pin_count}p${conn.part_number ? " · " + conn.part_number : ""}</div>
</div>
<button class="conn-edit-btn" title="Edit connector">✏</button>`;
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", "custom_" + conn.id); e.dataTransfer.effectAllowed = "copy"; });
item.addEventListener("dblclick", (e) => { if (e.target.classList.contains("conn-edit-btn")) return; if (!this.diagramId) return this._needDiagram(); this.addConnector("custom_" + conn.id, 220, 160); });
item.querySelector(".conn-edit-btn").addEventListener("click", (e) => { e.stopPropagation(); this.openConnectorModal(conn); });
return item;
}
_makeBuiltinItem(conn) {
const item = document.createElement("div");
item.className = "lib-conn-item";
item.draggable = true;
item.title = conn.description || conn.name;
item.innerHTML = `
<div class="conn-item-body">
<div class="conn-name">${conn.name}</div>
<div class="conn-meta">${conn.manufacturer} · ${conn.pinCount}p · ${conn.partNumber}</div>
</div>`;
item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", conn.id); e.dataTransfer.effectAllowed = "copy"; });
item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addConnector(conn.id, 220 + Math.random() * 80, 160 + Math.random() * 80); });
return item;
}
// ── Custom connector modal ────────────────────────────────────────────────────
_bindConnectorModal() {
document.getElementById("btn-new-connector")?.addEventListener("click", () => this.openConnectorModal(null));
document.getElementById("cm-save")?.addEventListener("click", () => this._saveConnectorModal());
document.getElementById("cm-delete")?.addEventListener("click", () => this._deleteConnectorModal());
document.getElementById("cm-cancel")?.addEventListener("click", () => this.closeConnectorModal());
// Close on backdrop click
document.getElementById("connector-modal")?.addEventListener("click", (e) => {
if (e.target.id === "connector-modal") this.closeConnectorModal();
});
// Escape key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") {
this.closeConnectorModal();
}
if (e.key === "Escape" && document.getElementById("pinout-modal")?.style.display !== "none") {
this._closePinoutPopup();
}
});
// Update pin labels when pin count changes
document.getElementById("cm-pincount")?.addEventListener("input", (e) => {
const count = Math.max(1, Math.min(64, parseInt(e.target.value) || 1));
const existing = [...document.querySelectorAll(".pin-label-input")].map(i => i.value);
this._buildPinLabelInputs(count, existing);
});
}
openConnectorModal(conn = null) {
this._editingConnId = conn?.id ?? null;
document.getElementById("cm-modal-title").textContent = conn ? "Edit Custom Connector" : "New Custom Connector";
document.getElementById("cm-name").value = conn?.name || "";
document.getElementById("cm-category").value = conn?.category || "Custom";
document.getElementById("cm-manufacturer").value = conn?.manufacturer || "";
document.getElementById("cm-partnumber").value = conn?.part_number || "";
document.getElementById("cm-description").value = conn?.description || "";
document.getElementById("cm-pincount").value = conn?.pin_count || 4;
document.getElementById("cm-delete").style.display = (conn?.id != null) ? "" : "none";
this._buildPinLabelInputs(conn?.pin_count || 4, conn?.pin_labels || []);
document.getElementById("connector-modal").style.display = "flex";
document.getElementById("cm-name").focus();
}
closeConnectorModal() {
document.getElementById("connector-modal").style.display = "none";
this._editingConnId = null;
}
_buildPinLabelInputs(count, existingLabels = []) {
const container = document.getElementById("cm-pin-labels");
container.innerHTML = "";
for (let i = 0; i < count; i++) {
const row = document.createElement("div");
row.className = "pin-label-row";
row.innerHTML = `<span class="pin-idx">${i + 1}</span>
<input type="text" class="pin-label-input" placeholder="Pin ${i + 1}" value="${(existingLabels[i] || "").replace(/"/g, "&quot;")}">`;
container.appendChild(row);
}
}
async _saveConnectorModal() {
const name = document.getElementById("cm-name").value.trim();
const pinCount = parseInt(document.getElementById("cm-pincount").value);
if (!name) { document.getElementById("cm-name").focus(); return; }
if (!pinCount || pinCount < 1 || pinCount > 64) { alert("Pin count must be 164"); return; }
const data = {
name,
category: document.getElementById("cm-category").value.trim() || "Custom",
manufacturer: document.getElementById("cm-manufacturer").value.trim(),
part_number: document.getElementById("cm-partnumber").value.trim(),
description: document.getElementById("cm-description").value.trim(),
pin_count: pinCount,
pin_labels: [...document.querySelectorAll(".pin-label-input")].map(i => i.value.trim()),
};
const btn = document.getElementById("cm-save");
btn.disabled = true; btn.textContent = "Saving…";
try {
if (this._editingConnId != null) {
await api.connectors.update(this._editingConnId, data);
} else {
await api.connectors.create(data);
}
this.closeConnectorModal();
await this.loadCustomConnectors();
this._flashSaved();
} catch (e) {
alert("Failed to save connector: " + e.message);
} finally {
btn.disabled = false; btn.textContent = "Save";
}
}
async _deleteConnectorModal() {
if (this._editingConnId == null) return;
if (!confirm(`Delete "${document.getElementById("cm-name").value}"? This cannot be undone.`)) return;
try {
await api.connectors.delete(this._editingConnId);
this.closeConnectorModal();
await this.loadCustomConnectors();
this._flashSaved();
} catch (e) { alert("Failed to delete: " + e.message); }
}
// ── Pinout popup ─────────────────────────────────────────────────────────────
_bindPinoutModal() {
document.getElementById("pinout-close")?.addEventListener("click", () => this._closePinoutPopup());
document.getElementById("pinout-modal")?.addEventListener("click", (e) => {
if (e.target.id === "pinout-modal") this._closePinoutPopup();
});
document.getElementById("prop-pinout-btn")?.addEventListener("click", () => {
if (this._propDevice) this._showPinoutPopup(this._propDevice);
});
document.getElementById("pinout-print-btn")?.addEventListener("click", () => {
if (this._propDevice) this._printPinout(this._propDevice);
});
document.getElementById("pinout-copy-btn")?.addEventListener("click", () => {
if (this._propDevice) this._copyPinoutTable(this._propDevice);
});
}
_showPinoutPopup(device) {
if (!device) return;
const connId = device.properties?.connectorLibraryId;
const conn = connId ? getConnectorById(connId) : null;
const pins = device.pins || [];
// ── SVG face view ────────────────────────────────────────────────────────
const svg = device.properties?.shape === "circular"
? this._buildCircularSVG(device, pins, false)
: this._buildSchematicSVG(device, pins, false);
// ── Pin table ────────────────────────────────────────────────────────────
const pinConnections = this._buildPinConnections(device);
const tbody = document.getElementById("pinout-table-body");
tbody.innerHTML = "";
pins.forEach((pin, i) => {
const num = i + 1;
const isDefault = !pin.name || pin.name === String(num);
const customNote = isDefault ? null : pin.name;
const conn = pinConnections.get(pin.id) || null;
const note = customNote || conn?.label || null;
const noteColor = customNote ? "#dde0f5" : conn ? "#8899cc" : "#333355";
// Wire color swatch — stripe drawn as a diagonal slash over the primary color
let swatchHTML = "";
if (conn?.colorPrimary) {
const cp = conn.colorPrimary;
const cs = conn.colorStripe;
if (cs) {
swatchHTML = `<svg width="22" height="12" style="vertical-align:middle;margin-right:5px;flex-shrink:0" xmlns="http://www.w3.org/2000/svg">
<rect width="22" height="12" rx="2" fill="${cp}"/>
<line x1="5" y1="0" x2="17" y2="12" stroke="${cs}" stroke-width="4"/>
</svg>`;
} else {
swatchHTML = `<span style="display:inline-block;width:22px;height:12px;border-radius:2px;background:${cp};vertical-align:middle;margin-right:5px;flex-shrink:0"></span>`;
}
}
const tr = document.createElement("tr");
tr.style.borderBottom = "1px solid #1a1a2e";
tr.innerHTML = `
<td style="padding:3px 6px;color:#556688;text-align:right;white-space:nowrap">${num}</td>
<td style="padding:3px 6px;display:flex;align-items:center;gap:0">${swatchHTML}<span style="color:${noteColor}">${note ?? "—"}</span></td>`;
tbody.appendChild(tr);
});
document.getElementById("pinout-modal-title").textContent = device.label;
document.getElementById("pinout-svg-container").innerHTML = svg;
document.getElementById("pinout-info").textContent =
[conn?.partNumber || device.properties?.partNumber, `${pins.length} pins`].filter(Boolean).join(" · ");
document.getElementById("pinout-modal").style.display = "flex";
}
_buildPinConnections(device) {
const map = new Map();
this.canvas.wireData.forEach(wire => {
const check = (thisDevId, thisPinId, otherDevId, otherPinId) => {
if (thisDevId !== device.id || map.has(thisPinId)) return;
const otherDev = this.canvas.deviceData.get(otherDevId);
if (!otherDev) return;
const otherPin = (otherDev.pins || []).find(p => p.id === otherPinId);
map.set(thisPinId, {
label: `${otherDev.label || otherDev.reference || `Device ${otherDevId}`}, ${otherPin?.name || otherPinId}`,
colorPrimary: wire.color_primary || null,
colorStripe: wire.color_stripe || null,
});
};
check(wire.from_device_id, wire.from_pin, wire.to_device_id, wire.to_pin);
check(wire.to_device_id, wire.to_pin, wire.from_device_id, wire.from_pin);
});
return map;
}
_printPinout(device) {
const pins = device.pins || [];
const connId = device.properties?.connectorLibraryId;
const conn = connId ? getConnectorById(connId) : null;
const conns = this._buildPinConnections(device);
const svg = device.properties?.shape === "circular"
? this._buildCircularSVG(device, pins, true)
: this._buildSchematicSVG(device, pins, true);
const subtitle = [conn?.partNumber || device.properties?.partNumber, `${pins.length} pins`]
.filter(Boolean).join(" · ");
const rows = pins.map((pin, i) => {
const num = i + 1;
const isDefault = !pin.name || pin.name === String(num);
const customNote = isDefault ? null : pin.name;
const c = conns.get(pin.id);
const note = customNote || c?.label || "—";
let swatch = "";
if (c?.colorPrimary) {
if (c.colorStripe) {
swatch = `<svg width="24" height="12" style="vertical-align:middle;margin-right:4px" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="12" rx="2" fill="${c.colorPrimary}" stroke="#ccc" stroke-width="0.5"/>
<line x1="6" y1="0" x2="18" y2="12" stroke="${c.colorStripe}" stroke-width="4"/>
</svg>`;
} else {
swatch = `<span style="display:inline-block;width:24px;height:12px;border-radius:2px;background:${c.colorPrimary};border:1px solid #bbb;vertical-align:middle;margin-right:4px"></span>`;
}
}
return `<tr><td class="pn">${num}</td><td class="pc">${swatch}</td><td>${note}</td></tr>`;
}).join("");
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>Pinout — ${device.label}</title>
<style>
* { box-sizing: border-box; }
body { font-family: Arial, sans-serif; font-size: 11px; margin: 16mm; color: #111; }
h1 { font-size: 15px; margin: 0 0 2px; }
.sub { color: #666; font-size: 10px; margin-bottom: 14px; }
.layout { display: flex; gap: 24px; align-items: flex-start; }
.tbl-wrap { flex: 1; }
table { border-collapse: collapse; width: 100%; }
th { border-bottom: 1.5px solid #333; padding: 4px 6px; text-align: left; font-size: 10px; color: #555; font-weight: 600; }
td { border-bottom: 1px solid #e8e8e8; padding: 3px 6px; vertical-align: middle; }
.pn { color: #666; text-align: right; width: 28px; font-size: 10px; }
.pc { width: 30px; }
@media print { @page { margin: 10mm; size: letter; } }
</style></head>
<body>
<h1>${device.label}</h1>
<div class="sub">${subtitle}</div>
<div class="layout">
<div>${svg}</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>#</th><th>Color</th><th>Note / Connection</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
<script>window.onload = () => { window.print(); };<\/script>
</body></html>`;
const win = window.open("", "_blank");
if (!win) { alert("Allow pop-ups to use the print feature."); return; }
win.document.write(html);
win.document.close();
}
_copyPinoutTable(device) {
const pins = device.pins || [];
const conns = this._buildPinConnections(device);
const header = "Pin\tWire Color\tNote / Connection";
const rows = pins.map((pin, i) => {
const num = i + 1;
const isDefault = !pin.name || pin.name === String(num);
const customNote = isDefault ? null : pin.name;
const c = conns.get(pin.id);
const note = customNote || c?.label || "";
const color = c?.colorPrimary
? (c.colorStripe ? `${c.colorPrimary} / ${c.colorStripe}` : c.colorPrimary)
: "";
return `${num}\t${color}\t${note}`;
});
navigator.clipboard.writeText([header, ...rows].join("\n")).then(() => {
const btn = document.getElementById("pinout-copy-btn");
if (!btn) return;
const orig = btn.textContent;
btn.textContent = "✓ Copied!";
setTimeout(() => { btn.textContent = orig; }, 1800);
}).catch(() => alert("Clipboard write failed — try again."));
}
_buildCircularSVG(device, pins, light = false) {
const C = light
? { body: "#e8e8f4", bodyStroke: "#334466", dash: "#aaaacc", notch: "#c8c8e8", notchStroke: "#334466", notchText: "#888aaa", pinFill: "#ffffff", pinStroke: "#334466", pinText: "#111133" }
: { body: "#12121f", bodyStroke: "#5a5a8a", dash: "#2a2a4a", notch: "#333355", notchStroke: "#5a5a8a", notchText: "#555577", pinFill: "#0d1020", pinStroke: "#5566aa", pinText: "#dde0f5" };
const rings = device.properties?.pinRings || [];
const pinCount = pins.length;
const ringCounts = rings.length ? rings : [pinCount];
const ringRadii = ringCounts.length === 3 ? [72, 48, 23]
: ringCounts.length === 2 ? [65, 34]
: [Math.min(65, Math.max(22, Math.sqrt(pinCount) * 13))];
const W = 200, H = 200, cx = 100, cy = 100, bodyR = 92;
let pinNum = 1;
let pinsHTML = "";
ringCounts.forEach((count, ri) => {
const r = ringRadii[ri] ?? 30;
for (let i = 0; i < count && pinNum <= pinCount; i++, pinNum++) {
const angle = -Math.PI / 2 + (2 * Math.PI * i / count);
const px = cx + r * Math.cos(angle);
const py = cy + r * Math.sin(angle);
pinsHTML += `<circle cx="${px.toFixed(1)}" cy="${py.toFixed(1)}" r="7" fill="${C.pinFill}" stroke="${C.pinStroke}" stroke-width="1.5"/>
<text x="${px.toFixed(1)}" y="${(py + 2.5).toFixed(1)}" font-size="6" text-anchor="middle" dominant-baseline="middle" fill="${C.pinText}" font-family="monospace" font-weight="bold">${pinNum}</text>`;
}
});
return `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
<circle cx="${cx}" cy="${cy}" r="${bodyR}" fill="${C.body}" stroke="${C.bodyStroke}" stroke-width="2"/>
<circle cx="${cx}" cy="${cy}" r="${bodyR - 5}" fill="none" stroke="${C.dash}" stroke-width="1" stroke-dasharray="3 3"/>
<rect x="${cx - 5}" y="${cy - bodyR - 2}" width="10" height="8" rx="2" fill="${C.notch}" stroke="${C.notchStroke}" stroke-width="1"/>
<text x="${cx}" y="${cy - bodyR + 13}" font-size="6" text-anchor="middle" fill="${C.notchText}" font-family="monospace">▲ key</text>
${pinsHTML}
</svg>`;
}
_buildSchematicSVG(device, pins, light = false) {
const C = light
? { body: "#e8e8f4", bodyStroke: "#334466", bodyText: "#888aaa", stubStroke: "#334466", pinFill: "#ffffff", pinStroke: "#334466", lblFill: "#334466" }
: { body: "#12121f", bodyStroke: "#5a5a8a", bodyText: "#444466", stubStroke: "#5566aa", pinFill: "#0d1020", pinStroke: "#5566aa", lblFill: "#8899cc" };
const bySide = { left: [], right: [], top: [], bottom: [] };
pins.forEach(p => { const s = p.side || "right"; (bySide[s] || (bySide[s] = [])).push(p); });
const lr = Math.max(bySide.left.length, bySide.right.length, 1);
const tb = Math.max(bySide.top.length, bySide.bottom.length, 0);
const rowH = Math.max(9, Math.min(18, 160 / lr));
const colW = tb ? Math.max(9, Math.min(18, 80 / tb)) : 18;
const bodyH = rowH * lr + 12;
const bodyW = Math.max(56, tb ? colW * tb + 12 : 56);
const STUB = 20, LBL = 30;
const padX = bySide.left.length || bySide.right.length ? STUB + LBL + 4 : 10;
const padY = bySide.top.length || bySide.bottom.length ? STUB + 14 : 12;
const W = bodyW + 2 * padX;
const H = bodyH + 2 * padY;
const bx = padX, by = padY;
let pinsHTML = "";
const stub = (x1, y1, x2, y2, anchor, lx, ly, label) =>
`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${C.stubStroke}" stroke-width="1.5"/>
<circle cx="${x2}" cy="${y2}" r="3.5" fill="${C.pinFill}" stroke="${C.pinStroke}" stroke-width="1.5"/>
<text x="${lx.toFixed(1)}" y="${(ly + 3).toFixed(1)}" font-size="8" text-anchor="${anchor}" fill="${C.lblFill}" font-family="monospace">${label}</text>`;
bySide.left.forEach((p, i) => {
const y = by + (i + 1) / (bySide.left.length + 1) * bodyH;
pinsHTML += stub(bx, y, bx - STUB, y, "end", bx - STUB - 3, y, p.name);
});
bySide.right.forEach((p, i) => {
const y = by + (i + 1) / (bySide.right.length + 1) * bodyH;
pinsHTML += stub(bx + bodyW, y, bx + bodyW + STUB, y, "start", bx + bodyW + STUB + 3, y, p.name);
});
bySide.top.forEach((p, i) => {
const x = bx + (i + 1) / (bySide.top.length + 1) * bodyW;
pinsHTML += stub(x, by, x, by - STUB, "middle", x, by - STUB - 4, p.name);
});
bySide.bottom.forEach((p, i) => {
const x = bx + (i + 1) / (bySide.bottom.length + 1) * bodyW;
pinsHTML += stub(x, by + bodyH, x, by + bodyH + STUB, "middle", x, by + bodyH + STUB + 7, p.name);
});
const scale = Math.min(1, 200 / Math.max(W, H));
const svgW = Math.round(W * scale);
const svgH = Math.round(H * scale);
return `<svg viewBox="0 0 ${W} ${H}" width="${svgW}" height="${svgH}" xmlns="http://www.w3.org/2000/svg">
<rect x="${bx}" y="${by}" width="${bodyW}" height="${bodyH}" rx="4" fill="${C.body}" stroke="${C.bodyStroke}" stroke-width="2"/>
<text x="${(bx + bodyW / 2).toFixed(1)}" y="${(by + bodyH / 2 + 4).toFixed(1)}" font-size="9" text-anchor="middle" fill="${C.bodyText}" font-family="monospace">${device.device_type}</text>
${pinsHTML}
</svg>`;
}
_closePinoutPopup() {
document.getElementById("pinout-modal").style.display = "none";
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
_bindTabs() {
document.querySelectorAll(".sidebar-tab").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".sidebar-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach(p => {
p.style.display = p.dataset.panel === btn.dataset.tab ? "" : "none";
});
});
});
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
_bindToolbar() {
const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn);
on("btn-select", () => this.setMode("select"));
on("btn-wire", () => this.setMode("wire"));
on("btn-delete", () => this.deleteSelected());
on("btn-duplicate", () => this.duplicateSelected());
on("btn-undo", () => this._undo());
on("btn-fit", () => this.canvas.fitView());
on("btn-new", () => this.newDiagram());
on("btn-bom", () => this._export("bom"));
on("btn-assembly", () => this._export("assembly"));
on("btn-json", () => this._export("json"));
on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); });
on("btn-formboard", () => this._export("formboard"));
on("btn-git", () => this._openGitModal());
on("btn-drc", () => this._runDrc());
on("btn-import", () => document.getElementById("import-file-input")?.click());
document.getElementById("import-file-input")?.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (ev) => {
try {
const data = JSON.parse(ev.target.result);
const diag = await api.diagrams.import(data);
await this.loadDiagramList();
await this.openDiagram(diag.id);
} catch (err) { alert("Import failed: " + err.message); }
e.target.value = "";
};
reader.readAsText(file);
});
on("btn-jumps", () => {
const btn = document.getElementById("btn-jumps");
const enabled = !this.canvas.jumpsEnabled;
this.canvas.setJumpsEnabled(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-snap", () => {
const btn = document.getElementById("btn-snap");
const enabled = !this.canvas.snapEnabled;
this.canvas.toggleSnap(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-harness", () => {
const btn = document.getElementById("btn-harness");
const enabled = !this.canvas.harnessMode;
this.canvas.setHarnessMode(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-route-ortho", () => this._setRouteMode("ortho"));
on("btn-route-direct", () => this._setRouteMode("direct"));
on("btn-route-curved", () => this._setRouteMode("curved"));
document.getElementById("export-toggle")?.addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("export-menu").classList.toggle("open"); });
document.addEventListener("click", (e) => {
document.getElementById("export-menu")?.classList.remove("open");
this._closeCtx();
if (!e.target.closest("#octopart-results") && !e.target.closest("#btn-octopart-search")) {
const res = document.getElementById("octopart-results");
if (res) res.style.display = "none";
}
});
document.addEventListener("contextmenu", (e) => {
// Close ctx menu if right-clicking outside canvas (browser default handles the rest)
if (!e.target.closest("#canvas-container")) this._closeCtx();
});
document.getElementById("diagram-name")?.addEventListener("change", async (e) => {
if (this.diagramId) { await api.diagrams.update(this.diagramId, { name: e.target.value }).catch(console.error); this._flashSaved(); }
});
}
// ── Context menu ──────────────────────────────────────────────────────────────
_ctxMenu() { return document.getElementById("ctx-menu"); }
_openCtx(x, y, items) {
const menu = this._ctxMenu();
menu.innerHTML = items.map(item => {
if (item === "---") return `<div class="ctx-sep"></div>`;
return `<div class="ctx-item${item.danger ? " danger" : ""}" data-action="${item.action}">
${item.icon ? `<span>${item.icon}</span>` : ""}<span>${item.label}</span>
</div>`;
}).join("");
menu.classList.add("open");
// Position, keeping within viewport
const vw = window.innerWidth, vh = window.innerHeight;
const mw = 180, mh = menu.childElementCount * 32;
menu.style.left = (x + mw > vw ? x - mw : x) + "px";
menu.style.top = (y + mh > vh ? y - mh : y) + "px";
}
_closeCtx() { this._ctxMenu().classList.remove("open"); }
_showCtxDevice(id, x, y) {
const d = this.canvas.deviceData.get(id);
const count = this.canvas.selectedDeviceIds.size;
const isMulti = count > 1;
const isGroup = d?.device_type === "group";
const isLocked = !!d?.properties?.locked;
const items = isMulti ? [
{ action: "dup", icon: "⊕", label: `Duplicate ${count} items` },
{ action: "zoom", icon: "⊞", label: "Zoom to selection" },
"---",
{ action: "align-left", icon: "⇤", label: "Align Left" },
{ action: "align-right", icon: "⇥", label: "Align Right" },
{ action: "align-top", icon: "⇡", label: "Align Top" },
{ action: "align-bottom", icon: "⇣", label: "Align Bottom" },
...(count >= 3 ? [
"---",
{ action: "dist-h", icon: "↔", label: "Distribute Horizontally" },
{ action: "dist-v", icon: "↕", label: "Distribute Vertically" },
] : []),
"---",
{ action: "del", icon: "✕", label: `Delete ${count} items`, danger: true },
] : [
{ action: "dup", icon: "⊕", label: "Duplicate" },
...(!isGroup ? [{ action: "addpin", icon: "+", label: "Add Pin" }] : []),
...(!isGroup ? [{ action: "savelb", icon: "⬆", label: "Save to Library" }] : []),
"---",
{ action: "front", icon: "▲", label: "Bring to Front" },
{ action: "back", icon: "▼", label: "Send to Back" },
"---",
isLocked
? { action: "unlock", icon: "🔓", label: "Unlock Position" }
: { action: "lock", icon: "🔒", label: "Lock Position" },
"---",
{ action: "del", icon: "✕", label: "Delete", danger: true },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "dup") this.duplicateSelected();
if (action === "del") this.deleteSelected();
if (action === "zoom") this.canvas.zoomToSelection();
if (action === "addpin" && d) this._addDevicePin(d);
if (action === "savelb" && d) this._saveDeviceAsConnector(d);
if (action === "front") { this.canvas.bringToFront(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } }
if (action === "back") { this.canvas.sendToBack(id); const dd = this.canvas.deviceData.get(id); if (dd) { dd.properties = { ...(dd.properties||{}), zOrder: -Date.now() }; api.devices.update(id, { properties: dd.properties }).catch(console.error); } }
if (action === "lock") { this.canvas.setDeviceLocked(id, true); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); }
if (action === "unlock") { this.canvas.setDeviceLocked(id, false); const dd = this.canvas.deviceData.get(id); if (dd) await api.devices.update(id, { properties: dd.properties }).catch(console.error); }
if (action === "align-left") this._alignDevices("left");
if (action === "align-right") this._alignDevices("right");
if (action === "align-top") this._alignDevices("top");
if (action === "align-bottom") this._alignDevices("bottom");
if (action === "dist-h") this._distributeDevices("h");
if (action === "dist-v") this._distributeDevices("v");
};
}
_showCtxWire(id, x, y) {
const multiIds = this.canvas.selectedWireIds;
if (multiIds.size > 1 && multiIds.has(id)) {
// Multi-wire context menu
const items = [
{ action: "multi-clearwp", icon: "⌀", label: `Clear waypoints (${multiIds.size} wires)` },
{ action: "multi-autoroute", icon: "⤢", label: `Auto-route (${multiIds.size} wires)` },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "multi-clearwp") {
for (const wid of multiIds) {
const w = this.canvas.wireData.get(wid);
if (!w) continue;
w.waypoints = [];
this.canvas.updateWire(w);
api.wires.update(wid, { waypoints: [] }).catch(console.error);
}
this._flashSaved();
}
if (action === "multi-autoroute") {
for (const wid of multiIds) {
const newWps = this.canvas.autoRouteWire(wid);
if (newWps !== null) {
api.wires.update(wid, { waypoints: newWps }).catch(console.error);
}
}
this._flashSaved();
}
};
return;
}
const wire = this.canvas.wireData.get(id);
const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0;
const items = [
{ action: "copystyle", icon: "⎘", label: "Copy Style" },
...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []),
...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []),
"---",
{ action: "autoroute", icon: "⤢", label: "Auto-route" },
{ action: "straighten", icon: "⤡", label: "Straighten wire" },
...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []),
"---",
{ action: "del", icon: "✕", label: "Delete wire", danger: true },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "del") this.deleteSelected();
if (action === "autoroute" && wire) {
const newWps = this.canvas.autoRouteWire(id);
if (newWps !== null) {
api.wires.update(id, { waypoints: newWps }).catch(console.error);
this._flashSaved();
}
}
if ((action === "clearwp" || action === "straighten") && wire) {
wire.waypoints = [];
this.canvas.updateWire(wire);
api.wires.update(id, { waypoints: [] }).catch(console.error);
this._flashSaved();
}
if (action === "copystyle" && wire) {
this._copiedWireStyle = {
color_primary: wire.color_primary,
color_stripe: wire.color_stripe,
gauge: wire.gauge,
twisted_pair: wire.twisted_pair,
twist_pitch: wire.twist_pitch,
shielded: wire.shielded,
show_size_label: wire.show_size_label,
};
}
if (action === "pastestyle" && wire && this._copiedWireStyle) {
Object.assign(wire, this._copiedWireStyle);
this.canvas.updateWire(wire);
await api.wires.update(id, this._copiedWireStyle).catch(console.error);
if (this.canvas.selectedId === id) this._showWireProps(wire);
this._flashSaved();
}
if (action === "selectnet" && wire?.label) {
this.canvas.highlightNet(wire.label);
}
};
}
_onMultiWireSelected(ids) {
// No panel update needed for multi-wire; the count could be shown in status bar
}
_setRouteMode(mode) {
this.canvas.setRouteMode(mode);
document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`btn-route-${mode}`)?.classList.add("active");
}
// True while any modal overlay is up. Canvas shortcuts must stand down then,
// otherwise Delete inside the fuse box editor deletes the device itself.
_anyModalOpen() {
return ["connector-modal", "pinout-modal", "drc-modal", "git-modal", "pdm-modal", "theme-modal"]
.some((id) => {
const el = document.getElementById(id);
return el && el.style.display && el.style.display !== "none";
});
}
_bindKeyboard() {
document.addEventListener("keydown", (e) => {
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (this._anyModalOpen()) return;
if (e.key === "Delete" || e.key === "Backspace") this.deleteSelected();
if (e.key === "Escape") { this.setMode("select"); this._closeCtx(); }
if (e.key === "w") this.setMode("wire");
if (e.key === "s") this.setMode("select");
if (e.key === "f" && !e.shiftKey) this.canvas.fitView();
if (e.key === "F" && e.shiftKey) this.canvas.zoomToSelection();
if (e.key === "g") { const btn = document.getElementById("btn-snap"); const en = !this.canvas.snapEnabled; this.canvas.toggleSnap(en); btn?.classList.toggle("active", en); }
if (e.key === "h") { const btn = document.getElementById("btn-harness"); const en = !this.canvas.harnessMode; this.canvas.setHarnessMode(en); btn?.classList.toggle("active", en); }
if ((e.ctrlKey || e.metaKey) && e.key === "c" && this.canvas.selectedType === "device") { e.preventDefault(); this.copySelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); }
if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); }
// Arrow key nudge
if (["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)) {
const ids = [...this.canvas.selectedDeviceIds];
if (!ids.length) return;
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0;
ids.forEach(id => {
const g = this.canvas.deviceNodes.get(id);
const d = this.canvas.deviceData.get(id);
if (!g || !d) return;
d.x += dx; d.y += dy;
g.x(d.x); g.y(d.y);
this.canvas._redrawWiresFor(id);
});
this.canvas.deviceLayer.batchDraw();
clearTimeout(this._nudgeSaveTimer);
this._nudgeSaveTimer = setTimeout(() => {
ids.forEach(id => {
const d = this.canvas.deviceData.get(id);
if (d) this._savePosForCurrentView(id, d.x, d.y);
});
this._flashSaved();
}, 300);
}
});
}
// ── Properties panel ──────────────────────────────────────────────────────────
_bindProps() {
this._onPropChange("prop-reference", (v) => this._patchDevice({ reference: v }, true));
this._onPropChange("prop-label", (v) => this._patchDevice({ label: v }, true));
this._onPropChange("prop-partnumber", (v) => this._patchDeviceProp("partNumber", v));
this._onPropChange("prop-manufacturer", (v) => this._patchDeviceProp("manufacturer", v));
this._onPropChange("prop-fontsize", (v) => this._patchDeviceProp("fontSize", Math.max(6, Math.min(72, parseInt(v) || 12)), true));
this._onPropChange("wire-label", (v) => this._patchWire({ label: v }));
this._onPropChange("wire-gauge", (v) => this._patchWire({ gauge: v }));
this._onPropChange("wire-length", (v) => this._patchWire({ length: v ? parseFloat(v) : null }, true));
this._onPropChange("wire-unit", (v) => this._patchWire({ length_unit: v }, true));
this._onPropChange("wire-notes", (v) => this._patchWire({ notes: v }));
document.getElementById("wire-color")?.addEventListener("input", (e) => this._patchWire({ color_primary: e.target.value }, true));
document.getElementById("wire-stripe-enabled")?.addEventListener("change", (e) => {
const picker = document.getElementById("wire-stripe");
picker.disabled = !e.target.checked;
picker.style.opacity = e.target.checked ? "1" : "0.3";
this._patchWire({ color_stripe: e.target.checked ? picker.value : null }, true);
});
document.getElementById("wire-stripe")?.addEventListener("input", (e) => {
const isTwisted = document.getElementById("wire-twisted")?.checked;
if (isTwisted || document.getElementById("wire-stripe-enabled")?.checked) {
this._patchWire({ color_stripe: e.target.value }, true);
}
});
document.getElementById("btn-octopart-search")?.addEventListener("click", () => this._octopartSearch());
document.getElementById("group-fill-color")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "group") return;
d.properties = { ...d.properties, fillColor: e.target.value };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("group-fill-opacity")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "group") return;
const opacity = parseFloat(e.target.value);
document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%";
d.properties = { ...d.properties, fillOpacity: opacity };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("cable-jacket-color")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "cable") return;
d.properties = { ...d.properties, jacketColor: e.target.value };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("cable-sleeve-length")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "cable") return;
const val = parseInt(e.target.value);
document.getElementById("cable-sleeve-length-val").textContent = val + " px";
d.properties = { ...d.properties, sleeveLength: val };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("prop-add-conductor")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d?.device_type === "cable") this._addConductor(d);
});
document.getElementById("wire-twisted")?.addEventListener("change", (e) => {
const pitchEl = document.getElementById("wire-twist-pitch");
pitchEl.disabled = !e.target.checked;
pitchEl.style.opacity = e.target.checked ? "1" : "0.3";
const updates = { twisted_pair: e.target.checked };
if (e.target.checked) {
const stripeEl = document.getElementById("wire-stripe");
if (!document.getElementById("wire-stripe-enabled").checked) {
stripeEl.value = "#cccccc";
updates.color_stripe = "#cccccc";
}
}
this._patchWire(updates, true);
this._updateWireTwistedUI(e.target.checked);
});
document.getElementById("wire-twist-pitch")?.addEventListener("change", (e) => {
this._patchWire({ twist_pitch: Math.max(8, Math.min(64, parseFloat(e.target.value) || 16)) }, true);
});
document.getElementById("wire-shielded")?.addEventListener("change", (e) => {
this._patchWire({ shielded: e.target.checked }, true);
});
document.getElementById("wire-show-size-label")?.addEventListener("change", (e) => {
this._patchWire({ show_size_label: e.target.checked }, true);
});
document.getElementById("wire-bundle-select")?.addEventListener("change", (e) => {
if (this._updatingBundleDropdown) return;
const bundleId = e.target.value ? parseInt(e.target.value) : null;
this._patchWire({ bundle_id: bundleId }, true);
this._refreshBundleEditPanel(bundleId);
});
document.getElementById("wire-bundle-new")?.addEventListener("click", async () => {
if (!this.diagramId) return;
try {
const b = await api.bundles.create({ diagram_id: this.diagramId, label: "New Bundle", jacket_color: "#2a2a2a" });
this._bundles.push(b);
this.canvas.loadBundles(this._bundles);
this._refreshBundleDropdown(b.id);
this._patchWire({ bundle_id: b.id }, true);
this._refreshBundleEditPanel(b.id);
} catch (e) { console.error("Create bundle failed:", e); }
});
document.getElementById("wire-bundle-label")?.addEventListener("change", async (e) => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId) return;
try {
const updated = await api.bundles.update(bundleId, { label: e.target.value });
const idx = this._bundles.findIndex(b => b.id === bundleId);
if (idx >= 0) this._bundles[idx] = updated;
this.canvas.loadBundles(this._bundles);
this._refreshBundleDropdown(bundleId);
} catch (ex) { console.error(ex); }
});
document.getElementById("wire-bundle-color")?.addEventListener("input", async (e) => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId) return;
try {
const updated = await api.bundles.update(bundleId, { jacket_color: e.target.value });
const idx = this._bundles.findIndex(b => b.id === bundleId);
if (idx >= 0) this._bundles[idx] = updated;
this.canvas.loadBundles(this._bundles);
} catch (ex) { console.error(ex); }
});
document.getElementById("wire-bundle-delete")?.addEventListener("click", async () => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId || !confirm("Delete this bundle? Wires will be unassigned.")) return;
try {
await api.bundles.delete(bundleId);
this._bundles = this._bundles.filter(b => b.id !== bundleId);
this.canvas.wireData.forEach((w, wId) => {
if (w.bundle_id === bundleId) { w.bundle_id = null; api.wires.update(wId, { bundle_id: null }).catch(console.error); }
});
this.canvas.loadBundles(this._bundles); // also calls _renderBundles
const selWire = this.canvas.wireData.get(this.canvas.selectedId);
if (selWire?.bundle_id === null) this._refreshBundleDropdown(null);
else this._refreshBundleDropdown(selWire?.bundle_id || null);
this._refreshBundleEditPanel(selWire?.bundle_id || null);
} catch (ex) { console.error(ex); }
});
document.getElementById("prop-add-pin")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d) this._addDevicePin(d);
});
document.getElementById("prop-save-connector")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d) this._saveDeviceAsConnector(d);
});
const presetSelect = document.getElementById("wire-color-preset");
if (presetSelect) {
WIRE_COLORS.forEach(c => {
const opt = document.createElement("option");
opt.value = c.hex; opt.textContent = c.name;
presetSelect.appendChild(opt);
});
presetSelect.addEventListener("change", (e) => {
if (!e.target.value) return;
document.getElementById("wire-color").value = e.target.value;
this._patchWire({ color_primary: e.target.value }, true);
presetSelect.value = "";
});
}
}
_onPropChange(id, fn) { document.getElementById(id)?.addEventListener("change", (e) => fn(e.target.value)); }
async _patchDevice(data, redraw = false) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
const d = this.canvas.deviceData.get(id);
const oldData = d ? Object.fromEntries(Object.keys(data).map(k => [k, d[k]])) : {};
if (d) Object.assign(d, data);
await api.devices.update(id, data).catch(console.error);
this._flashSaved();
if (redraw && d) this.canvas.updateDevice(d);
this._pushUndo(async () => {
const cur = this.canvas.deviceData.get(id);
if (cur) Object.assign(cur, oldData);
await api.devices.update(id, oldData).catch(console.error);
if (redraw && cur) this.canvas.updateDevice(cur);
this._flashSaved();
});
}
async _patchDeviceProp(key, value, redraw = false) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
const d = this.canvas.deviceData.get(id);
if (!d) return;
const oldProps = { ...(d.properties || {}) };
d.properties = { ...oldProps, [key]: value };
await api.devices.update(id, { properties: d.properties }).catch(console.error);
this._flashSaved();
if (redraw) { this.canvas.updateDevice(d); this.canvas.selectDevice(id); }
this._pushUndo(async () => {
const cur = this.canvas.deviceData.get(id);
if (cur) { cur.properties = oldProps; }
await api.devices.update(id, { properties: oldProps }).catch(console.error);
if (redraw) { if (cur) this.canvas.updateDevice(cur); this.canvas.selectDevice(id); }
this._flashSaved();
});
}
async _patchWire(data, redraw = false) {
if (this.canvas.selectedType !== "wire") return;
const id = this.canvas.selectedId;
const w = this.canvas.wireData.get(id);
const oldData = w ? Object.fromEntries(Object.keys(data).map(k => [k, w[k]])) : {};
if (w) {
Object.assign(w, data);
if (redraw) this.canvas.updateWire(w);
}
try {
await api.wires.update(id, data);
this._flashSaved();
this._pushUndo(async () => {
const cur = this.canvas.wireData.get(id);
if (cur) { Object.assign(cur, oldData); this.canvas.updateWire(cur); }
await api.wires.update(id, oldData).catch(console.error);
this._flashSaved();
});
} catch (e) {
console.error("Wire save failed:", e);
this._flashError();
}
}
// ── Diagram management ────────────────────────────────────────────────────────
async loadDiagramList() {
const list = document.getElementById("diagram-list");
try {
const diagrams = await api.diagrams.list();
if (!diagrams.length) {
list.innerHTML = '<p class="muted small" style="padding:6px 10px">No diagrams yet</p>';
return;
}
list.innerHTML = "";
diagrams.forEach(d => {
const el = document.createElement("div");
el.className = "diagram-item" + (d.id === this.diagramId ? " active" : "");
el.dataset.id = d.id;
el.innerHTML = `<span class="di-name">${d.name}</span>
<span class="di-date">${new Date(d.updated_at + "Z").toLocaleDateString()}</span>
<button class="di-dup-btn" title="Duplicate diagram">⊕</button>
<button class="di-del-btn" title="Delete diagram">×</button>`;
el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id));
el.querySelector(".di-date").addEventListener("click", () => this.openDiagram(d.id));
el.querySelector(".di-dup-btn").addEventListener("click", async (e) => {
e.stopPropagation();
try {
const copy = await api.diagrams.duplicate(d.id);
await this.loadDiagramList();
await this.openDiagram(copy.id);
} catch (err) { alert("Duplicate failed: " + err.message); }
});
el.querySelector(".di-del-btn").addEventListener("click", (e) => {
e.stopPropagation();
this.deleteDiagram(d.id, d.name);
});
list.appendChild(el);
});
} catch { list.innerHTML = '<p class="muted small" style="padding:6px 10px">Failed to load</p>'; }
}
async deleteDiagram(id, name) {
if (!confirm(`Delete "${name}" and all its contents? This cannot be undone.`)) return;
try {
await api.diagrams.delete(id);
if (this.diagramId === id) {
this.diagramId = null;
this.currentViewId = null;
this._views = [];
localStorage.removeItem("lastDiagramId");
this.canvas.loadDiagram({ devices: [], wires: [] });
document.getElementById("diagram-name").value = "";
document.getElementById("canvas-placeholder").style.display = "";
document.getElementById("view-tab-bar").style.display = "none";
this._clearProps();
}
await this.loadDiagramList();
} catch (e) { alert("Failed to delete: " + e.message); }
}
async newDiagram() {
const name = prompt("Diagram name:", "New Diagram");
if (!name) return;
try {
const d = await api.diagrams.create({ name });
await this.loadDiagramList();
await this.openDiagram(d.id);
} catch (e) { alert("Failed to create: " + e.message); }
}
async openDiagram(id) {
try {
// Tear down formboard canvas so it's rebuilt fresh for the new diagram
if (this._fb) { this._fb.destroy(); this._fb = null; }
this._fbActive = false;
document.getElementById("canvas-container").style.display = "";
document.getElementById("fb-container").style.display = "none";
document.getElementById("fb-toolbar").style.display = "none";
document.getElementById("props-formboard").style.display = "none";
document.getElementById("btn-formboard-tab")?.classList.remove("active");
const diagram = await api.diagrams.get(id);
this.diagramId = id;
this.currentViewId = null;
localStorage.setItem("lastDiagramId", id);
document.getElementById("diagram-name").value = diagram.name;
document.getElementById("canvas-placeholder").style.display = "none";
this._bundles = diagram.wire_bundles || [];
this.canvas.loadDiagram(diagram);
this.canvas.loadBundles(this._bundles);
if (diagram.devices?.length) setTimeout(() => this.canvas.fitView(), 50);
this.setMode("select");
this._clearProps();
await this.loadDiagramList();
await this._loadViews();
} catch (e) {
localStorage.removeItem("lastDiagramId");
console.error("Failed to open diagram:", e);
}
}
// ── Views ─────────────────────────────────────────────────────────────────────
async _loadViews() {
if (!this.diagramId) return;
try { this._views = await api.views.listForDiagram(this.diagramId); }
catch { this._views = []; }
this._renderViewTabs();
}
_renderViewTabs() {
const bar = document.getElementById("view-tab-bar");
if (!bar) return;
// Remove all view tabs (keep add button)
bar.querySelectorAll(".view-tab:not(.fb-tab)").forEach(t => t.remove());
const addBtn = document.getElementById("btn-add-view");
const mainTab = document.createElement("button");
mainTab.className = "view-tab" + (this.currentViewId === null ? " active" : "");
mainTab.dataset.viewId = "main";
mainTab.textContent = "Main";
mainTab.addEventListener("click", () => this._switchToView(null));
bar.insertBefore(mainTab, addBtn);
this._views.forEach(v => {
const tab = document.createElement("button");
tab.className = "view-tab" + (this.currentViewId === v.id ? " active" : "");
tab.dataset.viewId = v.id;
const label = document.createElement("span");
label.textContent = v.name;
tab.appendChild(label);
const closeBtn = document.createElement("span");
closeBtn.textContent = "×";
closeBtn.className = "view-tab-close";
closeBtn.title = "Delete view";
closeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Delete view "${v.name}"?`)) return;
try {
await api.views.delete(v.id);
this._views = this._views.filter(x => x.id !== v.id);
if (this.currentViewId === v.id) await this._switchToView(null);
else this._renderViewTabs();
} catch (err) { alert("Failed to delete view: " + err.message); }
});
tab.appendChild(closeBtn);
tab.addEventListener("dblclick", () => this._renameView(v));
tab.addEventListener("click", () => this._switchToView(v.id));
tab.addEventListener("contextmenu", (e) => { e.preventDefault(); this._viewTabCtx(v, e.clientX, e.clientY); });
bar.insertBefore(tab, addBtn);
});
bar.style.display = this.diagramId ? "" : "none";
if (!addBtn._bound) {
addBtn._bound = true;
addBtn.addEventListener("click", () => this._addView());
}
}
async _addView() {
const name = prompt("View name (e.g. \"Curved\"):", "View " + (this._views.length + 1));
if (!name || !this.diagramId) return;
const routeMode = prompt("Default routing (direct / curved / ortho):", "direct") || "direct";
try {
const v = await api.views.create({ diagram_id: this.diagramId, name, route_mode: routeMode });
this._views.push(v);
this._renderViewTabs();
await this._switchToView(v.id);
} catch (e) { alert("Failed to create view: " + e.message); }
}
async _renameView(v) {
const name = prompt("Rename view:", v.name);
if (!name) return;
try {
const updated = await api.views.update(v.id, { name });
const idx = this._views.findIndex(x => x.id === v.id);
if (idx >= 0) this._views[idx] = updated;
this._renderViewTabs();
} catch (e) { alert("Failed to rename: " + e.message); }
}
_viewTabCtx(v, x, y) {
this._openCtx(x, y, [
{ action: "rename", icon: "✏", label: "Rename…" },
{ action: "del", icon: "✕", label: "Delete view", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "rename") this._renameView(v);
if (action === "del") {
if (!confirm(`Delete view "${v.name}"?`)) return;
try {
await api.views.delete(v.id);
this._views = this._views.filter(x => x.id !== v.id);
if (this.currentViewId === v.id) await this._switchToView(null);
else this._renderViewTabs();
} catch (err) { alert("Failed to delete view: " + err.message); }
}
};
}
async _switchToView(viewId) {
this.currentViewId = viewId;
const diagram = await api.diagrams.get(this.diagramId).catch(() => null);
if (!diagram) return;
if (viewId === null) {
// Restore main layout — reload the diagram as-is
this.canvas.loadDiagram(diagram);
this.canvas.loadBundles(this._bundles);
const routeBtn = document.querySelector(".route-btn.active");
// Keep current route mode
} else {
const view = this._views.find(v => v.id === viewId);
const layout = await api.views.getLayout(viewId).catch(() => ({ device_positions: {}, wire_waypoints: {} }));
this.canvas.loadDiagram(diagram);
this.canvas.loadBundles(this._bundles);
// Apply stored view positions
const storedIds = new Set(Object.keys(layout.device_positions).map(Number));
Object.entries(layout.device_positions).forEach(([devIdStr, pos]) => {
const devId = parseInt(devIdStr);
const d = this.canvas.deviceData.get(devId);
const g = this.canvas.deviceNodes.get(devId);
if (d && g) { d.x = pos.x; d.y = pos.y; g.x(pos.x); g.y(pos.y); }
});
// Snapshot any device NOT yet in this view's layout so main-tab moves
// can never bleed through after this point.
const toSnapshot = [];
this.canvas.deviceData.forEach((d, devId) => {
if (!storedIds.has(devId)) toSnapshot.push({ devId, x: d.x, y: d.y });
});
toSnapshot.forEach(({ devId, x, y }) =>
api.views.updateDevicePos(viewId, devId, { x, y }).catch(console.error)
);
// Apply stored wire waypoints
Object.entries(layout.wire_waypoints).forEach(([wireIdStr, wps]) => {
const wireId = parseInt(wireIdStr);
const w = this.canvas.wireData.get(wireId);
if (w) { w.waypoints = wps; }
});
this.canvas.deviceLayer.batchDraw();
this.canvas.wireLayer.batchDraw();
if (view?.route_mode) this._setRouteMode(view.route_mode);
}
this._renderViewTabs();
this.setMode("select");
this._clearProps();
}
// ── DRC ───────────────────────────────────────────────────────────────────────
_runDrc() {
if (!this.diagramId) return this._needDiagram();
const devices = [...this.canvas.deviceData.values()];
const wires = [...this.canvas.wireData.values()];
const categories = [
{ key: "unconnected", label: "Unconnected Pins", icon: "🔴", items: [] },
{ key: "dupref", label: "Duplicate References",icon: "🟠", items: [] },
{ key: "nolength", label: "Missing Wire Length", icon: "🔵", items: [] },
{ key: "nopn", label: "Missing Part Numbers", icon: "🟡", items: [] },
];
const cat = Object.fromEntries(categories.map(c => [c.key, c.items]));
// Unconnected pins
const connectedPins = new Set();
wires.forEach(w => {
if (w.from_device_id) connectedPins.add(`${w.from_device_id}::${w.from_pin}`);
if (w.to_device_id) connectedPins.add(`${w.to_device_id}::${w.to_pin}`);
});
devices.forEach(d => {
if (d.device_type === "group") return;
(d.pins || []).forEach(pin => {
if (!connectedPins.has(`${d.id}::${pin.id}`))
cat.unconnected.push(`${d.reference || d.label || "Device"} — pin "${pin.name || pin.id}"`);
});
});
// Duplicate references
const refCount = new Map();
devices.forEach(d => { if (d.reference) refCount.set(d.reference, (refCount.get(d.reference) || 0) + 1); });
refCount.forEach((count, ref) => {
if (count > 1) cat.dupref.push(`"${ref}" used on ${count} devices`);
});
// Missing wire length
wires.forEach(w => {
if (!w.length) cat.nolength.push(`Wire ${w.label ? `"${w.label}"` : `ID ${w.id}`}`);
});
// Missing part numbers
devices.forEach(d => {
if (d.device_type === "group") return;
if (!d.properties?.partNumber) cat.nopn.push(`${d.reference || d.label || "Device"}`);
});
// Render
const modal = document.getElementById("drc-modal");
const results = document.getElementById("drc-results");
if (!modal || !results) return;
const totalIssues = categories.reduce((s, c) => s + c.items.length, 0);
if (!totalIssues) {
results.innerHTML = `<div style="color:#6ec97b;padding:16px;text-align:center;font-size:14px">✓ No issues found</div>`;
} else {
results.innerHTML = categories.map(c => {
const count = c.items.length;
const catId = `drc-cat-${c.key}`;
const rows = c.items.map(msg =>
`<div class="drc-row">${msg}</div>`
).join("");
return `
<div class="drc-category">
<label class="drc-cat-header">
<input type="checkbox" class="drc-toggle" data-cat="${catId}" ${count ? "checked" : "disabled"}>
<span class="drc-cat-icon">${c.icon}</span>
<span class="drc-cat-label">${c.label}</span>
<span class="drc-cat-count">${count}</span>
</label>
<div id="${catId}" class="drc-cat-body" ${!count ? 'style="display:none"' : ""}>
${count ? rows : '<div class="drc-row drc-none">None</div>'}
</div>
</div>`;
}).join("");
results.querySelectorAll(".drc-toggle").forEach(cb => {
cb.addEventListener("change", () => {
const body = document.getElementById(cb.dataset.cat);
if (body) body.style.display = cb.checked ? "" : "none";
});
});
}
modal.style.display = "flex";
document.getElementById("drc-close")?.addEventListener("click", () => { modal.style.display = "none"; }, { once: true });
modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }, { once: true });
}
// ── Actions ───────────────────────────────────────────────────────────────────
setMode(mode) {
this.canvas.setMode(mode);
document.querySelectorAll(".mode-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`btn-${mode}`)?.classList.add("active");
const ind = document.getElementById("mode-indicator");
if (ind) ind.textContent = mode === "wire"
? "Wire Mode — drag from a pin to another pin to connect (Esc to cancel)"
: "";
}
async addDevice(typeKey, wx, wy) {
if (!this.diagramId) return this._needDiagram();
const def = buildDefaultDevice(typeKey, this.diagramId);
if (!def) return;
def.x = wx - def.width / 2;
def.y = wy - def.height / 2;
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.removeDevice(device.id);
await api.devices.delete(device.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create device failed:", e); }
}
async addConnector(connId, wx, wy) {
if (!this.diagramId) return this._needDiagram();
let def;
if (connId.startsWith("custom_")) {
const id = parseInt(connId.replace("custom_", ""));
const conn = this._customConnectors.find(c => c.id === id);
if (!conn) return;
def = this._customConnectorToDevice(conn, this.diagramId);
} else {
def = connectorToDevice(connId, this.diagramId);
}
if (!def) return;
def.x = wx - def.width / 2;
def.y = wy - def.height / 2;
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.removeDevice(device.id);
await api.devices.delete(device.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create connector failed:", e); }
}
_customConnectorToDevice(conn, diagramId) {
const pinCount = conn.pin_count;
const w = 120;
const h = Math.max(60, pinCount * 18 + 20);
const pins = Array.from({ length: pinCount }, (_, i) => ({
id: `pin_${i + 1}`,
name: (conn.pin_labels && conn.pin_labels[i]) || String(i + 1),
side: "right",
x_offset: w,
y_offset: ((i + 1) / (pinCount + 1)) * h,
}));
return {
diagram_id: diagramId,
device_type: "connector",
label: conn.name,
reference: "",
x: 200, y: 200,
width: w, height: h,
properties: { pinCount, orientation: "right", partNumber: conn.part_number || "", manufacturer: conn.manufacturer || "", customConnectorId: conn.id },
pins,
};
}
_conductorColorForPin(deviceId, pinId) {
const d = this.canvas.deviceData.get(deviceId);
if (d?.device_type !== "cable") return null;
const m = pinId.match(/^[LR](\d+)$/);
if (!m) return null;
return d.properties?.conductors?.[parseInt(m[1]) - 1]?.color || null;
}
// Standard wire for a pin on a standard part — see partsLibrary.js.
// Returns null for ordinary connectors so existing behaviour is unchanged.
_standardWireForPin(deviceId, pinId) {
if (typeof standardWireForPin !== "function") return null;
return standardWireForPin(this.canvas.deviceData.get(deviceId), pinId);
}
// ── Appearance ────────────────────────────────────────────────────────────
// Theme changes repaint the canvas explicitly: Konva bakes colours in when it
// draws, so unlike the CSS-driven chrome it will not update on its own.
_bindTheme() {
const modal = document.getElementById("theme-modal");
if (!modal) return;
// Handy backgrounds for the case this exists to solve — a black wire on a
// near-black canvas, or a white one on white.
const BG_SWATCHES = ["#131320", "#1c1c1c", "#243447", "#0d1b16", "#f7f8fa", "#e8e4d9", "#ffffff"];
const wrap = document.getElementById("theme-bg-swatches");
BG_SWATCHES.forEach((hex) => {
const b = document.createElement("button");
b.className = "theme-swatch";
b.style.background = hex;
b.title = hex;
b.addEventListener("click", () => Theme.set({ canvasBg: hex }));
wrap.appendChild(b);
});
const sync = () => {
const t = Theme.get();
const p = Theme.preset();
document.getElementById("theme-dark").classList.toggle("active", t.mode === "dark");
document.getElementById("theme-light").classList.toggle("active", t.mode === "light");
document.getElementById("theme-canvas-bg").value = t.canvasBg || p.canvasBg;
document.getElementById("theme-grid-dot").value = t.gridDot || p.gridDot;
document.getElementById("theme-dev-stroke").value = t.deviceStroke || p.deviceStroke;
const byType = !t.deviceFill;
document.getElementById("theme-fill-bytype").checked = byType;
const fill = document.getElementById("theme-dev-fill");
fill.disabled = byType;
fill.style.opacity = byType ? 0.4 : 1;
if (!byType) fill.value = t.deviceFill;
};
Theme.onChange(() => { sync(); this.canvas?.repaintAll(); });
document.getElementById("btn-theme")?.addEventListener("click", () => {
sync();
modal.style.display = "flex";
});
const close = () => { modal.style.display = "none"; };
document.getElementById("theme-close")?.addEventListener("click", close);
modal.addEventListener("mousedown", (e) => { if (e.target === modal) close(); });
document.getElementById("theme-dark")?.addEventListener("click", () => Theme.set({ mode: "dark" }));
document.getElementById("theme-light")?.addEventListener("click", () => Theme.set({ mode: "light" }));
document.getElementById("theme-canvas-bg")?.addEventListener("input", (e) => Theme.set({ canvasBg: e.target.value }));
document.getElementById("theme-grid-dot")?.addEventListener("input", (e) => Theme.set({ gridDot: e.target.value }));
document.getElementById("theme-dev-stroke")?.addEventListener("input", (e) => Theme.set({ deviceStroke: e.target.value }));
document.getElementById("theme-dev-fill")?.addEventListener("input", (e) => Theme.set({ deviceFill: e.target.value }));
document.getElementById("theme-fill-bytype")?.addEventListener("change", (e) => {
// Unticking needs a concrete starting colour, so seed from the current
// connector tint rather than dropping the user on black.
Theme.set({ deviceFill: e.target.checked ? null : (Theme.get().deviceFill || "#2b3550") });
});
document.getElementById("theme-reset")?.addEventListener("click", () => Theme.reset());
sync();
}
// ── Fuse box configurator ─────────────────────────────────────────────────
// A spatial editor over the module's 280-cavity grid. Work happens on a draft
// copy so Cancel genuinely discards; Apply rewrites the device's properties
// and regenerates its pins, so what you place becomes wireable immediately.
openPdmConfigurator(device) {
if (!device || device.device_type !== "pdm" || typeof PDM_MODULES === "undefined") return;
this._pdmDevice = device;
this._pdmDraft = {
moduleId: device.properties?.moduleId || "gep-frh-a24",
circuits: JSON.parse(JSON.stringify(device.properties?.circuits || [])),
};
this._pdmSel = null;
const sel = document.getElementById("pdm-module");
sel.innerHTML = "";
Object.values(PDM_MODULES).forEach((m) => {
const o = document.createElement("option");
o.value = m.id;
o.textContent = `${m.name}${m.cols * m.rows} cavities (${m.cols}x${m.rows})`;
sel.appendChild(o);
});
sel.value = this._pdmDraft.moduleId;
document.getElementById("pdm-label").value = device.label || "";
document.getElementById("pdm-linked-ref").textContent =
device.reference ? `linked to ${device.reference}` : `linked to device #${device.id}`;
this._renderPdm();
document.getElementById("pdm-modal").style.display = "flex";
document.getElementById("pdm-grid").focus();
}
_closePdm() {
document.getElementById("pdm-modal").style.display = "none";
this._pdmDevice = null;
this._pdmDraft = null;
this._pdmSel = null;
}
_pdmCell() { return 38; }
_renderPdm() {
const draft = this._pdmDraft;
if (!draft) return;
const v = pdmValidate(draft);
const mod = v.module;
const CELL = this._pdmCell();
const PAD = 6;
// ── grid ──
const grid = document.getElementById("pdm-grid");
grid.innerHTML = "";
grid.style.width = `${mod.cols * CELL + PAD * 2}px`;
grid.style.height = `${mod.rows * CELL + PAD * 2}px`;
for (let r = 0; r < mod.rows; r++) {
for (let c = 0; c < mod.cols; c++) {
const cell = document.createElement("div");
cell.className = "pdm-cell";
cell.style.left = `${PAD + c * CELL}px`;
cell.style.top = `${PAD + r * CELL}px`;
cell.style.width = `${CELL}px`;
cell.style.height = `${CELL}px`;
const num = document.createElement("span");
num.className = "pdm-cell-num";
num.textContent = r * mod.cols + c + 1;
cell.appendChild(num);
grid.appendChild(cell);
}
}
// Bus bars paint last so they lie visibly across the blades they feed.
const painted = [...(draft.circuits || [])].sort(
(a, b) => (pdmIsBus(a) ? 1 : 0) - (pdmIsBus(b) ? 1 : 0));
painted.forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
const { w, h } = pdmSize(comp, ci.rot || 0);
const el = document.createElement("div");
el.className = (comp.isBus ? "pdm-bus" : "pdm-part")
+ (this._pdmSel === ci.id ? " sel" : "")
+ (v.badIds.has(ci.id) ? " bad" : "");
el.style.background = comp.colour;
el.dataset.id = ci.id;
if (comp.isBus) {
// Drawn as a narrow strip lying across the run, so the components it
// feeds stay visible underneath it.
const T = 15;
const horiz = w > h;
el.style.left = `${PAD + ci.col * CELL + (horiz ? 3 : CELL / 2 - T / 2)}px`;
el.style.top = `${PAD + ci.row * CELL + (horiz ? CELL / 2 - T / 2 : 3)}px`;
el.style.width = `${horiz ? w * CELL - 6 : T}px`;
el.style.height = `${horiz ? T : h * CELL - 6}px`;
el.title = `${ci.name || comp.label} — links ${Math.max(w, h)} cavities`;
} else {
el.style.left = `${PAD + ci.col * CELL + 2}px`;
el.style.top = `${PAD + ci.row * CELL + 2}px`;
el.style.width = `${w * CELL - 4}px`;
el.style.height = `${h * CELL - 4}px`;
const tag = document.createElement("span");
tag.className = "pdm-part-tag";
tag.textContent = `${comp.short} ${ci.rating}`;
el.appendChild(tag);
if (ci.name) {
const nm = document.createElement("span");
nm.className = "pdm-part-name";
nm.textContent = ci.name;
el.appendChild(nm);
}
// terminal markers, positioned in the rotated footprint. A terminal
// sitting on a bus bar is fed by it, so it is marked rather than wired.
pdmTerminals(ci, mod).forEach((t) => {
const onBus = t.fn === "KL30" && pdmBusAt(draft, t.c, t.r);
const m = document.createElement("span");
m.className = "pdm-term" + (onBus ? " bus-fed" : "");
m.textContent = t.t;
if (onBus) m.title = "Fed by bus bar";
m.style.left = `${(t.c - ci.col) * CELL + CELL / 2 - 6.5 - 2}px`;
m.style.top = `${(t.r - ci.row) * CELL + CELL - 15 - 2}px`;
el.appendChild(m);
});
}
el.addEventListener("mousedown", (e) => this._pdmDragStart(e, ci));
grid.appendChild(el);
});
// ── capacity ──
const pct = Math.min(100, (v.used / v.total) * 100);
const fill = document.getElementById("pdm-bar-fill");
fill.style.width = `${pct}%`;
fill.className = "pdm-bar-fill" + (!v.ok ? " over" : pct > 85 ? " warn" : "");
document.getElementById("pdm-capacity-text").textContent =
`${v.used} / ${v.total} cavities · ${(draft.circuits || []).length} components`;
document.getElementById("pdm-warn").textContent = v.ok ? "" : v.errors[0];
document.getElementById("pdm-save").disabled = !v.ok;
this._renderPdmSel();
this._renderPdmPinout(mod);
}
_renderPdmSel() {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
document.getElementById("pdm-sel").style.display = ci ? "" : "none";
document.getElementById("pdm-sel-empty").style.display = ci ? "none" : "";
if (!ci) return;
const comp = PDM_COMPONENTS[ci.type];
document.getElementById("pdm-sel-name").value = ci.name || "";
const selType = document.getElementById("pdm-sel-type");
selType.innerHTML = "";
Object.values(PDM_COMPONENTS).forEach((c) => {
const o = document.createElement("option");
o.value = c.key;
o.textContent = `${c.label} (${c.w}x${c.h})`;
selType.appendChild(o);
});
selType.value = ci.type;
const selRating = document.getElementById("pdm-sel-rating");
selRating.innerHTML = "";
comp.ratings.forEach((r) => {
const o = document.createElement("option");
o.value = r; o.textContent = r;
selRating.appendChild(o);
});
selRating.value = ci.rating;
}
_renderPdmPinout(mod) {
const body = document.getElementById("pdm-pinout");
body.innerHTML = "";
const rows = [];
(this._pdmDraft?.circuits || []).forEach((ci) => {
const comp = PDM_COMPONENTS[ci.type];
if (!comp) return;
pdmTerminals(ci, mod).forEach((t) => {
rows.push({ ci, comp, t });
});
});
rows.sort((a, b) => a.t.cavity - b.t.cavity);
rows.forEach(({ ci, comp, t }) => {
const tr = document.createElement("tr");
if (this._pdmSel === ci.id) tr.style.background = "#1e1e3a";
const c1 = document.createElement("td");
c1.className = "pdm-cavity";
c1.textContent = t.cavity;
tr.appendChild(c1);
const c2 = document.createElement("td");
c2.className = "pdm-cavity";
c2.textContent = t.t;
tr.appendChild(c2);
const c3 = document.createElement("td");
// Show the pin name the device will actually get, so naming a circuit
// "VCU" visibly turns its terminals into "VCU in" / "VCU out".
c3.textContent = pdmPinName(ci, comp, t);
if (!ci.name) c3.style.color = "var(--text-muted)";
tr.appendChild(c3);
tr.addEventListener("click", () => { this._pdmSel = ci.id; this._renderPdm(); });
body.appendChild(tr);
});
document.getElementById("pdm-empty").style.display = rows.length ? "none" : "";
}
// ── grid drag ──
_pdmDragStart(e, circuit) {
e.preventDefault();
this._pdmSel = circuit.id;
this._renderPdm();
const grid = document.getElementById("pdm-grid");
const CELL = this._pdmCell();
const PAD = 6;
const rect = grid.getBoundingClientRect();
const startCol = circuit.col;
const startRow = circuit.row;
// where inside the component the grab happened, in cells
const grabC = Math.floor((e.clientX - rect.left - PAD) / CELL) - startCol;
const grabR = Math.floor((e.clientY - rect.top - PAD) / CELL) - startRow;
// The component follows the cursor anywhere inside the grid, overlapping or
// not. Refusing invalid positions would mean an already-overlapping part
// could never be dragged apart — validation marks the clash in red and
// blocks Apply instead, which leaves you a way out.
const move = (ev) => {
const p = pdmClamp(
this._pdmDraft, circuit,
Math.floor((ev.clientX - rect.left - PAD) / CELL) - grabC,
Math.floor((ev.clientY - rect.top - PAD) / CELL) - grabR,
circuit.rot || 0,
);
if (p.col === circuit.col && p.row === circuit.row) return;
circuit.col = p.col;
circuit.row = p.row;
this._renderPdm();
};
const up = () => {
window.removeEventListener("mousemove", move);
window.removeEventListener("mouseup", up);
this._renderPdm();
};
window.addEventListener("mousemove", move);
window.addEventListener("mouseup", up);
}
_pdmRotate() {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (!ci) return;
// Always rotate; just pull the footprint back inside the grid. Overlap is
// allowed and flagged, same as dragging.
const next = ((ci.rot || 0) + 90) % 360;
const p = pdmClamp(this._pdmDraft, ci, ci.col, ci.row, next);
ci.rot = next;
ci.col = p.col;
ci.row = p.row;
this._renderPdm();
}
_pdmAdd(typeKey) {
const draft = this._pdmDraft;
if (!draft) return;
const ci = pdmNewCircuit(typeKey);
const spot = pdmAutoPlace(draft, ci);
if (!spot) {
document.getElementById("pdm-warn").textContent = "No free cavities for that component.";
return;
}
Object.assign(ci, spot);
draft.circuits.push(ci);
this._pdmSel = ci.id;
this._renderPdm();
document.getElementById("pdm-sel-name").focus();
}
_pdmRemove() {
const draft = this._pdmDraft;
if (!draft || !this._pdmSel) return;
const i = draft.circuits.findIndex((x) => x.id === this._pdmSel);
if (i < 0) return;
draft.circuits.splice(i, 1);
this._pdmSel = null;
this._renderPdm();
}
async _savePdm() {
const device = this._pdmDevice;
const draft = this._pdmDraft;
if (!device || !draft) return;
const v = pdmValidate(draft);
if (!v.ok) {
document.getElementById("pdm-warn").textContent = v.errors[0];
return;
}
const mod = v.module;
const label = document.getElementById("pdm-label").value.trim() || device.label || mod.name;
const props = {
...device.properties,
moduleId: draft.moduleId,
circuits: draft.circuits,
partNumber: mod.partNumber,
manufacturer: mod.manufacturer,
};
const size = DEVICE_TYPES.pdm.defaultSize(props);
const pins = pdmPins(props, size.w, size.h);
try {
const updated = await api.devices.update(device.id, {
label, properties: props, pins,
width: size.w, height: size.h,
});
this.canvas.updateDevice(updated);
this._showDeviceProps(updated);
this._flashSaved();
this._closePdm();
} catch (e) {
console.error("Fuse box save failed:", e);
document.getElementById("pdm-warn").textContent = "Could not save — see console.";
}
}
_bindPdm() {
document.getElementById("prop-pdm-btn")?.addEventListener("click", () => {
if (this._propDevice) this.openPdmConfigurator(this._propDevice);
});
document.getElementById("pdm-cancel")?.addEventListener("click", () => this._closePdm());
document.getElementById("pdm-save")?.addEventListener("click", () => this._savePdm());
document.getElementById("pdm-rotate")?.addEventListener("click", () => this._pdmRotate());
document.getElementById("pdm-delete")?.addEventListener("click", () => this._pdmRemove());
document.getElementById("pdm-module")?.addEventListener("change", (e) => {
if (!this._pdmDraft) return;
this._pdmDraft.moduleId = e.target.value;
// A narrower or shorter box would otherwise strand tiles off the edge
// where they cannot be clicked, so pull everything back in.
pdmClampAll(this._pdmDraft);
this._renderPdm();
});
const ADD = { fuse: "fuse", spdt: "relay_spdt", spst: "relay_spst", diode: "diode", breaker: "breaker", bus: "bus4" };
Object.entries(ADD).forEach(([kind, typeKey]) => {
document.getElementById(`pdm-add-${kind}`)?.addEventListener("click", () => this._pdmAdd(typeKey));
});
document.getElementById("pdm-sel-name")?.addEventListener("input", (e) => {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (!ci) return;
ci.name = e.target.value;
// repaint the tile label without stealing focus from the field
const el = document.querySelector(`.pdm-part[data-id="${ci.id}"] .pdm-part-name`);
if (el) el.textContent = ci.name;
else this._renderPdm();
this._renderPdmPinout(PDM_MODULES[this._pdmDraft.moduleId]);
});
document.getElementById("pdm-sel-rating")?.addEventListener("change", (e) => {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (ci) { ci.rating = e.target.value; this._renderPdm(); }
});
document.getElementById("pdm-sel-type")?.addEventListener("change", (e) => {
const ci = (this._pdmDraft?.circuits || []).find((x) => x.id === this._pdmSel);
if (!ci) return;
const prev = { type: ci.type, rating: ci.rating, rot: ci.rot };
ci.type = e.target.value;
ci.rating = PDM_COMPONENTS[ci.type].defaultRating;
if (!pdmFits(this._pdmDraft, ci, ci.col, ci.row, ci.rot || 0)) {
const spot = pdmAutoPlace(this._pdmDraft, ci);
if (spot) Object.assign(ci, spot);
else {
Object.assign(ci, prev);
document.getElementById("pdm-warn").textContent = "No room for that component.";
}
}
this._renderPdm();
});
// Bound on document, not the grid: clicking a tile preventDefaults the
// mousedown so the grid never reliably holds focus.
document.addEventListener("keydown", (e) => {
const modal = document.getElementById("pdm-modal");
if (!modal || modal.style.display === "none" || !modal.style.display) return;
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (e.key === "r" || e.key === "R") { e.preventDefault(); this._pdmRotate(); }
if (e.key === "Delete" || e.key === "Backspace") { e.preventDefault(); this._pdmRemove(); }
if (e.key === "Escape") { e.preventDefault(); this._closePdm(); }
});
document.getElementById("pdm-grid")?.addEventListener("mousedown", (e) => {
if (e.target.id === "pdm-grid" || e.target.classList.contains("pdm-cell")) {
this._pdmSel = null;
this._renderPdm();
}
});
document.getElementById("pdm-modal")?.addEventListener("mousedown", (e) => {
if (e.target.id === "pdm-modal") this._closePdm();
});
}
async createWire(fromDev, fromPin, toDev, toPin) {
if (!this.diagramId) return;
try {
// Standard parts win: a pin that declares a wire function fixes the
// colour, stripe and gauge so the same connection is identical on every
// build. Cable conductor colour is next, then the plain red fallback.
const std = this._standardWireForPin(fromDev, fromPin)
|| this._standardWireForPin(toDev, toPin);
const autoColor = std?.color_primary
|| this._conductorColorForPin(fromDev, fromPin)
|| this._conductorColorForPin(toDev, toPin)
|| "#CC0000";
const wire = await api.wires.create({
diagram_id: this.diagramId,
from_device_id: fromDev, from_pin: fromPin,
to_device_id: toDev, to_pin: toPin,
color_primary: autoColor,
color_stripe: std?.color_stripe ?? null,
gauge: std?.gauge || "18 AWG",
twisted_pair: std?.twisted_pair || false,
label: std?.signal || std?.label || "",
});
this.canvas.addWire(wire);
this.setMode("select");
this.canvas.selectWire(wire.id);
this._showWireProps(wire);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.clearSelection();
this._clearProps();
this.canvas.removeWire(wire.id);
await api.wires.delete(wire.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create wire failed:", e); }
}
async deleteSelected() {
const deviceIds = [...this.canvas.selectedDeviceIds];
if (deviceIds.length) {
// Snapshot data for undo before clearing
const deviceSnaps = deviceIds.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean);
const wireIds = new Set();
deviceIds.forEach(id => {
this.canvas.wireData.forEach((w, wId) => {
if (w.from_device_id === id || w.to_device_id === id) wireIds.add(wId);
});
});
const wireSnaps = [...wireIds].map(wId => JSON.parse(JSON.stringify(this.canvas.wireData.get(wId)))).filter(Boolean);
this.canvas.clearSelection();
this._clearProps();
try {
for (const wId of wireIds) { await api.wires.delete(wId); this.canvas.removeWire(wId); }
for (const id of deviceIds) { await api.devices.delete(id); this.canvas.removeDevice(id); }
this._flashSaved();
this._pushUndo(async () => {
for (const snap of deviceSnaps) {
const restored = await api.devices.create(snap).catch(console.error);
if (restored) this.canvas.addDevice(restored);
}
for (const snap of wireSnaps) {
const restored = await api.wires.create(snap).catch(console.error);
if (restored) this.canvas.addWire(restored);
}
this._flashSaved();
});
} catch (e) { console.error("Delete failed:", e); }
return;
}
if (this.canvas.selectedType === "wire" && this.canvas.selectedId) {
const id = this.canvas.selectedId;
const wireSnap = JSON.parse(JSON.stringify(this.canvas.wireData.get(id)));
this.canvas.clearSelection();
this._clearProps();
try {
await api.wires.delete(id); this.canvas.removeWire(id); this._flashSaved();
this._pushUndo(async () => {
const restored = await api.wires.create(wireSnap).catch(console.error);
if (restored) { this.canvas.addWire(restored); this._flashSaved(); }
});
}
catch (e) { console.error("Delete failed:", e); }
}
}
copySelected() {
const ids = new Set(this.canvas.selectedDeviceIds);
if (!ids.size) return;
this._clipboard = [...ids]
.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id))))
.filter(Boolean);
// Collect wires where both endpoints are within the selection
this._clipboardWires = [];
this.canvas.wireData.forEach(wire => {
if (ids.has(wire.from_device_id) && ids.has(wire.to_device_id))
this._clipboardWires.push(JSON.parse(JSON.stringify(wire)));
});
this._pasteCount = 0;
}
async pasteClipboard() {
if (!this._clipboard?.length || !this.diagramId) return;
this._pasteCount++;
await this._cloneDevices(this._clipboard, this._pasteCount * 30);
}
async duplicateSelected() {
const ids = new Set(this.canvas.selectedDeviceIds);
if (!ids.size || !this.diagramId) return;
const sources = [...ids]
.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id))))
.filter(Boolean);
this._clipboard = sources;
this._clipboardWires = [];
this.canvas.wireData.forEach(wire => {
if (ids.has(wire.from_device_id) && ids.has(wire.to_device_id))
this._clipboardWires.push(JSON.parse(JSON.stringify(wire)));
});
this._pasteCount = 1;
await this._cloneDevices(sources, 30);
}
async _cloneDevices(sources, offset) {
this.canvas.clearSelection();
const clonedIds = [];
const oldToNew = new Map(); // old device id → new device id
for (const source of sources) {
const def = {
diagram_id: this.diagramId,
device_type: source.device_type,
label: source.label,
reference: "",
x: source.x + offset,
y: source.y + offset,
width: source.width,
height: source.height,
properties: JSON.parse(JSON.stringify(source.properties || {})),
pins: JSON.parse(JSON.stringify(source.pins || [])),
};
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this.canvas.addToSelection(device.id);
clonedIds.push(device.id);
oldToNew.set(source.id, device.id);
} catch (e) { console.error("Clone device failed:", e); }
}
// Recreate wires that connect devices within the cloned set.
// Pin IDs are stored as JSON strings ("pin_1", etc.) and are preserved
// verbatim in the cloned device, so only device IDs need remapping.
const clonedWireIds = [];
for (const w of (this._clipboardWires || [])) {
const newFrom = oldToNew.get(w.from_device_id);
const newTo = oldToNew.get(w.to_device_id);
if (!newFrom || !newTo) continue;
try {
const wire = await api.wires.create({
diagram_id: this.diagramId,
from_device_id: newFrom, from_pin: w.from_pin,
to_device_id: newTo, to_pin: w.to_pin,
color_primary: w.color_primary,
color_stripe: w.color_stripe,
gauge: w.gauge,
label: w.label,
twisted_pair: w.twisted_pair,
twist_pitch: w.twist_pitch,
// waypoints not copied — they hold absolute positions that don't
// translate correctly when devices are offset or cross-diagram
});
this.canvas.addWire(wire);
clonedWireIds.push(wire.id);
} catch (e) { console.error("Clone wire failed:", e); }
}
this._flashSaved();
this._pushUndo(async () => {
this.canvas.clearSelection();
this._clearProps();
for (const wid of clonedWireIds) {
this.canvas.removeWire(wid);
await api.wires.delete(wid).catch(console.error);
}
for (const cid of clonedIds) {
this.canvas.removeDevice(cid);
await api.devices.delete(cid).catch(console.error);
}
this._flashSaved();
});
if (this.canvas.selectedDeviceIds.size === 1) {
const [id] = this.canvas.selectedDeviceIds;
this.canvas.selectedId = id;
this._showDeviceProps(this.canvas.deviceData.get(id));
} else if (this.canvas.selectedDeviceIds.size > 1) {
this._showMultiProps(this.canvas.selectedDeviceIds.size);
}
}
_export(type) {
if (!this.diagramId) return this._needDiagram();
document.getElementById("export-menu")?.classList.remove("open");
const url = type === "bom" ? api.export.bomUrl(this.diagramId)
: type === "assembly" ? api.export.assemblyUrl(this.diagramId)
: type === "formboard" ? api.export.formboardUrl(this.diagramId)
: api.export.jsonUrl(this.diagramId);
window.open(url, "_blank");
}
_needDiagram() { alert("Please open or create a diagram first."); }
// ── Octopart / Nexar part search ──────────────────────────────────────────────
async _checkOctopartStatus() {
try {
const { configured } = await api.octopart.status();
this._octopartReady = configured;
} catch { this._octopartReady = false; }
}
async _octopartSearch() {
const pn = document.getElementById("prop-partnumber")?.value?.trim();
const resultsEl = document.getElementById("octopart-results");
const statusEl = document.getElementById("octopart-status-msg");
const btn = document.getElementById("btn-octopart-search");
if (!resultsEl || !statusEl) return;
resultsEl.style.display = "none";
resultsEl.innerHTML = "";
statusEl.style.display = "none";
if (this._octopartReady === false) {
statusEl.textContent = "Nexar API not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET on the server.";
statusEl.style.display = "";
return;
}
if (!pn) {
statusEl.textContent = "Enter a part number first.";
statusEl.style.display = "";
return;
}
btn?.classList.add("searching");
try {
const results = await api.octopart.search(pn, 8);
btn?.classList.remove("searching");
if (!results.length) {
resultsEl.innerHTML = `<div class="op-no-results">No parts found for "${pn}"</div>`;
resultsEl.style.display = "";
return;
}
resultsEl.innerHTML = results.map((r, i) => `
<div class="op-result" data-idx="${i}">
<div class="op-mpn">${r.mpn}</div>
<div class="op-mfr">${r.manufacturer || "—"}</div>
${r.description ? `<div class="op-desc">${r.description}</div>` : ""}
${r.datasheet_url ? `<div class="op-ds">📄 datasheet available</div>` : ""}
</div>`).join("");
resultsEl.style.display = "";
resultsEl.querySelectorAll(".op-result").forEach((el, i) => {
el.addEventListener("click", () => {
this._applyOctopartResult(results[i]);
resultsEl.style.display = "none";
});
});
} catch (e) {
btn?.classList.remove("searching");
statusEl.textContent = `Search failed: ${e.message}`;
statusEl.style.display = "";
}
}
async _applyOctopartResult(result) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
const d = this.canvas.deviceData.get(id);
if (!d) return;
document.getElementById("prop-partnumber").value = result.mpn;
document.getElementById("prop-manufacturer").value = result.manufacturer || "";
d.properties = {
...(d.properties || {}),
partNumber: result.mpn,
manufacturer: result.manufacturer || "",
description: result.description || "",
datasheetUrl: result.datasheet_url || "",
};
await api.devices.update(id, {
properties: d.properties,
reference: d.reference,
}).catch(console.error);
this._showDatasheetLink(result.datasheet_url);
this._flashSaved();
}
_showDatasheetLink(url) {
const row = document.getElementById("prop-datasheet-row");
const link = document.getElementById("prop-datasheet-link");
if (!row || !link) return;
if (url) {
link.href = url;
row.style.display = "";
} else {
row.style.display = "none";
}
}
// ── Cable management ──────────────────────────────────────────────────────────
_refreshGroupSection(device) {
const section = document.getElementById("group-section");
if (!section) return;
if (device.device_type !== "group") { section.style.display = "none"; return; }
section.style.display = "";
const color = device.properties?.fillColor || "#2828a0";
const opacity = device.properties?.fillOpacity ?? 0.15;
document.getElementById("group-fill-color").value = color;
document.getElementById("group-fill-opacity").value = opacity;
document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%";
}
_refreshCableSection(device) {
const section = document.getElementById("cable-section");
if (!section) return;
if (device.device_type !== "cable") { section.style.display = "none"; return; }
section.style.display = "";
document.getElementById("cable-jacket-color").value = device.properties?.jacketColor || "#2a2a2a";
const sl = device.properties?.sleeveLength ?? 60;
document.getElementById("cable-sleeve-length").value = sl;
document.getElementById("cable-sleeve-length-val").textContent = sl + " px";
const tbody = document.getElementById("conductor-table-body");
tbody.innerHTML = "";
(device.properties?.conductors || []).forEach((cond, i) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td class="pin-side">${i + 1}</td>
<td><input type="text" class="conductor-name-input pin-input" data-idx="${i}" value="${(cond.name || String(i+1)).replace(/"/g, "&quot;")}"></td>
<td><input type="color" class="conductor-color-input" data-idx="${i}" value="${cond.color || "#888888"}" style="width:40px;height:22px;padding:1px;cursor:pointer"></td>
<td><button class="conductor-del-btn pin-del-btn" data-idx="${i}" title="Remove">×</button></td>`;
tr.querySelector(".conductor-name-input").addEventListener("change", async (e) => {
const d = this.canvas.deviceData.get(device.id);
if (!d?.properties?.conductors) return;
const idx = parseInt(e.target.dataset.idx);
d.properties.conductors[idx].name = e.target.value;
const lp = d.pins.find(p => p.id === `L${idx + 1}`);
const rp = d.pins.find(p => p.id === `R${idx + 1}`);
if (lp) lp.name = e.target.value;
if (rp) rp.name = e.target.value;
await api.devices.update(d.id, { properties: d.properties, pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
tr.querySelector(".conductor-color-input").addEventListener("input", async (e) => {
const d = this.canvas.deviceData.get(device.id);
if (!d?.properties?.conductors) return;
d.properties.conductors[parseInt(e.target.dataset.idx)].color = e.target.value;
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
tr.querySelector(".conductor-del-btn").addEventListener("click", () => {
const d = this.canvas.deviceData.get(device.id);
if (d) this._removeConductor(d, parseInt(tr.querySelector(".conductor-del-btn").dataset.idx));
});
tbody.appendChild(tr);
});
}
async _addConductor(device) {
const d = this.canvas.deviceData.get(device.id);
if (!d) return;
const conductors = [...(d.properties?.conductors || [])];
const color = CABLE_DEFAULT_COLORS[conductors.length % CABLE_DEFAULT_COLORS.length];
const i = conductors.length;
conductors.push({ name: String(i + 1), color });
const newH = conductors.length * 24 + 40;
d.properties = { ...d.properties, conductors, conductorCount: conductors.length };
d.height = newH;
d.pins.push(
{ id: `L${i + 1}`, name: String(i + 1), side: "left", x_offset: 0, y_offset: 26 + i * 24 + 12 },
{ id: `R${i + 1}`, name: String(i + 1), side: "right", x_offset: d.width, y_offset: 26 + i * 24 + 12 },
);
await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this.canvas.selectDevice(d.id);
this._flashSaved();
}
async _removeConductor(device, idx) {
const d = this.canvas.deviceData.get(device.id);
if (!d) return;
const conductors = [...(d.properties?.conductors || [])];
if (conductors.length <= 1) { alert("Cable must have at least one conductor."); return; }
conductors.splice(idx, 1);
d.pins = d.pins.filter(p => p.id !== `L${idx + 1}` && p.id !== `R${idx + 1}`);
// Renumber remaining pins
let li = 0, ri = 0;
d.pins = d.pins.map(p => {
if (p.side === "left") { const n = li++; return { ...p, id: `L${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; }
if (p.side === "right") { const n = ri++; return { ...p, id: `R${n+1}`, name: conductors[n]?.name || String(n+1), y_offset: 26 + n * 24 + 12 }; }
return p;
});
const newH = conductors.length * 24 + 40;
d.height = newH;
d.properties = { ...d.properties, conductors, conductorCount: conductors.length };
await api.devices.update(d.id, { properties: d.properties, height: newH, pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this.canvas.selectDevice(d.id);
this._flashSaved();
}
_updateWireTwistedUI(isTwisted) {
document.getElementById("wire-stripe-lbl").textContent = isTwisted ? "Wire 2 Color" : "Stripe Color";
document.getElementById("wire-stripe-toggle-lbl").style.display = isTwisted ? "none" : "";
const picker = document.getElementById("wire-stripe");
if (isTwisted) {
picker.disabled = false;
picker.style.opacity = "1";
} else {
const hasStripe = document.getElementById("wire-stripe-enabled").checked;
picker.disabled = !hasStripe;
picker.style.opacity = hasStripe ? "1" : "0.3";
}
}
// ── Properties panel ──────────────────────────────────────────────────────────
_showDeviceProps(device) {
this._propDevice = device;
this._setPropVisible("props-device");
document.getElementById("prop-type").textContent = DEVICE_TYPES[device.device_type]?.label || device.device_type;
document.getElementById("prop-reference").value = device.reference || "";
document.getElementById("prop-label").value = device.label || "";
document.getElementById("prop-partnumber").value = device.properties?.partNumber || "";
document.getElementById("prop-manufacturer").value = device.properties?.manufacturer || "";
document.getElementById("prop-fontsize").value = device.properties?.fontSize || 12;
const isGroup = device.device_type === "group";
if (!isGroup) this._showDatasheetLink(device.properties?.datasheetUrl || "");
else { const dr = document.getElementById("prop-datasheet-row"); if (dr) dr.style.display = "none"; }
document.getElementById("prop-save-connector").style.display = isGroup ? "none" : "";
document.getElementById("prop-add-pin").style.display = isGroup ? "none" : "";
document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : "";
document.getElementById("prop-pinout-btn").style.display =
(!isGroup && (device.pins || []).length > 0) ? "" : "none";
const pdmBtn = document.getElementById("prop-pdm-btn");
if (pdmBtn) pdmBtn.style.display = device.device_type === "pdm" ? "" : "none";
// Reset any open search results
const res = document.getElementById("octopart-results");
const msg = document.getElementById("octopart-status-msg");
if (res) { res.style.display = "none"; res.innerHTML = ""; }
if (msg) msg.style.display = "none";
this._refreshCableSection(device);
this._refreshGroupSection(device);
const tbody = document.getElementById("pin-table-body");
tbody.innerHTML = "";
(device.pins || []).forEach(pin => {
const tr = document.createElement("tr");
const sides = ["left", "right", "top", "bottom"];
const sideOpts = sides.map(s => `<option value="${s}"${pin.side === s ? " selected" : ""}>${s[0].toUpperCase()}</option>`).join("");
tr.innerHTML = `<td class="pin-side">${pin.id}</td>
<td><input class="pin-input" data-dev="${device.id}" data-pin="${pin.id}" value="${(pin.name || "").replace(/"/g, "&quot;")}"></td>
<td><select class="pin-side-select">${sideOpts}</select></td>
<td><button class="pin-del-btn" title="Remove pin">×</button></td>`;
tr.querySelector(".pin-side-select").addEventListener("change", async (e) => {
const d = this.canvas.deviceData.get(device.id);
if (!d) return;
const p = d.pins.find(p => p.id === pin.id);
if (!p) return;
p.side = e.target.value;
this._recalcPinOffsets(d.pins, d.width, d.height, d);
await api.devices.update(d.id, { pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._showDeviceProps(d);
this._flashSaved();
});
tr.querySelector(".pin-del-btn").addEventListener("click", () => {
const d = this.canvas.deviceData.get(device.id);
if (d) this._removeDevicePin(d, pin.id);
});
tbody.appendChild(tr);
});
tbody.querySelectorAll(".pin-input").forEach(inp => {
inp.addEventListener("change", async (e) => {
const devId = parseInt(e.target.dataset.dev);
const pinId = e.target.dataset.pin;
const d = this.canvas.deviceData.get(devId);
if (!d) return;
const pin = d.pins.find(p => p.id === pinId);
if (pin) pin.name = e.target.value;
await api.devices.update(devId, { pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
});
}
_showWireProps(wire) {
this._setPropVisible("props-wire");
const fd = this.canvas.deviceData.get(wire.from_device_id);
const td = this.canvas.deviceData.get(wire.to_device_id);
document.getElementById("wire-from").textContent = fd ? `${fd.reference || fd.label} : ${wire.from_pin}` : "—";
document.getElementById("wire-to").textContent = td ? `${td.reference || td.label} : ${wire.to_pin}` : "—";
document.getElementById("wire-label").value = wire.label || "";
document.getElementById("wire-color").value = wire.color_primary || "#cc0000";
const hasStripe = !!wire.color_stripe;
document.getElementById("wire-stripe-enabled").checked = hasStripe;
document.getElementById("wire-stripe").value = wire.color_stripe || "#cccccc";
document.getElementById("wire-stripe").disabled = !hasStripe;
document.getElementById("wire-stripe").style.opacity = hasStripe ? "1" : "0.3";
document.getElementById("wire-gauge").value = wire.gauge || "18 AWG";
document.getElementById("wire-length").value = wire.length != null ? wire.length : "";
document.getElementById("wire-unit").value = wire.length_unit || "in";
const isTwisted = !!wire.twisted_pair;
document.getElementById("wire-twisted").checked = isTwisted;
document.getElementById("wire-twist-pitch").value = wire.twist_pitch || 16;
document.getElementById("wire-twist-pitch").disabled = !isTwisted;
document.getElementById("wire-twist-pitch").style.opacity = isTwisted ? "1" : "0.3";
document.getElementById("wire-shielded").checked = !!wire.shielded;
document.getElementById("wire-show-size-label").checked = !!wire.show_size_label;
document.getElementById("wire-notes").value = wire.notes || "";
this._updateWireTwistedUI(isTwisted);
this._refreshBundleDropdown(wire.bundle_id || null);
this._refreshBundleEditPanel(wire.bundle_id || null);
}
_refreshBundleDropdown(selectedId) {
const sel = document.getElementById("wire-bundle-select");
if (!sel) return;
this._updatingBundleDropdown = true;
sel.innerHTML = '<option value="">— none —</option>';
this._bundles.forEach(b => {
const opt = document.createElement("option");
opt.value = b.id;
opt.textContent = b.label || `Bundle #${b.id}`;
if (b.id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
this._updatingBundleDropdown = false;
}
_refreshBundleEditPanel(bundleId) {
const panel = document.getElementById("wire-bundle-edit");
if (!panel) return;
if (!bundleId) { panel.style.display = "none"; return; }
const b = this._bundles.find(x => x.id === bundleId);
if (!b) { panel.style.display = "none"; return; }
panel.style.display = "";
document.getElementById("wire-bundle-label").value = b.label || "";
document.getElementById("wire-bundle-color").value = b.jacket_color || "#2a2a2a";
}
_clearProps() { this._setPropVisible("props-empty"); }
_showMultiProps(count) {
this._setPropVisible("props-multi");
const el = document.getElementById("props-multi-count");
if (el) el.textContent = `${count} items selected`;
}
_setPropVisible(activeId) {
["props-empty", "props-device", "props-wire", "props-multi"].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = id === activeId ? "" : "none";
});
}
// ── Pin management ────────────────────────────────────────────────────────────
_recalcPinOffsets(pins, width, height, device = null) {
if (device?.properties?.shape === "circular") {
const cx = width / 2, cy = height / 2;
const pinR = Math.min(width, height) * 0.471; // must exceed body radius (0.386)
pins.forEach((p, i) => {
const angle = -Math.PI / 2 + (2 * Math.PI * i / pins.length);
p.x_offset = Math.round(cx + pinR * Math.cos(angle));
p.y_offset = Math.round(cy + pinR * Math.sin(angle));
if (angle >= -3 * Math.PI / 4 && angle < -Math.PI / 4) p.side = "top";
else if (angle >= -Math.PI / 4 && angle < Math.PI / 4) p.side = "right";
else if (angle >= Math.PI / 4 && angle < 3 * Math.PI / 4) p.side = "bottom";
else p.side = "left";
});
return;
}
["left", "right", "top", "bottom"].forEach(side => {
const sp = pins.filter(p => p.side === side);
sp.forEach((p, i) => {
const t = (i + 1) / (sp.length + 1);
if (side === "left") { p.x_offset = 0; p.y_offset = t * height; }
if (side === "right") { p.x_offset = width; p.y_offset = t * height; }
if (side === "top") { p.x_offset = t * width; p.y_offset = 0; }
if (side === "bottom") { p.x_offset = t * width; p.y_offset = height; }
});
});
}
async _addDevicePin(device) {
const pins = device.pins || [];
const lastSide = pins[pins.length - 1]?.side || "right";
const maxNum = pins.reduce((m, p) => Math.max(m, parseInt(p.id.replace(/\D/g, "")) || 0), 0);
const newPins = [...pins, { id: `pin_${maxNum + 1}`, name: String(maxNum + 1), side: lastSide, x_offset: 0, y_offset: 0 }];
const newHeight = Math.max(device.height || 60, newPins.length * 18 + 20);
this._recalcPinOffsets(newPins, device.width, newHeight, device);
device.pins = newPins;
device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error);
this.canvas.updateDevice(device);
this._showDeviceProps(device);
this._flashSaved();
}
async _removeDevicePin(device, pinId) {
const connectedWireIds = [];
this.canvas.wireData.forEach((w, wId) => {
if ((w.from_device_id === device.id && w.from_pin === pinId) ||
(w.to_device_id === device.id && w.to_pin === pinId)) connectedWireIds.push(wId);
});
if (connectedWireIds.length && !confirm(`This pin has ${connectedWireIds.length} connected wire(s). Remove pin and its wires?`)) return;
for (const wId of connectedWireIds) {
await api.wires.delete(wId).catch(console.error);
this.canvas.removeWire(wId);
}
const newPins = (device.pins || []).filter(p => p.id !== pinId);
const newHeight = Math.max(60, newPins.length * 18 + 20);
this._recalcPinOffsets(newPins, device.width, newHeight, device);
device.pins = newPins;
device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error);
this.canvas.updateDevice(device);
this._showDeviceProps(device);
this._flashSaved();
}
_saveDeviceAsConnector(device) {
const pins = device.pins || [];
const connData = {
name: device.label || "From Canvas",
manufacturer: device.properties?.manufacturer || "",
part_number: device.properties?.partNumber || "",
category: "Custom",
description: "",
pin_count: pins.length,
pin_labels: pins.map(p => p.name || ""),
};
const customConnId = device.properties?.customConnectorId;
if (customConnId != null) {
const existing = this._customConnectors.find(c => c.id === customConnId);
if (existing) { this.openConnectorModal({ ...existing, ...connData }); return; }
}
this.openConnectorModal(connData);
}
// ── Git version control ───────────────────────────────────────────────────
async _openGitModal() {
const modal = document.getElementById("git-modal");
if (!modal) return;
modal.style.display = "flex";
// Wire close button once
const closeBtn = document.getElementById("git-close");
if (!closeBtn._gitBound) {
closeBtn._gitBound = true;
closeBtn.addEventListener("click", () => { modal.style.display = "none"; });
modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; });
}
// Wire action buttons once
const commitBtn = document.getElementById("btn-git-commit");
if (!commitBtn._gitBound) {
commitBtn._gitBound = true;
commitBtn.addEventListener("click", () => this._gitCommit());
document.getElementById("btn-git-push").addEventListener("click", () => this._gitPush());
// History scope toggle
const btnDiagram = document.getElementById("btn-git-log-diagram");
const btnAll = document.getElementById("btn-git-log-all");
btnDiagram.addEventListener("click", () => {
btnDiagram.classList.add("active"); btnAll.classList.remove("active");
this._gitLoadLog();
});
btnAll.addEventListener("click", () => {
btnAll.classList.add("active"); btnDiagram.classList.remove("active");
this._gitLoadLog();
});
}
// Default scope: "this diagram" if one is open, else "all"
const hasDiagram = !!this.diagramId;
document.getElementById("btn-git-log-diagram").classList.toggle("active", hasDiagram);
document.getElementById("btn-git-log-all").classList.toggle("active", !hasDiagram);
document.getElementById("btn-git-log-diagram").disabled = !hasDiagram;
await this._gitRefresh();
}
async _gitRefresh() {
await Promise.all([this._gitLoadStatus(), this._gitLoadLog()]);
}
async _gitLoadStatus() {
const badge = document.getElementById("git-status-badge");
if (!badge) return;
try {
const s = await api.git.status();
const ahead = s.commits_ahead > 0 ? ` · ${s.commits_ahead} ahead` : "";
const dirty = s.is_clean ? "clean" : `${s.changed_count} changed`;
badge.textContent = `${s.branch} ${dirty}${ahead}`;
badge.className = "git-badge " + (s.is_clean ? "git-clean" : "git-dirty");
} catch {
badge.textContent = "git unavailable";
badge.className = "git-badge";
}
}
async _gitLoadLog() {
const list = document.getElementById("git-log-list");
if (!list) return;
const diagramScope = document.getElementById("btn-git-log-diagram")?.classList.contains("active");
const diagramId = diagramScope && this.diagramId ? this.diagramId : null;
const title = document.getElementById("git-log-title");
if (title) title.textContent = diagramId ? "History — this diagram" : "History — all commits";
list.innerHTML = '<div style="color:#556;padding:4px">Loading…</div>';
try {
const entries = await api.git.log(diagramId);
if (!entries.length) {
list.innerHTML = '<div style="color:#556;padding:4px">No commits yet</div>';
return;
}
list.innerHTML = "";
entries.forEach(e => {
const row = document.createElement("div");
row.className = "git-log-row";
row.innerHTML = `
<span class="git-hash">${e.short}</span>
<span class="git-msg">${this._esc(e.message)}</span>
<span class="git-date">${e.date.slice(0, 10)}</span>
<button class="git-restore-btn" data-hash="${e.hash}" title="Restore a diagram from this commit">↩ Restore</button>
`;
row.querySelector(".git-restore-btn").addEventListener("click", () => this._gitRestoreFlow(e));
list.appendChild(row);
});
} catch {
list.innerHTML = '<div style="color:#e06c6c;padding:4px">Failed to load log</div>';
}
}
async _gitCommit() {
const msg = document.getElementById("git-commit-msg")?.value?.trim();
if (!msg) { alert("Enter a commit message first."); return; }
const currentOnly = document.getElementById("git-scope-current")?.checked;
if (currentOnly && !this.diagramId) { alert("No diagram is currently open."); return; }
const diagramId = currentOnly ? this.diagramId : null;
const status = document.getElementById("git-op-status");
status.textContent = "Committing…"; status.style.color = "#aabb88";
try {
const r = await api.git.commit(msg, diagramId);
if (r.status === "nothing_to_commit") {
status.textContent = "Nothing to commit — diagram unchanged.";
status.style.color = "#8899bb";
} else {
status.textContent = "✓ Committed: " + r.output.split("\n")[0];
status.style.color = "#88cc88";
document.getElementById("git-commit-msg").value = "";
await this._gitRefresh();
}
} catch (e) {
status.textContent = "✗ " + (e.message || "Commit failed");
status.style.color = "#e06c6c";
}
}
async _gitPush() {
const status = document.getElementById("git-op-status");
status.textContent = "Pushing…"; status.style.color = "#aabb88";
try {
const r = await api.git.push();
status.textContent = "✓ Pushed. " + (r.output || "").split("\n")[0];
status.style.color = "#88cc88";
await this._gitLoadStatus();
} catch (e) {
status.textContent = "✗ Push failed: " + (e.message || "unknown error");
status.style.color = "#e06c6c";
}
}
async _gitRestoreFlow(entry) {
const status = document.getElementById("git-op-status");
status.textContent = `Loading files from ${entry.short}`; status.style.color = "#aabb88";
try {
const { files } = await api.git.history(entry.hash);
if (!files.length) { status.textContent = "No diagrams in that commit."; status.style.color = "#8899bb"; return; }
// Build a small picker inline
const pick = files.map((f, i) => `${i + 1}. ${f.replace("diagrams/", "").replace(/\.json$/, "").replace(/^\d{4}_/, "").replace(/_/g, " ")}`).join("\n");
const choice = prompt(`Diagrams in commit ${entry.short} — "${entry.message}"\n\nEnter number to restore:\n${pick}`);
if (!choice) { status.textContent = ""; return; }
const idx = parseInt(choice) - 1;
if (isNaN(idx) || idx < 0 || idx >= files.length) { status.textContent = "Invalid selection."; return; }
const filepath = files[idx];
status.textContent = `Restoring ${filepath}`; status.style.color = "#aabb88";
const r = await api.git.restore(entry.hash, filepath);
status.textContent = `✓ Restored as "${r.name}" (id ${r.id})`;
status.style.color = "#88cc88";
await this.loadDiagramList();
} catch (e) {
status.textContent = "✗ Restore failed: " + (e.message || "unknown");
status.style.color = "#e06c6c";
}
}
_esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
// ── Formboard ─────────────────────────────────────────────────────────────
_bindFormboard() {
document.getElementById("btn-formboard-tab")?.addEventListener("click", () => {
if (this._fbActive) this._closeFormboard(); else this._openFormboard();
});
const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn);
on("fb-btn-select", () => this._fbSetMode("select"));
on("fb-btn-branch", () => this._fbSetMode("add-branch"));
on("fb-btn-splice", () => this._fbSetMode("add-splice"));
on("fb-btn-connect", () => this._fbSetMode("connect"));
on("fb-btn-fit", () => this._fb?.fitView());
on("fb-btn-sync", () => this._fbSync());
on("fb-btn-del", () => this._fbDeleteSelected());
// Node props
document.getElementById("fb-node-label")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "node") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchNode({ label: e.target.value }), 400);
});
document.getElementById("fb-node-notes")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "node") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchNode({ notes: e.target.value }), 400);
});
// Segment props
document.getElementById("fb-seg-fitting")?.addEventListener("change", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
const showColor = ["heat_shrink", "tape"].includes(e.target.value);
document.getElementById("fb-seg-color-row").style.display = showColor ? "" : "none";
this._fbPatchSeg({ fitting_type: e.target.value });
});
document.getElementById("fb-seg-fitting-color")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbColorTimer);
this._fbColorTimer = setTimeout(() => this._fbPatchSeg({ fitting_color: e.target.value }), 200);
});
document.getElementById("fb-seg-label")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchSeg({ label: e.target.value }), 400);
});
document.getElementById("fb-seg-length")?.addEventListener("input", (e) => {
if (!this._fb || this._fb.selectedType !== "segment") return;
clearTimeout(this._fbSaveTimer);
this._fbSaveTimer = setTimeout(() => this._fbPatchSeg({ length_mm: parseFloat(e.target.value) || null }), 400);
});
// Keyboard delete in formboard
document.addEventListener("keydown", (e) => {
if (!this._fbActive) return;
if (["INPUT","TEXTAREA","SELECT"].includes(e.target.tagName)) return;
if (e.key === "Delete" || e.key === "Backspace") this._fbDeleteSelected();
});
}
async _openFormboard() {
if (!this.diagramId) return this._needDiagram();
this._fbActive = true;
// Tab styling
document.getElementById("btn-formboard-tab")?.classList.add("active");
document.querySelectorAll(".view-tab:not(.fb-tab)").forEach(t => t.classList.remove("active"));
// Show formboard, hide main canvas
document.getElementById("canvas-container").style.display = "none";
document.getElementById("fb-container").style.display = "flex";
document.getElementById("fb-toolbar").style.display = "flex";
// Swap properties panel
document.getElementById("props-empty").style.display = "none";
document.getElementById("props-device").style.display = "none";
document.getElementById("props-wire").style.display = "none";
document.getElementById("props-formboard").style.display = "";
// Create canvas immediately (synchronous) so sync button is never "not ready"
if (!this._fb) {
try {
this._fb = new FormboardCanvas("fb-canvas", {
onNodeSelected: (n) => this._fbShowNodeProps(n),
onSegmentSelected: (s) => this._fbShowSegProps(s),
onSelectionCleared:() => this._fbClearProps(),
onNodeMoved: (id, x, y) => api.formboard.updateNode(id, { x, y }).catch(console.error),
onAddNode: async (data) => {
const n = await api.formboard.createNode(this.diagramId, data).catch(console.error);
if (n) this._fb.addNode(n);
},
onSegmentDrawn: async (fromId, toId) => {
const s = await api.formboard.createSegment(this.diagramId, { from_node_id: fromId, to_node_id: toId }).catch(console.error);
if (s) this._fb.addSegment(s);
},
onNodeCtx: (id, x, y) => this._fbNodeCtx(id, x, y),
onSegCtx: (id, x, y) => this._fbSegCtx(id, x, y),
});
} catch(err) { alert("FormboardCanvas error: " + err); return; }
}
// Load data — wait one frame first so the browser has computed fb-container layout
await new Promise(r => requestAnimationFrame(r));
const data = await api.formboard.get(this.diagramId).catch(() => null);
if (data) this._fb.load(data);
if (data?.nodes?.length) this._fb.fitView();
}
_closeFormboard() {
this._fbActive = false;
document.getElementById("btn-formboard-tab")?.classList.remove("active");
document.getElementById("canvas-container").style.display = "";
document.getElementById("fb-container").style.display = "none";
document.getElementById("fb-toolbar").style.display = "none";
document.getElementById("props-formboard").style.display = "none";
document.getElementById("props-empty").style.display = "";
this._renderViewTabs(); // restore active view tab highlight
}
_fbSetMode(mode) {
this._fb?.setMode(mode);
document.querySelectorAll(".fb-mode-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`fb-btn-${mode === "add-branch" ? "branch" : mode === "add-splice" ? "splice" : mode === "connect" ? "connect" : "select"}`)?.classList.add("active");
}
async _fbSync() {
if (!this.diagramId) return;
if (!this._fb) return;
const data = await api.formboard.sync(this.diagramId).catch(e => { alert("Sync failed: " + e.message); return null; });
if (!data) return;
this._fb.load(data);
this._fb.fitView();
if (!data.nodes?.length) alert("No devices in this diagram to sync.");
}
async _fbDeleteSelected() {
if (!this._fb || this._fb.selectedId === null) return;
const { selectedId, selectedType } = this._fb;
if (selectedType === "node") {
await api.formboard.deleteNode(selectedId).catch(console.error);
// Also remove segments that referenced this node from the canvas
[...this._fb.segData.values()]
.filter(s => s.from_node_id === selectedId || s.to_node_id === selectedId)
.forEach(s => this._fb.deleteSegment(s.id));
this._fb.deleteNode(selectedId);
} else if (selectedType === "segment") {
await api.formboard.deleteSegment(selectedId).catch(console.error);
this._fb.deleteSegment(selectedId);
}
this._fbClearProps();
}
async _fbPatchNode(data) {
if (!this._fb || this._fb.selectedType !== "node") return;
const id = this._fb.selectedId;
const updated = await api.formboard.updateNode(id, data).catch(console.error);
if (updated) this._fb.refreshNodeLabel(updated);
}
async _fbPatchSeg(data) {
if (!this._fb || this._fb.selectedType !== "segment") return;
const id = this._fb.selectedId;
const updated = await api.formboard.updateSegment(id, data).catch(console.error);
if (updated) this._fb.refreshSegment(updated);
}
_fbShowNodeProps(node) {
document.getElementById("fb-props-empty").style.display = "none";
document.getElementById("fb-seg-props").style.display = "none";
document.getElementById("fb-node-props").style.display = "";
const typeLabels = { connector: "Connector (synced from diagram)", branch: "Branch point", splice: "Splice / joint" };
document.getElementById("fb-node-type-badge").textContent = typeLabels[node.node_type] || node.node_type;
document.getElementById("fb-node-label").value = node.label || "";
document.getElementById("fb-node-notes").value = node.notes || "";
const infoEl = document.getElementById("fb-connector-info");
if (node.node_type === "connector" && node.device_id) {
const dev = this.canvas.deviceData.get(node.device_id);
document.getElementById("fb-connector-dev-label").textContent =
dev ? `${dev.reference || ""} ${dev.label || ""}`.trim() || `Device ${dev.id}` : `Device ID ${node.device_id}`;
infoEl.style.display = "";
} else {
infoEl.style.display = "none";
}
}
_fbShowSegProps(seg) {
document.getElementById("fb-props-empty").style.display = "none";
document.getElementById("fb-node-props").style.display = "none";
document.getElementById("fb-seg-props").style.display = "";
document.getElementById("fb-seg-fitting").value = seg.fitting_type || "open";
document.getElementById("fb-seg-fitting-color").value = seg.fitting_color || "#884444";
document.getElementById("fb-seg-label").value = seg.label || "";
document.getElementById("fb-seg-length").value = seg.length_mm || "";
const showColor = ["heat_shrink", "tape"].includes(seg.fitting_type || "");
document.getElementById("fb-seg-color-row").style.display = showColor ? "" : "none";
// Build wire checklist from main diagram
const wireList = document.getElementById("fb-wire-list");
const assignedIds = new Set(seg.wire_ids || []);
wireList.innerHTML = "";
this.canvas.wireData.forEach(w => {
const row = document.createElement("label");
row.style.cssText = "display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:3px;cursor:pointer;";
row.innerHTML = `
<input type="checkbox" ${assignedIds.has(w.id) ? "checked" : ""}>
<span style="width:12px;height:12px;border-radius:2px;background:${w.color_primary || "#cc0000"};flex-shrink:0"></span>
<span style="color:#bbc">${w.label || "(unlabeled)"}</span>
<span style="color:#556;margin-left:auto">${w.gauge || ""}</span>
`;
row.querySelector("input").addEventListener("change", () => {
const checked = [];
wireList.querySelectorAll("label[data-wire-id]").forEach(lbl => {
if (lbl.querySelector("input")?.checked) checked.push(parseInt(lbl.dataset.wireId));
});
this._fbPatchSeg({ wire_ids: checked });
});
row.dataset.wireId = w.id;
wireList.appendChild(row);
});
}
_fbClearProps() {
document.getElementById("fb-props-empty").style.display = "";
document.getElementById("fb-node-props").style.display = "none";
document.getElementById("fb-seg-props").style.display = "none";
}
_fbNodeCtx(id, x, y) {
this._openCtx(x, y, [
{ action: "del", icon: "✕", label: "Delete node + segments", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "del") { this._fb.selectNode(id); await this._fbDeleteSelected(); }
};
}
_fbSegCtx(id, x, y) {
this._openCtx(x, y, [
{ action: "del", icon: "✕", label: "Delete segment", danger: true },
]);
this._ctxMenu().onclick = async (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (action === "del") { this._fb.selectSegment(id); await this._fbDeleteSelected(); }
};
}
}
window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });