Below is a simplified view of the pipeline. The demo generates synthetic cells with gene-expression profiles that secretly belong to a few types. The algorithm builds a k-nearest-neighbor (kNN) graph connecting each cell to its most similar peers, then runs Leiden community detection to partition the graph into clusters.
<!-- controls and canvas for the single-cell clustering demo -->
<div class="controls">
<label>Neighbors k: <input type="range" id="kSlider" min="2" max="10" value="6"> <span id="kVal">6</span></label>
<label>Cells: <input type="range" id="nSlider" min="30" max="120" value="60"> <span id="nVal">60</span></label>
<button id="recluster" type="button">Recluster</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="cv" width="480" height="320"></canvas>
<div id="status" class="status">Adjust sliders and click Recluster.</div>
/* styles for the single-cell clustering demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f7fa; padding: .6rem; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin-bottom: .5rem; }
label { font-size: .85rem; display: flex; gap: .3rem; align-items: center; }
input[type=range] { width: 80px; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #fff; display: block; max-width: 100%; }
.status { margin-top: .4rem; font-size: .88rem; font-weight: 600; min-height: 1.2em; color: #444; }
.status.ok { color: #0a7d33; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// color palette — one color per discovered cluster
const PALETTE = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#6a4c93','#43aa8b','#f77f00'];
// generate synthetic cells arranged in Gaussian blobs
function generateCells(n) {
// five cluster centers in normalized [0,1] x [0,1] space
const centers = [
[0.2, 0.2], [0.75, 0.2], [0.5, 0.65],
[0.2, 0.75], [0.8, 0.75]
];
const cells = [];
for (let i = 0; i < n; i++) {
const c = centers[i % centers.length];
// Box-Muller transform for Gaussian noise
const u1 = Math.random(), u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1 + 1e-9)) * Math.cos(2 * Math.PI * u2);
const w = Math.sqrt(-2 * Math.log(Math.random() + 1e-9)) * Math.cos(2 * Math.PI * Math.random());
cells.push({ x: c[0] + z * 0.11, y: c[1] + w * 0.11, trueGroup: i % centers.length });
}
return cells;
}
// build a k-nearest-neighbor graph in 2D Euclidean space
function buildKNN(cells, k) {
const n = cells.length;
const adj = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) {
const dists = [];
for (let j = 0; j < n; j++) {
if (i === j) continue;
const dx = cells[i].x - cells[j].x, dy = cells[i].y - cells[j].y;
dists.push({ j, d: dx * dx + dy * dy });
}
dists.sort((a, b) => a.d - b.d);
for (let t = 0; t < k && t < dists.length; t++) {
adj[i].push(dists[t].j);
adj[dists[t].j].push(i);
}
}
// deduplicate undirected edges
return adj.map(a => [...new Set(a)]);
}
// simplified Leiden-style greedy modularity optimization
function leiden(adj) {
const n = adj.length;
let comm = Array.from({ length: n }, (_, i) => i);
// iterate until no improvement or max passes reached
let improved = true;
const totalEdges = adj.reduce((s, a) => s + a.length, 0) / 2;
const deg = adj.map(a => a.length);
function gain(node, target, comm) {
let k_in = 0;
for (const nb of adj[node]) if (comm[nb] === target) k_in++;
const sigma_tot = deg.filter((_, i) => comm[i] === target).reduce((a, b) => a + b, 0);
return k_in / totalEdges - (sigma_tot * deg[node]) / (2 * totalEdges * totalEdges);
}
// greedy local move phase: move each node to the best neighbor community
for (let pass = 0; pass < 20 && improved; pass++) {
improved = false;
for (let i = 0; i < n; i++) {
const cur = comm[i];
let bestG = 0, bestC = cur;
const candidates = new Set(adj[i].map(nb => comm[nb]));
candidates.delete(cur);
for (const c of candidates) {
const g = gain(i, c, comm) - gain(i, cur, comm);
if (g > bestG) { bestG = g; bestC = c; }
}
if (bestC !== cur) { comm[i] = bestC; improved = true; }
}
}
// relabel community ids to 0..numClusters-1
const map = new Map();
let next = 0;
return comm.map(c => { if (!map.has(c)) map.set(c, next++); return map.get(c); });
}
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const kSlider = document.getElementById('kSlider');
const nSlider = document.getElementById('nSlider');
const kVal = document.getElementById('kVal');
const nVal = document.getElementById('nVal');
const status = document.getElementById('status');
let cells = [], comm = [], adj = [];
function draw() {
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
// draw kNN graph edges in light gray
ctx.lineWidth = 0.5;
ctx.strokeStyle = '#cdd9e3';
for (let i = 0; i < cells.length; i++) {
for (const j of adj[i]) {
if (j > i) {
ctx.beginPath();
ctx.moveTo(cells[i].x * W, cells[i].y * H);
ctx.lineTo(cells[j].x * W, cells[j].y * H);
ctx.stroke();
}
}
}
// draw each cell dot colored by its cluster
for (let i = 0; i < cells.length; i++) {
const col = PALETTE[comm[i] % PALETTE.length];
ctx.beginPath();
ctx.arc(cells[i].x * W, cells[i].y * H, 5, 0, 2 * Math.PI);
ctx.fillStyle = col;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
function run() {
const k = +kSlider.value;
const n = +nSlider.value;
cells = generateCells(n);
adj = buildKNN(cells, k);
comm = leiden(adj);
const nClusters = new Set(comm).size;
draw();
status.textContent = i18nFmt('Found %n% cluster(s) with k=%k% neighbors.', { n: nClusters, k: k });
status.className = 'status ok';
}
kSlider.addEventListener('input', () => { kVal.textContent = kSlider.value; });
nSlider.addEventListener('input', () => { nVal.textContent = nSlider.value; });
document.getElementById('recluster').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => {
cells = []; comm = []; adj = [];
ctx.clearRect(0, 0, cv.width, cv.height);
status.textContent = 'Adjust sliders and click Recluster.';
status.className = 'status';
});
run();
<!-- controls and canvas for the single-cell clustering demo -->
<div class="controls">
<label>Neighbors k: <input type="range" id="kSlider" min="2" max="10" value="6"> <span id="kVal">6</span></label>
<label>Cells: <input type="range" id="nSlider" min="30" max="120" value="60"> <span id="nVal">60</span></label>
<button id="recluster" type="button">Recluster</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="cv" width="480" height="320"></canvas>
<div id="status" class="status">Adjust sliders and click Recluster.</div>
/* styles for the single-cell clustering demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f7fa; padding: .6rem; }
.controls { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin-bottom: .5rem; }
label { font-size: .85rem; display: flex; gap: .3rem; align-items: center; }
input[type=range] { width: 80px; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { border: 1px solid #cdd9e3; border-radius: 8px; background: #fff; display: block; max-width: 100%; }
.status { margin-top: .4rem; font-size: .88rem; font-weight: 600; min-height: 1.2em; color: #444; }
.status.ok { color: #0a7d33; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// color palette — one color per discovered cluster
const PALETTE = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#f4a261','#6a4c93','#43aa8b','#f77f00'];
// generate synthetic cells arranged in Gaussian blobs
function generateCells(n) {
// five cluster centers in normalized [0,1] x [0,1] space
const centers = [
[0.2, 0.2], [0.75, 0.2], [0.5, 0.65],
[0.2, 0.75], [0.8, 0.75]
];
const cells = [];
for (let i = 0; i < n; i++) {
const c = centers[i % centers.length];
// Box-Muller transform for Gaussian noise
const u1 = Math.random(), u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1 + 1e-9)) * Math.cos(2 * Math.PI * u2);
const w = Math.sqrt(-2 * Math.log(Math.random() + 1e-9)) * Math.cos(2 * Math.PI * Math.random());
cells.push({ x: c[0] + z * 0.11, y: c[1] + w * 0.11, trueGroup: i % centers.length });
}
return cells;
}
// build a k-nearest-neighbor graph in 2D Euclidean space
function buildKNN(cells, k) {
const n = cells.length;
const adj = Array.from({ length: n }, () => []);
for (let i = 0; i < n; i++) {
const dists = [];
for (let j = 0; j < n; j++) {
if (i === j) continue;
const dx = cells[i].x - cells[j].x, dy = cells[i].y - cells[j].y;
dists.push({ j, d: dx * dx + dy * dy });
}
dists.sort((a, b) => a.d - b.d);
for (let t = 0; t < k && t < dists.length; t++) {
adj[i].push(dists[t].j);
adj[dists[t].j].push(i);
}
}
// deduplicate undirected edges
return adj.map(a => [...new Set(a)]);
}
// simplified Leiden-style greedy modularity optimization
function leiden(adj) {
const n = adj.length;
let comm = Array.from({ length: n }, (_, i) => i);
// iterate until no improvement or max passes reached
let improved = true;
const totalEdges = adj.reduce((s, a) => s + a.length, 0) / 2;
const deg = adj.map(a => a.length);
function gain(node, target, comm) {
let k_in = 0;
for (const nb of adj[node]) if (comm[nb] === target) k_in++;
const sigma_tot = deg.filter((_, i) => comm[i] === target).reduce((a, b) => a + b, 0);
return k_in / totalEdges - (sigma_tot * deg[node]) / (2 * totalEdges * totalEdges);
}
// greedy local move phase: move each node to the best neighbor community
for (let pass = 0; pass < 20 && improved; pass++) {
improved = false;
for (let i = 0; i < n; i++) {
const cur = comm[i];
let bestG = 0, bestC = cur;
const candidates = new Set(adj[i].map(nb => comm[nb]));
candidates.delete(cur);
for (const c of candidates) {
const g = gain(i, c, comm) - gain(i, cur, comm);
if (g > bestG) { bestG = g; bestC = c; }
}
if (bestC !== cur) { comm[i] = bestC; improved = true; }
}
}
// relabel community ids to 0..numClusters-1
const map = new Map();
let next = 0;
return comm.map(c => { if (!map.has(c)) map.set(c, next++); return map.get(c); });
}
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const kSlider = document.getElementById('kSlider');
const nSlider = document.getElementById('nSlider');
const kVal = document.getElementById('kVal');
const nVal = document.getElementById('nVal');
const status = document.getElementById('status');
let cells = [], comm = [], adj = [];
function draw() {
const W = cv.width, H = cv.height;
ctx.clearRect(0, 0, W, H);
// draw kNN graph edges in light gray
ctx.lineWidth = 0.5;
ctx.strokeStyle = '#cdd9e3';
for (let i = 0; i < cells.length; i++) {
for (const j of adj[i]) {
if (j > i) {
ctx.beginPath();
ctx.moveTo(cells[i].x * W, cells[i].y * H);
ctx.lineTo(cells[j].x * W, cells[j].y * H);
ctx.stroke();
}
}
}
// draw each cell dot colored by its cluster
for (let i = 0; i < cells.length; i++) {
const col = PALETTE[comm[i] % PALETTE.length];
ctx.beginPath();
ctx.arc(cells[i].x * W, cells[i].y * H, 5, 0, 2 * Math.PI);
ctx.fillStyle = col;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}
}
function run() {
const k = +kSlider.value;
const n = +nSlider.value;
cells = generateCells(n);
adj = buildKNN(cells, k);
comm = leiden(adj);
const nClusters = new Set(comm).size;
draw();
status.textContent = i18nFmt('Found %n% cluster(s) with k=%k% neighbors.', { n: nClusters, k: k });
status.className = 'status ok';
}
kSlider.addEventListener('input', () => { kVal.textContent = kSlider.value; });
nSlider.addEventListener('input', () => { nVal.textContent = nSlider.value; });
document.getElementById('recluster').addEventListener('click', run);
document.getElementById('reset').addEventListener('click', () => {
cells = []; comm = []; adj = [];
ctx.clearRect(0, 0, cv.width, cv.height);
status.textContent = 'Adjust sliders and click Recluster.';
status.className = 'status';
});
run();
Comments
Loading comments...