app
adom-layout-viewer
Public Made by Adomby adom
Interactive PCB layout viewer: your EDA's own render plus live net/trace highlighting and per-pad stock, price and wiki lookup
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
/* adom-layout-viewer interactive viewer core — shared by the live app (app.html) and the
* self-contained embed (embed.html). Consumes window.__PCB__ = { svg, meta }
* (embed) OR fetches /state (live). The whole reason this works: the SVG is
* self-rendered with data-net / data-ref / data-pad, so net/trace highlighting
* and component hover are pure DOM queries. */
(function () {
"use strict";
const $ = (s, r) => (r || document).querySelector(s);
const state = { meta: null, active: null, pinned: null, scale: 1, enrichCache: {}, side: "top", raised: null };
// ── mount ──────────────────────────────────────────────────────────────
function mount(svgText, meta) {
state.meta = meta || { net_names: {}, components: [] };
const box = $("#svgbox");
box.innerHTML = svgText;
const svg = box.querySelector("svg");
if (!svg) return;
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.style.width = "100%";
svg.style.height = "100%";
state.svg = svg;
state.vb = (svg.getAttribute("viewBox") || "0 0 10 10").split(/\s+/).map(Number);
state.vb0 = state.vb.slice();
fitOverlayToBase(svg);
wireHover(svg);
wirePanZoom(svg);
buildLayerHUD();
buildNetList();
setStat();
updateChrome();
}
// Show the side panel (Layers/Nets) + stat only when there's room — i.e. the
// standalone tab or a fullscreened pane. In a small embedded thumbnail (APM
// board view) the chrome is hidden so it doesn't clutter the little preview.
function updateChrome() {
const big = innerWidth > 620;
// Header only when standalone/large; inside APM's iframe (embed) APM already
// wears the canonical header, so we'd double it. Panels still show when big.
document.body.classList.toggle("chrome", big && !window.__EMBED__);
["#side", "#stat"].forEach((s) => { const e = $(s); if (e) e.style.display = big ? "" : "none"; });
}
addEventListener("resize", updateChrome);
// ── observability: mirror console + errors to the server ring buffer (GET
// /console). Live-server only (an embed has no server). ──
function post(path, body) { try { fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); } catch (e) {} }
function wireObservability() {
if (window.__EMBED__) return;
["log", "warn", "error"].forEach((k) => { const orig = console[k]; console[k] = function () { try { post("console", { level: k, msg: [].slice.call(arguments).map(String).join(" ") }); } catch (e) {} return orig.apply(console, arguments); }; });
addEventListener("error", (e) => post("console", { level: "error", msg: (e.message || "") + " @" + (e.filename || "") + ":" + (e.lineno || 0) }));
addEventListener("unhandledrejection", (e) => post("console", { level: "error", msg: "unhandledrejection: " + (e.reason && e.reason.message || e.reason) }));
}
// ── toasts: every action answers back + the AI's live narration channel ──
const TOAST_ICONS = {
success: '<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
error: '<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
info: '<svg class="ti" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 8h.01M11 12h1v4h1"/><circle cx="12" cy="12" r="9"/></svg>',
};
function toast(msg, type) {
type = type || "info";
const box = $("#toasts"); if (!box || !msg) return;
const el = document.createElement("div"); el.className = "toast " + type;
el.innerHTML = (TOAST_ICONS[type] || TOAST_ICONS.info) + '<span class="tmsg"></span>';
el.querySelector(".tmsg").textContent = msg;
while (box.children.length >= 4) box.removeChild(box.firstChild);
box.appendChild(el);
const kill = () => { el.classList.add("out"); setTimeout(() => el.remove(), 200); };
el.onclick = kill;
if (type !== "error") setTimeout(kill, 4000);
}
// Drain AI-pushed toasts + drive-commands (POST /ui/toast, /ui/cmd). Live only.
function pollUi() {
if (window.__EMBED__) return;
fetch("ui/pull").then((r) => r.json()).then((q) => {
(q.toasts || []).forEach((t) => toast(t.message, t.type));
(q.cmds || []).forEach(runCmd);
}).catch(() => {});
setTimeout(pollUi, 1000);
}
function runCmd(c) {
if (!c || !c.action) return;
if (c.action === "fit") { fit(); toast("Fit to view", "info"); }
else if (c.action === "net" && c.arg) { highlightNet(c.arg); toast("Net " + c.arg, "info"); }
else if (c.action === "clear") { clearNet && clearNet(); toast("Cleared", "info"); }
else if (c.action === "toast") { toast(c.arg || "", c.type || "info"); }
}
// ── tooltip: body-appended fixed div, 600ms reveal, clamped to viewport ──
let ttEl = null, ttTimer = null;
function showTip(target, e) {
const msg = target.getAttribute("data-tip"); if (!msg) return;
clearTimeout(ttTimer);
const cx = e.clientX, cy = e.clientY;
ttTimer = setTimeout(() => {
if (!ttEl) { ttEl = document.createElement("div"); ttEl.id = "tt"; document.body.appendChild(ttEl); }
ttEl.textContent = msg; ttEl.style.left = "-9999px"; ttEl.style.top = "0px"; ttEl.classList.add("show");
const M = 8, w = ttEl.offsetWidth, h = ttEl.offsetHeight;
let x = cx + 14, y = cy + 16;
if (x + w > innerWidth - M) x = cx - w - 14;
if (y + h > innerHeight - M) y = cy - h - 14;
x = Math.max(M, Math.min(x, innerWidth - w - M));
y = Math.max(M, Math.min(y, innerHeight - h - M));
ttEl.style.left = x + "px"; ttEl.style.top = y + "px";
}, 600);
}
function hideTip() { clearTimeout(ttTimer); if (ttEl) ttEl.classList.remove("show"); }
document.addEventListener("mouseover", (e) => { const t = e.target.closest && e.target.closest("[data-tip]"); if (t) showTip(t, e); });
document.addEventListener("mouseout", (e) => { if (e.target.closest && e.target.closest("[data-tip]")) hideTip(); });
document.addEventListener("mousedown", hideTip);
// ── header subject ──
function setSubject(name, meta) {
const n = $("#hdr-name"), m = $("#hdr-meta");
if (n) n.textContent = name || "";
if (m) m.textContent = meta || "";
}
function fitToast() { fit(); toast("Fit to view", "info"); }
// ── net + component highlighting ─────────────────────────────────────────
function highlightNet(net) {
if (net == null || net === "0" || net === "") return clearNet();
state.active = net;
state.svg.classList.add("dim");
state.svg.querySelectorAll(".adom-overlay [data-net]").forEach((el) => {
el.classList.toggle("hl", el.getAttribute("data-net") === String(net));
});
const name = state.meta.net_names[net] || "(unnamed)";
const cnt = state.svg.querySelectorAll('.adom-overlay [data-net="' + net + '"]').length;
chip('<b>net</b> ' + escapeHtml(name) + ' <span class="muted">· ' + cnt + " items</span>");
document.querySelectorAll("#netlist .netrow").forEach((r) => r.classList.toggle("sel", r.getAttribute("data-net") === String(net)));
}
function clearNet() {
if (state.pinned != null) return; // keep pinned net lit
state.active = null;
state.svg.classList.remove("dim");
state.svg.querySelectorAll(".hl").forEach((el) => el.classList.remove("hl"));
document.querySelectorAll("#netlist .netrow.sel").forEach((r) => r.classList.remove("sel"));
chip("");
}
// gentle single-pad highlight (hover)
function padHl(pad) { clearPadHl(); pad.classList.add("pad-hl"); }
function clearPadHl() { state.svg.querySelectorAll(".pad-hl").forEach((el) => el.classList.remove("pad-hl")); }
// ── CROSS-PROBE: click a part here → highlight the SAME reference in the sibling
// schematic view (and vice-versa), relayed by the host (APM) between the two
// same-origin iframes. Keyed by the component reference (both views tag by it). ──
function xpost(ref) { try { if (window.parent && window.parent !== window) window.parent.postMessage({ adomXprobe: true, ref: ref }, "*"); } catch (e) {} }
function clearComp() { if (state.svg) state.svg.querySelectorAll(".comp-xhl").forEach((el) => el.classList.remove("comp-xhl")); }
function highlightComp(ref) {
clearComp();
if (!state.svg || !ref) return;
state.svg.querySelectorAll('[data-ref="' + cssEsc(ref) + '"]').forEach((el) => el.classList.add("comp-xhl"));
}
// union bbox of the component's pads (svg user units = board mm), for recenter
function compBBox(ref) {
let mnx = 1e9, mny = 1e9, mxx = -1e9, mxy = -1e9, found = false;
state.svg.querySelectorAll('[data-ref="' + cssEsc(ref) + '"]').forEach((el) => {
try { const b = el.getBBox(); if (b.width || b.height) { mnx = Math.min(mnx, b.x); mny = Math.min(mny, b.y); mxx = Math.max(mxx, b.x + b.width); mxy = Math.max(mxy, b.y + b.height); found = true; } } catch (e) {}
});
return found ? [mnx, mny, mxx, mxy] : null;
}
function centerOn(ref) { const bb = compBBox(ref); if (bb) frameBBox(bb); }
// Zoom-to-fit a bbox: frame the part with margin (matching the viewport aspect),
// keep some context for tiny parts, never zoom out past the whole board.
function frameBBox(bb) {
if (!bb || !state.svg) return;
const x0 = bb[0], y0 = bb[1], x1 = bb[2], y1 = bb[3], cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
const rc = state.svg.getBoundingClientRect();
const aspect = (rc.width && rc.height) ? rc.width / rc.height : ((state.vb0[2] / state.vb0[3]) || 1);
let span = Math.max(x1 - x0, y1 - y0, 0.3) * 3.2; // the part fills ~1/3 of the short side
span = Math.max(span, 8); // don't over-zoom a tiny footprint
let w = aspect >= 1 ? span * aspect : span;
let h = aspect >= 1 ? span : span / aspect;
if (w > state.vb0[2] && h > state.vb0[3]) { w = state.vb0[2]; h = state.vb0[3]; }
state.vb = [cx - w / 2, cy - h / 2, w, h];
state.svg.setAttribute("viewBox", state.vb.join(" "));
}
function xclear() { try { if (window.parent && window.parent !== window) window.parent.postMessage({ adomXprobe: true, clear: true }, "*"); } catch (e) {} }
// Apply a cross-probe FROM the schematic view: HIGHLIGHT ONLY + recenter (no info
// card — the card shows only when the user clicks a part IN this app). No re-emit.
function applyXprobe(ref) { if (!ref) return; highlightComp(ref); centerOn(ref); }
// deselect propagated from the sibling view → clear our cross-probe highlight
function applyXclear() { clearComp(); }
addEventListener("message", (e) => { const d = e.data; if (!d || !d.adomXprobe) return; if (d.clear) applyXclear(); else if (d.ref) applyXprobe(d.ref); });
function wireHover(svg) {
// HOVER: pads + vias. Highlight the element + show its info. No net/fill lighting.
svg.addEventListener("mousemove", (e) => {
const pad = e.target.closest(".adom-pad");
if (pad) { padHl(pad); showPadCard(pad, e); return; }
const via = e.target.closest(".adom-via");
if (via) { padHl(via); showViaCard(via, e); return; }
clearPadHl(); hideCard();
});
svg.addEventListener("mouseleave", () => { clearPadHl(); hideCard(); });
// CLICK: light up the whole net (the "fill") + show fill info. Toggle off on
// empty click or clicking the same net again.
svg.addEventListener("click", (e) => {
const t = e.target.closest("[data-net]");
// click empty space = deselect here AND in the sibling schematic view
if (!t) { state.pinned = null; clearNet(); clearComp(); xclear(); return; }
const net = t.getAttribute("data-net");
state.pinned = state.pinned === net ? null : net;
if (state.pinned) highlightNet(net); else clearNet();
// cross-probe: clicking a part (pad/footprint) selects it in the schematic too
const ref = t.getAttribute("data-ref");
if (ref) { highlightComp(ref); xpost(ref); }
});
}
// ── pad info card (with async enrichment for the parent part) ─────────────
function showPadCard(pad, e) {
const ref = pad.getAttribute("data-ref");
const num = pad.getAttribute("data-pad");
const net = pad.getAttribute("data-net");
const c = (state.meta.components || []).find((k) => k.reference === ref) || {};
const card = $("#card");
const mpn = c.mpn || "";
const netName = (state.meta.net_names || {})[net] || (net === "0" ? "(no net)" : "net " + net);
const rows = [];
rows.push('<div class="ci-h"><span class="ci-ref">' + escapeHtml(ref || "?") + ' · pad ' + escapeHtml(num || "?") +
'</span><span class="ci-val">' + escapeHtml(c.value || "") + "</span></div>");
rows.push(row("net", netName));
rows.push(row("pad", (pad.getAttribute("data-size") || "") + " · " + (pad.getAttribute("data-shape") || "") + " · " + (pad.getAttribute("data-side") === "B" ? "bottom" : "top")));
if (mpn) rows.push(row("mpn", mpn));
if (c.lcsc) rows.push(row("lcsc", c.lcsc));
rows.push('<div class="ci-enrich" id="ci-enrich">' + (mpn ? '<span class="muted">loading price · stock · wiki…</span>' : "") + "</div>");
card.innerHTML = rows.join("");
card.style.display = "block";
placeCard(card, e);
if (mpn) enrich(mpn, c.lcsc);
}
function showViaCard(via, e) {
const net = via.getAttribute("data-net");
const netName = (state.meta.net_names || {})[net] || (net === "0" ? "(no net)" : "net " + net);
const card = $("#card");
card.innerHTML = '<div class="ci-h"><span class="ci-ref">via</span></div>' + row("net", netName);
card.style.display = "block";
placeCard(card, e);
}
function row(k, v) { return '<div class="ci-row"><span class="ci-k">' + k + '</span><span class="ci-v">' + escapeHtml(String(v)) + "</span></div>"; }
function hideCard() { const c = $("#card"); if (c) c.style.display = "none"; }
function placeCard(card, e) {
const pad = 14, w = card.offsetWidth, h = card.offsetHeight;
let x = e.clientX + pad, y = e.clientY + pad;
if (x + w > innerWidth) x = e.clientX - w - pad;
if (y + h > innerHeight) y = e.clientY - h - pad;
card.style.left = Math.max(4, x) + "px";
card.style.top = Math.max(4, y) + "px";
}
function enrich(mpn, lcsc) {
const key = mpn + "|" + (lcsc || "");
const render = (d) => {
const el = $("#ci-enrich");
if (!el || state.enrichKey !== key) return;
if (!d || d.error) { el.innerHTML = '<span class="muted">enrichment unavailable</span>'; return; }
const parts = [];
if (d.stock != null) parts.push('<span class="pill ' + (d.stock > 0 ? "ok" : "no") + '">' + (d.stock > 0 ? d.stock + " in stock" : "no stock") + "</span>");
if (d.price) parts.push('<span class="pill">' + escapeHtml(d.price) + "</span>");
if (d.tier) parts.push('<span class="pill tier">' + escapeHtml(d.tier) + "</span>");
if (d.popularity != null) parts.push('<span class="pill pop">★ ' + d.popularity + "</span>");
let html = '<div class="pills">' + parts.join("") + "</div>";
if (d.wiki_url) html += '<a class="ci-wiki" href="' + escapeHtml(d.wiki_url) + '" target="_blank" rel="noopener">wiki page ↗</a>';
el.innerHTML = html || '<span class="muted">no data</span>';
};
state.enrichKey = key;
if (state.enrichCache[key]) return render(state.enrichCache[key]);
if (typeof fetch !== "function" || window.__EMBED__) return render(null);
const url = "enrich?mpn=" + encodeURIComponent(mpn) + (lcsc ? "&lcsc=" + encodeURIComponent(lcsc) : "");
let tries = 0;
const go = () => fetch(url).then((r) => r.json()).then((d) => {
if (d && d.pending && tries++ < 20) { setTimeout(go, 1500); return; } // vendor lookup warming
state.enrichCache[key] = d; render(d);
}).catch(() => render(null));
go();
}
// When the visual base is the EDA's OWN exported SVG (Altium's render/pcb.svg),
// the base is in PDF pixel space and our overlay is in board mm — unrelated
// systems. Measure both with getBBox() (which accounts for any transforms the
// exporter baked in) and scale/translate the overlay onto the base.
function fitOverlayToBase(svg) {
const base = svg.querySelector(".adom-base");
const fit = svg.querySelector(".adom-fit");
if (!base || !fit) return;
let bb, ob;
try { bb = base.getBBox(); ob = fit.getBBox(); } catch (e) { return; }
if (!bb.width || !bb.height || !ob.width || !ob.height) return;
// uniform scale so the overlay can't shear relative to the render
const s = Math.min(bb.width / ob.width, bb.height / ob.height);
const tx = bb.x + (bb.width - ob.width * s) / 2 - ob.x * s;
const ty = bb.y + (bb.height - ob.height * s) / 2 - ob.y * s;
fit.setAttribute("transform", "translate(" + tx + " " + ty + ") scale(" + s + ")");
state.baseFit = s;
}
// ── pan / zoom (mutate viewBox) ───────────────────────────────────────────
function wirePanZoom(svg) {
svg.addEventListener("wheel", (e) => {
e.preventDefault();
const [x, y, w, h] = state.vb;
const f = e.deltaY > 0 ? 1.12 : 0.893;
const r = svg.getBoundingClientRect();
const mx = x + ((e.clientX - r.left) / r.width) * w;
const my = y + ((e.clientY - r.top) / r.height) * h;
const nw = Math.min(state.vb0[2] * 8, Math.max(state.vb0[2] / 200, w * f));
const nh = nw * (h / w);
state.vb = [mx - (mx - x) * (nw / w), my - (my - y) * (nh / h), nw, nh];
svg.setAttribute("viewBox", state.vb.join(" "));
}, { passive: false });
let drag = null;
svg.addEventListener("mousedown", (e) => { drag = { x: e.clientX, y: e.clientY, vb: state.vb.slice() }; });
addEventListener("mouseup", () => (drag = null));
addEventListener("mousemove", (e) => {
if (!drag) return;
const r = svg.getBoundingClientRect();
const dx = ((e.clientX - drag.x) / r.width) * drag.vb[2];
const dy = ((e.clientY - drag.y) / r.height) * drag.vb[3];
state.vb = [drag.vb[0] - dx, drag.vb[1] - dy, drag.vb[2], drag.vb[3]];
svg.setAttribute("viewBox", state.vb.join(" "));
});
}
function fit() { if (state.svg) { state.vb = state.vb0.slice(); state.svg.setAttribute("viewBox", state.vb.join(" ")); } }
// ── Layers HUD ────────────────────────────────────────────────────────────
// key = <g class="adom-layer-KEY">; label shown; on = visible by default.
// KiCad-native base layers (each a <g class="adom-layer-KEY"> wrapping the EDA SVG)
const LAYERS = [
["copperF", "Copper · Top", true], ["copperB", "Copper · Bot", true],
["maskF", "Mask · Top", true], ["maskB", "Mask · Bot", true],
["silkF", "Silk · Top", true], ["silkB", "Silk · Bot", true],
["fabF", "Fab · Top", true], ["fabB", "Fab · Bot", true],
["crtydF", "Courtyard · Top", false], ["crtydB", "Courtyard · Bot", false],
["edge", "Board edge", true],
];
const SIDED = ["copper", "mask", "silk", "fab", "crtyd"]; // bases with F/B groups
// canonical z-order (bottom→top): whole FAR side first, then whole NEAR side.
// Within a side: mask (under copper), copper, silk, fab, courtyard.
const STACK = ["mask", "copper", "silk", "fab", "crtyd"];
// Inner copper layers for THIS board, front->back e.g. ["In1.Cu","In2.Cu"].
// Comes from meta so a 2/4/6-layer board configures itself.
function innerLayers() {
return (state.meta && state.meta.inner_layers) || [];
}
const innerKey = (l) => "copper" + String(l).replace(/\.Cu$/, "");
// Full copper stack, FRONT -> BACK: copperF, copperIn1 .. copperInN, copperB.
// This is the physical depth order and drives both z-ordering and dimming.
function copperStack() {
return ["copperF", ...innerLayers().map(innerKey), "copperB"];
}
function order(side) {
const F = side !== "bottom";
const lo = (b) => (F ? b + "B" : b + "F"); // far
const hi = (b) => (F ? b + "F" : b + "B"); // near
// inner copper sits physically between the two sides. Deepest-from-the-viewer
// first: viewing top that's In(n)..In1; viewing bottom it reverses.
const inners = innerLayers().map(innerKey);
const mid = F ? inners.slice().reverse() : inners.slice();
return [...STACK.map(lo), ...mid, ...STACK.map(hi), "edge"];
}
function setActiveSide(side) {
state.side = side;
const svg = state.svg, flip = svg.querySelector(".adom-flip");
if (!flip) return;
order(side).forEach((k) => { const g = svg.querySelector(".adom-layer-" + k); if (g) flip.appendChild(g); });
const ov = svg.querySelector(".adom-overlay"); if (ov) flip.appendChild(ov); // overlay stays on top
// MIRROR the board about its vertical centre when viewing the bottom
const cx = parseFloat(flip.getAttribute("data-cx")) || 0;
if (side === "bottom") flip.setAttribute("transform", "translate(" + (2 * cx) + " 0) scale(-1 1)");
else flip.removeAttribute("transform");
// fade the FAR side back (like KiCad) so the near side reads clearly
const far = side === "bottom" ? "F" : "B", near = side === "bottom" ? "B" : "F";
SIDED.forEach((base) => {
const fg = svg.querySelector(".adom-layer-" + base + far); if (fg) fg.style.opacity = "0.28";
const ng = svg.querySelector(".adom-layer-" + base + near); if (ng) ng.style.opacity = "1";
});
// buried copper reads dimmed until explicitly raised
innerLayers().map(innerKey).forEach((k) => {
const g = svg.querySelector(".adom-layer-" + k); if (g) g.style.opacity = "0.28";
});
state.raised = null;
document.querySelectorAll("#sidebtns .sidebtn").forEach((b) => b.classList.toggle("on", b.dataset.side === side));
markRaised();
}
// Copper layers physically IN FRONT of `key`, from the current viewing side.
// Viewing top, in front of In2 = [In1, F]; viewing bottom it is [In1's far side…]
// i.e. the same stack walked from the other end.
function copperInFrontOf(key) {
const stack = state.side === "bottom" ? copperStack().slice().reverse() : copperStack();
const i = stack.indexOf(key);
return i <= 0 ? [] : stack.slice(0, i);
}
function markRaised() {
document.querySelectorAll("#layers .lyr").forEach((r) => {
r.classList.toggle("raised", r.dataset.key === state.raised);
});
}
function raise(key) {
const svg = state.svg, flip = svg.querySelector(".adom-flip"), g = svg.querySelector(".adom-layer-" + key);
if (!g || !flip) return;
// Raising the SAME layer twice restores the normal stack.
if (state.raised === key) { setActiveSide(state.side); return; }
flip.appendChild(g);
const ov = svg.querySelector(".adom-overlay"); if (ov) flip.appendChild(ov);
const cb = document.querySelector('#layers input[data-layer="' + key + '"]');
if (cb && !cb.checked) { cb.checked = true; g.style.display = ""; }
if (copperStack().indexOf(key) >= 0) {
// Bring this copper layer fully forward and dim everything stacked in front
// of it — the near outer layer AND any intermediate inner layers — so the
// raised layer reads clearly through the board.
g.style.opacity = "1";
copperInFrontOf(key).forEach((k) => {
const o = svg.querySelector(".adom-layer-" + k); if (o) o.style.opacity = "0.28";
});
state.raised = key;
markRaised();
}
}
// LAYERS + this board's inner copper, inserted right after Copper · Top so the
// panel reads in physical stack order (Top, In1, In2, Bot).
function layerRows() {
const inners = innerLayers().map((l) => [innerKey(l), "Copper · " + String(l).replace(/\.Cu$/, ""), true]);
if (!inners.length) return LAYERS;
const rows = LAYERS.slice();
const at = rows.findIndex(([k]) => k === "copperF");
rows.splice(at < 0 ? 0 : at + 1, 0, ...inners);
return rows;
}
function buildLayerHUD() {
const el = $("#layers"); if (!el) return;
el.innerHTML =
'<div id="sidebtns"><span class="lbl">Front side</span>' +
'<button class="sidebtn on" data-side="top">Top</button>' +
'<button class="sidebtn" data-side="bottom">Bottom</button></div>' +
layerRows().map(([k, label, on]) =>
'<div class="lyr" data-key="' + k + '"><input type="checkbox" data-layer="' + k + '"' + (on ? " checked" : "") +
'><span class="lyr-name" data-raise="' + k + '">' + label + "</span></div>"
).join("");
el.querySelectorAll("input").forEach((cb) => cb.addEventListener("change", () => {
const g = state.svg.querySelector(".adom-layer-" + cb.getAttribute("data-layer"));
if (g) g.style.display = cb.checked ? "" : "none";
}));
el.querySelectorAll(".lyr-name").forEach((n) => n.addEventListener("click", () => raise(n.dataset.raise)));
el.querySelectorAll("#sidebtns .sidebtn").forEach((b) => b.addEventListener("click", () => setActiveSide(b.dataset.side)));
// apply default order + hide default-off layers
setActiveSide("top");
layerRows().filter(([, , on]) => !on).forEach(([k]) => { const g = state.svg.querySelector(".adom-layer-" + k); if (g) g.style.display = "none"; });
}
function buildNetList() {
const el = $("#netlist"); if (!el) return;
const names = state.meta.net_names || {};
const rows = Object.keys(names).filter((k) => k !== "0" && names[k]).sort((a, b) => names[a].localeCompare(names[b]));
el.innerHTML = rows.map((id) => '<div class="netrow" data-net="' + id + '">' + escapeHtml(names[id]) + "</div>").join("") || '<div class="muted">no named nets</div>';
el.querySelectorAll(".netrow").forEach((r) => {
// click-to-toggle only (net/fill highlight is a click action, not hover)
r.addEventListener("click", () => { const n = r.getAttribute("data-net"); state.pinned = state.pinned === n ? null : n; state.pinned ? highlightNet(n) : clearNet(); });
});
}
function setStat() {
const m = state.meta, el = $("#stat");
const txt = (m.components ? m.components.length : 0) + " components · " + (m.track_count || 0) + " tracks · " + (Object.keys(m.net_names || {}).length) + " nets";
if (el) el.textContent = txt;
setSubject(m.name || "Board", txt);
}
// ── helpers ───────────────────────────────────────────────────────────────
function chip(html) { const c = $("#netchip"); if (!c) return; c.innerHTML = html; c.style.display = html ? "block" : "none"; }
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
function cssEsc(s) { return String(s).replace(/["\\]/g, "\\$&"); }
// ── boot ──────────────────────────────────────────────────────────────────
window.AdomPcb = { mount, fit, highlightNet, toast, runCmd };
function boot() {
$("#fitbtn") && $("#fitbtn").addEventListener("click", fitToast);
$("#hdr-fit") && $("#hdr-fit").addEventListener("click", fitToast);
if (window.__PCB__) { window.__EMBED__ = true; mount(window.__PCB__.svg, window.__PCB__.meta); return; }
// live mode: AI channel (toasts + drive cmds), console ring buffer, header version
wireObservability();
pollUi();
fetch("version").then((r) => r.json()).then((v) => { const el = $("#ai-ver"); if (el && v && v.version) el.textContent = "v" + v.version; }).catch(() => {});
fetch("state").then((r) => r.json()).then((s) => {
if (s && s.svg) mount(s.svg, s.meta || {});
else $("#svgbox").innerHTML = '<div class="empty">No board loaded. POST a .kicad_pcb to /load.</div>';
}).catch(() => { $("#svgbox").innerHTML = '<div class="empty">server unreachable</div>'; });
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
})();