1use serde_json::Value;
6
7pub 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 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
91pub fn embed_json(value: &Value) -> String {
93 escape_for_script(&value.to_string())
94}
95
96pub 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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"#;