Skip to content

Instantly share code, notes, and snippets.

@lucashmorais
Created November 18, 2025 16:16
Show Gist options
  • Select an option

  • Save lucashmorais/6db0c2e70a78fee0fa5c74b3f11d9696 to your computer and use it in GitHub Desktop.

Select an option

Save lucashmorais/6db0c2e70a78fee0fa5c74b3f11d9696 to your computer and use it in GitHub Desktop.
<!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