Files
Wiring-Designer/frontend/js/app.js
T
Kyle f32203b630 Initial commit — wiring diagram maker with full feature set
- FastAPI + SQLite backend with routers for diagrams, devices, wires,
  bundles, connectors, export, and Octopart/Nexar search
- Konva.js canvas: ortho/direct/curved routing, wire crossings, harness
  mode, snap-to-grid, multi-select, drag, resize, undo
- Wire features: color/stripe, twisted pair helix, shielded glow,
  gauge size label, multi-core bundle grouping, length/unit tracking
- Device library: connector, terminal block, cable, splice, label, group,
  relay, fuse, switch, component with custom connector support
- Exports: BOM CSV, assembly TXT, JSON, formboard layout, PNG image
- Auto-migration for schema changes via ALTER TABLE at startup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:51:21 -04:00

1450 lines
65 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._savedTimer = null;
this._customConnectors = []; // loaded from backend
this._editingConnId = null; // id of custom connector being edited in modal
this._clipboard = null; // copied device data
this._pasteCount = 0; // paste offset counter
this._undoStack = []; // array of async undo functions
this._preDrag = null; // snapshot captured at dragstart for undo
this._octopartReady = null; // null=unknown, true/false after status check
this._bundles = []; // wire bundles for current diagram
this.canvas = new DiagramCanvas("canvas-container", {
onDeviceSelected: (d) => this._showDeviceProps(d),
onWireSelected: (w) => this._showWireProps(w),
onSelectionCleared: () => this._clearProps(),
onMultipleSelected: (count) => this._showMultiProps(count),
onDeviceMoved: (id, x, y) => { api.devices.update(id, { x, y }).catch(console.error); this._flashSaved(); },
onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy),
onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy),
onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin),
onWaypointsChanged: (id, wps) => { api.wires.update(id, { waypoints: wps }).catch(console.error); this._flashSaved(); },
onDragStarted: (snap) => { this._preDrag = snap; },
onDragEnded: (ids) => { this._pushMoveUndo(ids); },
onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y),
onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y),
onDeviceResized: async (id, x, y, w, h) => {
const device = this.canvas.deviceData.get(id);
if (!device) return;
this._recalcPinOffsets(device.pins || [], w, h);
await api.devices.update(id, { x, y, width: w, height: h, pins: device.pins }).catch(console.error);
this.canvas.updateDevice(device);
this.canvas.selectDevice(id);
this._flashSaved();
},
});
this._buildDeviceLibrary();
this._bindToolbar();
this._bindKeyboard();
this._bindProps();
this._bindTabs();
this._bindConnectorModal();
this._bindLibrarySearch();
this.loadDiagramList()
.then(() => this._autoOpenLast())
.then(() => this.loadCustomConnectors())
.then(() => this._checkOctopartStatus());
}
// ── Auto-reopen & saved indicator ────────────────────────────────────────────
_autoOpenLast() {
const lastId = localStorage.getItem("lastDiagramId");
if (lastId) return this.openDiagram(parseInt(lastId));
}
_flashSaved() {
clearTimeout(this._savedTimer);
this._savedTimer = setTimeout(() => {
const el = document.getElementById("saved-indicator");
if (!el) return;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1400);
}, 600);
}
// ── Undo ──────────────────────────────────────────────────────────────────────
_pushUndo(fn) {
this._undoStack.push(fn);
if (this._undoStack.length > 50) this._undoStack.shift();
}
async _undo() {
const fn = this._undoStack.pop();
if (fn) await fn();
}
_pushMoveUndo(movedIds) {
if (!this._preDrag) return;
const { positions, waypoints } = this._preDrag;
this._preDrag = null;
// Snapshot post-drag state for redo (not needed — single-level undo)
this._pushUndo(async () => {
for (const [id, { x, y }] of positions) {
const g = this.canvas.deviceNodes.get(id);
const d = this.canvas.deviceData.get(id);
if (g) { g.x(x); g.y(y); }
if (d) { d.x = x; d.y = y; }
await api.devices.update(id, { x, y }).catch(console.error);
this.canvas._redrawWiresFor(id);
}
for (const [wId, wps] of waypoints) {
const wire = this.canvas.wireData.get(wId);
if (wire) {
wire.waypoints = wps.map(wp => ({ ...wp }));
await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error);
}
}
this.canvas.wireLayer.batchDraw();
this._flashSaved();
});
}
// ── Device library ────────────────────────────────────────────────────────────
_buildDeviceLibrary() {
const container = document.getElementById("device-library");
container.innerHTML = "";
Object.entries(DEVICE_TYPES).forEach(([key, type]) => {
const item = document.createElement("div");
item.className = "lib-item";
item.draggable = true;
item.dataset.type = key;
item.innerHTML = `<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);
});
}
// ── 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() {
this._buildConnectorLibrary(); // refresh category list
const query = (document.getElementById("lib-search")?.value || "").toLowerCase();
const catFilter = document.getElementById("lib-category")?.value || "";
const list = document.getElementById("lib-list");
list.innerHTML = "";
// ── Custom connectors (from DB) ──────────────────────────────────────────
const customs = this._customConnectors.filter(conn => {
if (catFilter && (conn.category || "Custom") !== catFilter) return false;
if (query && !`${conn.name} ${conn.manufacturer} ${conn.part_number} ${conn.description}`.toLowerCase().includes(query)) return false;
return true;
});
if (customs.length) {
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = "Custom Connectors";
list.appendChild(header);
customs.forEach(conn => list.appendChild(this._makeCustomItem(conn)));
}
// ── Built-in library ─────────────────────────────────────────────────────
Object.entries(CONNECTOR_LIBRARY).forEach(([cat, conns]) => {
if (catFilter && cat !== catFilter) return;
const matched = conns.filter(conn => !query ||
`${conn.name} ${conn.manufacturer} ${conn.partNumber} ${conn.description}`.toLowerCase().includes(query));
if (!matched.length) return;
const header = document.createElement("div");
header.className = "lib-section-header";
header.textContent = cat;
list.appendChild(header);
matched.forEach(conn => list.appendChild(this._makeBuiltinItem(conn)));
});
if (!list.children.length) {
list.innerHTML = '<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();
}
});
// 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); }
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
_bindTabs() {
document.querySelectorAll(".sidebar-tab").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".sidebar-tab").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach(p => {
p.style.display = p.dataset.panel === btn.dataset.tab ? "" : "none";
});
});
});
}
// ── Toolbar ───────────────────────────────────────────────────────────────────
_bindToolbar() {
const on = (id, fn) => document.getElementById(id)?.addEventListener("click", fn);
on("btn-select", () => this.setMode("select"));
on("btn-wire", () => this.setMode("wire"));
on("btn-delete", () => this.deleteSelected());
on("btn-duplicate", () => this.duplicateSelected());
on("btn-undo", () => this._undo());
on("btn-fit", () => this.canvas.fitView());
on("btn-new", () => this.newDiagram());
on("btn-bom", () => this._export("bom"));
on("btn-assembly", () => this._export("assembly"));
on("btn-json", () => this._export("json"));
on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); });
on("btn-formboard", () => this._export("formboard"));
on("btn-snap", () => {
const btn = document.getElementById("btn-snap");
const enabled = !this.canvas.snapEnabled;
this.canvas.toggleSnap(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-harness", () => {
const btn = document.getElementById("btn-harness");
const enabled = !this.canvas.harnessMode;
this.canvas.setHarnessMode(enabled);
btn?.classList.toggle("active", enabled);
});
on("btn-route-ortho", () => this._setRouteMode("ortho"));
on("btn-route-direct", () => this._setRouteMode("direct"));
on("btn-route-curved", () => this._setRouteMode("curved"));
document.getElementById("export-toggle")?.addEventListener("click", (e) => { e.stopPropagation(); document.getElementById("export-menu").classList.toggle("open"); });
document.addEventListener("click", (e) => {
document.getElementById("export-menu")?.classList.remove("open");
this._closeCtx();
if (!e.target.closest("#octopart-results") && !e.target.closest("#btn-octopart-search")) {
const res = document.getElementById("octopart-results");
if (res) res.style.display = "none";
}
});
document.addEventListener("contextmenu", (e) => {
// Close ctx menu if right-clicking outside canvas (browser default handles the rest)
if (!e.target.closest("#canvas-container")) this._closeCtx();
});
document.getElementById("diagram-name")?.addEventListener("change", async (e) => {
if (this.diagramId) { await api.diagrams.update(this.diagramId, { name: e.target.value }).catch(console.error); this._flashSaved(); }
});
}
// ── Context menu ──────────────────────────────────────────────────────────────
_ctxMenu() { return document.getElementById("ctx-menu"); }
_openCtx(x, y, items) {
const menu = this._ctxMenu();
menu.innerHTML = items.map(item => {
if (item === "---") return `<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 isMulti = this.canvas.selectedDeviceIds.size > 1;
const isGroup = d?.device_type === "group";
const items = isMulti ? [
{ action: "dup", icon: "⊕", label: `Duplicate ${this.canvas.selectedDeviceIds.size} items` },
{ action: "zoom", icon: "⊞", label: "Zoom to selection" },
"---",
{ action: "del", icon: "✕", label: `Delete ${this.canvas.selectedDeviceIds.size} items`, danger: true },
] : isGroup ? [
{ action: "dup", icon: "⊕", label: "Duplicate" },
"---",
{ action: "del", icon: "✕", label: "Delete", danger: true },
] : [
{ action: "dup", icon: "⊕", label: "Duplicate" },
{ action: "addpin", icon: "+", label: "Add Pin" },
{ action: "savelb", icon: "⬆", label: "Save to Library" },
"---",
{ action: "del", icon: "✕", label: "Delete", danger: true },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "dup") this.duplicateSelected();
if (action === "del") this.deleteSelected();
if (action === "zoom") this.canvas.zoomToSelection();
if (action === "addpin" && d) this._addDevicePin(d);
if (action === "savelb" && d) this._saveDeviceAsConnector(d);
};
}
_showCtxWire(id, x, y) {
const wire = this.canvas.wireData.get(id);
const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0;
const items = [
{ action: "straighten", icon: "⤢", label: "Straighten wire" },
...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []),
"---",
{ action: "del", icon: "✕", label: "Delete wire", danger: true },
];
this._openCtx(x, y, items);
this._ctxMenu().onclick = (e) => {
const action = e.target.closest(".ctx-item")?.dataset.action;
this._closeCtx();
if (!action) return;
if (action === "del") this.deleteSelected();
if ((action === "clearwp" || action === "straighten") && wire) {
wire.waypoints = [];
this.canvas.updateWire(wire);
api.wires.update(id, { waypoints: [] }).catch(console.error);
this._flashSaved();
}
};
}
_setRouteMode(mode) {
this.canvas.setRouteMode(mode);
document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`btn-route-${mode}`)?.classList.add("active");
}
_bindKeyboard() {
document.addEventListener("keydown", (e) => {
if (["INPUT", "TEXTAREA", "SELECT"].includes(e.target.tagName)) return;
if (e.key === "Delete" || e.key === "Backspace") this.deleteSelected();
if (e.key === "Escape") { this.setMode("select"); this._closeCtx(); }
if (e.key === "w") this.setMode("wire");
if (e.key === "s") this.setMode("select");
if (e.key === "f" && !e.shiftKey) this.canvas.fitView();
if (e.key === "F" && e.shiftKey) this.canvas.zoomToSelection();
if (e.key === "g") { const btn = document.getElementById("btn-snap"); const en = !this.canvas.snapEnabled; this.canvas.toggleSnap(en); btn?.classList.toggle("active", en); }
if (e.key === "h") { const btn = document.getElementById("btn-harness"); const en = !this.canvas.harnessMode; this.canvas.setHarnessMode(en); btn?.classList.toggle("active", en); }
if ((e.ctrlKey || e.metaKey) && e.key === "c" && this.canvas.selectedType === "device") { e.preventDefault(); this.copySelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); }
if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); }
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); }
});
}
// ── Properties panel ──────────────────────────────────────────────────────────
_bindProps() {
this._onPropChange("prop-reference", (v) => this._patchDevice({ reference: v }, true));
this._onPropChange("prop-label", (v) => this._patchDevice({ label: v }, true));
this._onPropChange("prop-partnumber", (v) => this._patchDeviceProp("partNumber", v));
this._onPropChange("prop-manufacturer", (v) => this._patchDeviceProp("manufacturer", v));
this._onPropChange("prop-fontsize", (v) => this._patchDeviceProp("fontSize", Math.max(6, Math.min(72, parseInt(v) || 12)), true));
this._onPropChange("wire-label", (v) => this._patchWire({ label: v }));
this._onPropChange("wire-gauge", (v) => this._patchWire({ gauge: v }));
this._onPropChange("wire-length", (v) => this._patchWire({ length: v ? parseFloat(v) : null }, true));
this._onPropChange("wire-unit", (v) => this._patchWire({ length_unit: v }, true));
this._onPropChange("wire-notes", (v) => this._patchWire({ notes: v }));
document.getElementById("wire-color")?.addEventListener("input", (e) => this._patchWire({ color_primary: e.target.value }, true));
document.getElementById("wire-stripe-enabled")?.addEventListener("change", (e) => {
const picker = document.getElementById("wire-stripe");
picker.disabled = !e.target.checked;
picker.style.opacity = e.target.checked ? "1" : "0.3";
this._patchWire({ color_stripe: e.target.checked ? picker.value : null }, true);
});
document.getElementById("wire-stripe")?.addEventListener("input", (e) => {
const isTwisted = document.getElementById("wire-twisted")?.checked;
if (isTwisted || document.getElementById("wire-stripe-enabled")?.checked) {
this._patchWire({ color_stripe: e.target.value }, true);
}
});
document.getElementById("btn-octopart-search")?.addEventListener("click", () => this._octopartSearch());
document.getElementById("group-fill-color")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "group") return;
d.properties = { ...d.properties, fillColor: e.target.value };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("group-fill-opacity")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "group") return;
const opacity = parseFloat(e.target.value);
document.getElementById("group-fill-opacity-val").textContent = Math.round(opacity * 100) + "%";
d.properties = { ...d.properties, fillOpacity: opacity };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("cable-jacket-color")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "cable") return;
d.properties = { ...d.properties, jacketColor: e.target.value };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("cable-sleeve-length")?.addEventListener("input", async (e) => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (!d || d.device_type !== "cable") return;
const val = parseInt(e.target.value);
document.getElementById("cable-sleeve-length-val").textContent = val + " px";
d.properties = { ...d.properties, sleeveLength: val };
await api.devices.update(d.id, { properties: d.properties }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
document.getElementById("prop-add-conductor")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d?.device_type === "cable") this._addConductor(d);
});
document.getElementById("wire-twisted")?.addEventListener("change", (e) => {
const pitchEl = document.getElementById("wire-twist-pitch");
pitchEl.disabled = !e.target.checked;
pitchEl.style.opacity = e.target.checked ? "1" : "0.3";
const updates = { twisted_pair: e.target.checked };
if (e.target.checked) {
const stripeEl = document.getElementById("wire-stripe");
if (!document.getElementById("wire-stripe-enabled").checked) {
stripeEl.value = "#cccccc";
updates.color_stripe = "#cccccc";
}
}
this._patchWire(updates, true);
this._updateWireTwistedUI(e.target.checked);
});
document.getElementById("wire-twist-pitch")?.addEventListener("change", (e) => {
this._patchWire({ twist_pitch: Math.max(8, Math.min(64, parseFloat(e.target.value) || 16)) }, true);
});
document.getElementById("wire-shielded")?.addEventListener("change", (e) => {
this._patchWire({ shielded: e.target.checked }, true);
});
document.getElementById("wire-show-size-label")?.addEventListener("change", (e) => {
this._patchWire({ show_size_label: e.target.checked }, true);
});
document.getElementById("wire-bundle-select")?.addEventListener("change", (e) => {
const bundleId = e.target.value ? parseInt(e.target.value) : null;
this._patchWire({ bundle_id: bundleId }, true);
this._refreshBundleEditPanel(bundleId);
});
document.getElementById("wire-bundle-new")?.addEventListener("click", async () => {
if (!this.diagramId) return;
try {
const b = await api.bundles.create({ diagram_id: this.diagramId, label: "New Bundle", jacket_color: "#2a2a2a" });
this._bundles.push(b);
this.canvas.loadBundles(this._bundles);
this._refreshBundleDropdown(b.id);
this._patchWire({ bundle_id: b.id }, true);
this._refreshBundleEditPanel(b.id);
} catch (e) { console.error("Create bundle failed:", e); }
});
document.getElementById("wire-bundle-label")?.addEventListener("change", async (e) => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId) return;
try {
const updated = await api.bundles.update(bundleId, { label: e.target.value });
const idx = this._bundles.findIndex(b => b.id === bundleId);
if (idx >= 0) this._bundles[idx] = updated;
this.canvas.loadBundles(this._bundles);
this._refreshBundleDropdown(bundleId);
} catch (ex) { console.error(ex); }
});
document.getElementById("wire-bundle-color")?.addEventListener("input", async (e) => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId) return;
try {
const updated = await api.bundles.update(bundleId, { jacket_color: e.target.value });
const idx = this._bundles.findIndex(b => b.id === bundleId);
if (idx >= 0) this._bundles[idx] = updated;
this.canvas.loadBundles(this._bundles);
} catch (ex) { console.error(ex); }
});
document.getElementById("wire-bundle-delete")?.addEventListener("click", async () => {
const bundleId = parseInt(document.getElementById("wire-bundle-select")?.value);
if (!bundleId || !confirm("Delete this bundle? Wires will be unassigned.")) return;
try {
await api.bundles.delete(bundleId);
this._bundles = this._bundles.filter(b => b.id !== bundleId);
this.canvas.wireData.forEach((w, wId) => {
if (w.bundle_id === bundleId) { w.bundle_id = null; api.wires.update(wId, { bundle_id: null }).catch(console.error); }
});
this.canvas.loadBundles(this._bundles); // also calls _renderBundles
const selWire = this.canvas.wireData.get(this.canvas.selectedId);
if (selWire?.bundle_id === null) this._refreshBundleDropdown(null);
else this._refreshBundleDropdown(selWire?.bundle_id || null);
this._refreshBundleEditPanel(selWire?.bundle_id || null);
} catch (ex) { console.error(ex); }
});
document.getElementById("prop-add-pin")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d) this._addDevicePin(d);
});
document.getElementById("prop-save-connector")?.addEventListener("click", () => {
if (this.canvas.selectedType !== "device") return;
const d = this.canvas.deviceData.get(this.canvas.selectedId);
if (d) this._saveDeviceAsConnector(d);
});
const presetSelect = document.getElementById("wire-color-preset");
if (presetSelect) {
WIRE_COLORS.forEach(c => {
const opt = document.createElement("option");
opt.value = c.hex; opt.textContent = c.name;
presetSelect.appendChild(opt);
});
presetSelect.addEventListener("change", (e) => {
if (!e.target.value) return;
document.getElementById("wire-color").value = e.target.value;
this._patchWire({ color_primary: e.target.value }, true);
presetSelect.value = "";
});
}
}
_onPropChange(id, fn) { document.getElementById(id)?.addEventListener("change", (e) => fn(e.target.value)); }
async _patchDevice(data, redraw = false) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
await api.devices.update(id, data).catch(console.error);
this._flashSaved();
if (redraw) { const d = this.canvas.deviceData.get(id); if (d) { Object.assign(d, data); this.canvas.updateDevice(d); } }
}
async _patchDeviceProp(key, value, redraw = false) {
if (this.canvas.selectedType !== "device") return;
const id = this.canvas.selectedId;
const d = this.canvas.deviceData.get(id);
if (!d) return;
d.properties = { ...(d.properties || {}), [key]: value };
await api.devices.update(id, { properties: d.properties }).catch(console.error);
this._flashSaved();
if (redraw) { this.canvas.updateDevice(d); this.canvas.selectDevice(id); }
}
async _patchWire(data, redraw = false) {
if (this.canvas.selectedType !== "wire") return;
const id = this.canvas.selectedId;
const w = this.canvas.wireData.get(id);
if (w) {
Object.assign(w, data);
if (redraw) this.canvas.updateWire(w);
}
await api.wires.update(id, data).catch(e => console.error("Wire save failed:", e));
this._flashSaved();
}
// ── Diagram management ────────────────────────────────────────────────────────
async loadDiagramList() {
const list = document.getElementById("diagram-list");
try {
const diagrams = await api.diagrams.list();
if (!diagrams.length) {
list.innerHTML = '<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-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-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;
localStorage.removeItem("lastDiagramId");
this.canvas.loadDiagram({ devices: [], wires: [] });
document.getElementById("diagram-name").value = "";
document.getElementById("canvas-placeholder").style.display = "";
this._clearProps();
}
await this.loadDiagramList();
} catch (e) { alert("Failed to delete: " + e.message); }
}
async newDiagram() {
const name = prompt("Diagram name:", "New Diagram");
if (!name) return;
try {
const d = await api.diagrams.create({ name });
await this.loadDiagramList();
await this.openDiagram(d.id);
} catch (e) { alert("Failed to create: " + e.message); }
}
async openDiagram(id) {
try {
const diagram = await api.diagrams.get(id);
this.diagramId = id;
localStorage.setItem("lastDiagramId", id);
document.getElementById("diagram-name").value = diagram.name;
document.getElementById("canvas-placeholder").style.display = "none";
this._bundles = diagram.wire_bundles || [];
this.canvas.loadDiagram(diagram);
this.canvas.loadBundles(this._bundles);
if (diagram.devices?.length) setTimeout(() => this.canvas.fitView(), 50);
this.setMode("select");
this._clearProps();
await this.loadDiagramList();
} catch (e) {
localStorage.removeItem("lastDiagramId");
console.error("Failed to open diagram:", e);
}
}
// ── Actions ───────────────────────────────────────────────────────────────────
setMode(mode) {
this.canvas.setMode(mode);
document.querySelectorAll(".mode-btn").forEach(b => b.classList.remove("active"));
document.getElementById(`btn-${mode}`)?.classList.add("active");
const ind = document.getElementById("mode-indicator");
if (ind) ind.textContent = mode === "wire"
? "Wire Mode — drag from a pin to another pin to connect (Esc to cancel)"
: "";
}
async addDevice(typeKey, wx, wy) {
if (!this.diagramId) return this._needDiagram();
const def = buildDefaultDevice(typeKey, this.diagramId);
if (!def) return;
def.x = wx - def.width / 2;
def.y = wy - def.height / 2;
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.removeDevice(device.id);
await api.devices.delete(device.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create device failed:", e); }
}
async addConnector(connId, wx, wy) {
if (!this.diagramId) return this._needDiagram();
let def;
if (connId.startsWith("custom_")) {
const id = parseInt(connId.replace("custom_", ""));
const conn = this._customConnectors.find(c => c.id === id);
if (!conn) return;
def = this._customConnectorToDevice(conn, this.diagramId);
} else {
def = connectorToDevice(connId, this.diagramId);
}
if (!def) return;
def.x = wx - def.width / 2;
def.y = wy - def.height / 2;
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.removeDevice(device.id);
await api.devices.delete(device.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create connector failed:", e); }
}
_customConnectorToDevice(conn, diagramId) {
const pinCount = conn.pin_count;
const w = 120;
const h = Math.max(60, pinCount * 18 + 20);
const pins = Array.from({ length: pinCount }, (_, i) => ({
id: `pin_${i + 1}`,
name: (conn.pin_labels && conn.pin_labels[i]) || String(i + 1),
side: "right",
x_offset: w,
y_offset: ((i + 1) / (pinCount + 1)) * h,
}));
return {
diagram_id: diagramId,
device_type: "connector",
label: conn.name,
reference: "",
x: 200, y: 200,
width: w, height: h,
properties: { pinCount, orientation: "right", partNumber: conn.part_number || "", manufacturer: conn.manufacturer || "", customConnectorId: conn.id },
pins,
};
}
_conductorColorForPin(deviceId, pinId) {
const d = this.canvas.deviceData.get(deviceId);
if (d?.device_type !== "cable") return null;
const m = pinId.match(/^[LR](\d+)$/);
if (!m) return null;
return d.properties?.conductors?.[parseInt(m[1]) - 1]?.color || null;
}
async createWire(fromDev, fromPin, toDev, toPin) {
if (!this.diagramId) return;
try {
const autoColor = this._conductorColorForPin(fromDev, fromPin)
|| this._conductorColorForPin(toDev, toPin)
|| "#CC0000";
const wire = await api.wires.create({
diagram_id: this.diagramId,
from_device_id: fromDev, from_pin: fromPin,
to_device_id: toDev, to_pin: toPin,
color_primary: autoColor, gauge: "18 AWG",
});
this.canvas.addWire(wire);
this.setMode("select");
this.canvas.selectWire(wire.id);
this._showWireProps(wire);
this._flashSaved();
this._pushUndo(async () => {
this.canvas.clearSelection();
this._clearProps();
this.canvas.removeWire(wire.id);
await api.wires.delete(wire.id).catch(console.error);
this._flashSaved();
});
} catch (e) { console.error("Create wire failed:", e); }
}
async deleteSelected() {
const deviceIds = [...this.canvas.selectedDeviceIds];
if (deviceIds.length) {
// Snapshot data for undo before clearing
const deviceSnaps = deviceIds.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean);
const wireIds = new Set();
deviceIds.forEach(id => {
this.canvas.wireData.forEach((w, wId) => {
if (w.from_device_id === id || w.to_device_id === id) wireIds.add(wId);
});
});
const wireSnaps = [...wireIds].map(wId => JSON.parse(JSON.stringify(this.canvas.wireData.get(wId)))).filter(Boolean);
this.canvas.clearSelection();
this._clearProps();
try {
for (const wId of wireIds) { await api.wires.delete(wId); this.canvas.removeWire(wId); }
for (const id of deviceIds) { await api.devices.delete(id); this.canvas.removeDevice(id); }
this._flashSaved();
this._pushUndo(async () => {
for (const snap of deviceSnaps) {
const restored = await api.devices.create(snap).catch(console.error);
if (restored) this.canvas.addDevice(restored);
}
for (const snap of wireSnaps) {
const restored = await api.wires.create(snap).catch(console.error);
if (restored) this.canvas.addWire(restored);
}
this._flashSaved();
});
} catch (e) { console.error("Delete failed:", e); }
return;
}
if (this.canvas.selectedType === "wire" && this.canvas.selectedId) {
const id = this.canvas.selectedId;
const wireSnap = JSON.parse(JSON.stringify(this.canvas.wireData.get(id)));
this.canvas.clearSelection();
this._clearProps();
try {
await api.wires.delete(id); this.canvas.removeWire(id); this._flashSaved();
this._pushUndo(async () => {
const restored = await api.wires.create(wireSnap).catch(console.error);
if (restored) { this.canvas.addWire(restored); this._flashSaved(); }
});
}
catch (e) { console.error("Delete failed:", e); }
}
}
copySelected() {
const ids = [...this.canvas.selectedDeviceIds];
if (!ids.length) return;
this._clipboard = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean);
this._pasteCount = 0;
}
async pasteClipboard() {
if (!this._clipboard?.length || !this.diagramId) return;
this._pasteCount++;
await this._cloneDevices(this._clipboard, this._pasteCount * 30);
}
async duplicateSelected() {
const ids = [...this.canvas.selectedDeviceIds];
if (!ids.length || !this.diagramId) return;
const sources = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean);
this._clipboard = sources;
this._pasteCount = 1;
await this._cloneDevices(sources, 30);
}
async _cloneDevices(sources, offset) {
this.canvas.clearSelection();
for (const source of sources) {
const def = {
diagram_id: this.diagramId,
device_type: source.device_type,
label: source.label,
reference: "",
x: source.x + offset,
y: source.y + offset,
width: source.width,
height: source.height,
properties: JSON.parse(JSON.stringify(source.properties || {})),
pins: JSON.parse(JSON.stringify(source.pins || [])),
};
try {
const device = await api.devices.create(def);
this.canvas.addDevice(device);
this.canvas.addToSelection(device.id);
} catch (e) { console.error("Clone device failed:", e); }
}
this._flashSaved();
if (this.canvas.selectedDeviceIds.size === 1) {
const [id] = this.canvas.selectedDeviceIds;
this.canvas.selectedId = id;
this._showDeviceProps(this.canvas.deviceData.get(id));
} else if (this.canvas.selectedDeviceIds.size > 1) {
this._showMultiProps(this.canvas.selectedDeviceIds.size);
}
}
_export(type) {
if (!this.diagramId) return this._needDiagram();
document.getElementById("export-menu")?.classList.remove("open");
const url = type === "bom" ? api.export.bomUrl(this.diagramId)
: type === "assembly" ? api.export.assemblyUrl(this.diagramId)
: type === "formboard" ? api.export.formboardUrl(this.diagramId)
: api.export.jsonUrl(this.diagramId);
window.open(url, "_blank");
}
_needDiagram() { alert("Please open or create a diagram first."); }
// ── Octopart / Nexar part search ──────────────────────────────────────────────
async _checkOctopartStatus() {
try {
const { configured } = await api.octopart.status();
this._octopartReady = configured;
} catch { this._octopartReady = false; }
}
async _octopartSearch() {
const pn = document.getElementById("prop-partnumber")?.value?.trim();
const resultsEl = document.getElementById("octopart-results");
const statusEl = document.getElementById("octopart-status-msg");
const btn = document.getElementById("btn-octopart-search");
if (!resultsEl || !statusEl) return;
resultsEl.style.display = "none";
resultsEl.innerHTML = "";
statusEl.style.display = "none";
if (this._octopartReady === false) {
statusEl.textContent = "Nexar API not configured — set NEXAR_CLIENT_ID and NEXAR_CLIENT_SECRET on the server.";
statusEl.style.display = "";
return;
}
if (!pn) {
statusEl.textContent = "Enter a part number first.";
statusEl.style.display = "";
return;
}
btn?.classList.add("searching");
try {
const results = await api.octopart.search(pn, 8);
btn?.classList.remove("searching");
if (!results.length) {
resultsEl.innerHTML = `<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._setPropVisible("props-device");
document.getElementById("prop-type").textContent = DEVICE_TYPES[device.device_type]?.label || device.device_type;
document.getElementById("prop-reference").value = device.reference || "";
document.getElementById("prop-label").value = device.label || "";
document.getElementById("prop-partnumber").value = device.properties?.partNumber || "";
document.getElementById("prop-manufacturer").value = device.properties?.manufacturer || "";
document.getElementById("prop-fontsize").value = device.properties?.fontSize || 12;
const isGroup = device.device_type === "group";
if (!isGroup) this._showDatasheetLink(device.properties?.datasheetUrl || "");
else { const dr = document.getElementById("prop-datasheet-row"); if (dr) dr.style.display = "none"; }
document.getElementById("prop-save-connector").style.display = isGroup ? "none" : "";
document.getElementById("prop-add-pin").style.display = isGroup ? "none" : "";
document.getElementById("pin-table-body").closest(".prop-row").style.display = isGroup ? "none" : "";
// Reset any open search results
const res = document.getElementById("octopart-results");
const msg = document.getElementById("octopart-status-msg");
if (res) { res.style.display = "none"; res.innerHTML = ""; }
if (msg) msg.style.display = "none";
this._refreshCableSection(device);
this._refreshGroupSection(device);
const tbody = document.getElementById("pin-table-body");
tbody.innerHTML = "";
(device.pins || []).forEach(pin => {
const tr = document.createElement("tr");
const sides = ["left", "right", "top", "bottom"];
const sideOpts = sides.map(s => `<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);
await api.devices.update(d.id, { pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._showDeviceProps(d);
this._flashSaved();
});
tr.querySelector(".pin-del-btn").addEventListener("click", () => {
const d = this.canvas.deviceData.get(device.id);
if (d) this._removeDevicePin(d, pin.id);
});
tbody.appendChild(tr);
});
tbody.querySelectorAll(".pin-input").forEach(inp => {
inp.addEventListener("change", async (e) => {
const devId = parseInt(e.target.dataset.dev);
const pinId = e.target.dataset.pin;
const d = this.canvas.deviceData.get(devId);
if (!d) return;
const pin = d.pins.find(p => p.id === pinId);
if (pin) pin.name = e.target.value;
await api.devices.update(devId, { pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d);
this._flashSaved();
});
});
}
_showWireProps(wire) {
this._setPropVisible("props-wire");
const fd = this.canvas.deviceData.get(wire.from_device_id);
const td = this.canvas.deviceData.get(wire.to_device_id);
document.getElementById("wire-from").textContent = fd ? `${fd.reference || fd.label} : ${wire.from_pin}` : "—";
document.getElementById("wire-to").textContent = td ? `${td.reference || td.label} : ${wire.to_pin}` : "—";
document.getElementById("wire-label").value = wire.label || "";
document.getElementById("wire-color").value = wire.color_primary || "#cc0000";
const hasStripe = !!wire.color_stripe;
document.getElementById("wire-stripe-enabled").checked = hasStripe;
document.getElementById("wire-stripe").value = wire.color_stripe || "#cccccc";
document.getElementById("wire-stripe").disabled = !hasStripe;
document.getElementById("wire-stripe").style.opacity = hasStripe ? "1" : "0.3";
document.getElementById("wire-gauge").value = wire.gauge || "18 AWG";
document.getElementById("wire-length").value = wire.length != null ? wire.length : "";
document.getElementById("wire-unit").value = wire.length_unit || "in";
const isTwisted = !!wire.twisted_pair;
document.getElementById("wire-twisted").checked = isTwisted;
document.getElementById("wire-twist-pitch").value = wire.twist_pitch || 16;
document.getElementById("wire-twist-pitch").disabled = !isTwisted;
document.getElementById("wire-twist-pitch").style.opacity = isTwisted ? "1" : "0.3";
document.getElementById("wire-shielded").checked = !!wire.shielded;
document.getElementById("wire-show-size-label").checked = !!wire.show_size_label;
document.getElementById("wire-notes").value = wire.notes || "";
this._updateWireTwistedUI(isTwisted);
this._refreshBundleDropdown(wire.bundle_id || null);
this._refreshBundleEditPanel(wire.bundle_id || null);
}
_refreshBundleDropdown(selectedId) {
const sel = document.getElementById("wire-bundle-select");
if (!sel) return;
sel.innerHTML = '<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);
});
}
_refreshBundleEditPanel(bundleId) {
const panel = document.getElementById("wire-bundle-edit");
if (!panel) return;
if (!bundleId) { panel.style.display = "none"; return; }
const b = this._bundles.find(x => x.id === bundleId);
if (!b) { panel.style.display = "none"; return; }
panel.style.display = "";
document.getElementById("wire-bundle-label").value = b.label || "";
document.getElementById("wire-bundle-color").value = b.jacket_color || "#2a2a2a";
}
_clearProps() { this._setPropVisible("props-empty"); }
_showMultiProps(count) {
this._setPropVisible("props-multi");
const el = document.getElementById("props-multi-count");
if (el) el.textContent = `${count} items selected`;
}
_setPropVisible(activeId) {
["props-empty", "props-device", "props-wire", "props-multi"].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = id === activeId ? "" : "none";
});
}
// ── Pin management ────────────────────────────────────────────────────────────
_recalcPinOffsets(pins, width, height) {
["left", "right", "top", "bottom"].forEach(side => {
const sp = pins.filter(p => p.side === side);
sp.forEach((p, i) => {
const t = (i + 1) / (sp.length + 1);
if (side === "left") { p.x_offset = 0; p.y_offset = t * height; }
if (side === "right") { p.x_offset = width; p.y_offset = t * height; }
if (side === "top") { p.x_offset = t * width; p.y_offset = 0; }
if (side === "bottom") { p.x_offset = t * width; p.y_offset = height; }
});
});
}
async _addDevicePin(device) {
const pins = device.pins || [];
const lastSide = pins[pins.length - 1]?.side || "right";
const maxNum = pins.reduce((m, p) => Math.max(m, parseInt(p.id.replace(/\D/g, "")) || 0), 0);
const newPins = [...pins, { id: `pin_${maxNum + 1}`, name: String(maxNum + 1), side: lastSide, x_offset: 0, y_offset: 0 }];
const newHeight = Math.max(device.height || 60, newPins.length * 18 + 20);
this._recalcPinOffsets(newPins, device.width, newHeight);
device.pins = newPins;
device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error);
this.canvas.updateDevice(device);
this._showDeviceProps(device);
this._flashSaved();
}
async _removeDevicePin(device, pinId) {
const connectedWireIds = [];
this.canvas.wireData.forEach((w, wId) => {
if ((w.from_device_id === device.id && w.from_pin === pinId) ||
(w.to_device_id === device.id && w.to_pin === pinId)) connectedWireIds.push(wId);
});
if (connectedWireIds.length && !confirm(`This pin has ${connectedWireIds.length} connected wire(s). Remove pin and its wires?`)) return;
for (const wId of connectedWireIds) {
await api.wires.delete(wId).catch(console.error);
this.canvas.removeWire(wId);
}
const newPins = (device.pins || []).filter(p => p.id !== pinId);
const newHeight = Math.max(60, newPins.length * 18 + 20);
this._recalcPinOffsets(newPins, device.width, newHeight);
device.pins = newPins;
device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error);
this.canvas.updateDevice(device);
this._showDeviceProps(device);
this._flashSaved();
}
_saveDeviceAsConnector(device) {
const pins = device.pins || [];
const connData = {
name: device.label || "From Canvas",
manufacturer: device.properties?.manufacturer || "",
part_number: device.properties?.partNumber || "",
category: "Custom",
description: "",
pin_count: pins.length,
pin_labels: pins.map(p => p.name || ""),
};
const customConnId = device.properties?.customConnectorId;
if (customConnId != null) {
const existing = this._customConnectors.find(c => c.id === customConnId);
if (existing) { this.openConnectorModal({ ...existing, ...connData }); return; }
}
this.openConnectorModal(connData);
}
}
window.addEventListener("DOMContentLoaded", () => { window.app = new WiringApp(); });