Skip to main content

candle_graph/
viewer.rs

1//! Dependency-free interactive HTML viewer for structure (+ optional dataflow) JSON.
2//!
3//! Embeds escaped JSON, CSS, and JS into one document. No CDN or network fetches.
4
5use serde_json::Value;
6
7/// Render a complete standalone HTML document from a structure report and optional dataflow JSON.
8pub fn render_html(structure: &Value, dataflow: Option<&Value>) -> String {
9    let structure_json = embed_json(structure);
10    let dataflow_json = match dataflow {
11        Some(v) => embed_json(v),
12        None => "null".to_string(),
13    };
14    let empty_attr = if dataflow.is_some() {
15        ""
16    } else {
17        r#" data-empty-dataflow="true""#
18    };
19
20    // Avoid `format!` — CSS/JS are brace-heavy. Concatenate around escaped payloads.
21    let mut html = String::with_capacity(
22        4096 + CSS.len() + JS.len() + structure_json.len() + dataflow_json.len(),
23    );
24    html.push_str("<!DOCTYPE html>\n<html lang=\"en\" data-viewer=\"candle-graph\">\n<head>\n");
25    html.push_str("<meta charset=\"utf-8\"/>\n");
26    html.push_str("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"/>\n");
27    html.push_str("<title>candle-graph viewer</title>\n<style>");
28    html.push_str(CSS);
29    html.push_str("</style>\n</head>\n<body>\n");
30    html.push_str("<a class=\"skip\" href=\"#canvas-pane\">Skip to canvas</a>\n");
31    html.push_str("<header class=\"top\" role=\"banner\">\n");
32    html.push_str("  <div class=\"brand\">candle-graph</div>\n");
33    html.push_str(
34        "  <div class=\"cov\" data-coverage role=\"status\" aria-live=\"polite\"></div>\n",
35    );
36    html.push_str("  <button type=\"button\" id=\"theme-btn\" data-theme-toggle aria-label=\"Toggle color theme\">Theme</button>\n");
37    html.push_str("</header>\n<div class=\"layout\" id=\"app\"");
38    html.push_str(empty_attr);
39    html.push_str(">\n");
40    html.push_str(
41        "  <nav class=\"pane\" data-pane=\"hierarchy\" aria-label=\"Module hierarchy\">\n",
42    );
43    html.push_str("    <div class=\"pane-h\">Modules</div>\n");
44    html.push_str("    <label class=\"sr\" for=\"mod-search\">Search modules</label>\n");
45    html.push_str("    <input id=\"mod-search\" data-module-search type=\"search\" placeholder=\"Search…\" autocomplete=\"off\"/>\n");
46    html.push_str("    <div id=\"module-tree\" data-module-tree tabindex=\"0\" role=\"tree\" aria-label=\"Modules\"></div>\n");
47    html.push_str("    <div class=\"diag\" data-diagnostics aria-label=\"Diagnostics\"></div>\n");
48    html.push_str("  </nav>\n");
49    html.push_str("  <main class=\"pane\" data-pane=\"canvas\" id=\"canvas-pane\" aria-label=\"Dataflow canvas\">\n");
50    html.push_str("    <div class=\"pane-h\">Dataflow</div>\n");
51    html.push_str("    <div class=\"canvas-wrap\" id=\"canvas-wrap\">\n");
52    html.push_str("      <div id=\"empty-dataflow\" class=\"empty\" data-empty-state hidden>\n");
53    html.push_str("        <p><strong>No dataflow graph</strong></p>\n");
54    html.push_str("        <p>Structure is shown in the module tree. Pass dataflow JSON to visualize tensors, ops, and gradient state.</p>\n");
55    html.push_str("      </div>\n");
56    html.push_str("      <svg id=\"dataflow-canvas\" data-canvas role=\"img\" aria-label=\"Dataflow graph\" tabindex=\"0\"></svg>\n");
57    html.push_str("    </div>\n");
58    html.push_str("    <div class=\"legend\" data-legend role=\"list\" aria-label=\"Gradient state legend\">\n");
59    html.push_str("      <span role=\"listitem\" data-legend-item=\"Trainable\" class=\"lg Trainable\">Trainable</span>\n");
60    html.push_str("      <span role=\"listitem\" data-legend-item=\"Frozen\" class=\"lg Frozen\">Frozen</span>\n");
61    html.push_str("      <span role=\"listitem\" data-legend-item=\"Differentiable\" class=\"lg Differentiable\">Differentiable</span>\n");
62    html.push_str("      <span role=\"listitem\" data-legend-item=\"Severed\" class=\"lg Severed\">Severed</span>\n");
63    html.push_str("      <span role=\"listitem\" data-legend-item=\"LayoutDependent\" class=\"lg LayoutDependent\">LayoutDependent</span>\n");
64    html.push_str("      <span role=\"listitem\" data-legend-item=\"Unknown\" class=\"lg Unknown\">Unknown</span>\n");
65    html.push_str("    </div>\n");
66    html.push_str("  </main>\n");
67    html.push_str("  <aside class=\"pane\" data-pane=\"inspector\" aria-label=\"Inspector\">\n");
68    html.push_str("    <div class=\"pane-h\">Inspector</div>\n");
69    html.push_str("    <dl id=\"inspector\" data-inspector>\n");
70    html.push_str("      <div><dt>Source</dt><dd data-field=\"source\">—</dd></div>\n");
71    html.push_str("      <div><dt>Shape</dt><dd data-field=\"shape\">—</dd></div>\n");
72    html.push_str("      <div><dt>Dtype</dt><dd data-field=\"dtype\">—</dd></div>\n");
73    html.push_str("      <div><dt>Builder root</dt><dd data-field=\"root\">—</dd></div>\n");
74    html.push_str("      <div><dt>Certainty</dt><dd data-field=\"certainty\">—</dd></div>\n");
75    html.push_str("      <div><dt>Grad state</dt><dd data-field=\"grad\">—</dd></div>\n");
76    html.push_str("      <div><dt>Label</dt><dd data-field=\"label\">—</dd></div>\n");
77    html.push_str("      <div><dt>Kind</dt><dd data-field=\"kind\">—</dd></div>\n");
78    html.push_str("    </dl>\n");
79    html.push_str("  </aside>\n");
80    html.push_str("</div>\n");
81    html.push_str("<script id=\"cg-structure\" type=\"application/json\">");
82    html.push_str(&structure_json);
83    html.push_str("</script>\n<script id=\"cg-dataflow\" type=\"application/json\">");
84    html.push_str(&dataflow_json);
85    html.push_str("</script>\n<script>");
86    html.push_str(JS);
87    html.push_str("</script>\n</body>\n</html>\n");
88    html
89}
90
91/// Serialize JSON and escape so embedded payload cannot close a surrounding `<script>` tag.
92pub fn embed_json(value: &Value) -> String {
93    escape_for_script(&value.to_string())
94}
95
96/// Escape text for safe inclusion inside HTML `<script>` content.
97///
98/// Replaces `<` with a JSON unicode escape so `</script>` in source strings cannot break out.
99pub fn escape_for_script(s: &str) -> String {
100    let mut out = String::with_capacity(s.len());
101    for ch in s.chars() {
102        match ch {
103            '<' => out.push_str("\\u003c"),
104            '>' => out.push_str("\\u003e"),
105            '\u{2028}' => out.push_str("\\u2028"),
106            '\u{2029}' => out.push_str("\\u2029"),
107            _ => out.push(ch),
108        }
109    }
110    out
111}
112
113const CSS: &str = r#"
114:root{
115  --bg:#f4f6f8;--fg:#1a1f24;--muted:#5a6570;--pane:#fff;--border:#c9d1d8;
116  --accent:#0b6e4f;--focus:#0b57d0;--hazard:#a33;--canvas:#eef2f5;
117  --Trainable:#0b6e4f;--Frozen:#5a6570;--Differentiable:#0b57d0;
118  --Severed:#a33;--LayoutDependent:#9a6700;--Unknown:#6e40c9;
119}
120[data-theme=dark]{
121  --bg:#12161a;--fg:#e7ecf1;--muted:#9aa7b2;--pane:#1b2229;--border:#31404c;
122  --accent:#3dd68c;--focus:#8ab4f8;--hazard:#ff8a80;--canvas:#0f1418;
123  --Trainable:#3dd68c;--Frozen:#9aa7b2;--Differentiable:#8ab4f8;
124  --Severed:#ff8a80;--LayoutDependent:#f0c000;--Unknown:#c4a7ff;
125}
126*{box-sizing:border-box}
127html,body{margin:0;height:100%;font:14px/1.4 "IBM Plex Sans",ui-sans-serif,system-ui,sans-serif;background:var(--bg);color:var(--fg)}
128.skip{position:absolute;left:-999px;top:0;background:var(--pane);padding:.5rem;z-index:9}
129.skip:focus{left:0}
130.top{display:flex;gap:.75rem;align-items:center;padding:.5rem .75rem;border-bottom:1px solid var(--border);background:var(--pane)}
131.brand{font-weight:700;letter-spacing:.02em}
132.cov{flex:1;color:var(--muted);font-size:12px}
133#theme-btn{border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:.25rem .6rem;cursor:pointer}
134.layout{display:grid;grid-template-columns:minmax(200px,1fr) minmax(280px,2.2fr) minmax(200px,1fr);gap:0;height:calc(100% - 42px);min-height:320px}
135.pane{display:flex;flex-direction:column;min-width:0;min-height:0;border-right:1px solid var(--border);background:var(--pane)}
136.pane:last-child{border-right:0}
137.pane-h{font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border)}
138#mod-search{margin:.5rem .75rem;padding:.35rem .5rem;border:1px solid var(--border);background:var(--bg);color:var(--fg)}
139#module-tree{overflow:auto;flex:1;padding:.25rem .5rem .75rem;outline:none}
140#module-tree:focus{box-shadow:inset 0 0 0 2px var(--focus)}
141.tree-item{display:flex;align-items:center;gap:.25rem;padding:.15rem .25rem;border-radius:2px;cursor:pointer}
142.tree-item:hover,.tree-item[aria-selected=true]{background:color-mix(in srgb,var(--accent) 16%,transparent)}
143.tree-item:focus{outline:2px solid var(--focus);outline-offset:-2px}
144.tw{border:0;background:transparent;color:var(--muted);width:1.1rem;cursor:pointer}
145.tn{color:var(--muted);font-size:12px;margin-left:.25rem}
146.diag{max-height:30%;overflow:auto;border-top:1px solid var(--border);padding:.5rem .75rem;font-size:12px;color:var(--hazard)}
147.diag:empty{display:none}
148.canvas-wrap{position:relative;flex:1;min-height:200px;background:var(--canvas);overflow:hidden}
149#dataflow-canvas{width:100%;height:100%;touch-action:none;cursor:grab}
150#dataflow-canvas:active{cursor:grabbing}
151#dataflow-canvas:focus{outline:2px solid var(--focus);outline-offset:-2px}
152.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:1.5rem;text-align:center;color:var(--muted);z-index:1;pointer-events:none}
153.empty[hidden]{display:none}
154.legend{display:flex;flex-wrap:wrap;gap:.4rem .75rem;padding:.4rem .75rem;border-top:1px solid var(--border);font-size:12px}
155.lg::before{content:"";display:inline-block;width:.65rem;height:.65rem;margin-right:.3rem;border-radius:50%;background:var(--c,var(--muted));vertical-align:middle}
156.lg.Trainable{--c:var(--Trainable)}.lg.Frozen{--c:var(--Frozen)}.lg.Differentiable{--c:var(--Differentiable)}
157.lg.Severed{--c:var(--Severed)}.lg.LayoutDependent{--c:var(--LayoutDependent)}.lg.Unknown{--c:var(--Unknown)}
158#inspector{margin:0;padding:.5rem .75rem;overflow:auto;flex:1}
159#inspector>div{display:grid;grid-template-columns:7rem 1fr;gap:.25rem .5rem;padding:.3rem 0;border-bottom:1px solid var(--border)}
160dt{color:var(--muted);font-weight:500}dd{margin:0;word-break:break-word}
161.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
162.node{cursor:pointer}.node.sel rect,.edge.sel{stroke:var(--focus);stroke-width:2.5}
163.edge{cursor:pointer;fill:none;stroke-width:1.5}
164@media(max-width:900px){
165  .layout{grid-template-columns:1fr;grid-template-rows:minmax(160px,auto) minmax(240px,1fr) minmax(160px,auto);height:auto}
166  .pane{border-right:0;border-bottom:1px solid var(--border);max-height:50vh}
167  .canvas-wrap{min-height:280px}
168}
169"#;
170
171const JS: &str = r#"
172(function(){
173const S=JSON.parse(document.getElementById('cg-structure').textContent);
174const D=JSON.parse(document.getElementById('cg-dataflow').textContent);
175const GRAD=['Trainable','Frozen','Differentiable','Severed','LayoutDependent','Unknown'];
176const root=document.documentElement;
177const pref=matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
178root.setAttribute('data-theme',localStorage.getItem('cg-theme')||pref);
179document.getElementById('theme-btn').onclick=()=>{
180  const n=root.getAttribute('data-theme')==='dark'?'light':'dark';
181  root.setAttribute('data-theme',n);localStorage.setItem('cg-theme',n);
182};
183function cert(c){
184  if(c==null)return '—';
185  if(typeof c==='string')return c;
186  if(c.kind)return c.reason?c.kind+': '+c.reason:c.kind;
187  return String(c);
188}
189function cov(){
190  const c=S.coverage||{};
191  const el=document.querySelector('[data-coverage]');
192  el.textContent=[
193    (c.instances!=null?c.instances+' instances':null),
194    (c.params!=null?c.params+' params':null),
195    (c.params_certain!=null?c.params_certain+' certain':null),
196    (c.params_conditional!=null?c.params_conditional+' conditional':null),
197    (c.params_unknown!=null?c.params_unknown+' unknown':null),
198    (c.diagnostics!=null?c.diagnostics+' diagnostics':null)
199  ].filter(Boolean).join(' · ')||'No coverage fields';
200  const box=document.querySelector('[data-diagnostics]');
201  const diags=(S.diagnostics||[]).concat((D&&D.diagnostics)||[]);
202  box.innerHTML=diags.length?diags.map(d=>{
203    const m=esc(d.message||d||'');
204    const at=d.at?esc(String(d.at))+' — ':'';
205    return '<div>'+at+m+'</div>';
206  }).join(''):'';
207}
208function esc(s){return String(s).replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));}
209function setInspector(o){
210  o=o||{};
211  const map={
212    source:o.at||o.source||o.span||'—',
213    shape:o.shape!=null?(Array.isArray(o.shape)?o.shape.join('×'):String(o.shape)):'—',
214    dtype:o.dtype||'—',
215    root:o.root||o.builder_root||'—',
216    certainty:cert(o.certainty),
217    grad:o.grad_state||o.grad||'—',
218    label:o.label||o.key||o.type||o.name||'—',
219    kind:o.kind||o.op||'—'
220  };
221  Object.keys(map).forEach(k=>{
222    const el=document.querySelector('[data-field="'+k+'"]');
223    if(el)el.textContent=map[k];
224  });
225}
226function buildTree(){
227  const mods=S.modules||[];
228  const params=S.parameters||[];
229  const byParent={};
230  mods.forEach((m,i)=>{
231    const p=m.parent_id==null?'root':String(m.parent_id);
232    (byParent[p]=byParent[p]||[]).push({m,i});
233  });
234  const tree=document.getElementById('module-tree');
235  const open=new Set();
236  function kids(m){return byParent[String(m.id!=null?m.id:'legacy-'+m.prefix)]||[];}
237  function roots(){return byParent.root||mods.map((m,i)=>({m,i})).filter(({m})=>m.parent==null);}
238  function render(){
239    const q=(document.getElementById('mod-search').value||'').toLowerCase();
240    tree.innerHTML='';
241    function subtreeHit(m,seen){
242      const id=String(m.id!=null?m.id:'legacy-'+m.prefix);
243      if(seen.has(id))return false;
244      seen.add(id);
245      const label=(m.field?m.field+': ':'')+(m.type||m.prefix||'module');
246      const selfHit=!q||label.toLowerCase().includes(q)||String(m.prefix||'').toLowerCase().includes(q)||String(m.root||'').toLowerCase().includes(q);
247      return selfHit||kids(m).some(({m:child})=>subtreeHit(child,new Set(seen)));
248    }
249    function add(items,depth,seen){
250      items.forEach(({m,i})=>{
251        const id=String(m.id!=null?m.id:'legacy-'+m.prefix);
252        if(seen.has(id))return;
253        const branchSeen=new Set(seen);branchSeen.add(id);
254        const label=(m.field?m.field+': ':'')+(m.type||m.prefix||'module');
255        const path=String(m.prefix||'');
256        const hit=!q||label.toLowerCase().includes(q)||path.toLowerCase().includes(q)||String(m.root||'').toLowerCase().includes(q);
257        const ch=kids(m);
258        const has=ch.length>0;
259        if(!hit&&!subtreeHit(m,new Set()))return;
260        const row=document.createElement('div');
261        row.className='tree-item';
262        row.setAttribute('role','treeitem');
263        row.setAttribute('aria-level',String(depth+1));
264        row.tabIndex=-1;
265        row.dataset.idx=String(i);row.dataset.moduleId=id;
266        row.style.paddingLeft=(depth*12+4)+'px';
267        const expanded=open.has(id)||!!q;
268        if(has){
269          row.setAttribute('aria-expanded',expanded?'true':'false');
270          const b=document.createElement('button');
271          b.type='button';b.className='tw';b.setAttribute('aria-label',expanded?'Collapse':'Expand');
272          b.textContent=expanded?'▾':'▸';
273          b.onclick=e=>{e.stopPropagation();if(open.has(id))open.delete(id);else open.add(id);render();};
274          row.appendChild(b);
275        }else{
276          const sp=document.createElement('span');sp.className='tw';sp.textContent='·';row.appendChild(sp);
277        }
278        const t=document.createElement('span');t.textContent=label;row.appendChild(t);
279        const meta=document.createElement('span');meta.className='tn';meta.textContent=(m.root||'')+(m.repeat?' ×':'');row.appendChild(meta);
280        row.onclick=()=>{
281          [...tree.querySelectorAll('[aria-selected=true]')].forEach(x=>x.setAttribute('aria-selected','false'));
282          row.setAttribute('aria-selected','true');
283          const owned=params.filter(p=>p.module_prefix===path||p.module===m.type);
284          setInspector(Object.assign({},m,{label:label,kind:'module',shape:owned.length?owned.length+' params':undefined}));
285        };
286        tree.appendChild(row);
287        if(has&&expanded)add(ch,depth+1,branchSeen);
288      });
289    }
290    add(roots(),0,new Set());
291  }
292  document.getElementById('mod-search').oninput=render;
293  tree.addEventListener('keydown',e=>{
294    const items=[...tree.querySelectorAll('.tree-item')];
295    const cur=document.activeElement;
296    let i=items.indexOf(cur);
297    if(e.key==='ArrowDown'){e.preventDefault();(items[i+1]||items[0]||tree).focus();}
298    else if(e.key==='ArrowUp'){e.preventDefault();(items[i-1]||items[items.length-1]||tree).focus();}
299    else if(e.key==='Enter'&&cur&&cur.classList.contains('tree-item'))cur.click();
300    else if(e.key==='Home'){e.preventDefault();(items[0]||tree).focus();}
301    else if(e.key==='End'){e.preventDefault();(items[items.length-1]||tree).focus();}
302  });
303  render();
304}
305function canvas(){
306  const svg=document.getElementById('dataflow-canvas');
307  const empty=document.getElementById('empty-dataflow');
308  if(!D||!(D.nodes||D.tensors||[]).length){
309    empty.hidden=false;svg.setAttribute('aria-hidden','true');return;
310  }
311  empty.hidden=true;
312  const nodes=(D.nodes||D.tensors||[]).map((n,i)=>Object.assign({},n,{_id:String(n.id!=null?n.id:i)}));
313  const edges=(D.edges||D.ops||[]).map((e,i)=>Object.assign({},e,{
314    _id:String(e.id!=null?e.id:'e'+i),
315    _from:String(e.from!=null?e.from:e.src!=null?e.src:e.source),
316    _to:String(e.to!=null?e.to:e.dst!=null?e.dst:e.target)
317  }));
318  // Collapse repeated module groups by default (shared repeat/group/template key).
319  const groups={};
320  nodes.forEach(n=>{
321    const g=n.repeat_group||n.group||(n.repeat&&(n.module||n.label))||(n.template)||null;
322    if(g){(groups[g]=groups[g]||[]).push(n);}
323  });
324  const collapsed=new Set(Object.keys(groups));
325  let view={x:0,y:0,k:1};
326  function visibleNodes(){
327    const hide=new Set();
328    const reps=[];
329    collapsed.forEach(g=>{
330      const arr=groups[g];if(!arr)return;
331      arr.slice(1).forEach(n=>hide.add(n._id));
332      const r=Object.assign({},arr[0],{
333        label:(arr[0].label||arr[0].name||g)+' ×'+arr.length,
334        _group:g,_collapsed:true
335      });
336      reps.push(r);hide.add(arr[0]._id);
337    });
338    return nodes.filter(n=>!hide.has(n._id)).concat(reps);
339  }
340  function layout(vis){
341    const cols=Math.max(1,Math.ceil(Math.sqrt(vis.length)));
342    vis.forEach((n,i)=>{
343      n._x=(i%cols)*180+40;n._y=Math.floor(i/cols)*90+40;
344      n._w=150;n._h=48;
345    });
346  }
347  function gradOf(n){
348    const g=n.grad_state||n.grad||'Unknown';
349    return GRAD.indexOf(g)>=0?g:'Unknown';
350  }
351  function draw(){
352    const vis=visibleNodes();layout(vis);
353    const byId={};vis.forEach(n=>byId[n._id]=n);
354    // Map hidden members to representative.
355    const remap={};
356    collapsed.forEach(g=>{
357      const arr=groups[g];if(!arr)return;
358      arr.forEach(n=>{remap[n._id]=arr[0]._id;});
359    });
360    while(svg.firstChild)svg.removeChild(svg.firstChild);
361    const g=document.createElementNS(svg.namespaceURI,'g');
362    g.setAttribute('transform','translate('+view.x+','+view.y+') scale('+view.k+')');
363    svg.appendChild(g);
364    edges.forEach(e=>{
365      const a=byId[remap[e._from]||e._from],b=byId[remap[e._to]||e._to];
366      if(!a||!b||a===b)return;
367      const p=document.createElementNS(svg.namespaceURI,'path');
368      const x1=a._x+a._w,y1=a._y+a._h/2,x2=b._x,y2=b._y+b._h/2;
369      p.setAttribute('d','M'+x1+' '+y1+' C'+(x1+40)+' '+y1+','+(x2-40)+' '+y2+','+x2+' '+y2);
370      p.setAttribute('class','edge');
371      p.dataset.edgeId=e._id;
372      const sev=(e.grad_state||e.kind||'').toString().toLowerCase().includes('sever');
373      p.setAttribute('stroke',sev?'var(--Severed)':'var(--muted)');
374      p.setAttribute('marker-end','');
375      p.onclick=ev=>{ev.stopPropagation();selectEdge(e,p);};
376      g.appendChild(p);
377    });
378    vis.forEach(n=>{
379      const gg=document.createElementNS(svg.namespaceURI,'g');
380      gg.setAttribute('class','node');gg.dataset.nodeId=n._id;
381      gg.setAttribute('tabindex','0');gg.setAttribute('role','button');
382      gg.setAttribute('aria-label',n.label||n._id);
383      const r=document.createElementNS(svg.namespaceURI,'rect');
384      r.setAttribute('x',n._x);r.setAttribute('y',n._y);r.setAttribute('width',n._w);r.setAttribute('height',n._h);
385      r.setAttribute('rx',6);r.setAttribute('fill','var(--pane)');r.setAttribute('stroke','var(--'+gradOf(n)+')');
386      const t=document.createElementNS(svg.namespaceURI,'text');
387      t.setAttribute('x',n._x+8);t.setAttribute('y',n._y+20);
388      t.setAttribute('fill','var(--fg)');t.setAttribute('font-size','12');
389      t.textContent=String(n.label||n.key||n.op||n._id).slice(0,22);
390      const t2=document.createElementNS(svg.namespaceURI,'text');
391      t2.setAttribute('x',n._x+8);t2.setAttribute('y',n._y+38);
392      t2.setAttribute('fill','var(--muted)');t2.setAttribute('font-size','11');
393      t2.textContent=gradOf(n)+(n.dtype?' · '+n.dtype:'');
394      gg.appendChild(r);gg.appendChild(t);gg.appendChild(t2);
395      gg.onclick=ev=>{
396        ev.stopPropagation();
397        if(n._collapsed&&n._group){collapsed.delete(n._group);draw();return;}
398        selectNode(n,gg);
399      };
400      gg.onkeydown=ev=>{if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();gg.onclick(ev);}};
401      g.appendChild(gg);
402    });
403  }
404  function clearSel(){[...svg.querySelectorAll('.sel')].forEach(x=>x.classList.remove('sel'));}
405  function selectNode(n,el){clearSel();el.classList.add('sel');setInspector(n);}
406  function selectEdge(e,el){clearSel();el.classList.add('sel');setInspector(Object.assign({kind:'edge',label:e._id},e));}
407  let drag=null;
408  svg.addEventListener('pointerdown',e=>{
409    if(e.target.closest('.node,.edge'))return;
410    drag={x:e.clientX-view.x,y:e.clientY-view.y};svg.setPointerCapture(e.pointerId);
411  });
412  svg.addEventListener('pointermove',e=>{if(!drag)return;view.x=e.clientX-drag.x;view.y=e.clientY-drag.y;draw();});
413  svg.addEventListener('pointerup',()=>{drag=null;});
414  svg.addEventListener('wheel',e=>{
415    e.preventDefault();
416    const f=e.deltaY<0?1.1:0.9;view.k=Math.min(4,Math.max(0.25,view.k*f));draw();
417  },{passive:false});
418  svg.addEventListener('keydown',e=>{
419    if(e.key==='+'||e.key==='='){view.k=Math.min(4,view.k*1.1);draw();}
420    if(e.key==='-'){view.k=Math.max(0.25,view.k/1.1);draw();}
421    if(e.key==='0'){view={x:0,y:0,k:1};draw();}
422    if(e.key==='Escape'){clearSel();setInspector(null);}
423  });
424  draw();
425}
426cov();buildTree();canvas();
427})();
428"#;