Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 449049a

Browse files
feat: add Graph What-If Analyzer - interactive hypothetical modification tool
Interactive tool for exploring graph modifications with real-time impact analysis: - 8 graph presets (Petersen, K5, Karate Club, Grid, Binary Tree, Star, Cycle, Random) - Add/remove nodes and edges with visual tools - Real-time before/after metrics (density, diameter, clustering, components, etc.) - Autonomous recommendations: articulation points, bridges, diameter-reducing edges - Node impact heatmap showing connectivity fragility - Undo/redo change history - JSON export of analysis - Graph health/resilience scoring Usage: Open docs/what-if.html in a browser
1 parent f21d3dd commit 449049a

1 file changed

Lines changed: 274 additions & 0 deletions

File tree

docs/what-if.html

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Graph What-If Analyzer - GraphVisual</title>
7+
<style>
8+
*{margin:0;padding:0;box-sizing:border-box}
9+
body{background:#1a1a2e;color:#e0e0e0;font-family:'Segoe UI',system-ui,sans-serif;display:flex;height:100vh;overflow:hidden}
10+
.sidebar{width:280px;padding:12px;overflow-y:auto;border-right:1px solid #2a2a4a;flex-shrink:0}
11+
.sidebar-right{border-right:none;border-left:1px solid #2a2a4a}
12+
.center{flex:1;display:flex;flex-direction:column;min-width:0}
13+
canvas{flex:1;cursor:crosshair}
14+
h1{font-size:1.1em;color:#7c83ff;margin-bottom:8px}
15+
h2{font-size:.9em;color:#9ca0ff;margin:12px 0 6px;border-bottom:1px solid #2a2a4a;padding-bottom:4px}
16+
button{background:#2a2a4a;color:#e0e0e0;border:1px solid #3a3a6a;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:.8em;margin:2px}
17+
button:hover{background:#3a3a6a}
18+
button.active{background:#7c83ff;color:#fff}
19+
.preset-grid{display:flex;flex-wrap:wrap;gap:4px}
20+
.preset-grid button{flex:1 0 45%;font-size:.75em}
21+
.tools{display:flex;flex-wrap:wrap;gap:4px;margin:4px 0}
22+
.tools button{flex:1 0 45%}
23+
.rec{background:#16213e;border:1px solid #2a2a4a;border-radius:4px;padding:6px 8px;margin:4px 0;font-size:.78em;line-height:1.3}
24+
.rec.warn{border-left:3px solid #ff6b6b}
25+
.rec.info{border-left:3px solid #4ecdc4}
26+
.rec.suggest{border-left:3px solid #ffe66d}
27+
table{width:100%;border-collapse:collapse;font-size:.78em}
28+
th,td{padding:4px 6px;text-align:right;border-bottom:1px solid #2a2a4a}
29+
th{color:#9ca0ff;text-align:left}
30+
td:first-child{text-align:left;color:#ccc}
31+
.pos{color:#4ecdc4}.neg{color:#ff6b6b}.neu{color:#888}
32+
.history-item{font-size:.75em;padding:3px 6px;background:#16213e;margin:2px 0;border-radius:3px;display:flex;justify-content:space-between}
33+
.history-item .undo-mark{color:#ff6b6b;font-size:.7em}
34+
#status{padding:6px 10px;background:#16213e;font-size:.8em;color:#9ca0ff;border-top:1px solid #2a2a4a}
35+
.btn-row{display:flex;gap:4px;margin:4px 0}
36+
.btn-row button{flex:1}
37+
</style>
38+
</head>
39+
<body>
40+
<div class="sidebar" id="leftPanel">
41+
<h1>🔮 What-If Analyzer</h1>
42+
<h2>Presets</h2>
43+
<div class="preset-grid" id="presets"></div>
44+
<h2>Tools</h2>
45+
<div class="tools" id="toolBtns"></div>
46+
<div class="btn-row">
47+
<button onclick="toggleHeatmap()">🌡️ Heatmap</button>
48+
<button onclick="exportJSON()">📤 Export</button>
49+
</div>
50+
<div class="btn-row">
51+
<button onclick="undo()">↩ Undo</button>
52+
<button onclick="redo()">↪ Redo</button>
53+
</div>
54+
<h2>Recommendations</h2>
55+
<div id="recs"></div>
56+
</div>
57+
<div class="center">
58+
<canvas id="cv"></canvas>
59+
<div id="status">Click canvas to interact. Select a tool first.</div>
60+
</div>
61+
<div class="sidebar sidebar-right">
62+
<h2>Metrics Comparison</h2>
63+
<table><thead><tr><th>Metric</th><th>Base</th><th>Now</th><th>Δ</th></tr></thead><tbody id="metrics"></tbody></table>
64+
<h2>Change History</h2>
65+
<div id="history"></div>
66+
<h2>Graph Health</h2>
67+
<div id="health"></div>
68+
</div>
69+
<script>
70+
// Graph model
71+
let nodes=[],edges=[],baseNodes=[],baseEdges=[];
72+
let nextId=0,selected=null,tool='select',heatmapOn=false;
73+
let undoStack=[],redoStack=[];
74+
const cv=document.getElementById('cv'),ctx=cv.getContext('2d');
75+
76+
function resize(){cv.width=cv.parentElement.clientWidth;cv.height=cv.parentElement.clientHeight}
77+
window.addEventListener('resize',resize);resize();
78+
79+
// Presets
80+
const presets={
81+
'Petersen':()=>{let n=[];for(let i=0;i<5;i++){n.push({x:200+120*Math.cos(2*Math.PI*i/5-Math.PI/2),y:200+120*Math.sin(2*Math.PI*i/5-Math.PI/2)});n.push({x:200+55*Math.cos(2*Math.PI*i/5-Math.PI/2),y:200+55*Math.sin(2*Math.PI*i/5-Math.PI/2)})}let e=[];for(let i=0;i<5;i++){e.push([i*2,(((i+1)%5)*2)]);e.push([i*2,i*2+1]);e.push([i*2+1,((i+2)%5)*2+1])}return{n,e}},
82+
'K5':()=>{let n=[];for(let i=0;i<5;i++)n.push({x:200+100*Math.cos(2*Math.PI*i/5),y:200+100*Math.sin(2*Math.PI*i/5)});let e=[];for(let i=0;i<5;i++)for(let j=i+1;j<5;j++)e.push([i,j]);return{n,e}},
83+
'Karate Club':()=>{let n=[];for(let i=0;i<34;i++)n.push({x:100+Math.random()*300,y:100+Math.random()*300});let e=[[0,1],[0,2],[0,3],[0,4],[0,5],[0,6],[0,7],[0,8],[0,10],[0,11],[0,12],[0,13],[0,17],[0,19],[0,21],[0,31],[1,2],[1,3],[1,7],[1,13],[1,17],[1,19],[1,21],[1,30],[2,3],[2,7],[2,8],[2,9],[2,13],[2,27],[2,28],[2,32],[3,7],[3,12],[3,13],[4,6],[4,10],[5,6],[5,10],[5,16],[6,16],[8,30],[8,32],[8,33],[9,33],[13,33],[14,32],[14,33],[15,32],[15,33],[18,32],[18,33],[19,33],[20,32],[20,33],[22,32],[22,33],[23,25],[23,27],[23,29],[23,32],[23,33],[24,25],[24,27],[24,31],[25,31],[26,29],[26,33],[27,33],[28,31],[28,33],[29,32],[29,33],[30,32],[30,33],[31,32],[31,33],[32,33]];return{n,e}},
84+
'Grid 4x4':()=>{let n=[],e=[];for(let r=0;r<4;r++)for(let c=0;c<4;c++){n.push({x:150+c*80,y:150+r*80});let i=r*4+c;if(c<3)e.push([i,i+1]);if(r<3)e.push([i,i+4])}return{n,e}},
85+
'Binary Tree':()=>{let n=[{x:300,y:50}],e=[];for(let i=0;i<15;i++){let l=2*i+1,r=2*i+2;if(l<15){let d=Math.floor(Math.log2(l+1)),pos=l-Math.pow(2,d)+1;let w=500/Math.pow(2,d);n.push({x:w*pos+w/2,y:50+d*60});e.push([i,l])}if(r<15){let d=Math.floor(Math.log2(r+1)),pos=r-Math.pow(2,d)+1;let w=500/Math.pow(2,d);n.push({x:w*pos+w/2,y:50+d*60});e.push([i,r])}}return{n:n.slice(0,15),e}},
86+
'Star-8':()=>{let n=[{x:250,y:250}];for(let i=0;i<8;i++)n.push({x:250+130*Math.cos(2*Math.PI*i/8),y:250+130*Math.sin(2*Math.PI*i/8)});let e=[];for(let i=1;i<=8;i++)e.push([0,i]);return{n,e}},
87+
'Cycle-10':()=>{let n=[];for(let i=0;i<10;i++)n.push({x:250+120*Math.cos(2*Math.PI*i/10),y:250+120*Math.sin(2*Math.PI*i/10)});let e=[];for(let i=0;i<10;i++)e.push([i,(i+1)%10]);return{n,e}},
88+
'Random-12':()=>{let n=[];for(let i=0;i<12;i++)n.push({x:80+Math.random()*350,y:80+Math.random()*350});let e=[];for(let i=0;i<12;i++){let j=(i+1)%12;e.push([i,j])}for(let k=0;k<8;k++){let a=Math.floor(Math.random()*12),b=Math.floor(Math.random()*12);if(a!==b&&!e.some(e=>(e[0]===a&&e[1]===b)||(e[0]===b&&e[1]===a)))e.push([a,b])}return{n,e}}
89+
};
90+
91+
const presetDiv=document.getElementById('presets');
92+
Object.keys(presets).forEach(name=>{
93+
let b=document.createElement('button');b.textContent=name;
94+
b.onclick=()=>loadPreset(name);presetDiv.appendChild(b)});
95+
96+
function loadPreset(name){
97+
let{n,e}=presets[name]();
98+
nodes=n.map((p,i)=>({id:i,...p,vx:0,vy:0}));
99+
edges=e.filter(([a,b])=>a<nodes.length&&b<nodes.length).map(([a,b])=>({a,b}));
100+
nextId=nodes.length;selected=null;undoStack=[];redoStack=[];
101+
baseNodes=JSON.parse(JSON.stringify(nodes));baseEdges=JSON.parse(JSON.stringify(edges));
102+
heatmapOn=false;updateAll();setStatus('Loaded '+name)}
103+
104+
const tools=['select','addNode','addEdge','removeNode','removeEdge'];
105+
const toolLabels=['🖱️ Select','➕ Node','🔗 Edge','❌ Node','✂️ Edge'];
106+
const toolDiv=document.getElementById('toolBtns');
107+
tools.forEach((t,i)=>{let b=document.createElement('button');b.textContent=toolLabels[i];b.id='tool_'+t;
108+
b.onclick=()=>{tool=t;selected=null;document.querySelectorAll('.tools button').forEach(x=>x.classList.remove('active'));b.classList.add('active');setStatus('Tool: '+toolLabels[i])};toolDiv.appendChild(b)});
109+
110+
function setStatus(s){document.getElementById('status').textContent=s}
111+
112+
function saveState(){
113+
undoStack.push({nodes:JSON.parse(JSON.stringify(nodes)),edges:JSON.parse(JSON.stringify(edges)),nextId});
114+
redoStack=[];if(undoStack.length>50)undoStack.shift()}
115+
function undo(){if(!undoStack.length)return;redoStack.push({nodes:JSON.parse(JSON.stringify(nodes)),edges:JSON.parse(JSON.stringify(edges)),nextId});let s=undoStack.pop();nodes=s.nodes;edges=s.edges;nextId=s.nextId;selected=null;updateAll()}
116+
function redo(){if(!redoStack.length)return;undoStack.push({nodes:JSON.parse(JSON.stringify(nodes)),edges:JSON.parse(JSON.stringify(edges)),nextId});let s=redoStack.pop();nodes=s.nodes;edges=s.edges;nextId=s.nextId;selected=null;updateAll()}
117+
118+
// Canvas interaction
119+
let dragging=null,dragOff={x:0,y:0};
120+
cv.addEventListener('mousedown',e=>{
121+
let r=cv.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top;
122+
let hit=nodes.find(n=>Math.hypot(n.x-mx,n.y-my)<14);
123+
if(tool==='select'){if(hit){dragging=hit;dragOff={x:hit.x-mx,y:hit.y-my};selected=hit}else selected=null;draw()}
124+
else if(tool==='addNode'){saveState();nodes.push({id:nextId++,x:mx,y:my,vx:0,vy:0});updateAll();setStatus('Added node '+(nextId-1))}
125+
else if(tool==='addEdge'){if(hit){if(selected&&selected.id!==hit.id){if(!edges.some(e=>(e.a===selected.id&&e.b===hit.id)||(e.a===hit.id&&e.b===selected.id))){saveState();edges.push({a:selected.id,b:hit.id});selected=null;updateAll();setStatus('Added edge')}}else{selected=hit;setStatus('Select second node for edge');draw()}}
126+
}else if(tool==='removeNode'&&hit){saveState();edges=edges.filter(e=>e.a!==hit.id&&e.b!==hit.id);nodes=nodes.filter(n=>n.id!==hit.id);if(selected===hit)selected=null;updateAll();setStatus('Removed node '+hit.id)}
127+
else if(tool==='removeEdge'&&!hit){let best=null,bestD=12;edges.forEach(ed=>{let na=nodes.find(n=>n.id===ed.a),nb=nodes.find(n=>n.id===ed.b);if(!na||!nb)return;let d=distToSeg(mx,my,na.x,na.y,nb.x,nb.y);if(d<bestD){bestD=d;best=ed}});if(best){saveState();edges=edges.filter(e=>e!==best);updateAll();setStatus('Removed edge')}}
128+
});
129+
cv.addEventListener('mousemove',e=>{if(!dragging)return;let r=cv.getBoundingClientRect();dragging.x=e.clientX-r.left+dragOff.x;dragging.y=e.clientY-r.top+dragOff.y;draw()});
130+
cv.addEventListener('mouseup',()=>{dragging=null});
131+
132+
function distToSeg(px,py,ax,ay,bx,by){let dx=bx-ax,dy=by-ay,t=Math.max(0,Math.min(1,((px-ax)*dx+(py-ay)*dy)/(dx*dx+dy*dy)));return Math.hypot(px-(ax+t*dx),py-(ay+t*dy))}
133+
134+
// Force layout
135+
function forceLayout(its){
136+
for(let it=0;it<its;it++){
137+
nodes.forEach(n=>{n.vx=0;n.vy=0});
138+
// Repulsion
139+
for(let i=0;i<nodes.length;i++)for(let j=i+1;j<nodes.length;j++){
140+
let dx=nodes[j].x-nodes[i].x,dy=nodes[j].y-nodes[i].y,d=Math.max(Math.hypot(dx,dy),1);
141+
let f=800/(d*d);nodes[i].vx-=f*dx/d;nodes[i].vy-=f*dy/d;nodes[j].vx+=f*dx/d;nodes[j].vy+=f*dy/d}
142+
// Attraction
143+
edges.forEach(e=>{let na=nodes.find(n=>n.id===e.a),nb=nodes.find(n=>n.id===e.b);if(!na||!nb)return;
144+
let dx=nb.x-na.x,dy=nb.y-na.y,d=Math.max(Math.hypot(dx,dy),1),f=(d-80)*0.05;
145+
na.vx+=f*dx/d;na.vy+=f*dy/d;nb.vx-=f*dx/d;nb.vy-=f*dy/d});
146+
// Center gravity
147+
let cx=cv.width/2,cy=cv.height/2;
148+
nodes.forEach(n=>{n.vx+=(cx-n.x)*0.01;n.vy+=(cy-n.y)*0.01;
149+
n.x+=Math.max(-10,Math.min(10,n.vx));n.y+=Math.max(-10,Math.min(10,n.vy));
150+
n.x=Math.max(20,Math.min(cv.width-20,n.x));n.y=Math.max(20,Math.min(cv.height-20,n.y))})}}
151+
152+
// Metrics
153+
function adj(ns,es){let m={};ns.forEach(n=>m[n.id]=[]);es.forEach(e=>{if(m[e.a]&&m[e.b]){m[e.a].push(e.b);m[e.b].push(e.a)}});return m}
154+
function components(ns,es){let a=adj(ns,es),vis=new Set,comps=[];ns.forEach(n=>{if(vis.has(n.id))return;let q=[n.id],c=[];while(q.length){let v=q.pop();if(vis.has(v))continue;vis.add(v);c.push(v);(a[v]||[]).forEach(u=>{if(!vis.has(u))q.push(u)})}comps.push(c)});return comps}
155+
function bfs(start,a){let dist={},q=[start];dist[start]=0;while(q.length){let v=q.shift();(a[v]||[]).forEach(u=>{if(dist[u]===undefined){dist[u]=dist[v]+1;q.push(u)}})}return dist}
156+
function calcMetrics(ns,es){
157+
if(!ns.length)return{nodes:0,edges:0,density:0,avgDeg:0,maxDeg:0,components:0,diameter:0,clustering:0,avgPath:0};
158+
let a=adj(ns,es),degs=ns.map(n=>(a[n.id]||[]).length);
159+
let n=ns.length,m=es.length,density=n>1?2*m/(n*(n-1)):0;
160+
let avgDeg=degs.reduce((s,d)=>s+d,0)/n,maxDeg=Math.max(...degs);
161+
let comps=components(ns,es),numComp=comps.length;
162+
let diam=0,pathSum=0,pathCount=0;
163+
let largest=comps.reduce((a,b)=>a.length>b.length?a:b,[]);
164+
largest.forEach(v=>{let d=bfs(v,a);Object.values(d).forEach(dist=>{if(dist>0){pathSum+=dist;pathCount++;if(dist>diam)diam=dist}})});
165+
let avgPath=pathCount?pathSum/pathCount:0;
166+
// Clustering
167+
let clust=0,ccount=0;
168+
ns.forEach(nd=>{let nb=a[nd.id]||[];if(nb.length<2)return;let tri=0,pairs=nb.length*(nb.length-1)/2;
169+
for(let i=0;i<nb.length;i++)for(let j=i+1;j<nb.length;j++)if((a[nb[i]]||[]).includes(nb[j]))tri++;
170+
clust+=tri/pairs;ccount++});
171+
let clustering=ccount?clust/ccount:0;
172+
return{nodes:n,edges:m,density:+density.toFixed(4),avgDeg:+avgDeg.toFixed(2),maxDeg,components:numComp,diameter:diam,clustering:+clustering.toFixed(4),avgPath:+avgPath.toFixed(2)}}
173+
174+
function updateMetrics(){
175+
let base=calcMetrics(baseNodes,baseEdges),curr=calcMetrics(nodes,edges);
176+
let tbody=document.getElementById('metrics');tbody.innerHTML='';
177+
let labels={nodes:'Nodes',edges:'Edges',density:'Density',avgDeg:'Avg Degree',maxDeg:'Max Degree',components:'Components',diameter:'Diameter',clustering:'Clustering',avgPath:'Avg Path Len'};
178+
let lowerBetter=new Set(['components','diameter','avgPath']);
179+
Object.keys(labels).forEach(k=>{
180+
let d=curr[k]-base[k],cls='neu';
181+
if(d!==0){if(lowerBetter.has(k))cls=d<0?'pos':'neg';else cls=d>0?'pos':'neg';if(k==='density'||k==='clustering')cls=d>0?'pos':'neg'}
182+
let tr=document.createElement('tr');
183+
tr.innerHTML=`<td>${labels[k]}</td><td>${base[k]}</td><td>${curr[k]}</td><td class="${cls}">${d>0?'+':''}${typeof d==='number'?+d.toFixed(4):d}</td>`;
184+
tbody.appendChild(tr)})}
185+
186+
// Recommendations
187+
function findArticulationPoints(){
188+
let a=adj(nodes,edges),nc=components(nodes,edges).length,aps=[];
189+
nodes.forEach(n=>{let fn=nodes.filter(x=>x.id!==n.id),fe=edges.filter(e=>e.a!==n.id&&e.b!==n.id);
190+
if(components(fn,fe).length>nc)aps.push(n.id)});return aps}
191+
192+
function findBridges(){
193+
let a=adj(nodes,edges),nc=components(nodes,edges).length,br=[];
194+
edges.forEach((e,i)=>{let fe=edges.filter((_,j)=>j!==i);
195+
if(components(nodes,fe).length>nc)br.push(e)});return br}
196+
197+
function updateRecs(){
198+
let div=document.getElementById('recs');div.innerHTML='';
199+
if(!nodes.length)return;
200+
let aps=findArticulationPoints();
201+
aps.forEach(id=>addRec('warn','⚠️ Node '+id+' is an articulation point — removing it fragments the network'));
202+
let bridges=findBridges();
203+
bridges.forEach(e=>addRec('warn','⚠️ Edge '+e.a+'–'+e.b+' is a bridge — removing it disconnects components'));
204+
let a=adj(nodes,edges);
205+
nodes.forEach(n=>{if((a[n.id]||[]).length===0)addRec('info','💡 Node '+n.id+' is isolated — consider connecting it')});
206+
nodes.forEach(n=>{if((a[n.id]||[]).length===1)addRec('info','💡 Node '+n.id+' has degree 1 (leaf node)')});
207+
// Suggest diameter-reducing edges
208+
if(nodes.length>2&&nodes.length<30){
209+
let curr=calcMetrics(nodes,edges);
210+
if(curr.diameter>2){
211+
let best=null,bestD=curr.diameter;
212+
for(let i=0;i<nodes.length&&!best;i++)for(let j=i+1;j<nodes.length;j++){
213+
let ni=nodes[i].id,nj=nodes[j].id;
214+
if(edges.some(e=>(e.a===ni&&e.b===nj)||(e.a===nj&&e.b===ni)))continue;
215+
let te=[...edges,{a:ni,b:nj}],m=calcMetrics(nodes,te);
216+
if(m.diameter<bestD){bestD=m.diameter;best={a:ni,b:nj,d:m.diameter}}}
217+
if(best)addRec('suggest','💡 Add edge '+best.a+'–'+best.b+' to reduce diameter to '+best.d)}}
218+
// Health
219+
let m=calcMetrics(nodes,edges);
220+
let score=100;
221+
if(m.components>1)score-=20*(m.components-1);
222+
if(aps.length)score-=10*aps.length;
223+
if(bridges.length)score-=5*bridges.length;
224+
score=Math.max(0,Math.min(100,score));
225+
let label=score>=80?'🟢 Healthy':score>=50?'🟡 Moderate':'🔴 Fragile';
226+
document.getElementById('health').innerHTML=`<div class="rec info" style="font-size:1em"><strong>${label}</strong> — Resilience: ${score}/100</div>`}
227+
228+
function addRec(cls,text){let d=document.createElement('div');d.className='rec '+cls;d.textContent=text;document.getElementById('recs').appendChild(d)}
229+
230+
// Heatmap
231+
let heatmapScores={};
232+
function calcHeatmap(){
233+
heatmapScores={};let nc=components(nodes,edges).length;
234+
nodes.forEach(n=>{let fn=nodes.filter(x=>x.id!==n.id),fe=edges.filter(e=>e.a!==n.id&&e.b!==n.id);
235+
let newC=components(fn,fe).length;heatmapScores[n.id]=newC-nc})}
236+
function toggleHeatmap(){heatmapOn=!heatmapOn;if(heatmapOn)calcHeatmap();draw()}
237+
238+
// History display
239+
function updateHistory(){
240+
let div=document.getElementById('history');div.innerHTML='';
241+
let total=undoStack.length;
242+
if(!total){div.innerHTML='<div class="history-item">No changes yet</div>';return}
243+
for(let i=total-1;i>=Math.max(0,total-15);i--){
244+
let d=document.createElement('div');d.className='history-item';
245+
d.innerHTML=`<span>Change #${i+1}</span><span class="undo-mark">${i===total-1?'← current':''}</span>`;
246+
div.appendChild(d)}}
247+
248+
// Draw
249+
function draw(){
250+
ctx.clearRect(0,0,cv.width,cv.height);
251+
// Edges
252+
edges.forEach(e=>{let na=nodes.find(n=>n.id===e.a),nb=nodes.find(n=>n.id===e.b);if(!na||!nb)return;
253+
ctx.beginPath();ctx.moveTo(na.x,na.y);ctx.lineTo(nb.x,nb.y);ctx.strokeStyle='#3a3a6a';ctx.lineWidth=1.5;ctx.stroke()});
254+
// Nodes
255+
nodes.forEach(n=>{
256+
let r=10,col='#7c83ff';
257+
if(heatmapOn){let s=heatmapScores[n.id]||0;col=s>1?'#ff4444':s===1?'#ff8844':s===0?'#44aa44':'#4ecdc4'}
258+
if(selected&&selected.id===n.id)col='#ffe66d';
259+
ctx.beginPath();ctx.arc(n.x,n.y,r,0,Math.PI*2);ctx.fillStyle=col;ctx.fill();ctx.strokeStyle='#fff';ctx.lineWidth=1;ctx.stroke();
260+
ctx.fillStyle='#fff';ctx.font='bold 9px sans-serif';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(n.id,n.x,n.y)})}
261+
262+
function updateAll(){forceLayout(50);updateMetrics();updateRecs();updateHistory();draw()}
263+
264+
function exportJSON(){
265+
let data={base:calcMetrics(baseNodes,baseEdges),current:calcMetrics(nodes,edges),nodes:nodes.map(n=>({id:n.id,x:+n.x.toFixed(1),y:+n.y.toFixed(1)})),edges:edges.map(e=>({source:e.a,target:e.b})),changes:undoStack.length};
266+
let blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'});
267+
let a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='what-if-analysis.json';a.click()}
268+
269+
// Init
270+
loadPreset('Petersen');
271+
document.getElementById('tool_select').classList.add('active');
272+
</script>
273+
</body>
274+
</html>

0 commit comments

Comments
 (0)