Add views, import/duplicate, DRC, jumps toggle, multi-wire select, auto-route, delete view tabs
- Roadmap #22: File→Import JSON to restore a diagram (drag-drop or file picker) - Roadmap #23: Duplicate diagram from diagram list - Roadmap #24: DRC panel with collapsible categories (unconnected pins, dup refs, no length, no P/N) - Multi-view tabs: named views per diagram with independent device/wire layouts; snapshot-on-entry prevents bleed - Keyboard arrow nudge for selected device(s) - Undo for move, duplicate, label/property changes - Wire jump arcs toggle (⌒ Jumps button in toolbar) - Shift+click multi-wire selection; right-click bulk clear waypoints or auto-route - Auto-route: obstacle-avoiding ortho routing for single or bulk selected wires - Delete view tab with × button; right-click context menu for rename/delete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+388
-5
@@ -1,6 +1,8 @@
|
||||
class WiringApp {
|
||||
constructor() {
|
||||
this.diagramId = null;
|
||||
this.currentViewId = null; // null = main layout, number = view id
|
||||
this._views = []; // view objects for current diagram
|
||||
this._savedTimer = null;
|
||||
this._customConnectors = []; // loaded from backend
|
||||
this._editingConnId = null; // id of custom connector being edited in modal
|
||||
@@ -18,15 +20,16 @@ class WiringApp {
|
||||
onWireSelected: (w) => this._showWireProps(w),
|
||||
onSelectionCleared: () => this._clearProps(),
|
||||
onMultipleSelected: (count) => this._showMultiProps(count),
|
||||
onDeviceMoved: (id, x, y) => { api.devices.update(id, { x, y }).catch(console.error); this._flashSaved(); },
|
||||
onDeviceMoved: (id, x, y) => { this._savePosForCurrentView(id, x, y); this._flashSaved(); },
|
||||
onDeviceDropped: (type, wx, wy) => this.addDevice(type, wx, wy),
|
||||
onConnectorDropped: (connId, wx, wy) => this.addConnector(connId, wx, wy),
|
||||
onWireCreated: (fDev, fPin, tDev, tPin) => this.createWire(fDev, fPin, tDev, tPin),
|
||||
onWaypointsChanged: (id, wps) => { api.wires.update(id, { waypoints: wps }).catch(console.error); this._flashSaved(); },
|
||||
onWaypointsChanged: (id, wps) => { this._saveWaypointsForCurrentView(id, wps); this._flashSaved(); },
|
||||
onDragStarted: (snap) => { this._preDrag = snap; },
|
||||
onDragEnded: (ids) => { this._pushMoveUndo(ids); },
|
||||
onDeviceContextMenu: (id, x, y) => this._showCtxDevice(id, x, y),
|
||||
onWireContextMenu: (id, x, y) => this._showCtxWire(id, x, y),
|
||||
onMultiWireSelected: (ids) => this._onMultiWireSelected(ids),
|
||||
onDeviceResized: async (id, x, y, w, h) => {
|
||||
const device = this.canvas.deviceData.get(id);
|
||||
if (!device) return;
|
||||
@@ -52,6 +55,24 @@ class WiringApp {
|
||||
.then(() => this._checkOctopartStatus());
|
||||
}
|
||||
|
||||
// ── View-aware position/waypoint saving ───────────────────────────────────────
|
||||
|
||||
_savePosForCurrentView(id, x, y) {
|
||||
if (this.currentViewId) {
|
||||
api.views.updateDevicePos(this.currentViewId, id, { x, y }).catch(console.error);
|
||||
} else {
|
||||
api.devices.update(id, { x, y }).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
_saveWaypointsForCurrentView(wireId, waypoints) {
|
||||
if (this.currentViewId) {
|
||||
api.views.updateWireWaypoints(this.currentViewId, wireId, { waypoints }).catch(console.error);
|
||||
} else {
|
||||
api.wires.update(wireId, { waypoints }).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-reopen & saved indicator ────────────────────────────────────────────
|
||||
|
||||
_autoOpenLast() {
|
||||
@@ -167,14 +188,14 @@ class WiringApp {
|
||||
const d = this.canvas.deviceData.get(id);
|
||||
if (g) { g.x(x); g.y(y); }
|
||||
if (d) { d.x = x; d.y = y; }
|
||||
await api.devices.update(id, { x, y }).catch(console.error);
|
||||
this._savePosForCurrentView(id, x, y);
|
||||
this.canvas._redrawWiresFor(id);
|
||||
}
|
||||
for (const [wId, wps] of waypoints) {
|
||||
const wire = this.canvas.wireData.get(wId);
|
||||
if (wire) {
|
||||
wire.waypoints = wps.map(wp => ({ ...wp }));
|
||||
await api.wires.update(wId, { waypoints: wire.waypoints }).catch(console.error);
|
||||
this._saveWaypointsForCurrentView(wId, wire.waypoints);
|
||||
}
|
||||
}
|
||||
this.canvas.deviceLayer.batchDraw();
|
||||
@@ -434,6 +455,30 @@ class WiringApp {
|
||||
on("btn-json", () => this._export("json"));
|
||||
on("btn-img", () => { const n = document.getElementById("diagram-name")?.value || "diagram"; this.canvas.exportImage(n); });
|
||||
on("btn-formboard", () => this._export("formboard"));
|
||||
on("btn-drc", () => this._runDrc());
|
||||
on("btn-import", () => document.getElementById("import-file-input")?.click());
|
||||
document.getElementById("import-file-input")?.addEventListener("change", (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(ev.target.result);
|
||||
const diag = await api.diagrams.import(data);
|
||||
await this.loadDiagramList();
|
||||
await this.openDiagram(diag.id);
|
||||
} catch (err) { alert("Import failed: " + err.message); }
|
||||
e.target.value = "";
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
on("btn-jumps", () => {
|
||||
const btn = document.getElementById("btn-jumps");
|
||||
const enabled = !this.canvas.jumpsEnabled;
|
||||
this.canvas.setJumpsEnabled(enabled);
|
||||
btn?.classList.toggle("active", enabled);
|
||||
});
|
||||
on("btn-snap", () => {
|
||||
const btn = document.getElementById("btn-snap");
|
||||
const enabled = !this.canvas.snapEnabled;
|
||||
@@ -552,6 +597,41 @@ class WiringApp {
|
||||
}
|
||||
|
||||
_showCtxWire(id, x, y) {
|
||||
const multiIds = this.canvas.selectedWireIds;
|
||||
if (multiIds.size > 1 && multiIds.has(id)) {
|
||||
// Multi-wire context menu
|
||||
const items = [
|
||||
{ action: "multi-clearwp", icon: "⌀", label: `Clear waypoints (${multiIds.size} wires)` },
|
||||
{ action: "multi-autoroute", icon: "⤢", label: `Auto-route (${multiIds.size} wires)` },
|
||||
];
|
||||
this._openCtx(x, y, items);
|
||||
this._ctxMenu().onclick = async (e) => {
|
||||
const action = e.target.closest(".ctx-item")?.dataset.action;
|
||||
this._closeCtx();
|
||||
if (!action) return;
|
||||
if (action === "multi-clearwp") {
|
||||
for (const wid of multiIds) {
|
||||
const w = this.canvas.wireData.get(wid);
|
||||
if (!w) continue;
|
||||
w.waypoints = [];
|
||||
this.canvas.updateWire(w);
|
||||
api.wires.update(wid, { waypoints: [] }).catch(console.error);
|
||||
}
|
||||
this._flashSaved();
|
||||
}
|
||||
if (action === "multi-autoroute") {
|
||||
for (const wid of multiIds) {
|
||||
const newWps = this.canvas.autoRouteWire(wid);
|
||||
if (newWps !== null) {
|
||||
api.wires.update(wid, { waypoints: newWps }).catch(console.error);
|
||||
}
|
||||
}
|
||||
this._flashSaved();
|
||||
}
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const wire = this.canvas.wireData.get(id);
|
||||
const hasWaypoints = (wire?.waypoints?.length ?? 0) > 0;
|
||||
const items = [
|
||||
@@ -559,7 +639,8 @@ class WiringApp {
|
||||
...(this._copiedWireStyle ? [{ action: "pastestyle", icon: "⎗", label: "Paste Style" }] : []),
|
||||
...(wire?.label ? [{ action: "selectnet", icon: "⋈", label: `Select net "${wire.label}"` }] : []),
|
||||
"---",
|
||||
{ action: "straighten", icon: "⤢", label: "Straighten wire" },
|
||||
{ action: "autoroute", icon: "⤢", label: "Auto-route" },
|
||||
{ action: "straighten", icon: "⤡", label: "Straighten wire" },
|
||||
...(hasWaypoints ? [{ action: "clearwp", icon: "⌀", label: "Clear waypoints" }] : []),
|
||||
"---",
|
||||
{ action: "del", icon: "✕", label: "Delete wire", danger: true },
|
||||
@@ -570,6 +651,13 @@ class WiringApp {
|
||||
this._closeCtx();
|
||||
if (!action) return;
|
||||
if (action === "del") this.deleteSelected();
|
||||
if (action === "autoroute" && wire) {
|
||||
const newWps = this.canvas.autoRouteWire(id);
|
||||
if (newWps !== null) {
|
||||
api.wires.update(id, { waypoints: newWps }).catch(console.error);
|
||||
this._flashSaved();
|
||||
}
|
||||
}
|
||||
if ((action === "clearwp" || action === "straighten") && wire) {
|
||||
wire.waypoints = [];
|
||||
this.canvas.updateWire(wire);
|
||||
@@ -600,6 +688,10 @@ class WiringApp {
|
||||
};
|
||||
}
|
||||
|
||||
_onMultiWireSelected(ids) {
|
||||
// No panel update needed for multi-wire; the count could be shown in status bar
|
||||
}
|
||||
|
||||
_setRouteMode(mode) {
|
||||
this.canvas.setRouteMode(mode);
|
||||
document.querySelectorAll(".route-btn").forEach(b => b.classList.remove("active"));
|
||||
@@ -621,6 +713,33 @@ class WiringApp {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); this.pasteClipboard(); }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "d") { e.preventDefault(); this.duplicateSelected(); }
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "z") { e.preventDefault(); this._undo(); }
|
||||
|
||||
// Arrow key nudge
|
||||
if (["ArrowLeft","ArrowRight","ArrowUp","ArrowDown"].includes(e.key)) {
|
||||
const ids = [...this.canvas.selectedDeviceIds];
|
||||
if (!ids.length) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
|
||||
const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0;
|
||||
ids.forEach(id => {
|
||||
const g = this.canvas.deviceNodes.get(id);
|
||||
const d = this.canvas.deviceData.get(id);
|
||||
if (!g || !d) return;
|
||||
d.x += dx; d.y += dy;
|
||||
g.x(d.x); g.y(d.y);
|
||||
this.canvas._redrawWiresFor(id);
|
||||
});
|
||||
this.canvas.deviceLayer.batchDraw();
|
||||
clearTimeout(this._nudgeSaveTimer);
|
||||
this._nudgeSaveTimer = setTimeout(() => {
|
||||
ids.forEach(id => {
|
||||
const d = this.canvas.deviceData.get(id);
|
||||
if (d) this._savePosForCurrentView(id, d.x, d.y);
|
||||
});
|
||||
this._flashSaved();
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -890,9 +1009,18 @@ class WiringApp {
|
||||
el.dataset.id = d.id;
|
||||
el.innerHTML = `<span class="di-name">${d.name}</span>
|
||||
<span class="di-date">${new Date(d.updated_at + "Z").toLocaleDateString()}</span>
|
||||
<button class="di-dup-btn" title="Duplicate diagram">⊕</button>
|
||||
<button class="di-del-btn" title="Delete diagram">×</button>`;
|
||||
el.querySelector(".di-name").addEventListener("click", () => this.openDiagram(d.id));
|
||||
el.querySelector(".di-date").addEventListener("click", () => this.openDiagram(d.id));
|
||||
el.querySelector(".di-dup-btn").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const copy = await api.diagrams.duplicate(d.id);
|
||||
await this.loadDiagramList();
|
||||
await this.openDiagram(copy.id);
|
||||
} catch (err) { alert("Duplicate failed: " + err.message); }
|
||||
});
|
||||
el.querySelector(".di-del-btn").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.deleteDiagram(d.id, d.name);
|
||||
@@ -908,10 +1036,13 @@ class WiringApp {
|
||||
await api.diagrams.delete(id);
|
||||
if (this.diagramId === id) {
|
||||
this.diagramId = null;
|
||||
this.currentViewId = null;
|
||||
this._views = [];
|
||||
localStorage.removeItem("lastDiagramId");
|
||||
this.canvas.loadDiagram({ devices: [], wires: [] });
|
||||
document.getElementById("diagram-name").value = "";
|
||||
document.getElementById("canvas-placeholder").style.display = "";
|
||||
document.getElementById("view-tab-bar").style.display = "none";
|
||||
this._clearProps();
|
||||
}
|
||||
await this.loadDiagramList();
|
||||
@@ -932,6 +1063,7 @@ class WiringApp {
|
||||
try {
|
||||
const diagram = await api.diagrams.get(id);
|
||||
this.diagramId = id;
|
||||
this.currentViewId = null;
|
||||
localStorage.setItem("lastDiagramId", id);
|
||||
document.getElementById("diagram-name").value = diagram.name;
|
||||
document.getElementById("canvas-placeholder").style.display = "none";
|
||||
@@ -942,12 +1074,263 @@ class WiringApp {
|
||||
this.setMode("select");
|
||||
this._clearProps();
|
||||
await this.loadDiagramList();
|
||||
await this._loadViews();
|
||||
} catch (e) {
|
||||
localStorage.removeItem("lastDiagramId");
|
||||
console.error("Failed to open diagram:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async _loadViews() {
|
||||
if (!this.diagramId) return;
|
||||
try { this._views = await api.views.listForDiagram(this.diagramId); }
|
||||
catch { this._views = []; }
|
||||
this._renderViewTabs();
|
||||
}
|
||||
|
||||
_renderViewTabs() {
|
||||
const bar = document.getElementById("view-tab-bar");
|
||||
if (!bar) return;
|
||||
// Remove all view tabs (keep add button)
|
||||
bar.querySelectorAll(".view-tab").forEach(t => t.remove());
|
||||
const addBtn = document.getElementById("btn-add-view");
|
||||
|
||||
const mainTab = document.createElement("button");
|
||||
mainTab.className = "view-tab" + (this.currentViewId === null ? " active" : "");
|
||||
mainTab.dataset.viewId = "main";
|
||||
mainTab.textContent = "Main";
|
||||
mainTab.addEventListener("click", () => this._switchToView(null));
|
||||
bar.insertBefore(mainTab, addBtn);
|
||||
|
||||
this._views.forEach(v => {
|
||||
const tab = document.createElement("button");
|
||||
tab.className = "view-tab" + (this.currentViewId === v.id ? " active" : "");
|
||||
tab.dataset.viewId = v.id;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.textContent = v.name;
|
||||
tab.appendChild(label);
|
||||
|
||||
const closeBtn = document.createElement("span");
|
||||
closeBtn.textContent = "×";
|
||||
closeBtn.className = "view-tab-close";
|
||||
closeBtn.title = "Delete view";
|
||||
closeBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete view "${v.name}"?`)) return;
|
||||
try {
|
||||
await api.views.delete(v.id);
|
||||
this._views = this._views.filter(x => x.id !== v.id);
|
||||
if (this.currentViewId === v.id) await this._switchToView(null);
|
||||
else this._renderViewTabs();
|
||||
} catch (err) { alert("Failed to delete view: " + err.message); }
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
|
||||
tab.addEventListener("dblclick", () => this._renameView(v));
|
||||
tab.addEventListener("click", () => this._switchToView(v.id));
|
||||
tab.addEventListener("contextmenu", (e) => { e.preventDefault(); this._viewTabCtx(v, e.clientX, e.clientY); });
|
||||
bar.insertBefore(tab, addBtn);
|
||||
});
|
||||
|
||||
bar.style.display = this.diagramId ? "" : "none";
|
||||
|
||||
if (!addBtn._bound) {
|
||||
addBtn._bound = true;
|
||||
addBtn.addEventListener("click", () => this._addView());
|
||||
}
|
||||
}
|
||||
|
||||
async _addView() {
|
||||
const name = prompt("View name (e.g. \"Curved\"):", "View " + (this._views.length + 1));
|
||||
if (!name || !this.diagramId) return;
|
||||
const routeMode = prompt("Default routing (direct / curved / ortho):", "direct") || "direct";
|
||||
try {
|
||||
const v = await api.views.create({ diagram_id: this.diagramId, name, route_mode: routeMode });
|
||||
this._views.push(v);
|
||||
this._renderViewTabs();
|
||||
await this._switchToView(v.id);
|
||||
} catch (e) { alert("Failed to create view: " + e.message); }
|
||||
}
|
||||
|
||||
async _renameView(v) {
|
||||
const name = prompt("Rename view:", v.name);
|
||||
if (!name) return;
|
||||
try {
|
||||
const updated = await api.views.update(v.id, { name });
|
||||
const idx = this._views.findIndex(x => x.id === v.id);
|
||||
if (idx >= 0) this._views[idx] = updated;
|
||||
this._renderViewTabs();
|
||||
} catch (e) { alert("Failed to rename: " + e.message); }
|
||||
}
|
||||
|
||||
_viewTabCtx(v, x, y) {
|
||||
this._openCtx(x, y, [
|
||||
{ action: "rename", icon: "✏", label: "Rename…" },
|
||||
{ action: "del", icon: "✕", label: "Delete view", danger: true },
|
||||
]);
|
||||
this._ctxMenu().onclick = async (e) => {
|
||||
const action = e.target.closest(".ctx-item")?.dataset.action;
|
||||
this._closeCtx();
|
||||
if (action === "rename") this._renameView(v);
|
||||
if (action === "del") {
|
||||
if (!confirm(`Delete view "${v.name}"?`)) return;
|
||||
try {
|
||||
await api.views.delete(v.id);
|
||||
this._views = this._views.filter(x => x.id !== v.id);
|
||||
if (this.currentViewId === v.id) await this._switchToView(null);
|
||||
else this._renderViewTabs();
|
||||
} catch (err) { alert("Failed to delete view: " + err.message); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async _switchToView(viewId) {
|
||||
this.currentViewId = viewId;
|
||||
const diagram = await api.diagrams.get(this.diagramId).catch(() => null);
|
||||
if (!diagram) return;
|
||||
|
||||
if (viewId === null) {
|
||||
// Restore main layout — reload the diagram as-is
|
||||
this.canvas.loadDiagram(diagram);
|
||||
this.canvas.loadBundles(this._bundles);
|
||||
const routeBtn = document.querySelector(".route-btn.active");
|
||||
// Keep current route mode
|
||||
} else {
|
||||
const view = this._views.find(v => v.id === viewId);
|
||||
const layout = await api.views.getLayout(viewId).catch(() => ({ device_positions: {}, wire_waypoints: {} }));
|
||||
|
||||
this.canvas.loadDiagram(diagram);
|
||||
this.canvas.loadBundles(this._bundles);
|
||||
|
||||
// Apply stored view positions
|
||||
const storedIds = new Set(Object.keys(layout.device_positions).map(Number));
|
||||
Object.entries(layout.device_positions).forEach(([devIdStr, pos]) => {
|
||||
const devId = parseInt(devIdStr);
|
||||
const d = this.canvas.deviceData.get(devId);
|
||||
const g = this.canvas.deviceNodes.get(devId);
|
||||
if (d && g) { d.x = pos.x; d.y = pos.y; g.x(pos.x); g.y(pos.y); }
|
||||
});
|
||||
|
||||
// Snapshot any device NOT yet in this view's layout so main-tab moves
|
||||
// can never bleed through after this point.
|
||||
const toSnapshot = [];
|
||||
this.canvas.deviceData.forEach((d, devId) => {
|
||||
if (!storedIds.has(devId)) toSnapshot.push({ devId, x: d.x, y: d.y });
|
||||
});
|
||||
toSnapshot.forEach(({ devId, x, y }) =>
|
||||
api.views.updateDevicePos(viewId, devId, { x, y }).catch(console.error)
|
||||
);
|
||||
|
||||
// Apply stored wire waypoints
|
||||
Object.entries(layout.wire_waypoints).forEach(([wireIdStr, wps]) => {
|
||||
const wireId = parseInt(wireIdStr);
|
||||
const w = this.canvas.wireData.get(wireId);
|
||||
if (w) { w.waypoints = wps; }
|
||||
});
|
||||
this.canvas.deviceLayer.batchDraw();
|
||||
this.canvas.wireLayer.batchDraw();
|
||||
|
||||
if (view?.route_mode) this._setRouteMode(view.route_mode);
|
||||
}
|
||||
|
||||
this._renderViewTabs();
|
||||
this.setMode("select");
|
||||
this._clearProps();
|
||||
}
|
||||
|
||||
// ── DRC ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
_runDrc() {
|
||||
if (!this.diagramId) return this._needDiagram();
|
||||
const devices = [...this.canvas.deviceData.values()];
|
||||
const wires = [...this.canvas.wireData.values()];
|
||||
|
||||
const categories = [
|
||||
{ key: "unconnected", label: "Unconnected Pins", icon: "🔴", items: [] },
|
||||
{ key: "dupref", label: "Duplicate References",icon: "🟠", items: [] },
|
||||
{ key: "nolength", label: "Missing Wire Length", icon: "🔵", items: [] },
|
||||
{ key: "nopn", label: "Missing Part Numbers", icon: "🟡", items: [] },
|
||||
];
|
||||
const cat = Object.fromEntries(categories.map(c => [c.key, c.items]));
|
||||
|
||||
// Unconnected pins
|
||||
const connectedPins = new Set();
|
||||
wires.forEach(w => {
|
||||
if (w.from_device_id) connectedPins.add(`${w.from_device_id}::${w.from_pin}`);
|
||||
if (w.to_device_id) connectedPins.add(`${w.to_device_id}::${w.to_pin}`);
|
||||
});
|
||||
devices.forEach(d => {
|
||||
if (d.device_type === "group") return;
|
||||
(d.pins || []).forEach(pin => {
|
||||
if (!connectedPins.has(`${d.id}::${pin.id}`))
|
||||
cat.unconnected.push(`${d.reference || d.label || "Device"} — pin "${pin.name || pin.id}"`);
|
||||
});
|
||||
});
|
||||
|
||||
// Duplicate references
|
||||
const refCount = new Map();
|
||||
devices.forEach(d => { if (d.reference) refCount.set(d.reference, (refCount.get(d.reference) || 0) + 1); });
|
||||
refCount.forEach((count, ref) => {
|
||||
if (count > 1) cat.dupref.push(`"${ref}" used on ${count} devices`);
|
||||
});
|
||||
|
||||
// Missing wire length
|
||||
wires.forEach(w => {
|
||||
if (!w.length) cat.nolength.push(`Wire ${w.label ? `"${w.label}"` : `ID ${w.id}`}`);
|
||||
});
|
||||
|
||||
// Missing part numbers
|
||||
devices.forEach(d => {
|
||||
if (d.device_type === "group") return;
|
||||
if (!d.properties?.partNumber) cat.nopn.push(`${d.reference || d.label || "Device"}`);
|
||||
});
|
||||
|
||||
// Render
|
||||
const modal = document.getElementById("drc-modal");
|
||||
const results = document.getElementById("drc-results");
|
||||
if (!modal || !results) return;
|
||||
|
||||
const totalIssues = categories.reduce((s, c) => s + c.items.length, 0);
|
||||
|
||||
if (!totalIssues) {
|
||||
results.innerHTML = `<div style="color:#6ec97b;padding:16px;text-align:center;font-size:14px">✓ No issues found</div>`;
|
||||
} else {
|
||||
results.innerHTML = categories.map(c => {
|
||||
const count = c.items.length;
|
||||
const catId = `drc-cat-${c.key}`;
|
||||
const rows = c.items.map(msg =>
|
||||
`<div class="drc-row">${msg}</div>`
|
||||
).join("");
|
||||
return `
|
||||
<div class="drc-category">
|
||||
<label class="drc-cat-header">
|
||||
<input type="checkbox" class="drc-toggle" data-cat="${catId}" ${count ? "checked" : "disabled"}>
|
||||
<span class="drc-cat-icon">${c.icon}</span>
|
||||
<span class="drc-cat-label">${c.label}</span>
|
||||
<span class="drc-cat-count">${count}</span>
|
||||
</label>
|
||||
<div id="${catId}" class="drc-cat-body" ${!count ? 'style="display:none"' : ""}>
|
||||
${count ? rows : '<div class="drc-row drc-none">None</div>'}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
results.querySelectorAll(".drc-toggle").forEach(cb => {
|
||||
cb.addEventListener("change", () => {
|
||||
const body = document.getElementById(cb.dataset.cat);
|
||||
if (body) body.style.display = cb.checked ? "" : "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
document.getElementById("drc-close")?.addEventListener("click", () => { modal.style.display = "none"; }, { once: true });
|
||||
modal.addEventListener("click", (e) => { if (e.target === modal) modal.style.display = "none"; }, { once: true });
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
setMode(mode) {
|
||||
|
||||
Reference in New Issue
Block a user