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:
+392
-18
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user