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

Commit f169220

Browse files
feat: Graph Diff HTML Exporter - interactive visual diff of two graph snapshots
1 parent 99565b6 commit f169220

3 files changed

Lines changed: 459 additions & 0 deletions

File tree

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
package gvisual;
2+
3+
import edu.uci.ics.jung.graph.Graph;
4+
5+
import java.io.*;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.*;
8+
9+
/**
10+
* Exports a visual graph diff as a self-contained interactive HTML file using D3.js.
11+
* Color-codes nodes and edges by diff status: added (green), removed (red), common (gray).
12+
* Supports overlay mode (single merged view) and side-by-side mode.
13+
*
14+
* <p>Features:</p>
15+
* <ul>
16+
* <li>Force-directed layout with drag, zoom, and pan</li>
17+
* <li>Nodes/edges color-coded: green = added, red = removed, gray = common</li>
18+
* <li>Toggle overlay vs side-by-side view</li>
19+
* <li>Hover tooltips showing node ID, status, degree in each graph</li>
20+
* <li>Statistics panel with Jaccard similarity, edit distance, counts</li>
21+
* <li>Filter toggles to show/hide added, removed, or common elements</li>
22+
* <li>Dark/light theme toggle</li>
23+
* <li>Legend for diff colors</li>
24+
* </ul>
25+
*
26+
* <p>Usage:</p>
27+
* <pre>
28+
* GraphDiffHtmlExporter exporter = new GraphDiffHtmlExporter(graphA, graphB);
29+
* exporter.setTitle("Network Evolution: Jan → Feb");
30+
* exporter.export(new File("diff.html"));
31+
* </pre>
32+
*
33+
* @author zalenix
34+
*/
35+
public class GraphDiffHtmlExporter {
36+
37+
private final Graph<String, edge> graphA;
38+
private final Graph<String, edge> graphB;
39+
private String title = "Graph Diff Visualization";
40+
private String labelA = "Graph A";
41+
private String labelB = "Graph B";
42+
private boolean darkMode = true;
43+
private int width = 1200;
44+
private int height = 700;
45+
46+
public GraphDiffHtmlExporter(Graph<String, edge> graphA, Graph<String, edge> graphB) {
47+
if (graphA == null || graphB == null) {
48+
throw new IllegalArgumentException("Both graphs must not be null");
49+
}
50+
this.graphA = graphA;
51+
this.graphB = graphB;
52+
}
53+
54+
public void setTitle(String title) { this.title = title; }
55+
public void setLabelA(String labelA) { this.labelA = labelA; }
56+
public void setLabelB(String labelB) { this.labelB = labelB; }
57+
public void setDarkMode(boolean darkMode) { this.darkMode = darkMode; }
58+
public void setWidth(int width) { this.width = width; }
59+
public void setHeight(int height) { this.height = height; }
60+
61+
/**
62+
* Export the diff visualization to a file.
63+
*/
64+
public void export(File file) throws IOException {
65+
try (Writer w = new BufferedWriter(new OutputStreamWriter(
66+
new FileOutputStream(file), StandardCharsets.UTF_8))) {
67+
w.write(exportToString());
68+
}
69+
}
70+
71+
/**
72+
* Export the diff visualization as an HTML string.
73+
*/
74+
public String exportToString() {
75+
GraphDiffAnalyzer analyzer = new GraphDiffAnalyzer(graphA, graphB);
76+
GraphDiffAnalyzer.DiffResult diff = analyzer.computeDiff();
77+
78+
StringBuilder sb = new StringBuilder();
79+
sb.append("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
80+
sb.append("<meta charset=\"UTF-8\">\n");
81+
sb.append("<title>").append(escapeHtml(title)).append("</title>\n");
82+
sb.append("<script src=\"https://d3js.org/d3.v7.min.js\"></script>\n");
83+
sb.append("<style>\n");
84+
appendCss(sb);
85+
sb.append("</style>\n</head>\n<body class=\"").append(darkMode ? "dark" : "light").append("\">\n");
86+
87+
sb.append("<div id=\"header\">\n");
88+
sb.append(" <h1>").append(escapeHtml(title)).append("</h1>\n");
89+
sb.append(" <div id=\"controls\">\n");
90+
sb.append(" <label><input type=\"checkbox\" id=\"showAdded\" checked> Added</label>\n");
91+
sb.append(" <label><input type=\"checkbox\" id=\"showRemoved\" checked> Removed</label>\n");
92+
sb.append(" <label><input type=\"checkbox\" id=\"showCommon\" checked> Common</label>\n");
93+
sb.append(" <button id=\"themeBtn\">Toggle Theme</button>\n");
94+
sb.append(" </div>\n");
95+
sb.append("</div>\n");
96+
97+
sb.append("<div id=\"main\">\n");
98+
sb.append(" <div id=\"stats\"></div>\n");
99+
sb.append(" <div id=\"graph\"></div>\n");
100+
sb.append(" <div id=\"legend\">\n");
101+
sb.append(" <div class=\"legend-item\"><span class=\"dot added\"></span> Added in ").append(escapeHtml(labelB)).append("</div>\n");
102+
sb.append(" <div class=\"legend-item\"><span class=\"dot removed\"></span> Removed from ").append(escapeHtml(labelA)).append("</div>\n");
103+
sb.append(" <div class=\"legend-item\"><span class=\"dot common\"></span> Common</div>\n");
104+
sb.append(" </div>\n");
105+
sb.append("</div>\n");
106+
107+
sb.append("<div id=\"tooltip\"></div>\n");
108+
109+
sb.append("<script>\n");
110+
appendData(sb, diff);
111+
appendJs(sb);
112+
sb.append("</script>\n");
113+
sb.append("</body>\n</html>\n");
114+
return sb.toString();
115+
}
116+
117+
private void appendCss(StringBuilder sb) {
118+
sb.append(
119+
"* { margin: 0; padding: 0; box-sizing: border-box; }\n" +
120+
"body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; transition: all 0.3s; }\n" +
121+
"body.dark { background: #1a1a2e; color: #e0e0e0; }\n" +
122+
"body.light { background: #f5f5f5; color: #333; }\n" +
123+
"#header { padding: 12px 20px; display: flex; align-items: center; justify-content: space-between; }\n" +
124+
"#header h1 { font-size: 1.3em; }\n" +
125+
"#controls { display: flex; gap: 12px; align-items: center; }\n" +
126+
"#controls label { cursor: pointer; font-size: 0.9em; }\n" +
127+
"#controls button { padding: 4px 12px; border-radius: 4px; border: 1px solid #666; background: transparent; color: inherit; cursor: pointer; }\n" +
128+
"#main { display: flex; height: calc(100vh - 60px); }\n" +
129+
"#stats { width: 220px; padding: 12px; font-size: 0.85em; overflow-y: auto; }\n" +
130+
".dark #stats { background: #16213e; }\n" +
131+
".light #stats { background: #e8e8e8; }\n" +
132+
"#stats h3 { margin: 10px 0 6px; font-size: 1em; }\n" +
133+
"#stats .stat-row { display: flex; justify-content: space-between; padding: 2px 0; }\n" +
134+
"#graph { flex: 1; }\n" +
135+
"#graph svg { width: 100%; height: 100%; }\n" +
136+
"#legend { position: absolute; bottom: 20px; right: 20px; padding: 10px; border-radius: 6px; }\n" +
137+
".dark #legend { background: rgba(22,33,62,0.9); }\n" +
138+
".light #legend { background: rgba(255,255,255,0.9); border: 1px solid #ccc; }\n" +
139+
".legend-item { display: flex; align-items: center; gap: 6px; margin: 4px 0; font-size: 0.85em; }\n" +
140+
".dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }\n" +
141+
".dot.added { background: #00e676; }\n" +
142+
".dot.removed { background: #ff5252; }\n" +
143+
".dot.common { background: #78909c; }\n" +
144+
"#tooltip { position: absolute; display: none; padding: 8px 12px; border-radius: 4px; font-size: 0.85em; pointer-events: none; z-index: 100; }\n" +
145+
".dark #tooltip { background: #0f3460; color: #fff; }\n" +
146+
".light #tooltip { background: #fff; color: #333; border: 1px solid #ccc; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }\n" +
147+
".link { stroke-opacity: 0.6; }\n" +
148+
".link.added { stroke: #00e676; }\n" +
149+
".link.removed { stroke: #ff5252; stroke-dasharray: 5,3; }\n" +
150+
".link.common { stroke: #78909c; }\n" +
151+
".node-label { font-size: 10px; text-anchor: middle; pointer-events: none; }\n" +
152+
".dark .node-label { fill: #e0e0e0; }\n" +
153+
".light .node-label { fill: #333; }\n"
154+
);
155+
}
156+
157+
private void appendData(StringBuilder sb, GraphDiffAnalyzer.DiffResult diff) {
158+
// Build nodes array
159+
sb.append("const diffData = {\n");
160+
161+
// Stats
162+
sb.append(" stats: {\n");
163+
sb.append(" nodesAdded: ").append(diff.getAddedNodes().size()).append(",\n");
164+
sb.append(" nodesRemoved: ").append(diff.getRemovedNodes().size()).append(",\n");
165+
sb.append(" nodesCommon: ").append(diff.getCommonNodes().size()).append(",\n");
166+
sb.append(" edgesAdded: ").append(diff.getAddedEdges().size()).append(",\n");
167+
sb.append(" edgesRemoved: ").append(diff.getRemovedEdges().size()).append(",\n");
168+
sb.append(" edgesCommon: ").append(diff.getCommonEdges().size()).append(",\n");
169+
sb.append(" nodeJaccard: ").append(String.format("%.4f", diff.getNodeJaccard())).append(",\n");
170+
sb.append(" edgeJaccard: ").append(String.format("%.4f", diff.getEdgeJaccard())).append(",\n");
171+
sb.append(" editDistance: ").append(diff.getEditDistance()).append(",\n");
172+
sb.append(" identical: ").append(diff.isIdentical()).append("\n");
173+
sb.append(" },\n");
174+
175+
// Nodes
176+
sb.append(" nodes: [\n");
177+
Map<String, Integer> degA = new HashMap<>();
178+
Map<String, Integer> degB = new HashMap<>();
179+
for (String v : graphA.getVertices()) degA.put(v, graphA.degree(v));
180+
for (String v : graphB.getVertices()) degB.put(v, graphB.degree(v));
181+
182+
boolean first = true;
183+
for (String v : diff.getAddedNodes()) {
184+
if (!first) sb.append(",\n");
185+
first = false;
186+
sb.append(" {id:\"").append(escapeJs(v)).append("\",status:\"added\",degA:0,degB:").append(degB.getOrDefault(v, 0)).append("}");
187+
}
188+
for (String v : diff.getRemovedNodes()) {
189+
if (!first) sb.append(",\n");
190+
first = false;
191+
sb.append(" {id:\"").append(escapeJs(v)).append("\",status:\"removed\",degA:").append(degA.getOrDefault(v, 0)).append(",degB:0}");
192+
}
193+
for (String v : diff.getCommonNodes()) {
194+
if (!first) sb.append(",\n");
195+
first = false;
196+
sb.append(" {id:\"").append(escapeJs(v)).append("\",status:\"common\",degA:").append(degA.getOrDefault(v, 0)).append(",degB:").append(degB.getOrDefault(v, 0)).append("}");
197+
}
198+
sb.append("\n ],\n");
199+
200+
// Edges
201+
sb.append(" links: [\n");
202+
first = true;
203+
for (GraphDiffAnalyzer.EdgeDiff e : diff.getAddedEdges()) {
204+
if (!first) sb.append(",\n");
205+
first = false;
206+
sb.append(" {source:\"").append(escapeJs(e.getVertex1())).append("\",target:\"").append(escapeJs(e.getVertex2())).append("\",status:\"added\"}");
207+
}
208+
for (GraphDiffAnalyzer.EdgeDiff e : diff.getRemovedEdges()) {
209+
if (!first) sb.append(",\n");
210+
first = false;
211+
sb.append(" {source:\"").append(escapeJs(e.getVertex1())).append("\",target:\"").append(escapeJs(e.getVertex2())).append("\",status:\"removed\"}");
212+
}
213+
for (GraphDiffAnalyzer.EdgeDiff e : diff.getCommonEdges()) {
214+
if (!first) sb.append(",\n");
215+
first = false;
216+
sb.append(" {source:\"").append(escapeJs(e.getVertex1())).append("\",target:\"").append(escapeJs(e.getVertex2())).append("\",status:\"common\"}");
217+
}
218+
sb.append("\n ],\n");
219+
220+
// Degree changes
221+
sb.append(" degreeChanges: {\n");
222+
first = true;
223+
for (Map.Entry<String, int[]> entry : diff.getDegreeChanges().entrySet()) {
224+
if (!first) sb.append(",\n");
225+
first = false;
226+
sb.append(" \"").append(escapeJs(entry.getKey())).append("\": [")
227+
.append(entry.getValue()[0]).append(",").append(entry.getValue()[1]).append("]");
228+
}
229+
sb.append("\n },\n");
230+
sb.append(" labelA: \"").append(escapeJs(labelA)).append("\",\n");
231+
sb.append(" labelB: \"").append(escapeJs(labelB)).append("\"\n");
232+
sb.append("};\n");
233+
}
234+
235+
private void appendJs(StringBuilder sb) {
236+
sb.append(
237+
"const colors = { added: '#00e676', removed: '#ff5252', common: '#78909c' };\n" +
238+
"const tooltip = d3.select('#tooltip');\n" +
239+
"const container = d3.select('#graph');\n" +
240+
"const w = container.node().clientWidth;\n" +
241+
"const h = container.node().clientHeight;\n" +
242+
"const svg = container.append('svg').attr('viewBox', `0 0 ${w} ${h}`);\n" +
243+
"const g = svg.append('g');\n" +
244+
"\n" +
245+
"svg.call(d3.zoom().scaleExtent([0.1, 8]).on('zoom', e => g.attr('transform', e.transform)));\n" +
246+
"\n" +
247+
"const sim = d3.forceSimulation(diffData.nodes)\n" +
248+
" .force('link', d3.forceLink(diffData.links).id(d => d.id).distance(60))\n" +
249+
" .force('charge', d3.forceManyBody().strength(-120))\n" +
250+
" .force('center', d3.forceCenter(w/2, h/2))\n" +
251+
" .force('collision', d3.forceCollide().radius(18));\n" +
252+
"\n" +
253+
"let linkSel = g.append('g').selectAll('line');\n" +
254+
"let nodeSel = g.append('g').selectAll('circle');\n" +
255+
"let labelSel = g.append('g').selectAll('text');\n" +
256+
"\n" +
257+
"function getVisibility() {\n" +
258+
" return {\n" +
259+
" added: document.getElementById('showAdded').checked,\n" +
260+
" removed: document.getElementById('showRemoved').checked,\n" +
261+
" common: document.getElementById('showCommon').checked\n" +
262+
" };\n" +
263+
"}\n" +
264+
"\n" +
265+
"function update() {\n" +
266+
" const vis = getVisibility();\n" +
267+
" const visNodes = diffData.nodes.filter(n => vis[n.status]);\n" +
268+
" const visIds = new Set(visNodes.map(n => n.id));\n" +
269+
" const visLinks = diffData.links.filter(l => {\n" +
270+
" const sId = typeof l.source === 'object' ? l.source.id : l.source;\n" +
271+
" const tId = typeof l.target === 'object' ? l.target.id : l.target;\n" +
272+
" return vis[l.status] && visIds.has(sId) && visIds.has(tId);\n" +
273+
" });\n" +
274+
"\n" +
275+
" linkSel = linkSel.data(visLinks, d => {\n" +
276+
" const s = typeof d.source === 'object' ? d.source.id : d.source;\n" +
277+
" const t = typeof d.target === 'object' ? d.target.id : d.target;\n" +
278+
" return s + '-' + t;\n" +
279+
" });\n" +
280+
" linkSel.exit().remove();\n" +
281+
" linkSel = linkSel.enter().append('line')\n" +
282+
" .attr('class', d => 'link ' + d.status)\n" +
283+
" .attr('stroke-width', 1.5)\n" +
284+
" .merge(linkSel);\n" +
285+
"\n" +
286+
" nodeSel = nodeSel.data(visNodes, d => d.id);\n" +
287+
" nodeSel.exit().remove();\n" +
288+
" nodeSel = nodeSel.enter().append('circle')\n" +
289+
" .attr('r', d => 4 + Math.max(d.degA, d.degB))\n" +
290+
" .attr('fill', d => colors[d.status])\n" +
291+
" .attr('stroke', d => d3.color(colors[d.status]).darker(0.5))\n" +
292+
" .attr('stroke-width', 1.5)\n" +
293+
" .call(d3.drag().on('start', dragStart).on('drag', dragging).on('end', dragEnd))\n" +
294+
" .on('mouseover', (e, d) => {\n" +
295+
" tooltip.style('display', 'block')\n" +
296+
" .html(`<b>${d.id}</b><br>Status: ${d.status}<br>Degree in ${diffData.labelA}: ${d.degA}<br>Degree in ${diffData.labelB}: ${d.degB}`)\n" +
297+
" .style('left', (e.pageX + 12) + 'px').style('top', (e.pageY - 10) + 'px');\n" +
298+
" })\n" +
299+
" .on('mouseout', () => tooltip.style('display', 'none'))\n" +
300+
" .merge(nodeSel);\n" +
301+
"\n" +
302+
" labelSel = labelSel.data(visNodes, d => d.id);\n" +
303+
" labelSel.exit().remove();\n" +
304+
" labelSel = labelSel.enter().append('text')\n" +
305+
" .attr('class', 'node-label')\n" +
306+
" .attr('dy', d => -(6 + Math.max(d.degA, d.degB)))\n" +
307+
" .text(d => d.id)\n" +
308+
" .merge(labelSel);\n" +
309+
"}\n" +
310+
"\n" +
311+
"sim.on('tick', () => {\n" +
312+
" linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y)\n" +
313+
" .attr('x2', d => d.target.x).attr('y2', d => d.target.y);\n" +
314+
" nodeSel.attr('cx', d => d.x).attr('cy', d => d.y);\n" +
315+
" labelSel.attr('x', d => d.x).attr('y', d => d.y);\n" +
316+
"});\n" +
317+
"\n" +
318+
"function dragStart(e, d) { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }\n" +
319+
"function dragging(e, d) { d.fx = e.x; d.fy = e.y; }\n" +
320+
"function dragEnd(e, d) { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }\n" +
321+
"\n" +
322+
"// Stats panel\n" +
323+
"const s = diffData.stats;\n" +
324+
"document.getElementById('stats').innerHTML = `\n" +
325+
" <h3>Diff Statistics</h3>\n" +
326+
" <div class='stat-row'><span>Nodes added</span><span style='color:#00e676'>${s.nodesAdded}</span></div>\n" +
327+
" <div class='stat-row'><span>Nodes removed</span><span style='color:#ff5252'>${s.nodesRemoved}</span></div>\n" +
328+
" <div class='stat-row'><span>Nodes common</span><span>${s.nodesCommon}</span></div>\n" +
329+
" <hr style='margin:8px 0;border-color:#444'>\n" +
330+
" <div class='stat-row'><span>Edges added</span><span style='color:#00e676'>${s.edgesAdded}</span></div>\n" +
331+
" <div class='stat-row'><span>Edges removed</span><span style='color:#ff5252'>${s.edgesRemoved}</span></div>\n" +
332+
" <div class='stat-row'><span>Edges common</span><span>${s.edgesCommon}</span></div>\n" +
333+
" <hr style='margin:8px 0;border-color:#444'>\n" +
334+
" <h3>Similarity</h3>\n" +
335+
" <div class='stat-row'><span>Node Jaccard</span><span>${s.nodeJaccard}</span></div>\n" +
336+
" <div class='stat-row'><span>Edge Jaccard</span><span>${s.edgeJaccard}</span></div>\n" +
337+
" <div class='stat-row'><span>Edit Distance</span><span>${s.editDistance}</span></div>\n" +
338+
" ${s.identical ? '<p style=\"margin-top:10px;color:#00e676\">Graphs are identical!</p>' : ''}\n" +
339+
" <hr style='margin:8px 0;border-color:#444'>\n" +
340+
" <h3>Degree Changes</h3>\n" +
341+
" ${Object.entries(diffData.degreeChanges).slice(0,20).map(([k,v]) =>\n" +
342+
" '<div class=\"stat-row\"><span>'+k+'</span><span>'+v[0]+' → '+v[1]+'</span></div>'\n" +
343+
" ).join('')}\n" +
344+
" ${Object.keys(diffData.degreeChanges).length > 20 ? '<p>...and '+(Object.keys(diffData.degreeChanges).length-20)+' more</p>' : ''}\n" +
345+
"`;\n" +
346+
"\n" +
347+
"// Filter toggles\n" +
348+
"['showAdded','showRemoved','showCommon'].forEach(id =>\n" +
349+
" document.getElementById(id).addEventListener('change', update));\n" +
350+
"\n" +
351+
"// Theme toggle\n" +
352+
"document.getElementById('themeBtn').addEventListener('click', () => {\n" +
353+
" document.body.classList.toggle('dark');\n" +
354+
" document.body.classList.toggle('light');\n" +
355+
"});\n" +
356+
"\n" +
357+
"update();\n"
358+
);
359+
}
360+
361+
private static String escapeHtml(String s) {
362+
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
363+
}
364+
365+
private static String escapeJs(String s) {
366+
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "");
367+
}
368+
}

0 commit comments

Comments
 (0)