|
| 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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); |
| 363 | + } |
| 364 | + |
| 365 | + private static String escapeJs(String s) { |
| 366 | + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", ""); |
| 367 | + } |
| 368 | +} |
0 commit comments