|
| 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("&", "&").replace("<", "<") |
| 242 | + .replace(">", ">").replace("\"", """); |
| 243 | + } |
| 244 | +} |
0 commit comments