app
Adom Schematic
Public Made by Adomby adom
Interactive schematic viewer: your EDA's own render plus per-symbol highlighting and live MPN, stock and price on hover
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
/* adom-schematic core — KiCad-native schematic SVG + a component hotspot
* overlay. Hover a symbol → what it is (ref/value/footprint) + live stock & price.
* Consumes window.__SCH__ = { svg, meta } (embed) OR fetches /state (live). */
(function () {
"use strict";
const $ = (s, r) => (r || document).querySelector(s);
const state = { meta: null, pinned: null, enrichCache: {} };
function mount(svgText, meta) {
state.meta = meta || { 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.hlRef = null; // fresh DOM: nothing is highlighted yet
// Match the viewport to the sheet's own background so the drawing reads as one
// infinite sheet. The fill differs per EDA (Altium #ffffff, KiCad/Eagle #fbfbf6),
// so read it off the rendered sheet rather than hardcoding.
const bg = svg.querySelector(".adom-sheet-bg");
if (bg) { const f = bg.getAttribute("fill"); if (f) box.style.background = f; }
state.vb = (svg.getAttribute("viewBox") || "0 0 100 100").split(/\s+/).map(Number);
state.vb0 = state.vb.slice();
wireHover(svg);
wirePanZoom(svg);
buildCompList();
buildCrumb();
setStat();
updateChrome();
}
// Show the canonical header + Components panel + stat only when there's room
// (standalone tab or a fullscreened pane); hide them in a small embedded
// thumbnail (APM board view) so the sheet fills the pane.
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);
// ── POST helper + observability: mirror console + errors to the server ring
// buffer (GET /console) so the AI can read what the UI is doing. Live-server only
// (an embed has no server of its own). ──────────────────────────────────────
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); // errors stay until dismissed
}
// 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);
}
// The AI drives the front-end the same way a user would (see CLI `ui` verbs).
function runCmd(c) {
if (!c || !c.action) return;
if (c.action === "fit") { fit(); toast("Fit to view", "info"); }
else if (c.action === "sheet" && c.arg) { openSheet(c.arg); }
else if (c.action === "highlight" && c.arg) { state.pinned = c.arg; compHl(c.arg); showCard(c.arg); toast("Highlight " + c.arg, "info"); }
else if (c.action === "clear") { state.pinned = null; clearSym(); hideCard(); }
else if (c.action === "toast") { toast(c.arg || "", c.type || "info"); }
}
// ── tooltip: body-appended fixed div, 600ms reveal, clamped to viewport on BOTH
// axes, flips at the right/bottom edges. Never a ::after (clipped by panels). ──
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; // flip left at the right edge
if (y + h > innerHeight - M) y = cy - h - 14; // flip up at the bottom edge
x = Math.max(M, Math.min(x, innerWidth - w - M)); // clamp both axes so it never
y = Math.max(M, Math.min(y, innerHeight - h - M)); // runs off any edge
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 (center zone) ──
function setSubject(name, meta) {
const n = $("#hdr-name"), m = $("#hdr-meta");
if (n) n.textContent = name || "";
if (m) m.textContent = meta || "";
}
function curSheetLabel() {
if (!state.curSheet) return "Root sheet";
const subs = (state.sheets && state.sheets.__root__ && state.sheets.__root__.meta && state.sheets.__root__.meta.sub_sheets) || state.meta.sub_sheets || [];
const s = subs.find((x) => x.file === state.curSheet);
return s ? (s.name || s.file) : state.curSheet;
}
// ── CROSS-PROBE with the sibling PCB view (host/APM relays between the two
// same-origin iframes, keyed by the component reference). ──
function xpost(ref) { try { if (window.parent && window.parent !== window) window.parent.postMessage({ adomXprobe: true, ref: ref }, "*"); } catch (e) {} }
function centerOn(ref) {
const c = (state.meta.components || []).find((k) => k.reference === ref);
if (c && c.bbox) frameBBox(c.bbox);
}
// 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 sheet.
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.5) * 3.2; // the part fills ~1/3 of the short side
span = Math.max(span, 10); // don't over-zoom a tiny part
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 PCB view. If the referenced part lives on another
// bundled sheet, switch to it first, then 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;
if (!(state.meta.components || []).some((k) => k.reference === ref) && state.sheets) {
for (const key in state.sheets) { const cs = state.sheets[key].meta && state.sheets[key].meta.components; if (cs && cs.some((k) => k.reference === ref)) { mountSheet(key); break; } }
}
state.pinned = ref; compHl(ref); centerOn(ref);
}
// deselect propagated from the sibling view → clear our highlight + card too
function applyXclear() { state.pinned = null; clearSym(); hideCard(); }
addEventListener("message", (e) => { const d = e.data; if (!d || !d.adomXprobe) return; if (d.clear) applyXclear(); else if (d.ref) applyXprobe(d.ref); });
// ── component highlight: screen-blend teal over the symbol (turns the symbol
// strokes teal, leaves the white sheet white — no box) + list sync ──────────
function clearSym() {
if (state.hlRef == null) return; // nothing highlighted — skip the DOM sweep
state.hlRef = null;
state.svg.querySelectorAll(".symhl").forEach((el) => el.classList.remove("symhl"));
document.querySelectorAll("#complist .crow.sel").forEach((r) => r.classList.remove("sel"));
}
function compHl(ref) {
if (state.hlRef === ref) return; // already highlighted — don't re-tag ~1500 nodes
clearSym();
state.hlRef = ref;
// recolour the hovered symbol's real KiCad strokes teal (cheap paint, no filter)
state.svg.querySelectorAll('.adom-sheet [data-cref="' + cssEsc(ref) + '"]').forEach((el) => el.classList.add("symhl"));
document.querySelectorAll("#complist .crow").forEach((r) => r.classList.toggle("sel", r.getAttribute("data-ref") === ref));
}
function clearHl() { if (!state.pinned) clearSym(); }
function wireHover(svg) {
svg.addEventListener("mousemove", (e) => {
state.mouse = e; // remembered so a pin lands where the card already is
if (state.pinned) return; // keep the pinned card + highlight stable
const c = e.target.closest(".adom-comp");
if (c) {
const ref = c.getAttribute("data-ref");
// still on the SAME symbol: only trail the card. Don't re-highlight (a
// ~1500-node re-tag) or rebuild the card / re-fire enrichment every frame.
if (ref === state.hlRef) { placeCard($("#card"), e); return; }
compHl(ref); showCard(ref, e); return;
}
clearHl(); hideCard();
});
svg.addEventListener("mouseleave", () => { if (!state.pinned) { clearHl(); hideCard(); } });
svg.addEventListener("click", (e) => {
// hierarchical-sheet box: descend into that child sheet
const sh = e.target.closest(".adom-sheet-link");
if (sh) { openSheet(sh.getAttribute("data-sheet")); return; }
const c = e.target.closest(".adom-comp");
// click empty space = deselect here AND in the sibling PCB view
if (!c) { state.pinned = null; clearSym(); hideCard(); xclear(); return; }
const ref = c.getAttribute("data-ref");
state.pinned = state.pinned === ref ? null : ref;
// pin: keep the card exactly where the hover card already sits, and expand
// it to the full detail view so the links become reachable + cross-probe it.
// Un-pinning (toggling off) deselects the PCB too.
if (state.pinned) { compHl(ref); showCard(ref, e); xpost(ref); } else { clearSym(); hideCard(); xclear(); }
});
}
// ── hierarchical sheet navigation ─────────────────────────────────────────
// When every sheet is bundled (window.__SHEETS__, injected by `embed --sheet-dir`),
// switching sheets is INSTANT: we just re-mount the pre-rendered svg/meta, no
// reload, no fetch. The top-left is a dropdown of all sheets; sheet boxes are
// also click-through. Without a bundle we fall back to ?sheet= URL navigation.
function setQuery(key, val) {
const u = new URL(window.location.href);
if (val == null) u.searchParams.delete(key); else u.searchParams.set(key, val);
return u.pathname + u.search;
}
function openSheet(file) {
if (!file) return;
if (state.sheets && state.sheets[file]) { mountSheet(file); return; } // instant
window.location.href = setQuery("sheet", file); // fallback: server-rendered
}
// mount a bundled sheet in-place (key: filename, or "__root__")
function mountSheet(key) {
const s = state.sheets && state.sheets[key]; if (!s) return;
state.curSheet = key === "__root__" ? null : key;
mount(s.svg, s.meta);
toast("Sheet: " + curSheetLabel(), "info");
}
// the sheet list is the ROOT's children (so you can jump anywhere from anywhere)
function rootSubs() {
const r = state.sheets && state.sheets.__root__;
return (r && r.meta && r.meta.sub_sheets) || state.meta.sub_sheets || [];
}
function buildCrumb() {
const el = document.getElementById("crumb"); if (!el) return;
const subs = rootSubs();
if (!subs.length) { el.style.display = "none"; return; }
// a dropdown of every sheet (Root + children); selecting one switches instantly
const cur = state.curSheet || "__root__";
const opt = (v, label) => '<option value="' + escapeHtml(v) + '"' + (v === cur ? " selected" : "") + ">" + escapeHtml(label) + "</option>";
const opts = [opt("__root__", "Root")].concat(subs.map((s) => opt(s.file, s.name || s.file.replace(/\.kicad_sch$/, ""))));
el.innerHTML = '<span class="sheetlbl">Sheet</span><select id="sheetsel">' + opts.join("") + "</select>";
el.style.display = "flex";
// both the dropdown and the components panel live top-right; push the panel
// below the dropdown so they don't overlap (the panel is hidden on small panes)
const side = document.getElementById("side"); if (side) side.style.top = "52px";
const sel = el.querySelector("#sheetsel");
if (sel) sel.addEventListener("change", (e) => {
const v = e.target.value;
if (state.sheets && state.sheets[v]) mountSheet(v);
else window.location.href = setQuery("sheet", v === "__root__" ? null : v);
});
}
function comp(ref) { return (state.meta.components || []).find((k) => k.reference === ref); }
// Abridged while merely hovering on a small/embedded screen (APM's board pane):
// reference · MPN · stock, nothing else. Pinning always shows the full card.
function isBrief(ref) { return state.pinned !== ref && innerWidth <= 620; }
function showCard(ref, e) {
const c = comp(ref); if (!c) return;
const card = $("#card");
const pinned = state.pinned === ref, brief = isBrief(ref);
const rows = [];
if (brief) {
rows.push('<div class="ci-h"><span class="ci-ref">' + escapeHtml(ref) + "</span></div>");
// real MPN arrives with enrichment; show the schematic value meanwhile
rows.push('<div class="ci-mfr" id="ci-mpn">' + escapeHtml(c.value || "") + "</div>");
rows.push('<div class="ci-enrich" id="ci-enrich">' + (c.lcsc || c.value ? '<span class="muted">stock…</span>' : "") + "</div>");
} else {
rows.push('<div class="ci-h"><span class="ci-ref">' + escapeHtml(ref) + '</span><span class="ci-val">' + escapeHtml(c.value || "") + "</span></div>");
if (c.description) rows.push('<div class="ci-desc">' + escapeHtml(c.description) + "</div>");
if (c.footprint) rows.push(row("footprint", c.footprint.split(":").pop()));
if (c.lcsc) rows.push(row("lcsc", c.lcsc));
rows.push('<div class="ci-enrich" id="ci-enrich">' + (c.lcsc || c.value ? '<span class="muted">loading stock · price…</span>' : "") + "</div>");
if (c.datasheet && c.datasheet !== "~") rows.push('<a class="ci-ds" href="' + escapeHtml(c.datasheet) + '" target="_blank" rel="noopener">datasheet ↗</a>');
}
card.innerHTML = rows.join("");
card.classList.toggle("brief", brief);
card.style.display = "block";
// clickable only when pinned, so hovering never eats the pointer
card.classList.toggle("pinned", pinned);
// follow the cursor while hovering; once pinned it stays put where it is —
// but the pinned card is bigger than the brief one it replaced, so pull it
// back inside the viewport if that growth pushed it off an edge.
if (!pinned) placeCard(card, e || state.mouse); else clampCard(card);
state.briefMode = brief;
if (c.lcsc || c.value) enrich(c.value, c.lcsc);
}
// place near the cursor, flipping to the other side at the viewport edges
function placeCard(card, e) {
if (!e) { card.style.left = "12px"; card.style.top = "12px"; return; }
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";
}
// keep an already-placed card fully inside the viewport
function clampCard(card) {
const w = card.offsetWidth, h = card.offsetHeight;
const x = parseFloat(card.style.left) || 0, y = parseFloat(card.style.top) || 0;
card.style.left = Math.max(4, Math.min(x, innerWidth - w - 4)) + "px";
card.style.top = Math.max(4, Math.min(y, innerHeight - h - 4)) + "px";
}
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 && !state.pinned) { c.style.display = "none"; c.classList.remove("pinned", "brief"); } }
// ── enrichment (stock/price via LCSC PN, wiki page + popularity) ──────────
function enrich(value, lcsc) {
const key = (value || "") + "|" + (lcsc || "");
const render = (d) => {
const el = $("#ci-enrich"); if (!el || state.enrichKey !== key) return;
// abridged card: the real MPN + stock, nothing else
if (state.briefMode) {
const m = $("#ci-mpn");
if (m && d && d.mpn) m.textContent = d.mpn;
if (!d || d.error || d.stock == null) { el.innerHTML = '<span class="muted">no stock data</span>'; return; }
el.innerHTML = '<div class="pills"><span class="pill ' + (d.stock > 0 ? "ok" : "no") + '">' + (d.stock > 0 ? d.stock + " in stock" : "no stock") + "</span></div>";
return;
}
if (!d || d.error) { el.innerHTML = '<span class="muted">no stock/price found</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 = "";
if (d.mpn) html += '<div class="ci-mfr">' + escapeHtml(d.mpn) + "</div>";
if (d.mfr) html += '<div class="ci-mfr muted">' + escapeHtml(d.mfr) + "</div>";
html += '<div class="pills">' + parts.join("") + "</div>";
const links = [];
if (d.jlc_url) links.push('<a class="ci-lnk" href="' + escapeHtml(d.jlc_url) + '" target="_blank" rel="noopener">JLCPCB ↗</a>');
if (d.wiki_url) links.push('<a class="ci-lnk" href="' + escapeHtml(d.wiki_url) + '" target="_blank" rel="noopener">wiki page ↗</a>');
if (links.length) html += '<div class="ci-links">' + links.join("") + "</div>";
el.innerHTML = html || '<span class="muted">no data</span>';
};
state.enrichKey = key;
if (state.enrichCache[key]) return render(state.enrichCache[key]);
// NOTE: relative "enrich" resolves against whatever serves the page — our own
// /enrich when running standalone, or the HOST app's (APM's
// /board-view/<slug>/enrich) when embedded. So embeds get live stock too.
if (typeof fetch !== "function") return render(null);
const url = "enrich?mpn=" + encodeURIComponent(value || "") + (lcsc ? "&lcsc=" + encodeURIComponent(lcsc) : "");
let tries = 0;
const go = () => fetch(url).then((r) => r.json()).then((d) => {
if (d && d.pending && tries++ < 25) { setTimeout(go, 1500); return; }
state.enrichCache[key] = d; render(d);
}).catch(() => render(null));
go();
}
// ── pan / zoom ────────────────────────────────────────────────────────────
// A schematic sheet is ~4500 nodes, so every viewBox write re-rasterizes a lot.
// Coalesce writes to ONE per animation frame: a burst of wheel/drag events then
// costs a single repaint per frame instead of 2-3, which is what makes panning
// and zooming a dense sheet feel smooth.
function wirePanZoom(svg) {
let vbPending = false;
const flushVB = () => {
if (vbPending) return;
vbPending = true;
requestAnimationFrame(() => { vbPending = false; svg.setAttribute("viewBox", state.vb.join(" ")); });
};
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, 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)), nh = nw * (h / w);
state.vb = [mx - (mx - x) * (nw / w), my - (my - y) * (nh / h), nw, nh];
flushVB();
}, { passive: false });
let drag = null;
// cache the rect at grab time — it can't change mid-drag, so we avoid a forced
// layout (getBoundingClientRect) on every single pan frame.
svg.addEventListener("mousedown", (e) => {
const r = svg.getBoundingClientRect();
drag = { x: e.clientX, y: e.clientY, vb: state.vb.slice(), rw: r.width, rh: r.height };
});
addEventListener("mouseup", () => (drag = null));
addEventListener("mousemove", (e) => {
if (!drag) return;
// preserveAspectRatio="…meet" scales the viewBox UNIFORMLY to fit the box, so
// there is ONE viewBox-units-per-pixel factor for both axes: max(vbW/rw, vbH/rh)
// (the larger ratio is the limiting one under "meet"). Using vbW/rw for x but
// vbH/rh for y made horizontal and vertical drag move at different rates on any
// non-matching aspect (i.e. every wide schematic).
const upp = Math.max(drag.vb[2] / drag.rw, drag.vb[3] / drag.rh);
state.vb = [drag.vb[0] - (e.clientX - drag.x) * upp, drag.vb[1] - (e.clientY - drag.y) * upp, drag.vb[2], drag.vb[3]];
flushVB();
});
}
function fit() { if (state.svg) { state.vb = state.vb0.slice(); state.svg.setAttribute("viewBox", state.vb.join(" ")); } }
function fitToast() { fit(); toast("Fit to view", "info"); }
// ── component list ────────────────────────────────────────────────────────
function buildCompList() {
const el = $("#complist"); if (!el) return;
const cs = (state.meta.components || []).slice().sort((a, b) => a.reference.localeCompare(b.reference, undefined, { numeric: true }));
el.innerHTML = cs.map((c) => '<div class="crow" data-ref="' + escapeHtml(c.reference) + '"><b>' + escapeHtml(c.reference) + "</b> " + escapeHtml(c.value || "") + "</div>").join("") || '<div class="muted">no components</div>';
el.querySelectorAll(".crow").forEach((r) => {
const ref = r.getAttribute("data-ref");
r.addEventListener("mouseenter", () => { if (!state.pinned) compHl(ref); });
r.addEventListener("mouseleave", () => { if (!state.pinned) clearHl(); });
r.addEventListener("click", () => { state.pinned = state.pinned === ref ? null : ref; if (state.pinned) { compHl(ref); showCard(ref); } else { clearSym(); hideCard(); } });
});
}
function setStat() {
const n = state.meta.components ? state.meta.components.length : 0;
const el = $("#stat"); if (el) el.textContent = n + " components";
setSubject(curSheetLabel(), n + (n === 1 ? " component" : " components"));
}
// ── helpers ───────────────────────────────────────────────────────────────
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); }
function cssEsc(s) { return String(s).replace(/["\\]/g, "\\$&"); }
window.AdomSch = { mount, fit, compHl, toast, runCmd };
function boot() {
$("#fitbtn") && $("#fitbtn").addEventListener("click", fitToast);
$("#hdr-fit") && $("#hdr-fit").addEventListener("click", fitToast);
state.sheets = window.__SHEETS__ || null; // pre-rendered bundle for instant nav
if (window.__SCH__) { window.__EMBED__ = true; state.curSheet = null; mount(window.__SCH__.svg, window.__SCH__.meta); return; }
// standalone live server: enable the AI channel (toasts + drive cmds), the
// console ring buffer, and fill the header's app-info 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 schematic loaded. POST a .kicad_sch to /load.</div>';
}).catch(() => { $("#svgbox").innerHTML = '<div class="empty">server unreachable</div>'; });
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
})();