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

Commit df36e83

Browse files
feat: Graph Layout Comparer — 2x2 HTML export comparing force/circular/grid/radial layouts
Adds GraphLayoutComparer.java: exports a self-contained HTML page showing the same graph rendered with 4 layout algorithms side-by-side. Features: - Force-directed, circular, grid, and radial (by degree) layouts - Synchronized hover highlighting across all four views - Edge coloring by relationship type, node sizing by degree - Independent zoom/pan per panel - Dark/light theme toggle - Single HTML file, no server needed — uses D3.js from CDN Usage: Click 'Layout Compare' button in the toolbar, save the HTML, and open in any browser. Hover nodes to see them highlighted across all layouts simultaneously. Docs: docs/layout-compare.html
1 parent e271463 commit df36e83

3 files changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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 JUNG graph as a self-contained HTML page showing the same graph
11+
* rendered with 4 different layout algorithms side-by-side in a 2×2 grid:
12+
* force-directed, circular, grid, and radial (by degree).
13+
*
14+
* <p>Users can visually compare how different layouts reveal different
15+
* structural properties of the same network — clusters, hubs, symmetry, etc.</p>
16+
*
17+
* <p>Features:</p>
18+
* <ul>
19+
* <li>2×2 layout comparison grid (force, circular, grid, radial)</li>
20+
* <li>Synchronized hover highlighting across all four views</li>
21+
* <li>Edge coloring by relationship type</li>
22+
* <li>Node size by degree</li>
23+
* <li>Zoom and pan per panel</li>
24+
* <li>Dark/light theme toggle</li>
25+
* <li>Export-ready: single self-contained HTML file</li>
26+
* </ul>
27+
*
28+
* <p>Usage:</p>
29+
* <pre>
30+
* GraphLayoutComparer comparer = new GraphLayoutComparer(graph);
31+
* comparer.setTitle("My Network");
32+
* comparer.export(new File("layout-comparison.html"));
33+
* </pre>
34+
*
35+
* @author zalenix
36+
*/
37+
public class GraphLayoutComparer {
38+
39+
private final Graph<String, edge> graph;
40+
private String title = "Graph Layout Comparison";
41+
42+
public GraphLayoutComparer(Graph<String, edge> graph) {
43+
this.graph = Objects.requireNonNull(graph, "graph must not be null");
44+
}
45+
46+
public void setTitle(String title) {
47+
this.title = title;
48+
}
49+
50+
/**
51+
* Exports the comparison page to the given file.
52+
*/
53+
public void export(File file) throws IOException {
54+
String html = exportToString();
55+
try (Writer w = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) {
56+
w.write(html);
57+
}
58+
}
59+
60+
/**
61+
* Returns the full HTML string.
62+
*/
63+
public String exportToString() {
64+
StringBuilder sb = new StringBuilder(16384);
65+
Collection<String> vertices = graph.getVertices();
66+
Collection<edge> edges = graph.getEdges();
67+
68+
// Build JSON data
69+
StringBuilder nodeJson = new StringBuilder("[");
70+
Map<String, Integer> idMap = new HashMap<>();
71+
int idx = 0;
72+
for (String v : vertices) {
73+
if (idx > 0) nodeJson.append(",");
74+
int deg = graph.degree(v);
75+
idMap.put(v, idx);
76+
nodeJson.append("{\"id\":\"").append(escJs(v))
77+
.append("\",\"deg\":").append(deg).append("}");
78+
idx++;
79+
}
80+
nodeJson.append("]");
81+
82+
StringBuilder linkJson = new StringBuilder("[");
83+
boolean first = true;
84+
for (edge e : edges) {
85+
String v1 = e.getVertex1() != null ? e.getVertex1() :
86+
graph.getEndpoints(e).getFirst().toString();
87+
String v2 = e.getVertex2() != null ? e.getVertex2() :
88+
graph.getEndpoints(e).getSecond().toString();
89+
if (!idMap.containsKey(v1) || !idMap.containsKey(v2)) continue;
90+
if (!first) linkJson.append(",");
91+
first = false;
92+
String type = e.getType() != null ? e.getType() : "unknown";
93+
linkJson.append("{\"source\":").append(idMap.get(v1))
94+
.append(",\"target\":").append(idMap.get(v2))
95+
.append(",\"type\":\"").append(escJs(type)).append("\"}");
96+
}
97+
linkJson.append("]");
98+
99+
int nodeCount = vertices.size();
100+
int edgeCount = edges.size();
101+
102+
sb.append("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">");
103+
sb.append("<title>").append(escHtml(title)).append("</title>");
104+
sb.append("<script src=\"https://d3js.org/d3.v7.min.js\"></script>");
105+
sb.append("<style>");
106+
sb.append(getCSS());
107+
sb.append("</style></head><body>");
108+
sb.append("<div id=\"header\"><h1>").append(escHtml(title)).append("</h1>");
109+
sb.append("<span class=\"stats\">").append(nodeCount).append(" nodes, ")
110+
.append(edgeCount).append(" edges</span>");
111+
sb.append("<button id=\"themeBtn\" onclick=\"toggleTheme()\">🌙 Dark</button></div>");
112+
sb.append("<div id=\"grid\">");
113+
sb.append("<div class=\"cell\" id=\"cell-force\"><h3>Force-Directed</h3><svg id=\"svg-force\"></svg></div>");
114+
sb.append("<div class=\"cell\" id=\"cell-circular\"><h3>Circular</h3><svg id=\"svg-circular\"></svg></div>");
115+
sb.append("<div class=\"cell\" id=\"cell-grid\"><h3>Grid</h3><svg id=\"svg-grid\"></svg></div>");
116+
sb.append("<div class=\"cell\" id=\"cell-radial\"><h3>Radial (by Degree)</h3><svg id=\"svg-radial\"></svg></div>");
117+
sb.append("</div>");
118+
sb.append("<script>");
119+
sb.append("const rawNodes=").append(nodeJson).append(";");
120+
sb.append("const rawLinks=").append(linkJson).append(";");
121+
sb.append(getJS());
122+
sb.append("</script></body></html>");
123+
return sb.toString();
124+
}
125+
126+
private String getCSS() {
127+
return "*{margin:0;padding:0;box-sizing:border-box;}"
128+
+ "body{font-family:system-ui,sans-serif;background:#f5f5f5;color:#333;transition:all .3s;}"
129+
+ "body.dark{background:#1a1a2e;color:#eee;}"
130+
+ "#header{display:flex;align-items:center;gap:16px;padding:12px 20px;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.1);}"
131+
+ "body.dark #header{background:#16213e;}"
132+
+ "#header h1{font-size:18px;}"
133+
+ ".stats{color:#888;font-size:13px;}"
134+
+ "#themeBtn{margin-left:auto;cursor:pointer;border:1px solid #ccc;border-radius:6px;padding:4px 12px;background:transparent;color:inherit;}"
135+
+ "#grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:8px;height:calc(100vh - 56px);}"
136+
+ ".cell{background:#fff;border-radius:8px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 1px 3px rgba(0,0,0,.08);}"
137+
+ "body.dark .cell{background:#16213e;}"
138+
+ ".cell h3{font-size:13px;padding:6px 12px;border-bottom:1px solid #eee;}"
139+
+ "body.dark .cell h3{border-color:#2a2a4a;}"
140+
+ ".cell svg{flex:1;width:100%;}"
141+
+ "line.link{stroke-opacity:.4;stroke-width:1;}"
142+
+ "circle.node{stroke:#fff;stroke-width:1;cursor:pointer;}"
143+
+ "body.dark circle.node{stroke:#1a1a2e;}"
144+
+ ".highlight circle.node{opacity:.15;}.highlight line.link{opacity:.05;}"
145+
+ ".highlight circle.node.active{opacity:1;stroke-width:2;}"
146+
+ ".highlight line.link.active{opacity:.8;stroke-width:2;}";
147+
}
148+
149+
private String getJS() {
150+
return "const typeColors={f:'#4fc3f7',c:'#81c784',fs:'#ffb74d',s:'#e57373',sg:'#ba68c8',unknown:'#90a4ae'};"
151+
+ "let dark=false;"
152+
+ "function toggleTheme(){dark=!dark;document.body.classList.toggle('dark');document.getElementById('themeBtn').textContent=dark?'☀️ Light':'🌙 Dark';}"
153+
+ "const panels=['force','circular','grid','radial'];"
154+
+ "const svgs={};"
155+
+ "const W=()=>document.querySelector('.cell svg').clientWidth||400;"
156+
+ "const H=()=>document.querySelector('.cell svg').clientHeight||350;"
157+
+ "function cloneData(){return{nodes:rawNodes.map(d=>({...d})),links:rawLinks.map(d=>({...d}))}}"
158+
+ "function render(id,layoutFn){"
159+
+ " const{nodes,links}=cloneData();"
160+
+ " const svg=d3.select('#svg-'+id);"
161+
+ " const w=W(),h=H();"
162+
+ " svg.attr('viewBox','0 0 '+w+' '+h);"
163+
+ " const g=svg.append('g');"
164+
+ " svg.call(d3.zoom().scaleExtent([.3,5]).on('zoom',e=>g.attr('transform',e.transform)));"
165+
+ " layoutFn(nodes,links,w,h);"
166+
+ " const maxDeg=d3.max(nodes,d=>d.deg)||1;"
167+
+ " const linkSel=g.selectAll('line.link').data(links).join('line').attr('class','link')"
168+
+ " .attr('x1',d=>nodes[d.source]?nodes[d.source].x:d.source.x).attr('y1',d=>nodes[d.source]?nodes[d.source].y:d.source.y)"
169+
+ " .attr('x2',d=>nodes[d.target]?nodes[d.target].x:d.target.x).attr('y2',d=>nodes[d.target]?nodes[d.target].y:d.target.y)"
170+
+ " .attr('stroke',d=>typeColors[d.type]||typeColors.unknown);"
171+
+ " const nodeSel=g.selectAll('circle.node').data(nodes).join('circle').attr('class','node')"
172+
+ " .attr('cx',d=>d.x).attr('cy',d=>d.y)"
173+
+ " .attr('r',d=>3+Math.sqrt(d.deg/maxDeg)*8)"
174+
+ " .attr('fill',d=>{const nb=links.filter(l=>(l.source===d||l.source===d.index||nodes[l.source]===d)&&l.type);const t=nb.length?nb[0].type:'unknown';return typeColors[t]||typeColors.unknown;})"
175+
+ " .on('mouseenter',(_,d)=>highlightAll(d.id))"
176+
+ " .on('mouseleave',()=>clearHighlight());"
177+
+ " nodeSel.append('title').text(d=>d.id+' (deg '+d.deg+')');"
178+
+ " svgs[id]={svg,g,nodeSel,linkSel,nodes,links};"
179+
+ "}"
180+
+ "function highlightAll(nodeId){"
181+
+ " panels.forEach(p=>{"
182+
+ " if(!svgs[p])return;"
183+
+ " const s=svgs[p];"
184+
+ " s.svg.classed('highlight',true);"
185+
+ " const nbSet=new Set([nodeId]);"
186+
+ " s.links.forEach((l,i)=>{"
187+
+ " const si=typeof l.source==='object'?l.source.id:s.nodes[l.source].id;"
188+
+ " const ti=typeof l.target==='object'?l.target.id:s.nodes[l.target].id;"
189+
+ " if(si===nodeId||ti===nodeId){nbSet.add(si);nbSet.add(ti);s.linkSel.filter((_,j)=>j===i).classed('active',true);}"
190+
+ " });"
191+
+ " s.nodeSel.classed('active',d=>nbSet.has(d.id));"
192+
+ " });"
193+
+ "}"
194+
+ "function clearHighlight(){panels.forEach(p=>{if(!svgs[p])return;svgs[p].svg.classed('highlight',false);svgs[p].nodeSel.classed('active',false);svgs[p].linkSel.classed('active',false);});}"
195+
// Force layout
196+
+ "function layoutForce(nodes,links,w,h){"
197+
+ " const sim=d3.forceSimulation(nodes)"
198+
+ " .force('link',d3.forceLink(links).distance(40))"
199+
+ " .force('charge',d3.forceManyBody().strength(-60))"
200+
+ " .force('center',d3.forceCenter(w/2,h/2))"
201+
+ " .stop();"
202+
+ " for(let i=0;i<200;i++)sim.tick();"
203+
+ "}"
204+
// Circular layout
205+
+ "function layoutCircular(nodes,links,w,h){"
206+
+ " const cx=w/2,cy=h/2,r=Math.min(w,h)*0.4;"
207+
+ " nodes.forEach((d,i)=>{const a=2*Math.PI*i/nodes.length;d.x=cx+r*Math.cos(a);d.y=cy+r*Math.sin(a);});"
208+
+ "}"
209+
// Grid layout
210+
+ "function layoutGrid(nodes,links,w,h){"
211+
+ " const cols=Math.ceil(Math.sqrt(nodes.length));"
212+
+ " const sx=w/(cols+1),sy=h/(Math.ceil(nodes.length/cols)+1);"
213+
+ " nodes.forEach((d,i)=>{d.x=sx*(i%cols+1);d.y=sy*(Math.floor(i/cols)+1);});"
214+
+ "}"
215+
// Radial layout (by degree)
216+
+ "function layoutRadial(nodes,links,w,h){"
217+
+ " const cx=w/2,cy=h/2;"
218+
+ " const maxDeg=d3.max(nodes,d=>d.deg)||1;"
219+
+ " const sorted=[...nodes].sort((a,b)=>b.deg-a.deg);"
220+
+ " const shells=new Map();"
221+
+ " sorted.forEach(d=>{const shell=Math.floor((1-d.deg/maxDeg)*4);if(!shells.has(shell))shells.set(shell,[]);shells.get(shell).push(d);});"
222+
+ " shells.forEach((arr,shell)=>{"
223+
+ " const r=(shell+1)*Math.min(w,h)*0.1;"
224+
+ " arr.forEach((d,i)=>{const a=2*Math.PI*i/arr.length;d.x=cx+r*Math.cos(a);d.y=cy+r*Math.sin(a);});"
225+
+ " });"
226+
+ "}"
227+
+ "render('force',layoutForce);"
228+
+ "render('circular',layoutCircular);"
229+
+ "render('grid',layoutGrid);"
230+
+ "render('radial',layoutRadial);";
231+
}
232+
233+
private static String escJs(String s) {
234+
if (s == null) return "";
235+
return s.replace("\\", "\\\\").replace("\"", "\\\"")
236+
.replace("\n", "\\n").replace("\r", "");
237+
}
238+
239+
private static String escHtml(String s) {
240+
if (s == null) return "";
241+
return s.replace("&", "&amp;").replace("<", "&lt;")
242+
.replace(">", "&gt;").replace("\"", "&quot;");
243+
}
244+
}

Gvisual/src/gvisual/Main.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2506,6 +2506,24 @@ public final void initializeToolBar() {
25062506
});
25072507
toolPanel.add(diffHtmlButton);
25082508

2509+
// Layout Comparison HTML export — same graph with 4 different layouts
2510+
ExportActions.addExportButton(toolPanel, this,
2511+
"<html><center>Layout Compare<br/>Compare 4 layout<br/>algorithms for<br/>the same graph<br/>in one HTML page</center></html>",
2512+
"Export Layout Comparison HTML",
2513+
() -> "layout_compare_" + timeStamp + ".html",
2514+
new String[]{".html"},
2515+
outFile -> {
2516+
GraphLayoutComparer comparer = new GraphLayoutComparer(g);
2517+
comparer.setTitle("Layout Comparison \u2014 " + timeStamp);
2518+
comparer.export(outFile);
2519+
return "Layout comparison exported!\n"
2520+
+ "Nodes: " + g.getVertexCount() + "\n"
2521+
+ "Edges: " + g.getEdgeCount() + "\n"
2522+
+ "Layouts: Force-Directed, Circular, Grid, Radial\n"
2523+
+ "File: " + outFile.getName() + "\n\n"
2524+
+ "Open in any browser. Hover a node to highlight it across all layouts.";
2525+
});
2526+
25092527
toolPanel.add(legendPanel);
25102528
contentPanel.add(toolPanel, BorderLayout.WEST);
25112529
}

docs/layout-compare.html

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Layout Comparer – GraphVisual</title>
6+
<style>
7+
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; color: #333; }
8+
h1 { border-bottom: 2px solid #4fc3f7; padding-bottom: 8px; }
9+
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
10+
pre { background: #f0f0f0; padding: 16px; border-radius: 8px; overflow-x: auto; }
11+
.grid-demo { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 16px 0; }
12+
.grid-demo div { background: #e3f2fd; padding: 12px; border-radius: 6px; text-align: center; font-weight: 600; }
13+
a { color: #1976d2; }
14+
</style>
15+
</head>
16+
<body>
17+
<h1>📐 Graph Layout Comparer</h1>
18+
<p>Export your graph as a self-contained HTML page showing <strong>4 layout algorithms side-by-side</strong> in a 2×2 grid. Compare how different spatial arrangements reveal different structural properties of the same network.</p>
19+
20+
<h2>Layouts</h2>
21+
<div class="grid-demo">
22+
<div>🔄 Force-Directed<br><small>Clusters &amp; communities</small></div>
23+
<div>⭕ Circular<br><small>Symmetry &amp; connectivity</small></div>
24+
<div>📐 Grid<br><small>Node count overview</small></div>
25+
<div>🎯 Radial (by Degree)<br><small>Hub hierarchy</small></div>
26+
</div>
27+
28+
<h2>Features</h2>
29+
<ul>
30+
<li><strong>Synchronized hover</strong> — hover a node in one panel to highlight it and its neighbors across all four views</li>
31+
<li><strong>Zoom &amp; pan</strong> — each panel has independent zoom/pan via mouse wheel and drag</li>
32+
<li><strong>Edge type coloring</strong> — friend, classmate, familiar stranger, stranger, study group</li>
33+
<li><strong>Node sizing</strong> — nodes scaled by degree centrality</li>
34+
<li><strong>Dark/light theme</strong> — toggle with one click</li>
35+
<li><strong>Zero dependencies</strong> — single HTML file, just open in a browser</li>
36+
</ul>
37+
38+
<h2>Usage (GUI)</h2>
39+
<p>Click the <strong>"Layout Compare"</strong> button in the toolbar, choose a save location, and open the exported HTML file in any modern browser.</p>
40+
41+
<h2>Usage (Code)</h2>
42+
<pre>GraphLayoutComparer comparer = new GraphLayoutComparer(graph);
43+
comparer.setTitle("Student Network 2011");
44+
comparer.export(new File("layout-comparison.html"));</pre>
45+
46+
<h2>When to use which layout?</h2>
47+
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;">
48+
<tr><th>Layout</th><th>Best for revealing</th></tr>
49+
<tr><td>Force-Directed</td><td>Natural clusters, community structure, densely connected groups</td></tr>
50+
<tr><td>Circular</td><td>Overall connectivity patterns, edge crossing density, symmetry</td></tr>
51+
<tr><td>Grid</td><td>Full node inventory, finding isolated nodes, total graph size</td></tr>
52+
<tr><td>Radial</td><td>Hub nodes, degree hierarchy, core-periphery structure</td></tr>
53+
</table>
54+
55+
<p><a href="index.html">← Back to docs</a></p>
56+
</body>
57+
</html>

0 commit comments

Comments
 (0)