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:
@@ -23,3 +23,6 @@ Thumbs.db
|
|||||||
# Editor
|
# Editor
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|||||||
@@ -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 not node.label or node.label == f"Dev {dev.id}":
|
||||||
if node:
|
node.label = label
|
||||||
new_label = dev.reference or dev.label or f"Dev {dev.id}"
|
else:
|
||||||
if not node.label or node.label == f"Dev {dev.id}":
|
# Alternate connectors above (y=80) and below (y=320) the trunk
|
||||||
node.label = new_label
|
row_y = 80 if added % 2 == 0 else 320
|
||||||
|
node = models.FormboardNode(
|
||||||
|
layout_id=layout.id,
|
||||||
|
node_type="connector",
|
||||||
|
device_id=dev.id,
|
||||||
|
x=new_x, y=row_y,
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
db.flush() # get node.id
|
||||||
|
dev_to_node[dev.id] = node
|
||||||
|
new_x += 200
|
||||||
|
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
|
continue
|
||||||
node = models.FormboardNode(
|
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,
|
layout_id=layout.id,
|
||||||
node_type="connector",
|
from_node_id=node_a.id,
|
||||||
device_id=dev.id,
|
to_node_id=node_b.id,
|
||||||
x=x, y=180,
|
wire_ids=wire_ids,
|
||||||
label=dev.reference or dev.label or f"Dev {dev.id}",
|
fitting_type="open",
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(seg)
|
||||||
x += 200
|
existing_seg_pairs.add(pair)
|
||||||
added += 1
|
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],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+392
-18
@@ -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,17 +1823,26 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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, 12–20 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, 12–20 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) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user