|
|
|
@@ -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() {
|
|
|
|
|