← Commit history

1.1.5: shared viewer_core.js engine (app+embed, no more drift); clip KiCad worksheet frame out of the render; in-app 'Open interactive viewer' (/embed route + auto-save); async embed regen; audit hardening

John Lauer ·b7491da9de ·3mo ago ·parent 70f7c2d
10 files changed +202−75
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-symbol"-version = "1.1.4"+version = "1.1.5" edition = "2021" description = "Create and preview KiCad/Fusion 360 schematic symbols with interactive viewer" 
VERSION+1−1
@@ -1 +1 @@-1.1.4+1.1.5
bin/adom-symbol
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-symbol",-  "version": "1.1.4",+  "version": "1.1.5",   "type": "app",   "description": "KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360",   "dependencies": {
src/app.html+11−15
@@ -336,6 +336,10 @@         <button data-v="orcad">OrCAD</button>       </div>     </div>+    <div class="srow">+      <span class="slab" data-tooltip="Open the standalone INTERACTIVE viewer for this symbol in a new tab — the same read-only pan/zoom + pin-hover viewer you'd embed in a page or datasheet. It reflects the layout you see right now, and is auto-saved next to the chip (<mpn>-embed.html) on every Save.">Interactive viewer</span>+      <button class="tbtn" id="openEmbed" data-tooltip="Open this symbol's interactive HTML viewer in a new browser tab. It shows exactly what the shared/embedded viewer looks like — pan, zoom, hover any pin. Auto-regenerated on every Save.">⧉ Open viewer ↗</button>+    </div>     <div class="srow" style="justify-content:flex-end;flex-wrap:wrap;gap:6px">       <button class="tbtn" id="undoBtn" disabled data-tooltip="Undo the last change to THIS symbol — layout, 3D look, chip size, auto-place, or a label drag. Also Ctrl/⌘+Z. Steps back one edit at a time.">↶ Undo</button>       <button class="tbtn" id="applyAll" data-tooltip="Saves THIS chip's current Layout + 3D look as the per-chip look for every chip in the list right now — that's the current project filter ('All chips' = the whole library; pick a project first to scope it). It only writes each chip's info.json (no re-fetch, no regen), overrides their own saved look, and asks you to confirm with the exact count. Undo any chip afterward with Reset.">Apply to all</button>@@ -374,6 +378,7 @@   <div id="busy"><span class="spin"></span>rendering…</div>   <div id="dtt"></div> +  <script>/*__VIEWER_CORE__*/</script>   <script>   (function(){     const post=(lvl,a)=>{try{navigator.sendBeacon('console',JSON.stringify({level:lvl,msg:a.map(x=>{try{return typeof x==='string'?x:JSON.stringify(x)}catch{return String(x)}}).join(' ')}))}catch(e){}};@@ -595,21 +600,9 @@   document.getElementById('hitsb').onclick=e=>{hitsOn=!hitsOn;e.currentTarget.classList.toggle('active',hitsOn);document.querySelectorAll('.hit').forEach(h=>h.classList.toggle('show',hitsOn))};    // Smooth, delta-PROPORTIONAL zoom. A fixed factor per event makes the-  // trackpad leap: one gentle two-finger nudge fires a burst of tiny wheel-  // events and a fixed 1.12× compounds many times. Instead derive the factor-  // from how much was actually scrolled (exp → smooth + reversible), normalise-  // across device units, and clamp a single event so a chunky mouse notch-  // can't jump while a feather trackpad touch stays barely-there.-  const ZOOM_K=0.0022;   // sensitivity: lower = gentler-  stageWrap.addEventListener('wheel',e=>{-    e.preventDefault();-    let d=e.deltaY;-    if(e.deltaMode===1)d*=16;                  // lines → px-    else if(e.deltaMode===2)d*=stageWrap.clientHeight; // pages → px-    d=Math.max(-50,Math.min(50,d));            // cap one event's contribution-    const r=stageWrap.getBoundingClientRect();-    zoomAt(e.clientX-r.left,e.clientY-r.top,Math.exp(-d*ZOOM_K));-  },{passive:false});+  // Wheel/trackpad zoom feel lives in the SHARED viewer engine (viewer_core.js),+  // which the embed export shares verbatim — one source of truth, no drift.+  AdomSymViewer.attachWheelZoom(stageWrap, zoomAt);   let dragging=false,dsx=0,dsy=0,pStartX=0,pStartY=0;   stageWrap.addEventListener('mousedown',e=>{if(e.target.closest('.hit'))return;dragging=true;stageWrap.classList.add('drag');dsx=e.clientX;dsy=e.clientY;pStartX=panX;pStartY=panY});   window.addEventListener('mousemove',e=>{@@ -1138,6 +1131,9 @@   let regenOnSwitch=false;try{regenOnSwitch=localStorage.getItem('adomSymRegenOnSwitch')==='1'}catch(e){}   (function(){const cb=document.getElementById('regenOnSwitch');if(cb){cb.checked=regenOnSwitch;cb.addEventListener('change',()=>{regenOnSwitch=cb.checked;try{localStorage.setItem('adomSymRegenOnSwitch',regenOnSwitch?'1':'0')}catch(e){};if(regenOnSwitch)doRegen();});}})();   seg('segSize',v=>{chip3dSize=v;stickyChipSize=v;try{localStorage.setItem('adomSymChipSize',v)}catch(e){};if(window._lastState)apply3d(window._lastState);markDirty()});+  // Open the standalone interactive viewer for the current symbol in a new tab.+  // Server /embed renders the CURRENT layout on demand (always in sync).+  document.getElementById('openEmbed').onclick=()=>{ showToast('Opening interactive viewer…','warn'); window.open('embed','_blank'); };   // Send to desktop EDA tools   const toast=document.getElementById('toast');let toastT=null;   function showToast(msg,kind){toast.textContent=msg;toast.className=kind||'';toast.classList.add('show');clearTimeout(toastT);toastT=setTimeout(()=>toast.classList.remove('show'),4500)}
src/embed.html+14−55
@@ -139,6 +139,7 @@   </main>   <footer>__FOOTER__</footer>   <div id="tip"></div>+<script>/*__VIEWER_CORE__*/</script> <script> var PINS=__PINS__, OFF=__OFFSET__; (function(){@@ -148,10 +149,11 @@ var PINS=__PINS__, OFF=__OFFSET__;   if(svg){ svg.removeAttribute('width'); svg.removeAttribute('height'); }   var scale=1,tx=0,ty=0;   function apply(){ if(svg) svg.style.transform='translate('+tx+'px,'+ty+'px) scale('+scale+')'; }-  function zoomAt(cx,cy,f){ tx=cx-(cx-tx)*f; ty=cy-(cy-ty)*f; scale*=f; apply(); }+  // pointer-anchored zoom with the SHARED scale clamp (same limits as the app).+  function zoomAt(cx,cy,f){ var ns=AdomSymViewer.clampScale(scale*f); var r=ns/scale; tx=cx-(cx-tx)*r; ty=cy-(cy-ty)*r; scale=ns; apply(); }   function fit(){ scale=1;tx=0;ty=0;apply(); }-  vp.addEventListener('wheel',function(e){ e.preventDefault(); var r=vp.getBoundingClientRect();-    zoomAt(e.clientX-r.left,e.clientY-r.top, e.deltaY<0?1.12:1/1.12); },{passive:false});+  // Wheel/trackpad zoom feel comes from the SHARED engine — identical to SymView.+  AdomSymViewer.attachWheelZoom(vp, zoomAt);   var drag=false,lx=0,ly=0;   vp.addEventListener('pointerdown',function(e){ if(e.target.closest('.tools'))return; /* let toolbar buttons receive their click */ drag=true;lx=e.clientX;ly=e.clientY;vp.classList.add('grab');vp.setPointerCapture(e.pointerId); });   vp.addEventListener('pointermove',function(e){ if(!drag)return; tx+=e.clientX-lx;ty+=e.clientY-ly;lx=e.clientX;ly=e.clientY;apply(); });@@ -187,22 +189,9 @@ var PINS=__PINS__, OFF=__OFFSET__;   var NS='http://www.w3.org/2000/svg';   var tip=document.getElementById('tip');   var textMap=new Map();-  function norm(s){ return String(s||'').replace(/[~{}]/g,'').trim(); }-  // Pair each invisible <text opacity="0"> with the visible <g class="stroked-text">-  // glyph that follows it in document order (works for rotated top/bottom pins too).-  function glyphLabels(){-    var out=[], pending=null;-    if(!svg) return out;-    svg.querySelectorAll('text, g.stroked-text').forEach(function(n){-      if(n.classList && n.classList.contains('stroked-text')){-        if(pending){ var k=norm(pending.textContent); if(k) out.push({k:k,group:n}); pending=null; }-      } else if(n.getAttribute && n.getAttribute('opacity')==='0'){ pending=n; }-    });-    return out;-  }   function highlightPin(p){     clearHL();-    [p.name,p.number].forEach(function(k){ var a=textMap.get(norm(k)); if(a) a.forEach(function(g){ g.classList.add('hl'); }); });+    [p.name,p.number].forEach(function(k){ var a=textMap.get(AdomSymViewer.norm(k)); if(a) a.forEach(function(g){ g.classList.add('hl'); }); });     var m=document.getElementById('pinmark');     if(m && OFF){ m.setAttribute('cx',OFF.x+p.x); m.setAttribute('cy',OFF.y-p.y); m.setAttribute('r',0.7); m.style.opacity='1'; }   }@@ -224,48 +213,18 @@ var PINS=__PINS__, OFF=__OFFSET__;     tip.style.left=Math.max(8,tx)+'px'; tip.style.top=Math.max(8,ty)+'px';   }   function hideTip(){ tip.classList.remove('show'); clearHL(); }-  // One transparent hit-rect per pin label (number + name), matched to each pin's-  // glyph by proximity to the pin tip so duplicate names (e.g. many GND) resolve-  // to the right pin. Rects live in SVG space so they pan/zoom with the drawing.-  function buildHits(){-    if(!svg || !OFF) return;+  // Build the pin hit-zones with the SHARED engine — the exact glyph-pairing ++  // hit-box code the app's SymView uses. The embed only supplies the tooltip.+  if(svg && OFF){     if(!document.getElementById('pinmark')){ var pm=document.createElementNS(NS,'circle'); pm.id='pinmark'; svg.appendChild(pm); }-    var gl=glyphLabels();-    textMap=new Map();-    gl.forEach(function(o){ if(!textMap.has(o.k)) textMap.set(o.k,[]); textMap.get(o.k).push(o.group); });-    var labels=[];-    gl.forEach(function(o){ var b; try{ b=o.group.getBBox(); }catch(e){ return; } if(!b||!b.width) return;-      labels.push({k:o.k, box:b, cx:b.x+b.width/2, cy:b.y+b.height/2, used:false}); });-    function pick(k,ax,ay){ k=norm(k); var best=null,bd=1e9;-      labels.forEach(function(l){ if(l.used||l.k!==k) return; var d=Math.hypot(l.cx-ax,l.cy-ay); if(d<bd){ bd=d; best=l; } });-      if(best) best.used=true; return best; }     var hideT=null;-    function addBox(b,p){-      var pad=0.45, r=document.createElementNS(NS,'rect');-      r.setAttribute('class','hit');-      r.setAttribute('x',b.x-pad); r.setAttribute('y',b.y-pad);-      r.setAttribute('width',b.width+pad*2); r.setAttribute('height',b.height+pad*2); r.setAttribute('rx',.3);-      r.addEventListener('mouseenter',function(ev){ clearTimeout(hideT); showTip(p,ev.clientX,ev.clientY); highlightPin(p); });-      r.addEventListener('mousemove',function(ev){ if(tip.classList.contains('show')) posTip(ev.clientX,ev.clientY); });-      r.addEventListener('mouseleave',function(){ clearTimeout(hideT); hideT=setTimeout(hideTip,45); });-      svg.appendChild(r);-    }-    PINS.forEach(function(p){-      var tipX=OFF.x+p.x, tipY=OFF.y-p.y, L=p.length||2.54, dx=0, dy=0;-      if(p.angle===0) dx=1; else if(p.angle===180) dx=-1; else if(p.angle===90) dy=-1; else if(p.angle===270) dy=1;-      var nl=pick(p.number, tipX+dx*L*0.5, tipY+dy*L*0.5-(dx!==0?0.9:0));   // number: over the wire-      var ml=pick(p.name,   tipX+dx*(L+2), tipY+dy*(L+2));                  // name: inside the body-      if(nl) addBox(nl.box,p);-      if(ml) addBox(ml.box,p);-      // Discrete parts hide pin name+number → no glyphs. Fall back to a box on the wire.-      if(!nl && !ml){-        var ex=tipX+dx*L, ey=tipY+dy*L, HT=1.0;-        addBox({x:Math.min(tipX,ex)-(dx===0?HT:0.3), y:Math.min(tipY,ey)-(dy===0?HT:0.3),-                width:Math.abs(ex-tipX)+(dx===0?HT*2:0.6), height:Math.abs(ey-tipY)+(dy===0?HT*2:0.6)}, p);-      }+    var hits=AdomSymViewer.buildPinHits(svg, PINS, OFF, NS, {+      enter:function(p,ev){ clearTimeout(hideT); showTip(p,ev.clientX,ev.clientY); highlightPin(p); },+      move:function(ev){ if(tip.classList.contains('show')) posTip(ev.clientX,ev.clientY); },+      leave:function(){ clearTimeout(hideT); hideT=setTimeout(hideTip,45); }     });+    textMap=hits.textMap;   }-  buildHits();    apply(); })();
src/main.rs+1
@@ -508,6 +508,7 @@ pub(crate) fn build_embed_html(name: &str, svg: &str, meta: &parse::SymbolMeta)         foot.push_str(&format!("<span class=\"it descit\"><span class=\"k\">DESC</span><span class=\"v\">{}</span></span>", esc(&d)));     }     include_str!("embed.html")+        .replace("/*__VIEWER_CORE__*/", include_str!("viewer_core.js"))         .replace("__NAME__", &esc(name))         .replace("__SVG__", svg)         .replace("__PINS__", &pins_json)
src/render.rs+27−1
@@ -38,7 +38,33 @@ pub fn render_symbol_svg(kicad_sym: &str, symbol_name: &str) -> Result<String> {     // Drop KiCad 10's warm-white page background so the dark canvas shows.     let svg = svg.replace("fill:#F5F4EF", "fill:none").replace("stroke:#F5F4EF", "stroke:none");     let cropped = crop_to_symbol(&svg, &block);-    Ok(crate::theme::apply_dark_theme_to_symbol(&cropped))+    let themed = crate::theme::apply_dark_theme_to_symbol(&cropped);+    // Hard-clip everything to the symbol's viewBox so KiCad's A4 drawing sheet+    // (border + "Sheet/File/Title/Size" title block, drawn out at the page edges)+    // can NEVER appear when the viewer zooms out. We want a symbol, not a sheet.+    Ok(clip_to_viewbox(&themed))+}++/// Wrap the SVG body in a userSpace clip = its own viewBox, so nothing painted+/// outside the symbol's cropped box (i.e. the KiCad worksheet frame + title+/// block) is ever visible — even under the viewer's CSS zoom-out transform.+fn clip_to_viewbox(svg: &str) -> String {+    let vb = Regex::new(r#"viewBox="(-?[\d.]+) (-?[\d.]+) (-?[\d.]+) (-?[\d.]+)""#).unwrap();+    let (x, y, w, h) = match vb.captures(svg) {+        Some(c) => (c[1].to_string(), c[2].to_string(), c[3].to_string(), c[4].to_string()),+        None => return svg.to_string(),+    };+    let open = format!(+        "<defs><clipPath id=\"asym-crop\" clipPathUnits=\"userSpaceOnUse\"><rect x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\"/></clipPath></defs><g clip-path=\"url(#asym-crop)\">"+    );+    match svg.find('>') {+        Some(pos) => {+            let (head, tail) = svg.split_at(pos + 1);+            let body = tail.strip_suffix("</svg>").unwrap_or(tail);+            format!("{head}{open}{body}</g></svg>")+        }+        None => svg.to_string(),+    } }  fn extract_symbol_block(content: &str, name: &str) -> Option<String> {
src/server.rs+53−1
@@ -398,6 +398,29 @@ fn list_chips() -> Vec<(String, bool)> {     out } +/// Build the standalone interactive embed HTML for the CURRENTLY-shown symbol —+/// the exact layout on screen (source/pins/3D/labels), composited with the 3D+/// outline, so "open interactive viewer" always reflects what the studio shows.+fn build_embed_for(ks: &str, dir: &str, mpn: &str) -> Option<String> {+    if ks.trim().is_empty() { return None; }+    let render_name = crate::first_symbol_name(ks)?;+    let mut svg = crate::render::render_symbol_svg(ks, &render_name).ok()?;+    if let Some(p) = crate::render::default_white_outline_in(std::path::Path::new(dir), mpn) {+        let frac = crate::render::chip_size_frac("large");+        if let Ok(s) = crate::render::composite_iso_into_symbol(&svg, ks, &render_name, &p, frac) {+            svg = s;+        }+    }+    let meta = crate::parse::parse_kicad_sym(ks, &render_name);+    Some(crate::build_embed_html(&render_name, &svg, &meta))+}++fn build_current_embed() -> Option<String> {+    let (dir, mpn) = { let md = MODEL.lock().unwrap(); let m = md.as_ref()?; (m.dir.clone(), m.mpn.clone()) };+    let ks = { let st = STATE.lock().unwrap(); st.as_ref()?.kicad_sym.clone() };+    build_embed_for(&ks, &dir, &mpn)+}+ /// Persist the current symbol back to its library folder so chip-fetcher and /// adom-lbr see the latest: write the canonical `<mpn>.kicad_sym` (backing up the /// original vendor file once to `<mpn>.vendor.kicad_sym`) and re-render@@ -749,7 +772,8 @@ fn hd_post(body: &str) {  fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec<u8>>> {     match (method, url) {-        (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),+        (Method::Get, "/") | (Method::Get, "/index.html") =>+            html(&APP_HTML.replace("/*__VIEWER_CORE__*/", include_str!("viewer_core.js"))),         (Method::Get, "/favicon.svg") => svg(FAVICON),         (Method::Get, "/state") => json_resp(&state_json()),         (Method::Get, "/svg") => {@@ -759,6 +783,13 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             };             json_resp(&json!({ "svg": svg, "rev": rev }).to_string())         }+        // Standalone interactive viewer for the CURRENT symbol — opened in a new+        // tab by the app's "Open interactive viewer" button. Always live: it+        // reflects the layout on screen right now.+        (Method::Get, "/embed") => match build_current_embed() {+            Some(h) => html(&h),+            None => json_err(404, "no symbol loaded"),+        },         _ if url.starts_with("/3d/") => {             let mode = &url["/3d/".len()..];             let asset = STATE.lock().unwrap().as_ref().and_then(|s| s.three_d_assets.get(mode).cloned()).unwrap_or_default();@@ -839,6 +870,27 @@ fn route(method: &Method, url: &str, body: &str) -> Response<std::io::Cursor<Vec             let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);             let chip_size = v.get("chipSize").and_then(|x| x.as_str()).unwrap_or("large");             let saved = save_current(chip_size);+            // Keep the standalone interactive embed in sync with every Save — but+            // OFF the request thread (it re-renders via service-kicad, up to 60s).+            // Capture the just-saved symbol so a quick chip-switch can't race it.+            if saved {+                let dm = { let md = MODEL.lock().unwrap(); md.as_ref().map(|m| (m.dir.clone(), m.mpn.clone())) };+                let ks = { let st = STATE.lock().unwrap(); st.as_ref().map(|s| s.kicad_sym.clone()) };+                let cap = match (dm, ks) { (Some((dir, mpn)), Some(ks)) => Some((ks, dir, mpn)), _ => None };+                if let Some((ks, dir, mpn)) = cap {+                    std::thread::spawn(move || {+                        match build_embed_for(&ks, &dir, &mpn) {+                            Some(h) => {+                                let p = std::path::Path::new(&dir).join(format!("{mpn}-embed.html"));+                                if let Err(e) = std::fs::write(&p, h) {+                                    eprintln!("WARN: embed auto-write failed for {mpn}: {e}");+                                }+                            }+                            None => eprintln!("WARN: embed regen skipped for {mpn} (render failed)"),+                        }+                    });+                }+            }             json_resp(&json!({"ok": true, "saved": saved}).to_string())         }         (Method::Post, "/apply-all") => {
src/viewer_core.jsadded+93
@@ -0,0 +1,93 @@+// viewer_core.js — the ONE symbol-viewer engine, compiled into BOTH the live+// adom-symbol app (SymView) AND the standalone embed export. Neither file should+// reimplement pan/zoom or pin-hover; they call into here so they can never drift.+window.AdomSymViewer = (function(){+  var ZOOM_K = 0.0022;            // wheel sensitivity (lower = gentler). The feel.+  var MIN_SCALE = 0.05, MAX_SCALE = 60;++  function clampScale(s){ return Math.max(MIN_SCALE, Math.min(s, MAX_SCALE)); }++  // Device-independent wheel zoom: normalize deltaMode + cap one notch, then hand+  // the pointer-anchored factor to the host's zoomAt(cx, cy, factor). This is the+  // exact math the app has always used — the embed now shares it verbatim.+  function attachWheelZoom(el, zoomAt){+    el.addEventListener('wheel', function(e){+      e.preventDefault();+      var d = e.deltaY;+      if(e.deltaMode === 1) d *= 16;                  // lines  -> px+      else if(e.deltaMode === 2) d *= el.clientHeight; // pages -> px+      d = Math.max(-50, Math.min(50, d));              // cap one event's contribution+      var r = el.getBoundingClientRect();+      zoomAt(e.clientX - r.left, e.clientY - r.top, Math.exp(-d * ZOOM_K));+    }, { passive: false });+  }++  // ---- shared pin-hover engine (glyph pairing + hit-box geometry) ----+  function norm(s){ return String(s || '').replace(/[~{}]/g, '').trim(); }++  // Pair each invisible <text opacity="0"> with the visible <g class="stroked-text">+  // glyph that follows it in document order (handles rotated top/bottom pins).+  function glyphLabels(svg){+    var out = [], pending = null;+    if(!svg) return out;+    svg.querySelectorAll('text, g.stroked-text').forEach(function(n){+      if(n.classList && n.classList.contains('stroked-text')){+        if(pending){ var k = norm(pending.textContent); if(k) out.push({ k: k, group: n }); pending = null; }+      } else if(n.getAttribute && n.getAttribute('opacity') === '0'){ pending = n; }+    });+    return out;+  }++  // Build one transparent hit-rect per pin label (number + name), matched to each+  // pin's glyph by proximity to the pin tip (so duplicate names resolve to the+  // right pin). Rects are appended to `svg` in user space so they pan/zoom with it.+  //   pins:   [{number,name,x,y,angle,length,...}]+  //   offset: {x,y} symbol->svg-unit origin (state.offset)+  //   NS:     the svg namespace+  //   cb:     { enter(pin,ev), move(ev), leave(), click(pin,ev)? }+  // Returns { textMap } so the host can highlight a pin's glyphs on demand.+  function buildPinHits(svg, pins, offset, NS, cb){+    var textMap = new Map();+    if(!svg || !offset || !pins || !pins.length) return { textMap: textMap };+    cb = cb || {};+    var gl = glyphLabels(svg);+    gl.forEach(function(o){ if(!textMap.has(o.k)) textMap.set(o.k, []); textMap.get(o.k).push(o.group); });+    var labels = [];+    gl.forEach(function(o){ var b; try { b = o.group.getBBox(); } catch(e){ return; }+      if(!b || !b.width) return;+      labels.push({ k: o.k, box: b, cx: b.x + b.width/2, cy: b.y + b.height/2, used: false }); });+    function pick(k, ax, ay){ k = norm(k); var best = null, bd = 1e9;+      labels.forEach(function(l){ if(l.used || l.k !== k) return; var d = Math.hypot(l.cx - ax, l.cy - ay); if(d < bd){ bd = d; best = l; } });+      if(best) best.used = true; return best; }+    function addBox(b, p){+      var pad = 0.45, r = document.createElementNS(NS, 'rect');+      r.setAttribute('class', 'hit');+      r.setAttribute('x', b.x - pad); r.setAttribute('y', b.y - pad);+      r.setAttribute('width', b.width + pad*2); r.setAttribute('height', b.height + pad*2); r.setAttribute('rx', .3);+      r.addEventListener('mouseenter', function(ev){ if(cb.enter) cb.enter(p, ev); });+      r.addEventListener('mousemove', function(ev){ if(cb.move) cb.move(ev); });+      r.addEventListener('mouseleave', function(){ if(cb.leave) cb.leave(); });+      if(cb.click) r.addEventListener('click', function(ev){ cb.click(p, ev); });+      svg.appendChild(r);+    }+    pins.forEach(function(p){+      var tipX = offset.x + p.x, tipY = offset.y - p.y, L = p.length || 2.54, dx = 0, dy = 0;+      if(p.angle === 0) dx = 1; else if(p.angle === 180) dx = -1; else if(p.angle === 90) dy = -1; else if(p.angle === 270) dy = 1;+      var nl = pick(p.number, tipX + dx*L*0.5, tipY + dy*L*0.5 - (dx !== 0 ? 0.9 : 0));  // number: over the wire+      var ml = pick(p.name,   tipX + dx*(L+2), tipY + dy*(L+2));                          // name: inside the body+      if(nl) addBox(nl.box, p);+      if(ml) addBox(ml.box, p);+      if(!nl && !ml){                                                                      // discrete parts: no glyphs+        var ex = tipX + dx*L, ey = tipY + dy*L, HT = 1.0;+        addBox({ x: Math.min(tipX, ex) - (dx === 0 ? HT : 0.3), y: Math.min(tipY, ey) - (dy === 0 ? HT : 0.3),+                 width: Math.abs(ex - tipX) + (dx === 0 ? HT*2 : 0.6), height: Math.abs(ey - tipY) + (dy === 0 ? HT*2 : 0.6) }, p);+      }+    });+    return { textMap: textMap };+  }++  return {+    ZOOM_K: ZOOM_K, clampScale: clampScale, attachWheelZoom: attachWheelZoom,+    norm: norm, glyphLabels: glyphLabels, buildPinHits: buildPinHits+  };+})();