master
Colby Knox Widgets: safe to render inline in a wiki README (adom/wiki#229) 4408302 7d ago
/*!
 * <adom-molecule> — the ganged-component widget for a molecule (a reusable sub-circuit).
 * Shows the molecule's parts as a ganged set + how they connect (nets). Hover a net to
 * light every part on it; hover a part to light its nets — the "ganged" relationship.
 *
 *   <adom-molecule src="molecule.json"></adom-molecule>
 *   el.data = {
 *     name, mpn?,
 *     components: [ { ref, mpn, value?, package?, type? } ],   // type: mcu|cap|res|xtal|ic|conn|diode|other
 *     nets:       [ { name, pins: [ {ref, pin} ] } ]
 *   }
 * Self-contained, theme-aware, shares the adom-lbr lbr-hover bus (posts a part's ref).
 */
(() => {
  const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
  const TC = { mcu: '#b57cff', ic: '#4c9dff', cap: '#00d3c8', res: '#ffb454', xtal: '#4c9dff', diode: '#ff5c5c', conn: '#e0913a', other: '#8b97ac' };
  const TLAB = { mcu: 'MCU', ic: 'IC', cap: 'cap', res: 'res', xtal: 'xtal', diode: 'diode', conn: 'conn', other: '' };
  function inferType(c) {
    if (c.type) return c.type;
    const r = String(c.ref || '').toUpperCase();
    if (r[0] === 'U') return /STM|MCU|ESP|ATMEGA|RP2/i.test(c.mpn || '') ? 'mcu' : 'ic';
    if (r[0] === 'C') return 'cap'; if (r[0] === 'R') return 'res'; if (r[0] === 'Y' || r[0] === 'X') return 'xtal';
    if (r[0] === 'D') return 'diode'; if (r[0] === 'J' || r[0] === 'P') return 'conn';
    return 'other';
  }

  class AdomMolecule extends HTMLElement {
    set data(d) { this._data = d; if (this.shadowRoot) this._render(); }
    get data() { return this._data; }
    async connectedCallback() {
      // A host page that pins its color-scheme (the wiki is dark-only) decides the theme; inline, prefers-color-scheme
      // would follow the reader's OS instead (a frame inherits the embedding page's scheme).
      if (!this.hasAttribute('theme')) { const cs = getComputedStyle(document.documentElement).colorScheme.trim(); if (/^(only )?(dark|light)( only)?$/.test(cs)) this.setAttribute('theme', cs.replace(/only|\s/g, '')); }
      if (this._init) return; this._init = true;
      this.attachShadow({ mode: 'open' });
      this._self = 'm' + Math.random().toString(36).slice(2);
      this._bus = ('BroadcastChannel' in window) ? new BroadcastChannel('lbr-hover') : null;
      if (!this._data) {
        const inl = this.querySelector('script[type="application/json"]');
        if (inl) { try { this._data = JSON.parse(inl.textContent); } catch (e) { /* */ } }
        const src = this.getAttribute('src');
        if (!this._data && src) { try { this._data = await (await fetch(src)).json(); } catch (e) { this._err = 'could not load ' + src; } }
      }
      this._render();
    }
    disconnectedCallback() { try { this._bus && this._bus.close(); } catch (_) { /* */ } }

    _render() {
      const d = this._data, root = this.shadowRoot;
      if (this._err || !d || !Array.isArray(d.components)) {
        root.innerHTML = `<style>${this._css()}</style><div class="card"><div class="empty">${esc(this._err || 'no molecule data')}</div></div>`;
        return;
      }
      const comps = d.components.map(c => ({ ...c, type: inferType(c) }));
      const nets = Array.isArray(d.nets) ? d.nets : [];
      const netOf = new Map(); // ref -> [net names]
      nets.forEach(n => (n.pins || []).forEach(p => { const a = netOf.get(p.ref) || []; if (!a.includes(n.name)) a.push(n.name); netOf.set(p.ref, a); }));
      root.innerHTML = `<style>${this._css()}</style>
        <div class="card">
          <div class="hd"><span class="mpn">${esc(d.name || d.mpn || 'molecule')}</span><span class="count">${comps.length} parts · ${nets.length} nets</span></div>
          <div class="readout" data-readout>hover a part or net&nbsp;→</div>
          <div class="gang">${comps.map(c => `
            <div class="part" data-ref="${esc(c.ref)}" style="--c:${TC[c.type] || TC.other}">
              <span class="ref">${esc(c.ref)}</span>
              <span class="val">${esc(c.value || c.mpn || '')}</span>
              <span class="meta">${esc(c.package || '')}${TLAB[c.type] ? ' · ' + TLAB[c.type] : ''}</span>
            </div>`).join('')}</div>
          <div class="nets">${nets.map(n => `<div class="net" data-net="${esc(n.name)}" data-refs="${esc((n.pins || []).map(p => p.ref).join(','))}">
            <span class="nn">${esc(n.name)}</span><span class="nc">${(n.pins || []).length} pins</span>
            <span class="nr">${esc([...new Set((n.pins || []).map(p => p.ref))].join(' '))}</span></div>`).join('')}</div>
        </div>`;
      this._wire(netOf);
    }

    _wire(netOf) {
      const root = this.shadowRoot, ro = root.querySelector('[data-readout]');
      const parts = [...root.querySelectorAll('.part')], netEls = [...root.querySelectorAll('.net')];
      const clear = () => { parts.forEach(p => p.classList.remove('hot', 'dim')); netEls.forEach(n => n.classList.remove('hot', 'dim')); ro.innerHTML = 'hover a part or net&nbsp;→'; this._post(null); };
      const litParts = refs => { const set = new Set(refs); parts.forEach(p => { p.classList.toggle('hot', set.has(p.dataset.ref)); p.classList.toggle('dim', !set.has(p.dataset.ref)); }); };
      const litNets = names => { const set = new Set(names); netEls.forEach(n => { n.classList.toggle('hot', set.has(n.dataset.net)); n.classList.toggle('dim', !set.has(n.dataset.net)); }); };
      netEls.forEach(n => {
        n.onmouseenter = () => { const refs = (n.dataset.refs || '').split(',').filter(Boolean); litParts(refs); n.classList.add('hot'); netEls.forEach(x => x !== n && x.classList.add('dim')); ro.innerHTML = `<b>${esc(n.dataset.net)}</b> · ${refs.length} pins on ${new Set(refs).size} parts`; };
        n.onmouseleave = clear;
      });
      parts.forEach(p => {
        p.onmouseenter = () => { const names = netOf.get(p.dataset.ref) || []; litNets(names); p.classList.add('hot'); parts.forEach(x => x !== p && x.classList.add('dim')); ro.innerHTML = `<b>${esc(p.dataset.ref)}</b> · on ${names.length ? names.join(', ') : 'no nets'}`; this._post(p.dataset.ref); };
        p.onmouseleave = clear;
      });
    }
    _post(ref) { const m = { type: 'lbr-hover', ref: ref, mpn: (this._data && this._data.name) || '', src: this._self }; try { this._bus && this._bus.postMessage(m); } catch (_) { /* */ } try { if (/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname)) window.parent.postMessage(m, '*'); } catch (_) { /* */ } }

    _css() {
      return `
      :host{display:block;--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc}
      @media (prefers-color-scheme:light){:host{--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88}}
      :host([theme="dark"]){--bg:#0d1420;--panel:#121a26;--line:#26324a;--line2:#33425f;--text:#dbe2ec;--dim:#8b97ac;--dim2:#66738c;--accent:#00e6dc}
      :host([theme="light"]){--bg:#f6f8fc;--panel:#fff;--line:#dbe2ee;--line2:#c3cede;--text:#1a2230;--dim:#5a6678;--dim2:#8592a6;--accent:#0d8f88}
      *{box-sizing:border-box}
      .card{background:var(--bg);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text);font:14px -apple-system,'Segoe UI',Roboto,Arial,sans-serif}
      .hd{display:flex;align-items:baseline;gap:10px;margin-bottom:8px}
      .hd .mpn{font:700 16px ui-monospace,'SF Mono',Menlo,monospace} .hd .count{margin-left:auto;font:12px ui-monospace,monospace;color:var(--dim2)}
      .readout{height:22px;display:flex;align-items:center;font:600 12.5px ui-monospace,monospace;color:var(--dim);margin-bottom:10px}
      .readout b{color:var(--accent)}
      .gang{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}
      .part{border:1px solid var(--line2);border-left:3px solid var(--c);border-radius:9px;padding:7px 11px;min-width:88px;background:var(--panel);transition:opacity .1s}
      .part .ref{font:700 12px ui-monospace,monospace;color:var(--c)} .part .val{display:block;font:600 12.5px ui-monospace,monospace;margin-top:1px} .part .meta{display:block;font-size:10.5px;color:var(--dim2);margin-top:1px}
      .part.hot{box-shadow:0 0 0 1px var(--c);background:color-mix(in srgb,var(--c) 10%,var(--panel))} .part.dim{opacity:.32}
      .nets{border-top:1px solid var(--line);padding-top:8px}
      .net{display:flex;align-items:center;gap:10px;padding:4px 8px;border-radius:6px;font:12px ui-monospace,monospace;cursor:default}
      .net .nn{color:var(--text);font-weight:700;min-width:70px} .net .nc{color:var(--dim2);font-size:11px} .net .nr{color:var(--dim);margin-left:auto;font-size:11px;text-align:right}
      .net.hot{background:color-mix(in srgb,var(--accent) 12%,transparent)} .net.hot .nn{color:var(--accent)} .net.dim{opacity:.34}
      .empty{color:var(--dim2);padding:26px;text-align:center;font-family:ui-monospace,monospace}`;
    }
  }
  if (!customElements.get('adom-molecule')) customElements.define('adom-molecule', AdomMolecule);
})();