Created
November 18, 2025 16:16
-
-
Save lucashmorais/6db0c2e70a78fee0fa5c74b3f11d9696 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Label Propagation Graph Clustering – Animation</title> | |
| <style> | |
| :root { | |
| color-scheme: dark; | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| background: #050711; | |
| color: #e5e7eb; | |
| } | |
| body { | |
| margin: 0; | |
| padding: 1.5rem; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| background: radial-gradient(circle at top, #0b1020 0, #050711 50%, #020309 100%); | |
| } | |
| h1 { | |
| margin: 0 0 0.75rem 0; | |
| font-size: 1.4rem; | |
| letter-spacing: 0.03em; | |
| text-transform: uppercase; | |
| color: #e5e7eb; | |
| } | |
| #subtitle { | |
| font-size: 0.9rem; | |
| color: #9ca3af; | |
| margin-bottom: 1rem; | |
| max-width: 800px; | |
| text-align: center; | |
| } | |
| #container { | |
| border-radius: 1rem; | |
| padding: 1rem 1rem 0.5rem 1rem; | |
| background: linear-gradient(135deg, rgba(34, 197, 94, 0.12), rgba(56, 189, 248, 0.08)); | |
| box-shadow: 0 18px 40px rgba(0,0,0,0.65); | |
| backdrop-filter: blur(18px); | |
| } | |
| #controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| margin-bottom: 0.7rem; | |
| font-size: 0.9rem; | |
| color: #e5e7eb; | |
| } | |
| button { | |
| border-radius: 999px; | |
| border: none; | |
| padding: 0.4rem 0.9rem; | |
| font-size: 0.85rem; | |
| cursor: pointer; | |
| font-weight: 500; | |
| letter-spacing: 0.02em; | |
| background: rgba(15, 23, 42, 0.9); | |
| color: #e5e7eb; | |
| box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.4); | |
| transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease; | |
| } | |
| button:hover { | |
| transform: translateY(-1px); | |
| box-shadow: 0 6px 18px rgba(15, 23, 42, 0.8); | |
| background: rgba(15, 23, 42, 1); | |
| } | |
| button:active { | |
| transform: translateY(0); | |
| box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.4); | |
| } | |
| #iterationInfo { | |
| margin-left: auto; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-end; | |
| line-height: 1.2; | |
| } | |
| #iterationLabel { | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| color: #9ca3af; | |
| } | |
| #iterationValue { | |
| font-size: 0.95rem; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| #status { | |
| font-size: 0.75rem; | |
| color: #9ca3af; | |
| margin-top: 0.1rem; | |
| } | |
| #legend { | |
| display: flex; | |
| gap: 1.5rem; | |
| font-size: 0.8rem; | |
| color: #9ca3af; | |
| margin: 0.4rem 0 0.7rem; | |
| align-items: center; | |
| } | |
| #legend span { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 0.3rem; | |
| } | |
| .dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 999px; | |
| background: #22c55e; | |
| box-shadow: 0 0 12px rgba(34, 197, 94, 0.7); | |
| } | |
| .dot-changed { | |
| background: #f59e0b; | |
| box-shadow: 0 0 12px rgba(245, 158, 11, 0.7); | |
| } | |
| svg { | |
| background: radial-gradient(circle at center, #020617 0, #020617 40%, #000 100%); | |
| border-radius: 0.85rem; | |
| box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.9); | |
| } | |
| .edge { | |
| stroke: rgba(148, 163, 184, 0.35); | |
| stroke-width: 1.8; | |
| stroke-linecap: round; | |
| } | |
| .node circle { | |
| r: 16; | |
| stroke-width: 2; | |
| stroke: rgba(15, 23, 42, 0.95); | |
| transition: fill 0.35s ease, stroke-width 0.2s ease, r 0.2s ease, filter 0.2s ease; | |
| filter: drop-shadow(0 0 6px rgba(15, 23, 42, 0.5)); | |
| } | |
| .node-label { | |
| fill: #f9fafb; | |
| font-size: 11px; | |
| text-anchor: middle; | |
| dominant-baseline: central; | |
| pointer-events: none; | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .node.changed circle { | |
| stroke-width: 3; | |
| r: 18; | |
| filter: drop-shadow(0 0 12px rgba(248, 250, 252, 0.8)); | |
| } | |
| .node-outline { | |
| fill: none; | |
| stroke: rgba(148, 163, 184, 0.25); | |
| stroke-width: 12; | |
| opacity: 0; | |
| transition: opacity 0.25s ease; | |
| } | |
| .node.changed .node-outline { | |
| opacity: 1; | |
| } | |
| @media (max-width: 860px) { | |
| body { | |
| padding: 0.75rem; | |
| } | |
| #container { | |
| width: 100%; | |
| padding: 0.9rem 0.9rem 0.4rem; | |
| } | |
| svg { | |
| width: 100%; | |
| height: auto; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Label Propagation Graph Clustering</h1> | |
| <div id="subtitle"> | |
| Each node starts with a unique label (color) and repeatedly adopts the most frequent label among its neighbors, | |
| causing communities to emerge as colors stabilize. | |
| </div> | |
| <div id="container"> | |
| <div id="controls"> | |
| <button id="playPause">Play</button> | |
| <button id="step">Step</button> | |
| <button id="reset">Reset</button> | |
| <div id="iterationInfo"> | |
| <div id="iterationLabel">Iteration</div> | |
| <div id="iterationValue">0</div> | |
| <div id="status">Ready</div> | |
| </div> | |
| </div> | |
| <div id="legend"> | |
| <span><span class="dot"></span>Current label (community)</span> | |
| <span><span class="dot dot-changed"></span>Nodes that changed this step</span> | |
| </div> | |
| <svg id="graph" width="800" height="460" viewBox="0 0 800 460"></svg> | |
| </div> | |
| <script> | |
| // --- Graph definition: 3 clusters with a few inter-community edges --- | |
| const nodes = [ | |
| { id: 0, x: 120, y: 130 }, | |
| { id: 1, x: 75, y: 225 }, | |
| { id: 2, x: 170, y: 230 }, | |
| { id: 3, x: 215, y: 150 }, | |
| { id: 4, x: 360, y: 120 }, | |
| { id: 5, x: 320, y: 220 }, | |
| { id: 6, x: 405, y: 230 }, | |
| { id: 7, x: 450, y: 150 }, | |
| { id: 8, x: 600, y: 120 }, | |
| { id: 9, x: 560, y: 220 }, | |
| { id: 10, x: 645, y: 230 }, | |
| { id: 11, x: 690, y: 150 } | |
| ]; | |
| const edges = [ | |
| // Cluster 1 | |
| { source: 0, target: 1 }, | |
| { source: 0, target: 2 }, | |
| { source: 1, target: 2 }, | |
| { source: 2, target: 3 }, | |
| { source: 0, target: 3 }, | |
| { source: 1, target: 3 }, | |
| // Cluster 2 | |
| { source: 4, target: 5 }, | |
| { source: 4, target: 6 }, | |
| { source: 5, target: 6 }, | |
| { source: 6, target: 7 }, | |
| { source: 4, target: 7 }, | |
| { source: 5, target: 7 }, | |
| // Cluster 3 | |
| { source: 8, target: 9 }, | |
| { source: 8, target: 10 }, | |
| { source: 9, target: 10 }, | |
| { source: 10, target: 11 }, | |
| { source: 8, target: 11 }, | |
| { source: 9, target: 11 }, | |
| // Weak inter-cluster links | |
| { source: 2, target: 4 }, | |
| { source: 3, target: 5 }, | |
| { source: 6, target: 8 }, | |
| { source: 7, target: 9 } | |
| ]; | |
| // --- State --- | |
| const adjacency = []; | |
| const nodeCircles = []; | |
| const nodeLabels = []; | |
| const nodeGroups = []; | |
| let labels = []; | |
| let iteration = 0; | |
| let running = false; | |
| let intervalId = null; | |
| const svg = document.getElementById("graph"); | |
| const playPauseBtn = document.getElementById("playPause"); | |
| const stepBtn = document.getElementById("step"); | |
| const resetBtn = document.getElementById("reset"); | |
| const iterationValueEl = document.getElementById("iterationValue"); | |
| const statusEl = document.getElementById("status"); | |
| const NS = "http://www.w3.org/2000/svg"; | |
| // --- Utility: color mapping for labels --- | |
| function labelToColor(label) { | |
| // Map numeric label to HSL color in green/teal band | |
| const n = nodes.length || 1; | |
| const num = Number(label) % n; | |
| const hue = 120 + (num * 120) / (n - 1 || 1); // 120–240 degrees | |
| return "hsl(" + hue + ", 70%, 55%)"; | |
| } | |
| // --- Build adjacency and SVG --- | |
| function initGraph() { | |
| // Adjacency | |
| for (let i = 0; i < nodes.length; i++) { | |
| adjacency[i] = []; | |
| } | |
| for (const e of edges) { | |
| adjacency[e.source].push(e.target); | |
| adjacency[e.target].push(e.source); | |
| } | |
| // Edges | |
| for (const e of edges) { | |
| const line = document.createElementNS(NS, "line"); | |
| const s = nodes[e.source]; | |
| const t = nodes[e.target]; | |
| line.setAttribute("x1", s.x); | |
| line.setAttribute("y1", s.y); | |
| line.setAttribute("x2", t.x); | |
| line.setAttribute("y2", t.y); | |
| line.setAttribute("class", "edge"); | |
| svg.appendChild(line); | |
| } | |
| // Nodes | |
| nodes.forEach((node, i) => { | |
| const g = document.createElementNS(NS, "g"); | |
| g.setAttribute("class", "node"); | |
| g.setAttribute("transform", "translate(" + node.x + "," + node.y + ")"); | |
| const halo = document.createElementNS(NS, "circle"); | |
| halo.setAttribute("class", "node-outline"); | |
| halo.setAttribute("r", "22"); | |
| const circle = document.createElementNS(NS, "circle"); | |
| const label = document.createElementNS(NS, "text"); | |
| label.setAttribute("class", "node-label"); | |
| label.setAttribute("y", "1"); // slight vertical offset | |
| g.appendChild(halo); | |
| g.appendChild(circle); | |
| g.appendChild(label); | |
| svg.appendChild(g); | |
| nodeGroups[i] = g; | |
| nodeCircles[i] = circle; | |
| nodeLabels[i] = label; | |
| }); | |
| } | |
| // --- Label propagation step --- | |
| function computeNextLabels(current) { | |
| const n = nodes.length; | |
| const next = current.slice(); | |
| const changedNodes = []; | |
| for (let i = 0; i < n; i++) { | |
| const neighbors = adjacency[i]; | |
| if (neighbors.length === 0) continue; | |
| const counts = new Map(); | |
| for (const nb of neighbors) { | |
| const lbl = current[nb]; | |
| counts.set(lbl, (counts.get(lbl) || 0) + 1); | |
| } | |
| // Choose label with maximum frequency; break ties by smallest numeric label | |
| let bestLabel = current[i]; | |
| let bestCount = -1; | |
| for (const [lbl, cnt] of counts.entries()) { | |
| const lblNum = Number(lbl); | |
| const bestNum = Number(bestLabel); | |
| if (cnt > bestCount || (cnt === bestCount && lblNum < bestNum)) { | |
| bestCount = cnt; | |
| bestLabel = lbl; | |
| } | |
| } | |
| if (bestLabel !== current[i]) { | |
| next[i] = bestLabel; | |
| changedNodes.push(i); | |
| } | |
| } | |
| return { next, changedNodes }; | |
| } | |
| // --- Rendering helpers --- | |
| function updateNodeAppearance(changedNodes) { | |
| const changedSet = new Set(changedNodes); | |
| for (let i = 0; i < nodes.length; i++) { | |
| const circle = nodeCircles[i]; | |
| const label = nodeLabels[i]; | |
| circle.setAttribute("fill", labelToColor(labels[i])); | |
| label.textContent = labels[i]; | |
| if (changedSet.has(i)) { | |
| nodeGroups[i].classList.add("changed"); | |
| } else { | |
| nodeGroups[i].classList.remove("changed"); | |
| } | |
| } | |
| } | |
| function updateIterationInfo(message) { | |
| iterationValueEl.textContent = iteration; | |
| statusEl.textContent = message || ""; | |
| } | |
| // --- Control logic --- | |
| function stepOnce() { | |
| const { next, changedNodes } = computeNextLabels(labels); | |
| if (changedNodes.length === 0) { | |
| // Converged | |
| running = false; | |
| if (intervalId !== null) { | |
| clearInterval(intervalId); | |
| intervalId = null; | |
| } | |
| updateNodeAppearance([]); | |
| updateIterationInfo("Converged – no labels changed"); | |
| playPauseBtn.textContent = "Play"; | |
| return false; | |
| } | |
| labels = next; | |
| iteration += 1; | |
| updateNodeAppearance(changedNodes); | |
| updateIterationInfo("Updated " + changedNodes.length + " node(s)"); | |
| return true; | |
| } | |
| function play() { | |
| if (running) return; | |
| running = true; | |
| playPauseBtn.textContent = "Pause"; | |
| updateIterationInfo("Running label propagation…"); | |
| if (intervalId !== null) clearInterval(intervalId); | |
| intervalId = setInterval(() => { | |
| const continueRunning = stepOnce(); | |
| if (!continueRunning) { | |
| // Converged | |
| running = false; | |
| playPauseBtn.textContent = "Play"; | |
| if (intervalId !== null) { | |
| clearInterval(intervalId); | |
| intervalId = null; | |
| } | |
| } | |
| }, 900); | |
| } | |
| function pause() { | |
| running = false; | |
| playPauseBtn.textContent = "Play"; | |
| if (intervalId !== null) { | |
| clearInterval(intervalId); | |
| intervalId = null; | |
| } | |
| updateIterationInfo("Paused"); | |
| } | |
| function reset() { | |
| pause(); | |
| labels = nodes.map(n => n.id); // each node starts with its own label | |
| iteration = 0; | |
| updateNodeAppearance([]); | |
| updateIterationInfo("Ready"); | |
| } | |
| // --- Wire up controls --- | |
| function initControls() { | |
| playPauseBtn.addEventListener("click", () => { | |
| if (running) { | |
| pause(); | |
| } else { | |
| play(); | |
| } | |
| }); | |
| stepBtn.addEventListener("click", () => { | |
| if (running) { | |
| pause(); | |
| } | |
| stepOnce(); | |
| }); | |
| resetBtn.addEventListener("click", () => { | |
| reset(); | |
| }); | |
| } | |
| // --- Initialize everything --- | |
| (function main() { | |
| initGraph(); | |
| initControls(); | |
| reset(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment