Click any cell to toggle it between foreground (colored) and background (white). The algorithm runs instantly after every click, assigning a different color to each connected island.
<!-- interactive connected-components labeling demo -->
<p class="hint">Click or drag to toggle cells. Each connected island of colored cells gets its own color. Watch two islands merge when you bridge them with a single pixel.</p>
<div class="controls">
<button id="btn-clear" type="button" class="ghost">Clear</button>
<button id="btn-random" type="button" class="ghost">Random</button>
<span class="count-badge" id="count-badge">Islands: <strong id="island-count">0</strong></span>
</div>
<div id="grid" class="grid" role="grid" aria-label="Pixel grid — click cells to toggle foreground"></div>
<div class="legend" id="legend"></div>
/* layout and color for the CCL demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.count-badge { font: 600 14px system-ui; color: #1d3557; margin-left: auto; }
.grid { display: grid; gap: 2px; width: fit-content; user-select: none; margin-bottom: .5rem; }
.cell { width: 28px; height: 28px; border-radius: 4px; cursor: pointer;
background: #e8eef3; border: 1px solid #cdd9e3; transition: background .08s; }
.cell.fg { border-color: transparent; }
.cell:hover { opacity: .82; }
.legend { display: flex; flex-wrap: wrap; gap: .35rem .6rem; font-size: .8rem; color: #555; }
.legend-item { display: flex; align-items: center; gap: .3rem; }
.legend-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
/* Connected-components labeling demo — union-find on a pixel grid */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* grid dimensions and color palette (up to 15 distinct component colors) */
const ROWS = 10, COLS = 14;
const PALETTE = [
'#e63946','#2a9d8f','#e9c46a','#264653','#a8dadc',
'#f4a261','#457b9d','#6a994e','#bc6c25','#9b5de5',
'#0077b6','#f15bb5','#00b4d8','#8338ec','#fb5607'
];
/* pixel state: 1 = foreground, 0 = background */
const grid = Array.from({ length: ROWS }, () => new Array(COLS).fill(0));
let dragging = false;
let dragValue = 1; /* 1=paint, 0=erase */
/* Union-Find (disjoint-set forest) with path splitting and union-by-rank */
function makeUF(n) {
const parent = Array.from({ length: n }, (_, i) => i);
const rank = new Array(n).fill(0);
function find(x) {
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
return x;
}
function union(a, b) {
a = find(a); b = find(b);
if (a === b) return;
if (rank[a] < rank[b]) { const t = a; a = b; b = t; }
parent[b] = a;
if (rank[a] === rank[b]) rank[a]++;
}
return { find, union };
}
/* Run one-pass CCL: sweep left-right/top-bottom, union with left and top neighbors */
function labelGrid() {
const n = ROWS * COLS;
const uf = makeUF(n);
const idx = (r, c) => r * COLS + c;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!grid[r][c]) continue;
/* merge with left neighbor */
if (c > 0 && grid[r][c - 1]) uf.union(idx(r, c), idx(r, c - 1));
/* merge with top neighbor */
if (r > 0 && grid[r - 1][c]) uf.union(idx(r, c), idx(r - 1, c));
}
}
/* assign compact labels: root -> sequential integer */
const rootToLabel = new Map();
let nextLabel = 0;
const labels = new Array(n).fill(-1);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!grid[r][c]) continue;
const root = uf.find(idx(r, c));
if (!rootToLabel.has(root)) { rootToLabel.set(root, nextLabel++); }
labels[idx(r, c)] = rootToLabel.get(root);
}
}
return { labels, count: nextLabel };
}
/* re-render every cell with its component color */
function render() {
const { labels, count } = labelGrid();
const cells = document.querySelectorAll('.cell');
cells.forEach((cell, i) => {
const lbl = labels[i];
if (lbl === -1) {
cell.style.background = '';
cell.classList.remove('fg');
} else {
cell.style.background = PALETTE[lbl % PALETTE.length];
cell.classList.add('fg');
}
});
document.getElementById('island-count').textContent = count;
renderLegend(count);
}
/* build a color legend below the grid */
function renderLegend(count) {
const el = document.getElementById('legend');
if (count === 0) { el.innerHTML = ''; return; }
el.innerHTML = Array.from({ length: count }, (_, i) => {
const color = PALETTE[i % PALETTE.length];
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${i18nFmt('Component %n%', { n: i + 1 })}</span>`;
}).join('');
}
/* build the DOM grid once */
function buildGrid() {
const g = document.getElementById('grid');
g.style.gridTemplateColumns = `repeat(${COLS}, 28px)`;
g.innerHTML = '';
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.r = r;
cell.dataset.c = c;
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', i18nFmt('Cell row %r% column %c%', { r: r + 1, c: c + 1 }));
g.appendChild(cell);
}
}
}
/* set one pixel and re-render */
function toggleCell(r, c, value) {
grid[r][c] = value;
render();
}
/* mouse events: click to toggle, drag to paint/erase */
document.getElementById('grid').addEventListener('mousedown', (e) => {
const cell = e.target.closest('.cell');
if (!cell) return;
const r = +cell.dataset.r, c = +cell.dataset.c;
dragValue = grid[r][c] ? 0 : 1; /* first cell decides paint vs erase for the whole drag */
dragging = true;
toggleCell(r, c, dragValue);
});
document.getElementById('grid').addEventListener('mouseover', (e) => {
if (!dragging) return;
const cell = e.target.closest('.cell');
if (!cell) return;
toggleCell(+cell.dataset.r, +cell.dataset.c, dragValue);
});
document.addEventListener('mouseup', () => { dragging = false; });
document.getElementById('btn-clear').addEventListener('click', () => {
for (let r = 0; r < ROWS; r++) grid[r].fill(0);
render();
});
document.getElementById('btn-random').addEventListener('click', () => {
/* fill at ~42% density for interesting connectivity */
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
grid[r][c] = Math.random() < 0.42 ? 1 : 0;
render();
});
/* initial render */
buildGrid();
/* pre-load a few islands so the grid is not empty on load */
const SEED = [[1,1],[1,2],[2,1],[4,3],[4,4],[5,4],[7,1],[7,2],[7,3],[2,8],[3,8],[3,9],[5,10],[6,10],[6,11]];
SEED.forEach(([r, c]) => { grid[r][c] = 1; });
render();
<!-- interactive connected-components labeling demo -->
<p class="hint">Click or drag to toggle cells. Each connected island of colored cells gets its own color. Watch two islands merge when you bridge them with a single pixel.</p>
<div class="controls">
<button id="btn-clear" type="button" class="ghost">Clear</button>
<button id="btn-random" type="button" class="ghost">Random</button>
<span class="count-badge" id="count-badge">Islands: <strong id="island-count">0</strong></span>
</div>
<div id="grid" class="grid" role="grid" aria-label="Pixel grid — click cells to toggle foreground"></div>
<div class="legend" id="legend"></div>
/* layout and color for the CCL demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.count-badge { font: 600 14px system-ui; color: #1d3557; margin-left: auto; }
.grid { display: grid; gap: 2px; width: fit-content; user-select: none; margin-bottom: .5rem; }
.cell { width: 28px; height: 28px; border-radius: 4px; cursor: pointer;
background: #e8eef3; border: 1px solid #cdd9e3; transition: background .08s; }
.cell.fg { border-color: transparent; }
.cell:hover { opacity: .82; }
.legend { display: flex; flex-wrap: wrap; gap: .35rem .6rem; font-size: .8rem; color: #555; }
.legend-item { display: flex; align-items: center; gap: .3rem; }
.legend-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
/* Connected-components labeling demo — union-find on a pixel grid */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* grid dimensions and color palette (up to 15 distinct component colors) */
const ROWS = 10, COLS = 14;
const PALETTE = [
'#e63946','#2a9d8f','#e9c46a','#264653','#a8dadc',
'#f4a261','#457b9d','#6a994e','#bc6c25','#9b5de5',
'#0077b6','#f15bb5','#00b4d8','#8338ec','#fb5607'
];
/* pixel state: 1 = foreground, 0 = background */
const grid = Array.from({ length: ROWS }, () => new Array(COLS).fill(0));
let dragging = false;
let dragValue = 1; /* 1=paint, 0=erase */
/* Union-Find (disjoint-set forest) with path splitting and union-by-rank */
function makeUF(n) {
const parent = Array.from({ length: n }, (_, i) => i);
const rank = new Array(n).fill(0);
function find(x) {
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
return x;
}
function union(a, b) {
a = find(a); b = find(b);
if (a === b) return;
if (rank[a] < rank[b]) { const t = a; a = b; b = t; }
parent[b] = a;
if (rank[a] === rank[b]) rank[a]++;
}
return { find, union };
}
/* Run one-pass CCL: sweep left-right/top-bottom, union with left and top neighbors */
function labelGrid() {
const n = ROWS * COLS;
const uf = makeUF(n);
const idx = (r, c) => r * COLS + c;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!grid[r][c]) continue;
/* merge with left neighbor */
if (c > 0 && grid[r][c - 1]) uf.union(idx(r, c), idx(r, c - 1));
/* merge with top neighbor */
if (r > 0 && grid[r - 1][c]) uf.union(idx(r, c), idx(r - 1, c));
}
}
/* assign compact labels: root -> sequential integer */
const rootToLabel = new Map();
let nextLabel = 0;
const labels = new Array(n).fill(-1);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
if (!grid[r][c]) continue;
const root = uf.find(idx(r, c));
if (!rootToLabel.has(root)) { rootToLabel.set(root, nextLabel++); }
labels[idx(r, c)] = rootToLabel.get(root);
}
}
return { labels, count: nextLabel };
}
/* re-render every cell with its component color */
function render() {
const { labels, count } = labelGrid();
const cells = document.querySelectorAll('.cell');
cells.forEach((cell, i) => {
const lbl = labels[i];
if (lbl === -1) {
cell.style.background = '';
cell.classList.remove('fg');
} else {
cell.style.background = PALETTE[lbl % PALETTE.length];
cell.classList.add('fg');
}
});
document.getElementById('island-count').textContent = count;
renderLegend(count);
}
/* build a color legend below the grid */
function renderLegend(count) {
const el = document.getElementById('legend');
if (count === 0) { el.innerHTML = ''; return; }
el.innerHTML = Array.from({ length: count }, (_, i) => {
const color = PALETTE[i % PALETTE.length];
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${i18nFmt('Component %n%', { n: i + 1 })}</span>`;
}).join('');
}
/* build the DOM grid once */
function buildGrid() {
const g = document.getElementById('grid');
g.style.gridTemplateColumns = `repeat(${COLS}, 28px)`;
g.innerHTML = '';
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const cell = document.createElement('div');
cell.className = 'cell';
cell.dataset.r = r;
cell.dataset.c = c;
cell.setAttribute('role', 'gridcell');
cell.setAttribute('aria-label', i18nFmt('Cell row %r% column %c%', { r: r + 1, c: c + 1 }));
g.appendChild(cell);
}
}
}
/* set one pixel and re-render */
function toggleCell(r, c, value) {
grid[r][c] = value;
render();
}
/* mouse events: click to toggle, drag to paint/erase */
document.getElementById('grid').addEventListener('mousedown', (e) => {
const cell = e.target.closest('.cell');
if (!cell) return;
const r = +cell.dataset.r, c = +cell.dataset.c;
dragValue = grid[r][c] ? 0 : 1; /* first cell decides paint vs erase for the whole drag */
dragging = true;
toggleCell(r, c, dragValue);
});
document.getElementById('grid').addEventListener('mouseover', (e) => {
if (!dragging) return;
const cell = e.target.closest('.cell');
if (!cell) return;
toggleCell(+cell.dataset.r, +cell.dataset.c, dragValue);
});
document.addEventListener('mouseup', () => { dragging = false; });
document.getElementById('btn-clear').addEventListener('click', () => {
for (let r = 0; r < ROWS; r++) grid[r].fill(0);
render();
});
document.getElementById('btn-random').addEventListener('click', () => {
/* fill at ~42% density for interesting connectivity */
for (let r = 0; r < ROWS; r++)
for (let c = 0; c < COLS; c++)
grid[r][c] = Math.random() < 0.42 ? 1 : 0;
render();
});
/* initial render */
buildGrid();
/* pre-load a few islands so the grid is not empty on load */
const SEED = [[1,1],[1,2],[2,1],[4,3],[4,4],[5,4],[7,1],[7,2],[7,3],[2,8],[3,8],[3,9],[5,10],[6,10],[6,11]];
SEED.forEach(([r, c]) => { grid[r][c] = 1; });
render();
Notice what happens when two previously separate blobs become connected by a single bridge pixel: they instantly merge into one component. That merge is the heart of the union-find data structure — or, in the two-pass approach, the moment a second-pass equivalence table collapses two labels into one. Either way, the total work stays proportional to the number of pixels.
Comments
Loading comments...