app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
← 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
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+ };+})();