app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
master
John Lauer
1.1.13: balanced grouped 2-column, 15 mm icon lane, body-centred designators, stroke_mm; widget gains a parity-tested JS engine for CSP-blocked wasm
47c81ac
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
/*!
* <adom-symbol-variants> : alternate schematic-symbol layouts for a wiki component page.
*
* ::adom-symbol-variants{src="/ray/ch340g/render/page.json"}
*
* Attributes:
* src page.json of a component page (wiki render route). The widget reads
* component.mpn / parts.symbol / pins[] from it and fetches the default
* .kicad_sym from the same page. May also point straight at a .kicad_sym.
* mpn override the part name shown / used as the Value.
* layout initial layout: 2c | 2cg | sq | sqg (default 2cg)
* outline initial chip-outline size: off | small | medium | large (default large
* when the page has a <mpn>-3d-outline-*-named.svg, else off)
*
* The generator is adom-symbol's own `sym_gen` (Rust) compiled to wasm32 and baked
* into this file, so the browser produces byte-identical .kicad_sym to the CLI:
* grouping, DIP / QFP walks, square layouts, the text-free rectangle, Reference /
* Value placement, the chip-outline fit. Rendering is a small SVG renderer here.
* Nothing is written to the wiki: users download the variant, or copy the CLI
* recipe to regenerate the whole set in their own Hydrogen container.
*/
(() => {
const WASM_B64 = "__WASM_B64__";
const VERSION = "__VERSION__";
// ── wasm generator ──────────────────────────────────────────────────────
let wasmP = null;
function wasm() {
if (!wasmP) {
const bin = Uint8Array.from(atob(WASM_B64), c => c.charCodeAt(0));
wasmP = WebAssembly.instantiate(bin, {}).then(r => r.instance);
}
return wasmP;
}
/*__SYMGEN_JS__*/
let engine = 'wasm';
async function generateSym(input) {
if (engine === 'js') return generateJs(input);
try { return await generateWasm(input); }
catch (e) {
// The wiki widget frame's CSP has no 'wasm-unsafe-eval' yet (adom/wiki#163):
// fall back to the JS port of the same generator (parity-tested each build).
if (!(e && /WebAssembly|CSP|Content Security/i.test(String(e.message || e)))) throw e;
engine = 'js';
return generateJs(input);
}
}
function generateJs(input) {
const text = (root_AdomSymGen()).generateFromCreateJson(input);
if (text.startsWith("ERROR:")) throw new Error(text);
return text;
}
function root_AdomSymGen() { return (typeof AdomSymGen !== 'undefined') ? AdomSymGen : globalThis.AdomSymGen; }
async function generateWasm(input) {
const inst = await wasm();
const ex = inst.exports;
const enc = new TextEncoder().encode(JSON.stringify(input));
const inPtr = ex.ab_alloc(enc.length);
new Uint8Array(ex.memory.buffer, inPtr, enc.length).set(enc);
const outPtr = ex.ab_generate(inPtr, enc.length);
const len = new DataView(ex.memory.buffer).getUint32(outPtr, true);
const text = new TextDecoder().decode(new Uint8Array(ex.memory.buffer, outPtr + 4, len));
ex.ab_free(outPtr, len + 4);
ex.ab_free(inPtr, enc.length);
if (text.startsWith("ERROR:")) throw new Error(text);
return text;
}
// ── .kicad_sym parsing (vendor or generated) ───────────────────────────
const RE_PIN = /\(pin\s+(\w+)\s+\w+\s*\(at\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(length\s+(-?[\d.]+)\)[\s\S]*?\(name\s+"((?:[^"\\]|\\.)*)"[\s\S]*?\(number\s+"((?:[^"\\]|\\.)*)"/g;
function parsePins(text) {
const pins = [];
const seen = new Set();
for (const m of text.matchAll(RE_PIN)) {
const num = m[7];
if (seen.has(num)) continue;
seen.add(num);
pins.push({ type: m[1], x: +m[2], y: +m[3], angle: +m[4], length: +m[5], name: m[6], number: num });
}
return pins;
}
function prop(text, name) {
const re = new RegExp('\\(property\\s+"' + name + '"\\s+"((?:[^"\\\\]|\\\\.)*)"\\s*\\(at\\s+(-?[\\d.]+)\\s+(-?[\\d.]+)\\s+(-?[\\d.]+)\\)([\\s\\S]*?)\\)\\s*\\)', '');
const m = re.exec(text);
if (!m) return null;
const eff = m[5] || '';
const just = /\(justify\s+(left|right)/.exec(eff);
return { value: m[1], x: +m[2], y: +m[3], rot: +m[4], hidden: /\bhide\b|\(hide yes\)/.test(eff), justify: just ? just[1] : 'center' };
}
function parseRects(text) {
const out = [];
for (const m of text.matchAll(/\(rectangle\s*\(start\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(end\s+(-?[\d.]+)\s+(-?[\d.]+)\)/g))
out.push({ x0: Math.min(+m[1], +m[3]), y0: Math.min(+m[2], +m[4]), x1: Math.max(+m[1], +m[3]), y1: Math.max(+m[2], +m[4]) });
return out.sort((a, b) => (b.x1 - b.x0) * (b.y1 - b.y0) - (a.x1 - a.x0) * (a.y1 - a.y0));
}
function parseTexts(text) {
const out = [];
for (const m of text.matchAll(/\(text\s+"((?:[^"\\]|\\.)*)"\s*\(at\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\)\s*\(effects\s*\(font\s*\(size\s+(-?[\d.]+)[\s\S]*?\)(?:[\s\S]*?\(justify\s+(left|right)\))?/g))
out.push({ text: m[1], x: +m[2], y: +m[3], rot: +m[4], size: +m[5], justify: m[6] || 'center' });
return out;
}
function pinSortKey(n) { const v = parseInt(n, 10); return isNaN(v) ? [1, n] : [0, v]; }
function sortPackageOrder(pins) {
return pins.slice().sort((a, b) => {
const ka = pinSortKey(a.number), kb = pinSortKey(b.number);
return ka[0] - kb[0] || (ka[0] === 0 ? ka[1] - kb[1] : String(ka[1]).localeCompare(String(kb[1])));
});
}
// ── group heuristics (used when the page carries no groups) ───────────
const RULES = [
[/^(VDD|VCC|VSS|GND|V3|VBAT|VDDA|VSSA|VREF\+?|VREF-|AVDD|AVSS|DVDD|DVSS|VIN|VOUT|V\+|V-|PVDD|PGND|AGND|DGND|VCAP\d*|VLCD|VDDUSB|VDDIO2?|SW|BST|BOOT|LX|FB|EN|COMP|SS|PG|PGOOD|EP|PAD)$/i, 'Power'],
[/^(RTS|CTS|DSR|DTR|DCD|RI)$/i, 'Handshake'],
[/^(TXD?|RXD?|UART|USART)/i, 'UART'],
[/^(R232|CFG|CONFIG|MODE|SEL)/i, 'Config'],
[/^(UD\+|UD-|D\+|D-|DP|DM|USB|VBUS)/i, 'USB'],
[/^(XI|XO|XTAL|OSC|X1|X2|OSC_IN|OSC_OUT|PC14|PC15|PH0|PH1)/i, 'Crystal'],
[/^(NRST|RST|RESET|BOOT0|BOOT1|NC|SWDIO|SWCLK|SWO|JTAG|TMS|TCK|TDI|TDO)/i, 'Control'],
[/^(SCL|SDA|I2C)/i, 'I2C'],
[/^(MOSI|MISO|SCK|CS|NSS|SPI)/i, 'SPI'],
[/^(PA\d)/i, 'Port A'], [/^(PB\d)/i, 'Port B'], [/^(PC\d)/i, 'Port C'],
[/^(PD\d)/i, 'Port D'], [/^(PE\d)/i, 'Port E'], [/^(PH\d)/i, 'Port H'],
[/^(ADC|AIN|AN\d)/i, 'Analog'],
];
function plainName(n) { return n.replace(/~\{([^}]*)\}/g, '$1').replace(/#$/, ''); }
function groupFor(name, type) {
const n = plainName(name);
for (const [re, g] of RULES) if (re.test(n)) return g;
if (type === 'power_in' || type === 'power_out') return 'Power';
return 'IO';
}
// ── free-rect + chip-outline fit (same constants as sym_gen.rs / render.rs) ──
const NAME_CHAR_W = 1.5, NAME_EXT_OFFSET = 1.016 + 0.6, LABEL_CHAR_W = 0.9, LABEL_INSET = 1.02;
const OUTLINE_BODY_FRAC = 0.62;
function freeRect(body, pins, texts) {
let l = body.x0, b = body.y0, r = body.x1, t = body.y1;
for (const p of pins) {
const n = plainName(p.name).length; if (!n) continue;
const ext = n * NAME_CHAR_W + NAME_EXT_OFFSET;
const a = ((p.angle % 360) + 360) % 360;
if (a === 0) l = Math.max(l, body.x0 + ext);
else if (a === 180) r = Math.min(r, body.x1 - ext);
else if (a === 270) t = Math.min(t, body.y1 - ext);
else if (a === 90) b = Math.max(b, body.y0 + ext);
}
for (const x of texts) {
if (x.justify === 'center') continue;
const ext = x.text.length * LABEL_CHAR_W + LABEL_INSET;
if (x.rot === 0 && x.justify === 'left') l = Math.max(l, body.x0 + ext);
else if (x.rot === 0 && x.justify === 'right') r = Math.min(r, body.x1 - ext);
else if (x.rot === 900 && x.justify === 'right') t = Math.min(t, body.y1 - ext);
else if (x.rot === 900 && x.justify === 'left') b = Math.max(b, body.y0 + ext);
}
if (r - l < 2 || t - b < 2) return { l: body.x0, b: body.y0, r: body.x1, t: body.y1 };
return { l, b, r, t };
}
function iconBox(body, free, ref, val, frac) {
const f = { ...free };
for (const d of [ref, val]) {
if (!d || d.hidden) continue;
if (d.x > body.x0 && d.x < body.x1 && d.y > body.y0 && d.y < body.y1) {
const cy = (f.b + f.t) / 2;
if (d.y >= cy) f.t = Math.min(f.t, d.y - 1.3); else f.b = Math.max(f.b, d.y + 1.3);
}
}
const M = ICON_MARGIN;
const fw = Math.max(1, f.r - f.l - 2 * M), fh = Math.max(1, f.t - f.b - 2 * M);
const w = Math.min((body.x1 - body.x0) * frac, fw), h = Math.min((body.y1 - body.y0) * frac, fh);
const bcx = (body.x0 + body.x1) / 2;
const cx = (bcx - w / 2 >= free.l + M && bcx + w / 2 <= free.r - M) ? bcx : (f.l + f.r) / 2;
return { cx, cy: (f.b + f.t) / 2, w, h };
}
const MIN_ICON_MM = 15.0, ICON_MARGIN = 1.0;
const FRAC = { large: OUTLINE_BODY_FRAC, medium: OUTLINE_BODY_FRAC * 0.78, small: OUTLINE_BODY_FRAC * 0.58 };
// ── SVG renderer (Adom dark theme, mm units, Y flipped) ────────────────
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const C = { body: '#1a2332', bodyStroke: '#3d4654', wire: '#7d8796', name: '#e6edf3', num: '#9fb0c8', label: '#8C6BF7', ref: '#8b949e', value: '#e6edf3', centred: '#646464', outline: '#e6edf3' };
function kicadName(n) {
// ~{X} -> overline
const parts = [];
let i = 0; const re = /~\{([^}]*)\}/g; let m;
while ((m = re.exec(n))) { if (m.index > i) parts.push([n.slice(i, m.index), false]); parts.push([m[1], true]); i = m.index + m[0].length; }
if (i < n.length) parts.push([n.slice(i), false]);
return parts.map(([t, ov]) => ov ? `<tspan text-decoration="overline">${esc(t)}</tspan>` : esc(t)).join('');
}
function renderSvg(sym, opts) {
const pins = parsePins(sym);
const rects = parseRects(sym);
const texts = parseTexts(sym);
const ref = prop(sym, 'Reference'), val = prop(sym, 'Value');
const body = rects[0] || null;
const F = 1.27, FL = 0.762, SW = 0.2;
let g = '';
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
const bump = (x, y) => { minx = Math.min(minx, x); maxx = Math.max(maxx, x); miny = Math.min(miny, -y); maxy = Math.max(maxy, -y); };
if (body) {
g += `<rect x="${body.x0}" y="${-body.y1}" width="${body.x1 - body.x0}" height="${body.y1 - body.y0}" rx="0.4" fill="${C.body}" stroke="${C.bodyStroke}" stroke-width="0.25"/>`;
bump(body.x0, body.y0); bump(body.x1, body.y1);
}
// chip outline
if (opts.outline && body && opts.outlineSize !== 'off') {
const fr = freeRect(body, pins, texts);
const ib = iconBox(body, fr, ref, val, FRAC[opts.outlineSize] || FRAC.large);
// Same rule as the CLI: an icon under 0.6 x MIN_ICON_MM would draw as a blob; omit it.
if (Math.min(ib.w, ib.h) >= MIN_ICON_MM * 0.6) {
const href = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(opts.outline)));
g += `<image x="${ib.cx - ib.w / 2}" y="${-ib.cy - ib.h / 2}" width="${ib.w}" height="${ib.h}" preserveAspectRatio="xMidYMid meet" opacity="0.96" href="${href}"/>`;
}
}
for (const x of texts) {
const isLabel = x.size < 1;
const col = isLabel ? C.label : C.centred;
const anchor = x.justify === 'left' ? 'start' : x.justify === 'right' ? 'end' : 'middle';
const tf = x.rot === 900 ? ` transform="rotate(-90 ${x.x} ${-x.y})"` : '';
g += `<text x="${x.x}" y="${-x.y}" font-size="${x.size}" font-weight="${isLabel ? 700 : 400}" fill="${col}" text-anchor="${anchor}" dominant-baseline="central"${tf}>${esc(x.text)}</text>`;
}
for (const p of pins) {
const a = ((p.angle % 360) + 360) % 360;
const rad = a * Math.PI / 180, dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
const ex = p.x + dx * p.length, ey = p.y + dy * p.length;
g += `<line x1="${p.x}" y1="${-p.y}" x2="${ex}" y2="${-ey}" stroke="${C.wire}" stroke-width="${SW}"/>`;
g += `<circle cx="${p.x}" cy="${-p.y}" r="0.25" fill="${C.wire}"/>`;
bump(p.x, p.y); bump(ex, ey);
const nx = ex + dx * 1.016, ny = ey + dy * 1.016;
const mx = (p.x + ex) / 2, my = (p.y + ey) / 2;
if (dy === 0) {
g += `<text x="${nx}" y="${-ny}" font-size="${F}" fill="${C.name}" text-anchor="${dx > 0 ? 'start' : 'end'}" dominant-baseline="central">${kicadName(p.name)}</text>`;
g += `<text x="${mx}" y="${-my - 0.45}" font-size="${F}" fill="${C.num}" text-anchor="middle">${esc(p.number)}</text>`;
bump(p.x - (dx < 0 ? 0 : 2), p.y + 1.2); bump(p.x, p.y - 1.2);
} else {
// vertical: text reads bottom-to-top; top pins (a=270) anchor at the top end.
g += `<text x="${nx}" y="${-ny}" font-size="${F}" fill="${C.name}" text-anchor="${dy < 0 ? 'end' : 'start'}" dominant-baseline="central" transform="rotate(-90 ${nx} ${-ny})">${kicadName(p.name)}</text>`;
g += `<text x="${mx - 0.45}" y="${-my}" font-size="${F}" fill="${C.num}" text-anchor="middle" transform="rotate(-90 ${mx - 0.45} ${-my})">${esc(p.number)}</text>`;
bump(p.x - 1.4, p.y); bump(p.x + 1.4, p.y);
}
}
for (const [d, col] of [[ref, C.ref], [val, C.value]]) {
if (!d || d.hidden) continue;
const anchor = d.justify === 'left' ? 'start' : d.justify === 'right' ? 'end' : 'middle';
g += `<text x="${d.x}" y="${-d.y}" font-size="${F}" fill="${col}" text-anchor="${anchor}" dominant-baseline="central">${esc(d.value)}</text>`;
const w = d.value.length * NAME_CHAR_W;
bump(d.x - (anchor === 'middle' ? w / 2 : anchor === 'end' ? w : 0), d.y + 0.9);
bump(d.x + (anchor === 'middle' ? w / 2 : anchor === 'start' ? w : 0), d.y - 0.9);
}
if (!isFinite(minx)) { minx = -10; miny = -10; maxx = 10; maxy = 10; }
const M = 3;
const vb = `${minx - M} ${miny - M} ${maxx - minx + 2 * M} ${maxy - miny + 2 * M}`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${vb}" font-family="'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace">${g}</svg>`;
}
// ── the element ─────────────────────────────────────────────────────────
const LAYOUTS = [
['2c', '2-column', 'left-right', false],
['2cg', '2-column grouped', 'left-right', true],
['sq', 'Square', 'square', false],
['sqg', 'Square grouped', 'square-grouped', true],
];
const SIZES = ['off', 'small', 'medium', 'large'];
const CSS = `
:host{display:block;--bg:#0d1117;--panel:#11161f;--line:#222b38;--text:#e6edf3;--dim:#8b949e;--accent:#00e6dc;--purple:#8C6BF7;font:14px 'Satoshi',-apple-system,'Segoe UI',Roboto,sans-serif;color:var(--text)}
*{box-sizing:border-box}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
.hd{display:flex;align-items:center;gap:10px;height:44px;padding:0 14px;border-bottom:1px solid var(--line)}
.hd .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);box-shadow:0 0 10px var(--accent)}
.hd .t{font-weight:700;letter-spacing:.2px}
.hd .s{color:var(--dim);font:12px 'JetBrains Mono',ui-monospace,monospace}
.hd .sp{flex:1}
.hd .v{color:var(--dim);font:11px 'JetBrains Mono',ui-monospace,monospace}
.ctl{display:flex;flex-wrap:wrap;gap:10px 18px;padding:10px 14px;border-bottom:1px solid var(--line)}
.grp{display:flex;flex-direction:column;gap:5px}
.grp .k{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--dim)}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
.seg button{background:transparent;border:0;color:var(--dim);padding:6px 10px;font:inherit;font-size:13px;cursor:pointer;min-height:32px}
.seg button+button{border-left:1px solid var(--line)}
.seg button.on{background:#1a2332;color:var(--text)}
.seg button:disabled{opacity:.35;cursor:not-allowed}
.tog{display:inline-flex;align-items:center;gap:8px;cursor:pointer;min-height:32px;font-size:13px}
.tog input{accent-color:var(--accent);width:16px;height:16px}
.stage{background:var(--bg);background-image:linear-gradient(#ffffff08 1px,transparent 1px),linear-gradient(90deg,#ffffff08 1px,transparent 1px);background-size:20px 20px;padding:12px;min-height:220px;display:flex;align-items:center;justify-content:center}
.stage svg{width:100%;max-height:520px;height:auto;display:block}
.ft{display:flex;flex-wrap:wrap;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--line)}
.ft .d{color:var(--dim);font-size:12px;flex:1;min-width:180px}
.btn{background:#1a2332;border:1px solid var(--line);color:var(--text);border-radius:8px;padding:7px 12px;font:inherit;font-size:13px;cursor:pointer;min-height:32px}
.btn.pri{border-color:var(--accent);color:var(--accent)}
.btn:hover{border-color:#485162}
.msg{padding:14px;color:var(--dim);font-size:13px}
.err{color:#ef5350}
pre{margin:0;padding:10px 14px;background:#0d1117;border-top:1px solid var(--line);color:var(--dim);font:11px 'JetBrains Mono',ui-monospace,monospace;white-space:pre-wrap;word-break:break-all;max-height:180px;overflow:auto}
.hint{color:var(--dim);font-size:11px}
.toast{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);background:#1a2332;border:1px solid var(--accent);color:var(--text);padding:7px 12px;border-radius:8px;font-size:12px;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.on{opacity:1}
.wrap{position:relative}`;
class AdomSymbolVariants extends HTMLElement {
static get observedAttributes() { return ['src', 'mpn', 'layout', 'outline']; }
constructor() { super(); this.state = { layout: '2cg', outline: 'off', centred: false }; }
connectedCallback() {
if (this._init) return; this._init = true;
this.attachShadow({ mode: 'open' });
const l = this.getAttribute('layout'); if (l && LAYOUTS.some(x => x[0] === l)) this.state.layout = l;
this._shell('Loading part data');
this._load().catch(e => this._shell('Could not load: ' + (e && e.message || e), true));
}
_shell(msg, err) { this.shadowRoot.innerHTML = `<style>${CSS}</style><div class="card"><div class="hd"><span class="dot"></span><span class="t">Symbol variants</span><span class="sp"></span><span class="v">adom-symbol ${VERSION}</span></div><div class="msg${err ? ' err' : ''}">${esc(msg)}</div></div>`; }
async _load() {
const src = this.getAttribute('src') || '';
if (!src) throw new Error('src attribute required (a page.json render URL)');
const abs = new URL(src, location.href);
const m = /^(.*?\/render)\//.exec(abs.pathname);
const base = m ? abs.origin + m[1] + '/' : abs.href.replace(/[^/]*$/, '');
let page = null, symUrl = abs.href, mpn = this.getAttribute('mpn') || '';
if (/\.json$/i.test(abs.pathname)) {
page = await (await fetch(abs.href)).json();
const c = page.component || page;
mpn = mpn || c.mpn || page.title || page.slug || '';
const symFile = (c.parts && c.parts.symbol) || null;
symUrl = base + (symFile || (mpn.toLowerCase() + '.kicad_sym'));
this.pageMeta = { manufacturer: c.manufacturer || page.manufacturer || '', package: c.package || '', description: page.brief || page.description || c.description || '', datasheet: c.datasheet || '', pins: c.pins || [] };
} else {
this.pageMeta = { manufacturer: '', package: '', description: '', datasheet: '', pins: [] };
}
const symText = await (await fetch(symUrl)).text();
const pins = sortPackageOrder(parsePins(symText));
if (!pins.length) throw new Error('no pins found in ' + symUrl);
if (!mpn) { const v = prop(symText, 'Value'); mpn = v ? v.value : 'SYMBOL'; }
this.mpn = mpn; this.defaultSym = symText; this.pins = pins; this.base = base;
const pkg = this.pageMeta.package || (prop(symText, 'Footprint') || { value: '' }).value.split(':').pop();
this.pageMeta.package = pkg;
// pin groups: page.json groups > name heuristics
const byNum = new Map(this.pageMeta.pins.map(p => [String(p.number), p]));
for (const p of pins) { const pp = byNum.get(String(p.number)); p.group = (pp && pp.group) || groupFor(p.name, p.type); }
// chip outline asset (optional)
this.outline = null;
for (const style of ['heavy', 'dark', 'hairline', 'teal', 'bold']) {
try {
const r = await fetch(base + mpn + '-3d-outline-' + style + '-named.svg');
if (r.ok) { const t = await r.text(); if (/<svg/i.test(t)) { this.outline = t; break; } }
} catch (_) { /* none */ }
}
const o = this.getAttribute('outline');
this.state.outline = this.outline ? (SIZES.includes(o) ? o : 'large') : 'off';
await this._render();
}
_input() {
const L = LAYOUTS.find(x => x[0] === this.state.layout) || LAYOUTS[1];
const suffix = L[0].toUpperCase() + (this.state.centred ? 'C' : '');
return {
symbolName: this.mpn + '-' + suffix,
manufacturer: this.pageMeta.manufacturer, package: this.pageMeta.package,
description: this.pageMeta.description, datasheetUrl: this.pageMeta.datasheet,
value: this.mpn, category: 'ic',
pins: this.pins.map(p => ({ name: p.name, number: p.number, type: p.type, ...(L[3] ? { group: p.group } : {}) })),
preferences: { pinLayout: L[2], chipNameCentered: this.state.centred },
};
}
async _render() {
const input = this._input();
let sym, svg, err = '';
try { sym = await generateSym(input); svg = renderSvg(sym, { outline: this.outline, outlineSize: this.state.outline }); }
catch (e) { err = String(e && e.message || e); }
this.sym = sym; this.input = input;
const s = this.state;
const seg = (name, items, cur, dis) => `<div class="seg" data-seg="${name}">${items.map(([v, lab]) => `<button data-v="${v}" class="${v === cur ? 'on' : ''}" ${dis ? 'disabled' : ''}>${lab}</button>`).join('')}</div>`;
const nPins = this.pins.length;
this.shadowRoot.innerHTML = `<style>${CSS}</style><div class="card wrap">
<div class="hd"><span class="dot"></span><span class="t">Symbol variants</span><span class="s">${esc(this.mpn)} · ${nPins} pins${this.pageMeta.package ? ' · ' + esc(this.pageMeta.package) : ''}</span><span class="sp"></span><span class="v">adom-symbol ${VERSION} · ${engine}</span></div>
<div class="ctl">
<div class="grp"><span class="k">Layout</span>${seg('layout', LAYOUTS.map(x => [x[0], x[1]]), s.layout)}</div>
<div class="grp"><span class="k">Chip outline</span>${seg('outline', SIZES.map(v => [v, v[0].toUpperCase() + v.slice(1)]), s.outline, !this.outline)}${this.outline ? '' : '<span class="hint">no 3D outline on this page yet (run adom-chip-thumbnailer)</span>'}</div>
<div class="grp"><span class="k">Name</span><label class="tog"><input type="checkbox" id="centred" ${s.centred ? 'checked' : ''}> Centred in body</label></div>
</div>
<div class="stage">${err ? `<div class="msg err">${esc(err)}</div>` : svg}</div>
<div class="ft">
<span class="d">${esc(input.symbolName)}: ${esc(descNote(s, this.pins))}. Generated in your browser by the same engine as the adom-symbol CLI; the default symbol on this page is untouched.</span>
<button class="btn pri" id="dl">Download .kicad_sym</button>
<button class="btn" id="dlj">Download create JSON</button>
<button class="btn" id="recipe">CLI recipe</button>
</div>
<pre id="pre" hidden></pre>
<div class="toast" id="toast"></div>
</div>`;
const root = this.shadowRoot;
root.querySelectorAll('.seg button').forEach(b => b.addEventListener('click', () => {
const k = b.closest('.seg').dataset.seg; this.state[k] = b.dataset.v; this._render();
}));
root.getElementById('centred').addEventListener('change', e => { this.state.centred = e.target.checked; this._render(); });
root.getElementById('dl').addEventListener('click', () => this._download(input.symbolName + '.kicad_sym', sym || ''));
root.getElementById('dlj').addEventListener('click', () => this._download(input.symbolName + '.json', JSON.stringify(input, null, 1)));
root.getElementById('recipe').addEventListener('click', () => {
const pre = root.getElementById('pre');
pre.hidden = !pre.hidden;
pre.textContent = this._recipe();
});
}
_recipe() {
const i = this.input;
return [
'# Regenerate this variant (or the whole set) in your Hydrogen container:',
'adom-wiki pkg install adom/adom-symbol',
`adom-wiki repo clone ${this._pageRef()} --dir ${this.mpn}`,
`cd ${this.mpn}`,
`# create input = the "Download create JSON" file, saved as ${i.symbolName}.json`,
`adom-symbol create --file ${i.symbolName}.json --out symbols/`,
`# pixel-exact KiCad render + outline contract for adom-lbr:`,
`adom-symbol render --file symbols/${i.symbolName}/${i.symbolName}.kicad_sym --out symbols/${i.symbolName}.svg${this.outline ? ` --iso ${this.mpn}-3d-outline-heavy-named.svg` : ''}`,
`# Fusion/EAGLE library with every variant as one component:`,
`adom-lbr generate --sym ${this._defaultSymName()} --alt-sym symbols/${i.symbolName}/${i.symbolName}.kicad_sym --output ${this.mpn}.lbr`,
`# then: adom-wiki pr create ${this._pageRef()} ... (additive: symbols/ only)`,
].join('\n');
}
_pageRef() { const m = /^https?:\/\/[^/]+\/([^/]+)\/([^/]+)\/render\//.exec(this.base); return m ? m[1] + '/' + m[2] : '<owner>/<slug>'; }
_defaultSymName() { const c = this.pageMeta; return (c && c.symbolFile) || (this.mpn.toLowerCase() + '.kicad_sym'); }
_download(name, text) {
try {
const blob = new Blob([text], { type: 'text/plain' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name;
this.shadowRoot.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
this._toast('Downloading ' + name);
} catch (e) {
const pre = this.shadowRoot.getElementById('pre'); pre.hidden = false; pre.textContent = text;
this._toast('Download blocked here; the file is shown below to copy');
}
}
_toast(t) { const el = this.shadowRoot.getElementById('toast'); if (!el) return; el.textContent = t; el.classList.add('on'); clearTimeout(this._tt); this._tt = setTimeout(() => el.classList.remove('on'), 2200); }
attributeChangedCallback() { if (this._init && this.pins) this._render(); }
}
function descNote(s, pins) {
const grouped = s.layout.endsWith('g');
const shape = s.layout.startsWith('sq') ? (grouped ? '4-side square symbol' : '4-side square symbol, pins in package order') : '2-column symbol';
const parts = [shape]; if (grouped) parts.push('pins grouped by function'); if (s.centred) parts.push('chip name centred');
return parts.join(', ');
}
if (!customElements.get('adom-symbol-variants')) customElements.define('adom-symbol-variants', AdomSymbolVariants);
})();