Five colored sites are placed on the canvas. Each has a weight — shown as the radius of the circle around it. The colored region shows the power cell: every pixel is assigned to whichever site minimizes its power distance.
<p class="hint">Click a colored site to select it, then drag the <b>Weight</b> slider to watch its cell grow or shrink. A weight of 0 gives the standard Voronoi cell.</p>
<canvas id="canvas" width="460" height="300"></canvas>
<div class="controls">
<label>
<span id="sel-label">Select a site to adjust its weight</span>
<input type="range" id="weight-slider" min="0" max="120" value="0" step="1" disabled>
</label>
<button id="reset-btn" type="button" class="ghost">Reset weights</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; }
#canvas { display: block; border-radius: 8px; border: 1px solid #cdd9e3;
cursor: pointer; max-width: 100%; }
.controls { margin-top: .7rem; display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
label { display: flex; align-items: center; gap: .5rem; flex: 1; min-width: 220px; font-size: .9rem; }
#sel-label { white-space: nowrap; min-width: 210px; font-weight: 600; }
input[type=range] { flex: 1; min-width: 100px; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Power diagram (Laguerre–Voronoi): site s with weight w claims point p when
// pow(p, s) = dist(p,s)^2 - w^2 is minimized over all sites.
// When all weights are equal, this reduces to the standard Voronoi diagram.
// Computed here by brute-force pixel assignment (O(W*H*n)) which is fast enough
// for the demo canvas; a production algorithm uses O(n log n) convex hull lifting.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Five sites with fixed positions and initial weight 0
const PALETTE = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#6a4c93'];
const INIT_POS = [[115,90],[345,80],[230,190],[80,220],[380,230]];
const sites = INIT_POS.map((p, i) => ({ x: p[0], y: p[1], w: 0, color: PALETTE[i] }));
let selected = null;
// Build an offscreen pixel buffer for the Voronoi fill
function powerDist(px, py, s) {
const dx = px - s.x, dy = py - s.y;
return dx*dx + dy*dy - s.w*s.w;
}
function draw() {
// Pixel-fill cells
const img = ctx.createImageData(W, H);
const data = img.data;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let best = Infinity, owner = 0;
for (let i = 0; i < sites.length; i++) {
const d = powerDist(x, y, sites[i]);
if (d < best) { best = d; owner = i; }
}
const idx = (y * W + x) * 4;
const c = hexToRgb(PALETTE[owner]);
data[idx] = c[0];
data[idx+1] = c[1];
data[idx+2] = c[2];
data[idx+3] = 50; // light fill
}
}
ctx.putImageData(img, 0, 0);
// Draw border lines by detecting cell transitions
for (let y = 1; y < H-1; y++) {
for (let x = 1; x < W-1; x++) {
const o = owner2d(x, y);
if (owner2d(x+1, y) !== o || owner2d(x, y+1) !== o) {
ctx.fillStyle = 'rgba(80,80,80,0.35)';
ctx.fillRect(x, y, 1, 1);
}
}
}
// Draw sites + weight circles
for (let i = 0; i < sites.length; i++) {
const s = sites[i];
const isSelected = (i === selected);
if (s.w > 0) {
ctx.beginPath();
ctx.arc(s.x, s.y, s.w, 0, 2*Math.PI);
ctx.strokeStyle = s.color;
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.setLineDash([5, 3]);
ctx.stroke();
ctx.setLineDash([]);
}
// Site dot
ctx.beginPath();
ctx.arc(s.x, s.y, isSelected ? 10 : 7, 0, 2*Math.PI);
ctx.fillStyle = s.color;
ctx.fill();
ctx.strokeStyle = isSelected ? '#fff' : 'rgba(0,0,0,0.3)';
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.stroke();
}
}
// Fast owner lookup for border drawing (recompute per pixel)
function owner2d(x, y) {
let best = Infinity, o = 0;
for (let i = 0; i < sites.length; i++) {
const d = powerDist(x, y, sites[i]);
if (d < best) { best = d; o = i; }
}
return o;
}
function hexToRgb(hex) {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
// Click: select nearest site
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width, scaleY = H / rect.height;
const cx = (e.clientX - rect.left) * scaleX;
const cy = (e.clientY - rect.top) * scaleY;
let best = Infinity, pick = -1;
for (let i = 0; i < sites.length; i++) {
const dx = cx - sites[i].x, dy = cy - sites[i].y;
const d = dx*dx + dy*dy;
if (d < best) { best = d; pick = i; }
}
selected = pick;
const slider = document.getElementById('weight-slider');
slider.disabled = false;
slider.value = sites[selected].w;
const lbl = PALETTE[selected];
document.getElementById('sel-label').textContent =
fmt('Site %selected%%v% weight: %w%', { selected: selected, v: 1, w: sites[selected].w });
draw();
});
document.getElementById('weight-slider').addEventListener('input', e => {
if (selected === null) return;
sites[selected].w = +e.target.value;
document.getElementById('sel-label').textContent =
fmt('Site %selected%%v% weight: %w%', { selected: selected, v: 1, w: sites[selected].w });
draw();
});
document.getElementById('reset-btn').addEventListener('click', () => {
sites.forEach(s => s.w = 0);
selected = null;
const slider = document.getElementById('weight-slider');
slider.disabled = true; slider.value = 0;
document.getElementById('sel-label').textContent = 'Select a site to adjust its weight';
draw();
});
draw();
<p class="hint">Click a colored site to select it, then drag the <b>Weight</b> slider to watch its cell grow or shrink. A weight of 0 gives the standard Voronoi cell.</p>
<canvas id="canvas" width="460" height="300"></canvas>
<div class="controls">
<label>
<span id="sel-label">Select a site to adjust its weight</span>
<input type="range" id="weight-slider" min="0" max="120" value="0" step="1" disabled>
</label>
<button id="reset-btn" type="button" class="ghost">Reset weights</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; }
#canvas { display: block; border-radius: 8px; border: 1px solid #cdd9e3;
cursor: pointer; max-width: 100%; }
.controls { margin-top: .7rem; display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
label { display: flex; align-items: center; gap: .5rem; flex: 1; min-width: 220px; font-size: .9rem; }
#sel-label { white-space: nowrap; min-width: 210px; font-weight: 600; }
input[type=range] { flex: 1; min-width: 100px; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Power diagram (Laguerre–Voronoi): site s with weight w claims point p when
// pow(p, s) = dist(p,s)^2 - w^2 is minimized over all sites.
// When all weights are equal, this reduces to the standard Voronoi diagram.
// Computed here by brute-force pixel assignment (O(W*H*n)) which is fast enough
// for the demo canvas; a production algorithm uses O(n log n) convex hull lifting.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// Five sites with fixed positions and initial weight 0
const PALETTE = ['#e63946','#457b9d','#2a9d8f','#e9c46a','#6a4c93'];
const INIT_POS = [[115,90],[345,80],[230,190],[80,220],[380,230]];
const sites = INIT_POS.map((p, i) => ({ x: p[0], y: p[1], w: 0, color: PALETTE[i] }));
let selected = null;
// Build an offscreen pixel buffer for the Voronoi fill
function powerDist(px, py, s) {
const dx = px - s.x, dy = py - s.y;
return dx*dx + dy*dy - s.w*s.w;
}
function draw() {
// Pixel-fill cells
const img = ctx.createImageData(W, H);
const data = img.data;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let best = Infinity, owner = 0;
for (let i = 0; i < sites.length; i++) {
const d = powerDist(x, y, sites[i]);
if (d < best) { best = d; owner = i; }
}
const idx = (y * W + x) * 4;
const c = hexToRgb(PALETTE[owner]);
data[idx] = c[0];
data[idx+1] = c[1];
data[idx+2] = c[2];
data[idx+3] = 50; // light fill
}
}
ctx.putImageData(img, 0, 0);
// Draw border lines by detecting cell transitions
for (let y = 1; y < H-1; y++) {
for (let x = 1; x < W-1; x++) {
const o = owner2d(x, y);
if (owner2d(x+1, y) !== o || owner2d(x, y+1) !== o) {
ctx.fillStyle = 'rgba(80,80,80,0.35)';
ctx.fillRect(x, y, 1, 1);
}
}
}
// Draw sites + weight circles
for (let i = 0; i < sites.length; i++) {
const s = sites[i];
const isSelected = (i === selected);
if (s.w > 0) {
ctx.beginPath();
ctx.arc(s.x, s.y, s.w, 0, 2*Math.PI);
ctx.strokeStyle = s.color;
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.setLineDash([5, 3]);
ctx.stroke();
ctx.setLineDash([]);
}
// Site dot
ctx.beginPath();
ctx.arc(s.x, s.y, isSelected ? 10 : 7, 0, 2*Math.PI);
ctx.fillStyle = s.color;
ctx.fill();
ctx.strokeStyle = isSelected ? '#fff' : 'rgba(0,0,0,0.3)';
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.stroke();
}
}
// Fast owner lookup for border drawing (recompute per pixel)
function owner2d(x, y) {
let best = Infinity, o = 0;
for (let i = 0; i < sites.length; i++) {
const d = powerDist(x, y, sites[i]);
if (d < best) { best = d; o = i; }
}
return o;
}
function hexToRgb(hex) {
const v = parseInt(hex.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
// Click: select nearest site
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width, scaleY = H / rect.height;
const cx = (e.clientX - rect.left) * scaleX;
const cy = (e.clientY - rect.top) * scaleY;
let best = Infinity, pick = -1;
for (let i = 0; i < sites.length; i++) {
const dx = cx - sites[i].x, dy = cy - sites[i].y;
const d = dx*dx + dy*dy;
if (d < best) { best = d; pick = i; }
}
selected = pick;
const slider = document.getElementById('weight-slider');
slider.disabled = false;
slider.value = sites[selected].w;
const lbl = PALETTE[selected];
document.getElementById('sel-label').textContent =
fmt('Site %selected%%v% weight: %w%', { selected: selected, v: 1, w: sites[selected].w });
draw();
});
document.getElementById('weight-slider').addEventListener('input', e => {
if (selected === null) return;
sites[selected].w = +e.target.value;
document.getElementById('sel-label').textContent =
fmt('Site %selected%%v% weight: %w%', { selected: selected, v: 1, w: sites[selected].w });
draw();
});
document.getElementById('reset-btn').addEventListener('click', () => {
sites.forEach(s => s.w = 0);
selected = null;
const slider = document.getElementById('weight-slider');
slider.disabled = true; slider.value = 0;
document.getElementById('sel-label').textContent = 'Select a site to adjust its weight';
draw();
});
draw();
Comments
Loading comments...