Update wiring diagram maker: formboard router + frontend

Backend formboard router and frontend (canvas, formboard, connector library,
app/api, index/css) updates. Ignore *.log.
This commit is contained in:
2026-07-14 07:09:10 -04:00
parent 906a0a1075
commit f3564cbbb0
9 changed files with 591 additions and 40 deletions
+1
View File
@@ -6,6 +6,7 @@ async function apiFetch(path, options = {}) {
const text = await res.text();
throw new Error(`API ${res.status}: ${text}`);
}
if (res.status === 204) return null;
return res.json();
}
+392 -18
View File
@@ -30,10 +30,11 @@ class WiringApp {
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);
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);
@@ -50,6 +51,7 @@ class WiringApp {
this._bindProps();
this._bindTabs();
this._bindConnectorModal();
this._bindPinoutModal();
this._bindLibrarySearch();
this._bindFormboard();
@@ -252,9 +254,12 @@ class WiringApp {
}
_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 = document.getElementById("lib-category")?.value || "";
const catFilter = catSelect?.value || "";
const list = document.getElementById("lib-list");
list.innerHTML = "";
@@ -342,6 +347,9 @@ class WiringApp {
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
@@ -429,6 +437,291 @@ class WiringApp {
} 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() {
@@ -1066,6 +1359,15 @@ class WiringApp {
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;
@@ -1099,7 +1401,7 @@ class WiringApp {
const bar = document.getElementById("view-tab-bar");
if (!bar) return;
// Remove all view tabs (keep add button)
bar.querySelectorAll(".view-tab").forEach(t => t.remove());
bar.querySelectorAll(".view-tab:not(.fb-tab)").forEach(t => t.remove());
const addBtn = document.getElementById("btn-add-view");
const mainTab = document.createElement("button");
@@ -1500,9 +1802,17 @@ class WiringApp {
}
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);
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;
}
@@ -1513,17 +1823,26 @@ class WiringApp {
}
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);
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 clonedIds = [];
const oldToNew = new Map(); // old device id → new device id
for (const source of sources) {
const def = {
diagram_id: this.diagramId,
@@ -1542,12 +1861,45 @@ class WiringApp {
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);
@@ -1800,6 +2152,7 @@ class WiringApp {
// ── 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 || "";
@@ -1814,6 +2167,8 @@ class WiringApp {
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";
// Reset any open search results
const res = document.getElementById("octopart-results");
const msg = document.getElementById("octopart-status-msg");
@@ -1839,7 +2194,7 @@ class WiringApp {
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);
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);
@@ -1938,7 +2293,21 @@ class WiringApp {
// ── Pin management ────────────────────────────────────────────────────────────
_recalcPinOffsets(pins, width, height) {
_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) => {
@@ -1957,7 +2326,7 @@ class WiringApp {
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);
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);
@@ -1979,7 +2348,7 @@ class WiringApp {
}
const newPins = (device.pins || []).filter(p => p.id !== pinId);
const newHeight = Math.max(60, newPins.length * 18 + 20);
this._recalcPinOffsets(newPins, device.width, newHeight);
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);
@@ -2247,8 +2616,9 @@ class WiringApp {
document.getElementById("props-wire").style.display = "none";
document.getElementById("props-formboard").style.display = "";
// Create canvas if needed
// 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),
@@ -2265,9 +2635,11 @@ class WiringApp {
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
// 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();
@@ -2292,10 +2664,12 @@ class WiringApp {
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);
if (data.added > 0) this._fb?.fitView();
this._fb.load(data);
this._fb.fitView();
if (!data.nodes?.length) alert("No devices in this diagram to sync.");
}
async _fbDeleteSelected() {
+28
View File
@@ -1188,6 +1188,28 @@ class DiagramCanvas {
x: 4, y: device.height - 14, width: device.width - 8, align: "right",
text: "cable", fontSize: 8, fontFamily: "monospace", fill: "#888888",
}));
} else if (device.device_type === "connector" && device.properties?.shape === "circular") {
rect.visible(false);
// Body radius is intentionally smaller than pin radius (0.471) so wire
// endpoints sit outside the body and remain visible above it.
const r = Math.min(device.width, device.height) * 0.386;
const cx = device.width / 2, cy = device.height / 2;
group.add(new Konva.Circle({
name: "device-body",
x: cx, y: cy, radius: r,
fill: this._deviceFill("connector"), stroke: "#5a5a8a", strokeWidth: 2,
}));
// Key notch anchored to body top edge
group.add(new Konva.Rect({
x: cx - 5, y: cy - r - 3, width: 10, height: 7, cornerRadius: 2,
fill: "#333355", stroke: "#5a5a8a", strokeWidth: 1, listening: false,
}));
group.add(new Konva.Text({
name: "device-label",
x: 4, y: cy - 6, width: device.width - 8,
text: device.label, fontSize: Math.min(10, fontSize),
fontFamily: "monospace", fill: "#dde0f5", align: "center",
}));
} else {
if (device.reference) {
group.add(new Konva.Text({ x: 6, y: 5, text: device.reference, fontSize: 10, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" }));
@@ -1263,6 +1285,12 @@ class DiagramCanvas {
this.selectDevice(device.id);
}
});
group.on("dblclick", (e) => {
if (this.mode !== "select") return;
e.cancelBubble = true;
this.cb.onPinoutRequested?.(device);
});
group.on("dragstart", () => {
if (this.wireStart) { group.stopDrag(); return; }
if (device.properties?.locked) { group.stopDrag(); return; }
+59
View File
@@ -130,6 +130,20 @@ const CONNECTOR_LIBRARY = {
{ id: "can-j1939", name: "SAE J1939 9-pin", manufacturer: "Amphenol", partNumber: "HD10-9-1939P", pinCount: 9, description: "J1939 CAN heavy-vehicle datalink", pinLabels: ["Battery","+Battery","GND","CAN H","Shield","GND","CAN L","NC","NC"] },
{ id: "can-obd2", name: "OBD-II 16-pin", manufacturer: "Standard", partNumber: "SAE J1962", pinCount: 16, description: "On-board diagnostics port", pinLabels: ["Mfr","Mfr","GND","Mfr","Sig GND","CAN H","K-Line","Mfr","Bat+","Mfr","Mfr","Mfr","Mfr","CAN L","L-Line","OBD+"] },
],
"Deutsch DRC (Round)": [
{
id: "drc-30p", name: "DRC04-30P", manufacturer: "TE Connectivity",
partNumber: "DRC04-30P-A191", pinCount: 30, shape: "circular",
pinRings: [14, 10, 6],
description: "30-pin circular environmental plug (pin contacts), IP67, 1220 AWG",
},
{
id: "drc-30s", name: "DRC06-30S", manufacturer: "TE Connectivity",
partNumber: "DRC06-30S-A191", pinCount: 30, shape: "circular",
pinRings: [14, 10, 6],
description: "30-pin circular environmental socket (receptacle), IP67, 1220 AWG",
},
],
"General Automotive / EV": [
{ id: "ga-motor3", name: "Motor Phase 3-pin", manufacturer: "varies", partNumber: "varies", pinCount: 3, description: "3-phase motor connections", pinLabels: ["U","V","W"] },
{ id: "ga-res2", name: "Resolver 2-pin", manufacturer: "varies", partNumber: "varies", pinCount: 2, description: "Motor resolver position sensor", pinLabels: ["SIN","COS"] },
@@ -155,6 +169,51 @@ function connectorToDevice(connId, diagramId) {
if (!conn) return null;
const pinCount = conn.pinCount || 2;
if (conn.shape === "circular") {
const size = 140;
const cx = size / 2, cy = size / 2;
const pinR = size * 0.471; // pins outside the body circle (body uses 0.386 ratio)
const pins = Array.from({ length: pinCount }, (_, i) => {
const angle = -Math.PI / 2 + (2 * Math.PI * i / pinCount);
const x = cx + pinR * Math.cos(angle);
const y = cy + pinR * Math.sin(angle);
let side;
if (angle >= -3 * Math.PI / 4 && angle < -Math.PI / 4) side = "top";
else if (angle >= -Math.PI / 4 && angle < Math.PI / 4) side = "right";
else if (angle >= Math.PI / 4 && angle < 3 * Math.PI / 4) side = "bottom";
else side = "left";
return {
id: `pin_${i + 1}`,
name: conn.pinLabels ? (conn.pinLabels[i] || String(i + 1)) : String(i + 1),
side,
x_offset: Math.round(x * 10) / 10,
y_offset: Math.round(y * 10) / 10,
};
});
return {
diagram_id: diagramId,
device_type: "connector",
label: conn.name,
reference: "",
x: 200, y: 200,
width: size, height: size,
properties: {
pinCount,
shape: "circular",
pinRings: conn.pinRings || [],
orientation: "right",
partNumber: conn.partNumber || "",
manufacturer: conn.manufacturer || "",
connectorLibraryId: connId,
},
pins,
};
}
// Standard rectangular connector
const w = 120;
const h = Math.max(60, pinCount * 18 + 20);
const pins = Array.from({ length: pinCount }, (_, i) => ({
+7 -3
View File
@@ -80,9 +80,11 @@ class FormboardCanvas {
const el = this.stage?.container()?.parentElement;
if (!el) return;
const w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return;
this.stage.width(w); this.stage.height(h);
this._bg.width(w); this._bg.height(h);
this.bgLayer.batchDraw();
this._applyTransform();
}
_s2w(sx, sy) { return { x: (sx - this.offsetX) / this.scale, y: (sy - this.offsetY) / this.scale }; }
@@ -100,7 +102,7 @@ class FormboardCanvas {
setMode(mode) {
this.mode = mode;
if (mode !== "connect") this._cancelConnect();
this.stage?.container().style.cursor =
if (this.stage) this.stage.container().style.cursor =
["add-branch", "add-splice", "connect"].includes(mode) ? "crosshair" : "default";
}
@@ -223,8 +225,8 @@ class FormboardCanvas {
this.segKonva.clear(); this.segData.clear();
this.selectedId = null; this.selectedType = null;
(data.segments || []).forEach(s => { this.segData.set(s.id, s); this._renderSeg(s); });
(data.nodes || []).forEach(n => { this.nodeData.set(n.id, n); this._renderNode(n); });
(data.segments || []).forEach(s => { this.segData.set(s.id, s); this._renderSeg(s); });
this.segLayer.batchDraw(); this.nodeLayer.batchDraw();
}
@@ -376,9 +378,11 @@ class FormboardCanvas {
fitView() {
if (!this.nodeData.size) return;
const sw = this.stage.width(), sh = this.stage.height();
if (!sw || !sh) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.nodeData.forEach(n => { minX = Math.min(minX, n.x); minY = Math.min(minY, n.y); maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y); });
const pad = 120, sw = this.stage.width(), sh = this.stage.height();
const pad = 120;
this.scale = Math.min(sw / (maxX - minX + pad * 2), sh / (maxY - minY + pad * 2), 2);
this.offsetX = sw / 2 - ((minX + maxX) / 2) * this.scale;
this.offsetY = sh / 2 - ((minY + maxY) / 2) * this.scale;