Compare commits

...

2 Commits

Author SHA1 Message Date
Kyle 7c3f8ddca6 Export saved diagrams (K10, Land Rover 12V, New Diagram)
Re-exported from the current app state: Land Rover 12V (70 devices / 97 wires),
K10 (11 / 16), New Diagram.
2026-07-14 07:09:10 -04:00
Kyle f3564cbbb0 Update wiring diagram maker: formboard router + frontend
Backend formboard router and frontend (canvas, formboard, connector library,
app/api, index/css) updates. Ignore *.log.
2026-07-14 07:09:10 -04:00
12 changed files with 4163 additions and 589 deletions
+3
View File
@@ -23,3 +23,6 @@ Thumbs.db
# Editor # Editor
.vscode/ .vscode/
*.swp *.swp
# Logs
*.log
+59 -13
View File
@@ -54,41 +54,87 @@ def get_layout(diagram_id: int, db: Session = Depends(get_db)):
@router.post("/{diagram_id}/sync") @router.post("/{diagram_id}/sync")
def sync_from_diagram(diagram_id: int, db: Session = Depends(get_db)): def sync_from_diagram(diagram_id: int, db: Session = Depends(get_db)):
"""Import all devices from the main diagram as connector nodes. """Sync devices as connector nodes and build segments from wire connections.
Existing nodes (matched by device_id) are label-synced but not moved."""
Layout strategy:
- Connectors alternate above/below a central trunk line (y=100 / y=300)
- Segments are auto-created for each unique device-pair connected by wires,
with wire_ids populated so thickness reflects the bundle size.
Existing nodes are label-synced but not moved; existing segments are not duplicated.
"""
diagram = db.query(models.Diagram).filter_by(id=diagram_id).first() diagram = db.query(models.Diagram).filter_by(id=diagram_id).first()
if not diagram: if not diagram:
raise HTTPException(404, "Diagram not found") raise HTTPException(404, "Diagram not found")
layout = _get_or_create(diagram_id, db) layout = _get_or_create(diagram_id, db)
existing_dev_ids = {n.device_id for n in layout.nodes if n.device_id is not None} existing_dev_ids = {n.device_id for n in layout.nodes if n.device_id is not None}
dev_to_node: dict = {n.device_id: n for n in layout.nodes if n.device_id is not None}
added = 0 added = 0
x = 80 new_x = 80 + len([n for n in layout.nodes if n.device_id is not None]) * 200
for dev in diagram.devices:
for i, dev in enumerate(diagram.devices):
label = dev.reference or dev.label or f"Dev {dev.id}"
if dev.id in existing_dev_ids: if dev.id in existing_dev_ids:
# Keep label in sync if the node label is still the default node = dev_to_node[dev.id]
node = next((n for n in layout.nodes if n.device_id == dev.id), None)
if node:
new_label = dev.reference or dev.label or f"Dev {dev.id}"
if not node.label or node.label == f"Dev {dev.id}": if not node.label or node.label == f"Dev {dev.id}":
node.label = new_label node.label = label
continue else:
# Alternate connectors above (y=80) and below (y=320) the trunk
row_y = 80 if added % 2 == 0 else 320
node = models.FormboardNode( node = models.FormboardNode(
layout_id=layout.id, layout_id=layout.id,
node_type="connector", node_type="connector",
device_id=dev.id, device_id=dev.id,
x=x, y=180, x=new_x, y=row_y,
label=dev.reference or dev.label or f"Dev {dev.id}", label=label,
) )
db.add(node) db.add(node)
x += 200 db.flush() # get node.id
dev_to_node[dev.id] = node
new_x += 200
added += 1 added += 1
# Build segments from wire connections
# Group wires by (min_dev_id, max_dev_id) pair to avoid direction duplicates
from collections import defaultdict
pair_wires: dict = defaultdict(list)
for wire in diagram.wires:
a, b = wire.from_device_id, wire.to_device_id
if a and b and a != b:
pair_wires[(min(a, b), max(a, b))].append(wire.id)
# Find existing segments by node-pair to avoid duplicates
existing_seg_pairs = set()
for seg in layout.segments:
existing_seg_pairs.add((min(seg.from_node_id, seg.to_node_id),
max(seg.from_node_id, seg.to_node_id)))
segs_added = 0
for (dev_a, dev_b), wire_ids in pair_wires.items():
node_a = dev_to_node.get(dev_a)
node_b = dev_to_node.get(dev_b)
if not node_a or not node_b:
continue
pair = (min(node_a.id, node_b.id), max(node_a.id, node_b.id))
if pair in existing_seg_pairs:
continue
seg = models.FormboardSegment(
layout_id=layout.id,
from_node_id=node_a.id,
to_node_id=node_b.id,
wire_ids=wire_ids,
fitting_type="open",
)
db.add(seg)
existing_seg_pairs.add(pair)
segs_added += 1
db.commit() db.commit()
db.refresh(layout) db.refresh(layout)
return { return {
"added": added, "added": added,
"segs_added": segs_added,
"nodes": [_nd(n) for n in layout.nodes], "nodes": [_nd(n) for n in layout.nodes],
"segments": [_sd(s) for s in layout.segments], "segments": [_sd(s) for s in layout.segments],
} }
+1047 -18
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+51 -1
View File
@@ -2,6 +2,56 @@
"id": 4, "id": 4,
"name": "New Diagram", "name": "New Diagram",
"description": "", "description": "",
"devices": [], "sheets": [],
"wire_bundles": [],
"devices": [
{
"id": 62,
"sheet_id": null,
"device_type": "connector",
"label": "Connector",
"reference": "",
"x": 599.1646500326872,
"y": -1321.7662846344028,
"width": 100.0,
"height": 100.0,
"properties": {
"pinCount": 4,
"orientation": "right",
"partNumber": "",
"manufacturer": ""
},
"pins": [
{
"id": "pin_1",
"name": "1",
"side": "right",
"x_offset": 100,
"y_offset": 20
},
{
"id": "pin_2",
"name": "2",
"side": "right",
"x_offset": 100,
"y_offset": 40
},
{
"id": "pin_3",
"name": "3",
"side": "right",
"x_offset": 100,
"y_offset": 60
},
{
"id": "pin_4",
"name": "4",
"side": "right",
"x_offset": 100,
"y_offset": 80
}
]
}
],
"wires": [] "wires": []
} }
+1 -1
View File
@@ -280,7 +280,7 @@ button:active { background: #1a1a3a; }
.conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; } .conn-edit-btn:hover { background: #2a2a5a; border-color: #6666aa; }
/* ── Modal ───────────────────────────────────────────────────────────────── */ /* ── Modal ───────────────────────────────────────────────────────────────── */
#connector-modal, #drc-modal, #git-modal { #connector-modal, #drc-modal, #git-modal, #pinout-modal {
position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000; position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000;
align-items: center; justify-content: center; align-items: center; justify-content: center;
} }
+36
View File
@@ -230,6 +230,9 @@
<button id="prop-add-pin" style="flex:1;font-size:11px;padding:3px 6px">+ Add Pin</button> <button id="prop-add-pin" style="flex:1;font-size:11px;padding:3px 6px">+ Add Pin</button>
<button id="prop-save-connector" class="btn-primary" style="flex:1;font-size:11px;padding:3px 6px">⬆ Save to Library</button> <button id="prop-save-connector" class="btn-primary" style="flex:1;font-size:11px;padding:3px 6px">⬆ Save to Library</button>
</div> </div>
<div style="margin-top:4px">
<button id="prop-pinout-btn" style="display:none;width:100%;font-size:11px;padding:3px 6px">⊙ View Pinout</button>
</div>
</div> </div>
</div> </div>
@@ -459,6 +462,39 @@
</div> </div>
</div> </div>
<!-- ── Pinout Modal ───────────────────────────────────────────────────────────── -->
<div id="pinout-modal" style="display:none">
<div class="modal-box" style="min-width:580px;max-width:700px">
<div class="modal-header">
<h3 id="pinout-modal-title">Connector Pinout</h3>
<span id="pinout-info" style="font-size:11px;color:#888;margin-left:10px"></span>
</div>
<div class="modal-body" style="flex-direction:row;align-items:flex-start;padding:14px;gap:16px">
<div id="pinout-svg-container" style="flex-shrink:0"></div>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;min-width:0">
<div style="font-size:11px;color:#8899cc;font-weight:bold;padding-bottom:2px;border-bottom:1px solid #2a2a4a">Pin Assignments</div>
<div id="pinout-table-container" style="overflow-y:auto;max-height:260px">
<table id="pinout-table" style="width:100%;border-collapse:collapse;font-size:11px;font-family:monospace">
<thead>
<tr style="color:#666688;text-align:left">
<th style="padding:3px 6px;width:36px">#</th>
<th style="padding:3px 6px">Note / Connection</th>
</tr>
</thead>
<tbody id="pinout-table-body"></tbody>
</table>
</div>
</div>
</div>
<div class="modal-footer">
<button id="pinout-print-btn">🖨 Print</button>
<button id="pinout-copy-btn">⎘ Copy Table</button>
<div style="flex:1"></div>
<button id="pinout-close" class="btn-primary">Close</button>
</div>
</div>
</div>
<!-- ── Context Menu ──────────────────────────────────────────────────────────── --> <!-- ── Context Menu ──────────────────────────────────────────────────────────── -->
<div id="ctx-menu"> <div id="ctx-menu">
<!-- populated dynamically by app.js --> <!-- populated dynamically by app.js -->
+1
View File
@@ -6,6 +6,7 @@ async function apiFetch(path, options = {}) {
const text = await res.text(); const text = await res.text();
throw new Error(`API ${res.status}: ${text}`); throw new Error(`API ${res.status}: ${text}`);
} }
if (res.status === 204) return null;
return res.json(); return res.json();
} }
+391 -17
View File
@@ -30,10 +30,11 @@ class WiringApp {
onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y), onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y),
onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y), onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y),
onMultiWireSelected: (ids) => this._onMultiWireSelected(ids), onMultiWireSelected: (ids) => this._onMultiWireSelected(ids),
onPinoutRequested: (d) => this._showPinoutPopup(d),
onDeviceResized: async (id, x, y, w, h) => { onDeviceResized: async (id, x, y, w, h) => {
const device = this.canvas.deviceData.get(id); const device = this.canvas.deviceData.get(id);
if (!device) return; 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); await api.devices.update(id, { x, y, width: w, height: h, pins: device.pins }).catch(console.error);
this.canvas.updateDevice(device); this.canvas.updateDevice(device);
this.canvas.selectDevice(id); this.canvas.selectDevice(id);
@@ -50,6 +51,7 @@ class WiringApp {
this._bindProps(); this._bindProps();
this._bindTabs(); this._bindTabs();
this._bindConnectorModal(); this._bindConnectorModal();
this._bindPinoutModal();
this._bindLibrarySearch(); this._bindLibrarySearch();
this._bindFormboard(); this._bindFormboard();
@@ -252,9 +254,12 @@ class WiringApp {
} }
_filterConnectors() { _filterConnectors() {
const savedCat = document.getElementById("lib-category")?.value || "";
this._buildConnectorLibrary(); // refresh category list 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 query = (document.getElementById("lib-search")?.value || "").toLowerCase();
const catFilter = document.getElementById("lib-category")?.value || ""; const catFilter = catSelect?.value || "";
const list = document.getElementById("lib-list"); const list = document.getElementById("lib-list");
list.innerHTML = ""; list.innerHTML = "";
@@ -342,6 +347,9 @@ class WiringApp {
if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") { if (e.key === "Escape" && document.getElementById("connector-modal")?.style.display !== "none") {
this.closeConnectorModal(); this.closeConnectorModal();
} }
if (e.key === "Escape" && document.getElementById("pinout-modal")?.style.display !== "none") {
this._closePinoutPopup();
}
}); });
// Update pin labels when pin count changes // Update pin labels when pin count changes
@@ -429,6 +437,291 @@ class WiringApp {
} catch (e) { alert("Failed to delete: " + e.message); } } 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 ────────────────────────────────────────────────────────────────────── // ── Tabs ──────────────────────────────────────────────────────────────────────
_bindTabs() { _bindTabs() {
@@ -1066,6 +1359,15 @@ class WiringApp {
async openDiagram(id) { async openDiagram(id) {
try { 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); const diagram = await api.diagrams.get(id);
this.diagramId = id; this.diagramId = id;
this.currentViewId = null; this.currentViewId = null;
@@ -1099,7 +1401,7 @@ class WiringApp {
const bar = document.getElementById("view-tab-bar"); const bar = document.getElementById("view-tab-bar");
if (!bar) return; if (!bar) return;
// Remove all view tabs (keep add button) // 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 addBtn = document.getElementById("btn-add-view");
const mainTab = document.createElement("button"); const mainTab = document.createElement("button");
@@ -1500,9 +1802,17 @@ class WiringApp {
} }
copySelected() { copySelected() {
const ids = [...this.canvas.selectedDeviceIds]; const ids = new Set(this.canvas.selectedDeviceIds);
if (!ids.length) return; if (!ids.size) return;
this._clipboard = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); 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; this._pasteCount = 0;
} }
@@ -1513,10 +1823,17 @@ class WiringApp {
} }
async duplicateSelected() { async duplicateSelected() {
const ids = [...this.canvas.selectedDeviceIds]; const ids = new Set(this.canvas.selectedDeviceIds);
if (!ids.length || !this.diagramId) return; if (!ids.size || !this.diagramId) return;
const sources = ids.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id)))).filter(Boolean); const sources = [...ids]
.map(id => JSON.parse(JSON.stringify(this.canvas.deviceData.get(id))))
.filter(Boolean);
this._clipboard = sources; 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; this._pasteCount = 1;
await this._cloneDevices(sources, 30); await this._cloneDevices(sources, 30);
} }
@@ -1524,6 +1841,8 @@ class WiringApp {
async _cloneDevices(sources, offset) { async _cloneDevices(sources, offset) {
this.canvas.clearSelection(); this.canvas.clearSelection();
const clonedIds = []; const clonedIds = [];
const oldToNew = new Map(); // old device id → new device id
for (const source of sources) { for (const source of sources) {
const def = { const def = {
diagram_id: this.diagramId, diagram_id: this.diagramId,
@@ -1542,12 +1861,45 @@ class WiringApp {
this.canvas.addDevice(device); this.canvas.addDevice(device);
this.canvas.addToSelection(device.id); this.canvas.addToSelection(device.id);
clonedIds.push(device.id); clonedIds.push(device.id);
oldToNew.set(source.id, device.id);
} catch (e) { console.error("Clone device failed:", e); } } 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._flashSaved();
this._pushUndo(async () => { this._pushUndo(async () => {
this.canvas.clearSelection(); this.canvas.clearSelection();
this._clearProps(); this._clearProps();
for (const wid of clonedWireIds) {
this.canvas.removeWire(wid);
await api.wires.delete(wid).catch(console.error);
}
for (const cid of clonedIds) { for (const cid of clonedIds) {
this.canvas.removeDevice(cid); this.canvas.removeDevice(cid);
await api.devices.delete(cid).catch(console.error); await api.devices.delete(cid).catch(console.error);
@@ -1800,6 +2152,7 @@ class WiringApp {
// ── Properties panel ────────────────────────────────────────────────────────── // ── Properties panel ──────────────────────────────────────────────────────────
_showDeviceProps(device) { _showDeviceProps(device) {
this._propDevice = device;
this._setPropVisible("props-device"); this._setPropVisible("props-device");
document.getElementById("prop-type").textContent = DEVICE_TYPES[device.device_type]?.label || device.device_type; document.getElementById("prop-type").textContent = DEVICE_TYPES[device.device_type]?.label || device.device_type;
document.getElementById("prop-reference").value = device.reference || ""; 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-save-connector").style.display = isGroup ? "none" : "";
document.getElementById("prop-add-pin").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("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 // Reset any open search results
const res = document.getElementById("octopart-results"); const res = document.getElementById("octopart-results");
const msg = document.getElementById("octopart-status-msg"); const msg = document.getElementById("octopart-status-msg");
@@ -1839,7 +2194,7 @@ class WiringApp {
const p = d.pins.find(p => p.id === pin.id); const p = d.pins.find(p => p.id === pin.id);
if (!p) return; if (!p) return;
p.side = e.target.value; 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); await api.devices.update(d.id, { pins: d.pins }).catch(console.error);
this.canvas.updateDevice(d); this.canvas.updateDevice(d);
this._showDeviceProps(d); this._showDeviceProps(d);
@@ -1938,7 +2293,21 @@ class WiringApp {
// ── Pin management ──────────────────────────────────────────────────────────── // ── 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 => { ["left", "right", "top", "bottom"].forEach(side => {
const sp = pins.filter(p => p.side === side); const sp = pins.filter(p => p.side === side);
sp.forEach((p, i) => { 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 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 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); 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.pins = newPins;
device.height = newHeight; device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error); 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 newPins = (device.pins || []).filter(p => p.id !== pinId);
const newHeight = Math.max(60, newPins.length * 18 + 20); 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.pins = newPins;
device.height = newHeight; device.height = newHeight;
await api.devices.update(device.id, { pins: newPins, height: newHeight }).catch(console.error); 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-wire").style.display = "none";
document.getElementById("props-formboard").style.display = ""; document.getElementById("props-formboard").style.display = "";
// Create canvas if needed // Create canvas immediately (synchronous) so sync button is never "not ready"
if (!this._fb) { if (!this._fb) {
try {
this._fb = new FormboardCanvas("fb-canvas", { this._fb = new FormboardCanvas("fb-canvas", {
onNodeSelected: (n) => this._fbShowNodeProps(n), onNodeSelected: (n) => this._fbShowNodeProps(n),
onSegmentSelected: (s) => this._fbShowSegProps(s), onSegmentSelected: (s) => this._fbShowSegProps(s),
@@ -2265,9 +2635,11 @@ class WiringApp {
onNodeCtx: (id, x, y) => this._fbNodeCtx(id, x, y), onNodeCtx: (id, x, y) => this._fbNodeCtx(id, x, y),
onSegCtx: (id, x, y) => this._fbSegCtx(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); const data = await api.formboard.get(this.diagramId).catch(() => null);
if (data) this._fb.load(data); if (data) this._fb.load(data);
if (data?.nodes?.length) this._fb.fitView(); if (data?.nodes?.length) this._fb.fitView();
@@ -2292,10 +2664,12 @@ class WiringApp {
async _fbSync() { async _fbSync() {
if (!this.diagramId) return; 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; }); const data = await api.formboard.sync(this.diagramId).catch(e => { alert("Sync failed: " + e.message); return null; });
if (!data) return; if (!data) return;
this._fb?.load(data); this._fb.load(data);
if (data.added > 0) this._fb?.fitView(); this._fb.fitView();
if (!data.nodes?.length) alert("No devices in this diagram to sync.");
} }
async _fbDeleteSelected() { async _fbDeleteSelected() {
+28
View File
@@ -1188,6 +1188,28 @@ class DiagramCanvas {
x: 4, y: device.height - 14, width: device.width - 8, align: "right", x: 4, y: device.height - 14, width: device.width - 8, align: "right",
text: "cable", fontSize: 8, fontFamily: "monospace", fill: "#888888", 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 { } else {
if (device.reference) { if (device.reference) {
group.add(new Konva.Text({ x: 6, y: 5, text: device.reference, fontSize: 10, fontFamily: "monospace", fill: "#99aaee", fontStyle: "bold" })); 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); this.selectDevice(device.id);
} }
}); });
group.on("dblclick", (e) => {
if (this.mode !== "select") return;
e.cancelBubble = true;
this.cb.onPinoutRequested?.(device);
});
group.on("dragstart", () => { group.on("dragstart", () => {
if (this.wireStart) { group.stopDrag(); return; } if (this.wireStart) { group.stopDrag(); return; }
if (device.properties?.locked) { 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-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+"] }, { 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": [ "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-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"] }, { 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; if (!conn) return null;
const pinCount = conn.pinCount || 2; 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 w = 120;
const h = Math.max(60, pinCount * 18 + 20); const h = Math.max(60, pinCount * 18 + 20);
const pins = Array.from({ length: pinCount }, (_, i) => ({ const pins = Array.from({ length: pinCount }, (_, i) => ({
+7 -3
View File
@@ -80,9 +80,11 @@ class FormboardCanvas {
const el = this.stage?.container()?.parentElement; const el = this.stage?.container()?.parentElement;
if (!el) return; if (!el) return;
const w = el.clientWidth, h = el.clientHeight; const w = el.clientWidth, h = el.clientHeight;
if (!w || !h) return;
this.stage.width(w); this.stage.height(h); this.stage.width(w); this.stage.height(h);
this._bg.width(w); this._bg.height(h); this._bg.width(w); this._bg.height(h);
this.bgLayer.batchDraw(); this.bgLayer.batchDraw();
this._applyTransform();
} }
_s2w(sx, sy) { return { x: (sx - this.offsetX) / this.scale, y: (sy - this.offsetY) / this.scale }; } _s2w(sx, sy) { return { x: (sx - this.offsetX) / this.scale, y: (sy - this.offsetY) / this.scale }; }
@@ -100,7 +102,7 @@ class FormboardCanvas {
setMode(mode) { setMode(mode) {
this.mode = mode; this.mode = mode;
if (mode !== "connect") this._cancelConnect(); 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"; ["add-branch", "add-splice", "connect"].includes(mode) ? "crosshair" : "default";
} }
@@ -223,8 +225,8 @@ class FormboardCanvas {
this.segKonva.clear(); this.segData.clear(); this.segKonva.clear(); this.segData.clear();
this.selectedId = null; this.selectedType = null; 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.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(); this.segLayer.batchDraw(); this.nodeLayer.batchDraw();
} }
@@ -376,9 +378,11 @@ class FormboardCanvas {
fitView() { fitView() {
if (!this.nodeData.size) return; 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; 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); }); 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.scale = Math.min(sw / (maxX - minX + pad * 2), sh / (maxY - minY + pad * 2), 2);
this.offsetX = sw / 2 - ((minX + maxX) / 2) * this.scale; this.offsetX = sw / 2 - ((minX + maxX) / 2) * this.scale;
this.offsetY = sh / 2 - ((minY + maxY) / 2) * this.scale; this.offsetY = sh / 2 - ((minY + maxY) / 2) * this.scale;