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.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 = `${type.icon}${type.label}`; item.title = type.description; item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("device_type", key); e.dataTransfer.effectAllowed = "copy"; }); item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addDevice(key, 200 + Math.random() * 100, 150 + Math.random() * 100); }); container.appendChild(item); }); } // ── Connector library ───────────────────────────────────────────────────────── async loadCustomConnectors() { try { this._customConnectors = await api.connectors.list(); } catch { this._customConnectors = []; } this._filterConnectors(); } _bindLibrarySearch() { document.getElementById("lib-search")?.addEventListener("input", () => this._filterConnectors()); document.getElementById("lib-category")?.addEventListener("change", () => this._filterConnectors()); } _buildConnectorLibrary() { const catSelect = document.getElementById("lib-category"); catSelect.innerHTML = ''; const cats = new Set(Object.keys(CONNECTOR_LIBRARY)); this._customConnectors.forEach(c => cats.add(c.category || "Custom")); cats.forEach(cat => { const opt = document.createElement("option"); opt.value = cat; opt.textContent = cat; catSelect.appendChild(opt); }); } _filterConnectors() { 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 = '

No connectors match

'; } } _makeCustomItem(conn) { const item = document.createElement("div"); item.className = "lib-conn-item"; item.draggable = true; item.title = conn.description || conn.name; item.innerHTML = `
${conn.name} custom
${conn.manufacturer || "—"} · ${conn.pin_count}p${conn.part_number ? " · " + conn.part_number : ""}
`; item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", "custom_" + conn.id); e.dataTransfer.effectAllowed = "copy"; }); item.addEventListener("dblclick", (e) => { if (e.target.classList.contains("conn-edit-btn")) return; if (!this.diagramId) return this._needDiagram(); this.addConnector("custom_" + conn.id, 220, 160); }); item.querySelector(".conn-edit-btn").addEventListener("click", (e) => { e.stopPropagation(); this.openConnectorModal(conn); }); return item; } _makeBuiltinItem(conn) { const item = document.createElement("div"); item.className = "lib-conn-item"; item.draggable = true; item.title = conn.description || conn.name; item.innerHTML = `
${conn.name}
${conn.manufacturer} · ${conn.pinCount}p · ${conn.partNumber}
`; item.addEventListener("dragstart", (e) => { e.dataTransfer.setData("connector_id", conn.id); e.dataTransfer.effectAllowed = "copy"; }); item.addEventListener("dblclick", () => { if (!this.diagramId) return this._needDiagram(); this.addConnector(conn.id, 220 + Math.random() * 80, 160 + Math.random() * 80); }); return item; } // ── Custom connector modal ──────────────────────────────────────────────────── _bindConnectorModal() { document.getElementById("btn-new-connector")?.addEventListener("click", () => this.openConnectorModal(null)); document.getElementById("cm-save")?.addEventListener("click", () => this._saveConnectorModal()); document.getElementById("cm-delete")?.addEventListener("click", () => this._deleteConnectorModal()); document.getElementById("cm-cancel")?.addEventListener("click", () => this.closeConnectorModal()); // Close on backdrop click document.getElementById("connector-modal")?.addEventListener("click", (e) => { if (e.target.id === "connector-modal") this.closeConnectorModal(); }); // Escape key document.addEventListener("keydown", (e) => { if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") { this.closeConnectorModal(); } 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 = `${i + 1} `; container.appendChild(row); } } async _saveConnectorModal() { const name = document.getElementById("cm-name").value.trim(); const pinCount = parseInt(document.getElementById("cm-pincount").value); if (!name) { document.getElementById("cm-name").focus(); return; } if (!pinCount || pinCount < 1 || pinCount > 64) { alert("Pin count must be 1–64"); return; } const data = { name, category: document.getElementById("cm-category").value.trim() || "Custom", manufacturer: document.getElementById("cm-manufacturer").value.trim(), part_number: document.getElementById("cm-partnumber").value.trim(), description: document.getElementById("cm-description").value.trim(), pin_count: pinCount, pin_labels: [...document.querySelectorAll(".pin-label-input")].map(i => i.value.trim()), }; const btn = document.getElementById("cm-save"); btn.disabled = true; btn.textContent = "Saving…"; try { if (this._editingConnId != null) { await api.connectors.update(this._editingConnId, data); } else { await api.connectors.create(data); } this.closeConnectorModal(); await this.loadCustomConnectors(); this._flashSaved(); } catch (e) { alert("Failed to save connector: " + e.message); } finally { btn.disabled = false; btn.textContent = "Save"; } } async _deleteConnectorModal() { if (this._editingConnId == null) return; if (!confirm(`Delete "${document.getElementById("cm-name").value}"? This cannot be undone.`)) return; try { await api.connectors.delete(this._editingConnId); this.closeConnectorModal(); await this.loadCustomConnectors(); this._flashSaved(); } catch (e) { alert("Failed to delete: " + e.message); } } // ── 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 = ` `; } else { swatchHTML = ``; } } const tr = document.createElement("tr"); tr.style.borderBottom = "1px solid #1a1a2e"; tr.innerHTML = ` ${num} ${swatchHTML}${note ?? "—"}`; 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 = ` `; } else { swatch = ``; } } return `${num}${swatch}${note}`; }).join(""); const html = ` Pinout — ${device.label}

${device.label}

${subtitle}
${svg}
${rows}
#ColorNote / Connection