<p class="hint">Each colored dot is a <b>site</b>. Every pixel is shaded by whichever site is currently closest to it. Drag a site to see its cell — and every neighboring cell — redraw live.</p>
<canvas id="board" class="board" width="480" height="320"></canvas>
<div class="status" id="status">Drag a site, or add a new one.</div>
<div class="btns">
<button id="addSite" type="button">Add random site</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.board { display: block; width: 100%; max-width: 480px; height: auto; aspect-ratio: 480 / 320;
border-radius: 10px; border: 1px solid #cdd9e3; cursor: crosshair; touch-action: none; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Voronoi diagram demo: for every pixel, find the nearest site (by squared
// Euclidean distance) and color it accordingly. Dragging a site just moves one
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const CELL = 6; // pixel block size for the coloring pass (bigger = faster, coarser borders)
// one fixed color per site, reused cyclically if more sites are added
const PALETTE = ['#e63946', '#1d3557', '#2a9d8f', '#e9c46a', '#8338ec', '#ff6d00', '#457b9d', '#d62828'];
let sites = [
{ x: 110, y: 90 },
{ x: 340, y: 70 },
{ x: 260, y: 210 },
{ x: 90, y: 250 },
];
let dragIndex = -1;
function dist2(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }
// Linear scan over all sites — O(n) per pixel, fine for a handful of sites.
function nearestSite(x, y) {
let best = 0, bestD = Infinity;
for (let i = 0; i < sites.length; i++) {
const d = dist2(x, y, sites[i].x, sites[i].y);
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
function render() {
// Coloring pass: recompute the nearest site for every block on the canvas.
for (let py = 0; py < H; py += CELL) {
for (let px = 0; px < W; px += CELL) {
const owner = nearestSite(px + CELL / 2, py + CELL / 2);
ctx.fillStyle = PALETTE[owner % PALETTE.length] + '33';
ctx.fillRect(px, py, CELL, CELL);
}
}
// Draw the site markers on top of their own cells.
sites.forEach((s, i) => {
ctx.beginPath();
ctx.arc(s.x, s.y, 8, 0, Math.PI * 2);
ctx.fillStyle = PALETTE[i % PALETTE.length];
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#fff';
ctx.stroke();
});
}
function canvasPoint(evt) {
const rect = canvas.getBoundingClientRect();
const cx = (evt.touches ? evt.touches[0].clientX : evt.clientX) - rect.left;
const cy = (evt.touches ? evt.touches[0].clientY : evt.clientY) - rect.top;
return { x: cx * (W / rect.width), y: cy * (H / rect.height) };
}
function setStatus(t) { document.getElementById('status').textContent = t; }
function pointerDown(evt) {
const p = canvasPoint(evt);
let hit = -1, hitD = 400; // squared pixel radius (~20px) to grab a site with the pointer
sites.forEach((s, i) => { const d = dist2(p.x, p.y, s.x, s.y); if (d < hitD) { hitD = d; hit = i; } });
dragIndex = hit;
if (hit >= 0) setStatus('Dragging… release to drop the site.');
evt.preventDefault();
}
function pointerMove(evt) {
if (dragIndex < 0) return;
const p = canvasPoint(evt);
sites[dragIndex].x = Math.max(0, Math.min(W, p.x));
sites[dragIndex].y = Math.max(0, Math.min(H, p.y));
render();
evt.preventDefault();
}
function pointerUp() {
if (dragIndex >= 0) setStatus('Cell borders updated — every border sits exactly halfway between two sites.');
dragIndex = -1;
}
canvas.addEventListener('mousedown', pointerDown);
canvas.addEventListener('mousemove', pointerMove);
window.addEventListener('mouseup', pointerUp);
canvas.addEventListener('touchstart', pointerDown, { passive: false });
canvas.addEventListener('touchmove', pointerMove, { passive: false });
window.addEventListener('touchend', pointerUp);
document.getElementById('addSite').addEventListener('click', () => {
if (sites.length >= PALETTE.length) { setStatus('Palette full — reset to start over with fewer sites.'); return; }
sites.push({ x: 40 + Math.random() * (W - 80), y: 40 + Math.random() * (H - 80) });
render();
setStatus(i18nFmt('Site #%n% added.', { n: sites.length }));
});
document.getElementById('reset').addEventListener('click', () => {
sites = [
{ x: 110, y: 90 }, { x: 340, y: 70 }, { x: 260, y: 210 }, { x: 90, y: 250 },
];
render();
setStatus('Drag a site, or add a new one.');
});
render();
<p class="hint">Each colored dot is a <b>site</b>. Every pixel is shaded by whichever site is currently closest to it. Drag a site to see its cell — and every neighboring cell — redraw live.</p>
<canvas id="board" class="board" width="480" height="320"></canvas>
<div class="status" id="status">Drag a site, or add a new one.</div>
<div class="btns">
<button id="addSite" type="button">Add random site</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.board { display: block; width: 100%; max-width: 480px; height: auto; aspect-ratio: 480 / 320;
border-radius: 10px; border: 1px solid #cdd9e3; cursor: crosshair; touch-action: none; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Voronoi diagram demo: for every pixel, find the nearest site (by squared
// Euclidean distance) and color it accordingly. Dragging a site just moves one
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const CELL = 6; // pixel block size for the coloring pass (bigger = faster, coarser borders)
// one fixed color per site, reused cyclically if more sites are added
const PALETTE = ['#e63946', '#1d3557', '#2a9d8f', '#e9c46a', '#8338ec', '#ff6d00', '#457b9d', '#d62828'];
let sites = [
{ x: 110, y: 90 },
{ x: 340, y: 70 },
{ x: 260, y: 210 },
{ x: 90, y: 250 },
];
let dragIndex = -1;
function dist2(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; }
// Linear scan over all sites — O(n) per pixel, fine for a handful of sites.
function nearestSite(x, y) {
let best = 0, bestD = Infinity;
for (let i = 0; i < sites.length; i++) {
const d = dist2(x, y, sites[i].x, sites[i].y);
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
function render() {
// Coloring pass: recompute the nearest site for every block on the canvas.
for (let py = 0; py < H; py += CELL) {
for (let px = 0; px < W; px += CELL) {
const owner = nearestSite(px + CELL / 2, py + CELL / 2);
ctx.fillStyle = PALETTE[owner % PALETTE.length] + '33';
ctx.fillRect(px, py, CELL, CELL);
}
}
// Draw the site markers on top of their own cells.
sites.forEach((s, i) => {
ctx.beginPath();
ctx.arc(s.x, s.y, 8, 0, Math.PI * 2);
ctx.fillStyle = PALETTE[i % PALETTE.length];
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#fff';
ctx.stroke();
});
}
function canvasPoint(evt) {
const rect = canvas.getBoundingClientRect();
const cx = (evt.touches ? evt.touches[0].clientX : evt.clientX) - rect.left;
const cy = (evt.touches ? evt.touches[0].clientY : evt.clientY) - rect.top;
return { x: cx * (W / rect.width), y: cy * (H / rect.height) };
}
function setStatus(t) { document.getElementById('status').textContent = t; }
function pointerDown(evt) {
const p = canvasPoint(evt);
let hit = -1, hitD = 400; // squared pixel radius (~20px) to grab a site with the pointer
sites.forEach((s, i) => { const d = dist2(p.x, p.y, s.x, s.y); if (d < hitD) { hitD = d; hit = i; } });
dragIndex = hit;
if (hit >= 0) setStatus('Dragging… release to drop the site.');
evt.preventDefault();
}
function pointerMove(evt) {
if (dragIndex < 0) return;
const p = canvasPoint(evt);
sites[dragIndex].x = Math.max(0, Math.min(W, p.x));
sites[dragIndex].y = Math.max(0, Math.min(H, p.y));
render();
evt.preventDefault();
}
function pointerUp() {
if (dragIndex >= 0) setStatus('Cell borders updated — every border sits exactly halfway between two sites.');
dragIndex = -1;
}
canvas.addEventListener('mousedown', pointerDown);
canvas.addEventListener('mousemove', pointerMove);
window.addEventListener('mouseup', pointerUp);
canvas.addEventListener('touchstart', pointerDown, { passive: false });
canvas.addEventListener('touchmove', pointerMove, { passive: false });
window.addEventListener('touchend', pointerUp);
document.getElementById('addSite').addEventListener('click', () => {
if (sites.length >= PALETTE.length) { setStatus('Palette full — reset to start over with fewer sites.'); return; }
sites.push({ x: 40 + Math.random() * (W - 80), y: 40 + Math.random() * (H - 80) });
render();
setStatus(i18nFmt('Site #%n% added.', { n: sites.length }));
});
document.getElementById('reset').addEventListener('click', () => {
sites = [
{ x: 110, y: 90 }, { x: 340, y: 70 }, { x: 260, y: 210 }, { x: 90, y: 250 },
];
render();
setStatus('Drag a site, or add a new one.');
});
render();
Comments
Loading comments...