app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
← Commit history
1.1.1: embed export gets live-SymView pin hovers (highlight + tooltip); fixes embed svg selection (pan/zoom)
6 files changed
+118−5
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-symbol"-version = "1.1.0"+version = "1.1.1" edition = "2021" description = "Create and preview KiCad/Fusion 360 schematic symbols with interactive viewer"
VERSION+1−1@@ -1 +1 @@-1.1.0+1.1.1
bin/adom-symbol⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-symbol",- "version": "1.1.0",+ "version": "1.1.1", "type": "app", "description": "KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360", "dependencies": {
src/embed.html+105−2@@ -40,6 +40,19 @@ .pin.on .d{display:block} .empty{color:var(--muted);font-size:12px;padding:14px} @media(max-width:620px){aside{flex-basis:200px}}+ /* pin hover — highlight + tooltip, parity with the live SymView */+ .vp svg text,.vp svg g.stroked-text{pointer-events:none}+ .hit{fill:rgba(0,230,220,0);stroke:none;cursor:pointer}+ .vp svg g.stroked-text.hl path{stroke:var(--accent)!important;fill:var(--accent)!important}+ .vp svg path.hl{stroke:var(--accent)!important;stroke-width:.22!important}+ #pinmark{pointer-events:none;fill:rgba(0,230,220,.18);stroke:var(--accent);stroke-width:.12;opacity:0;transition:opacity .1s}+ #tip{position:fixed;z-index:9999;max-width:300px;background:rgba(22,27,34,.97);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(0,184,177,.34);border-radius:10px;padding:9px 12px;color:var(--text);font-size:12px;line-height:1.5;pointer-events:none;opacity:0;transition:opacity .12s;box-shadow:0 12px 40px rgba(0,0,0,.55)}+ #tip.show{opacity:1}+ #tip .r1{display:flex;align-items:baseline;gap:8px;justify-content:space-between}+ #tip .nm{font-weight:700;font-size:13px}+ #tip .pn{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:11px;color:var(--muted);white-space:nowrap}+ #tip .badge{display:inline-block;margin-top:5px;font-size:10px;font-weight:700;padding:2px 7px;border-radius:5px;letter-spacing:.2px}+ #tip .desc{margin-top:6px;color:#aeb6c2;font-size:11.5px} </style></head> <body> <header><span class="dot"></span><span class="b">adom-symbol <span>· interactive</span></span><span class="nm">__NAME__</span></header>@@ -60,10 +73,13 @@ <div class="pins" id="pins"></div> </aside> </main>+ <div id="tip"></div> <script>-var PINS=__PINS__;+var PINS=__PINS__, OFF=__OFFSET__; (function(){- var vp=document.getElementById('vp'), svg=vp.querySelector('svg');+ // The symbol svg is the DIRECT child of #vp; querySelector('svg') alone would+ // grab a toolbar button's zoom icon (also broke pan/zoom before this fix).+ var vp=document.getElementById('vp'), svg=vp.querySelector(':scope > svg')||vp.querySelector('svg'); 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+')'; }@@ -93,12 +109,99 @@ var PINS=__PINS__; +'<span class="ty" style="color:'+col+';border-color:'+col+'66">'+esc(p.type||'')+'</span></div>' +(p.desc?'<div class="d">'+esc(p.desc)+'</div>':''); el.onclick=function(){ el.classList.toggle('on'); };+ el.addEventListener('mouseenter',function(){ highlightPin(p); });+ el.addEventListener('mouseleave',function(){ clearHL(); }); box.appendChild(el); }); } function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c];}); } document.getElementById('q').addEventListener('input',function(e){ render(e.target.value); }); render('');++ // ── pin hover: highlight + tooltip on the SVG, parity with the live SymView ──+ 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'); }); });+ 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'; }+ }+ function clearHL(){+ if(svg) svg.querySelectorAll('.hl').forEach(function(e){ e.classList.remove('hl'); });+ var m=document.getElementById('pinmark'); if(m) m.style.opacity='0';+ }+ function showTip(p,x,y){+ var col=p.color||'#8b949e';+ var h='<div class="r1"><span class="nm">'+esc(p.name||'(unnamed)')+'</span><span class="pn">Pin '+esc(p.number)+'</span></div>';+ if(p.type) h+='<span class="badge" style="background:'+col+'22;color:'+col+';border:1px solid '+col+'44">'+esc(p.type)+'</span>';+ if(p.desc) h+='<div class="desc">'+esc(p.desc)+'</div>';+ tip.innerHTML=h; tip.classList.add('show'); posTip(x,y);+ }+ function posTip(x,y){+ var w=tip.offsetWidth||260, hh=tip.offsetHeight||110, tx=x+14, ty=y+14;+ if(tx+w>innerWidth-14) tx=x-w-14;+ if(ty+hh>innerHeight-14) ty=y-hh-14;+ 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;+ 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);+ }+ });+ }+ buildHits();+ apply(); })(); </script>
src/main.rs+10@@ -452,14 +452,24 @@ pub(crate) fn build_embed_html(name: &str, svg: &str, meta: &parse::SymbolMeta) serde_json::json!({ "number": p.number, "name": p.name, "type": p.r#type, "color": p.color, "desc": p.desc,+ // geometry so the embed can place pin-hover hit zones exactly+ // where the live SymView does (KiCad symbol-local mm, Y-up).+ "x": p.x, "y": p.y, "angle": p.angle, "length": p.length, }) }) .collect(); let pins_json = serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into());+ // The symbol->SVG-unit offset that aligns pin geometry to the rendered glyphs+ // (same helper the live server hands the app as `state.offset`).+ let offset_json = match parse::compute_offset(svg, &meta.pins) {+ Some((x, y)) => format!("{{\"x\":{x},\"y\":{y}}}"),+ None => "null".into(),+ }; include_str!("embed.html") .replace("__NAME__", &esc(name)) .replace("__SVG__", svg) .replace("__PINS__", &pins_json)+ .replace("__OFFSET__", &offset_json) } fn fetch_lib_from_service(library: &str) -> Result<String> {