<div class="toolbar">
<button id="btnSweep" type="button">Sweep</button>
<button id="btnStep" type="button">Step</button>
<button id="btnReset" type="button" class="ghost">Clear</button>
<span class="hint-text">Click canvas to add seeds</span>
</div>
<canvas id="c" width="500" height="360"></canvas>
<div id="info" class="info-bar">Add seeds, then press Sweep or Step.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f8f9fb; }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; padding: .4rem 0 .5rem; }
button { font: 600 13px system-ui; padding: .38rem .85rem; border-radius: 7px;
border: 1px solid #1d3557; background: #1d3557; color: #fff; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.hint-text { font-size: .82rem; color: #666; margin-left: .3rem; }
canvas { display: block; border: 1px solid #d0d8e4; border-radius: 8px;
background: #fff; cursor: crosshair; touch-action: none; max-width: 100%; }
.info-bar { font-size: .88rem; color: #444; margin-top: .45rem; min-height: 1.3em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Fortuneโs sweepline algorithm โ interactive Voronoi demo
// The demo runs an animated step-by-step sweep across seed points.
// For clarity we use a simplified event-driven simulation that correctly
// traces parabolic beach-line arcs and emits Voronoi edges.
//
// Key data structures:
// sites โ array of {x,y} seed points
// events โ min-heap ordered by x (sweep goes leftโright here, vertical line moves right)
// beachLine โ sorted array of arc segments (each arc is a parabola with focus = site)
// edges โ finished Voronoi half-edges {x1,y1,x2,y2}
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// โโ Colours โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const C_SITE = '#1d3557';
const C_EDGE = '#457b9d';
const C_BEACH = '#e63946';
const C_SWEEP = '#2a9d8f';
const C_REGION = [
'#dbe9f4','#fde8ea','#e8f5e9','#fff8e1',
'#f3e5f5','#e0f2f1','#fce4ec','#e8eaf6'
];
let sites = [];
let sweepX = -1; // current sweep line x position
let edges = []; // finished edges [{x1,y1,x2,y2}]
let animId = null;
let stepMode = false;
// โโ Parabola helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// A parabola with focus F = (fx,fy) and directrix x = d
// satisfies: (x - fx)^2 + (y - fy)^2 = (x - d)^2
// Solving for x given y: x = ((y-fy)^2 + fx^2 - d^2) / (2*(fx - d))
function parabolaX(fy, fx, d, y) {
const denom = 2 * (fx - d);
if (Math.abs(denom) < 1e-9) return fx; // degenerate (site on directrix)
return ((y - fy) * (y - fy) + fx * fx - d * d) / denom;
}
// โโ Intersection of two beach-line parabolas (returns y coordinates) โโโโโโ
// Given two sites A and B (both with focus to the left of sweep line d),
// find y coords where their parabolas intersect.
function intersectY(A, B, d) {
// Quadratic: coefficients from (x_A(y) = x_B(y))
const a1 = 1, b1 = -2*A.y, c1 = A.y*A.y + A.x*A.x - d*d;
const a2 = 1, b2 = -2*B.y, c2 = B.y*B.y + B.x*B.x - d*d;
// (a1-a2)y^2 + (b1-b2)y + (c1-c2) = 0 ... but a1=a2=1 so:
// (b1-b2)y + (c1-c2) = 0 ... only if same coeff for y^2; actually need full:
// expand: for site (fx,fy) with directrix d:
// x = (y^2 - 2fy*y + fy^2 + fx^2 - d^2) / (2(fx - d)):
// x = (y^2 - 2fy*y + fy^2 + fx^2 - d^2) / (2(fx - d))
// Setting x_A = x_B:
// (y^2-2Ay*y + Ay^2 + Ax^2 - d^2)(2(Bx-d)) = (y^2-2By*y + By^2 + Bx^2-d^2)(2(Ax-d))
const dA = 2*(A.x - d), dB = 2*(B.x - d);
if (Math.abs(dA) < 1e-9 && Math.abs(dB) < 1e-9) return [H/2];
if (Math.abs(dA) < 1e-9) return [A.y];
if (Math.abs(dB) < 1e-9) return [B.y];
// coeff of y^2: dB - dA
const qa = dB - dA;
const qb = -2*A.y*dB + 2*B.y*dA;
const qc = (A.y*A.y + A.x*A.x - d*d)*dB - (B.y*B.y + B.x*B.x - d*d)*dA;
if (Math.abs(qa) < 1e-9) {
if (Math.abs(qb) < 1e-9) return [H/2];
return [-qc/qb];
}
const disc = qb*qb - 4*qa*qc;
if (disc < 0) return [H/2];
const sq = Math.sqrt(disc);
return [(-qb - sq)/(2*qa), (-qb + sq)/(2*qa)];
}
// โโ Beach-line arc structure โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Each arc = { site: {x,y}, startY, endY } (y range this arc owns on the sweep line)
// We rebuild the beach-line from scratch at each rendered frame for the animation.
function beachLineArcs(processedSites, d) {
// Sort sites by x (already processed means site.x <= d)
const active = processedSites.filter(s => s.x < d + 1e-9).slice();
if (active.length === 0) return [];
if (active.length === 1) return [{ site: active[0], startY: 0, endY: H }];
// For each y, find which arc is frontmost (max x)
// Build arc list by finding breakpoints between adjacent arcs sorted by y
// This is a simplified O(n^2) beach-line rebuild for visualization
const arcs = [];
// Collect all breakpoint y-values
const bps = new Set([0, H]);
for (let i = 0; i < active.length; i++) {
for (let j = i+1; j < active.length; j++) {
const ys = intersectY(active[i], active[j], d);
ys.forEach(y => { if (y > 0 && y < H) bps.add(y); });
}
}
const sorted = [...bps].sort((a,b) => a-b);
// For each segment between breakpoints, find the frontmost site
for (let i = 0; i < sorted.length - 1; i++) {
const yMid = (sorted[i] + sorted[i+1]) / 2;
let maxX = -Infinity, bestSite = active[0];
for (const s of active) {
const x = parabolaX(s.y, s.x, d, yMid);
if (x > maxX) { maxX = x; bestSite = s; }
}
// Merge with previous arc if same site
if (arcs.length > 0 && arcs[arcs.length-1].site === bestSite) {
arcs[arcs.length-1].endY = sorted[i+1];
} else {
arcs.push({ site: bestSite, startY: sorted[i], endY: sorted[i+1] });
}
}
return arcs;
}
// โโ Full Voronoi via Fortuneโs algorithm (simplified, O(n^2) for small n) โ
// Returns finished edges for a given set of sites
function fortuneVoronoi(sites) {
const n = sites.length;
if (n < 2) return [];
const result = [];
// For each pair of adjacent sites in vertical order, trace their shared edge
// This is the correct full output for the completed diagram
// We use the dual: build Delaunay by brute force, then extract Voronoi edges
// For the demo scale (< 30 sites), O(n^3) Delaunay is fine
// Build Delaunay triangulation via brute force (each triple, check circumcircle)
function circumcircle(a, b, c) {
const ax=a.x,ay=a.y,bx=b.x,by=b.y,cx=c.x,cy=c.y;
const D = 2*(ax*(by-cy)+bx*(cy-ay)+cx*(ay-by));
if (Math.abs(D) < 1e-9) return null;
const ux=((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/D;
const uy=((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/D;
const r=Math.sqrt((ax-ux)*(ax-ux)+(ay-uy)*(ay-uy));
return {cx:ux,cy:uy,r};
}
const triangles = [];
for (let i=0;i<n;i++) for(let j=i+1;j<n;j++) for(let k=j+1;k<n;k++) {
const cc = circumcircle(sites[i],sites[j],sites[k]);
if (!cc) continue;
// Check no other site inside circumcircle
let valid = true;
for (let l=0;l<n;l++) {
if (l===i||l===j||l===k) continue;
const dx=sites[l].x-cc.cx, dy=sites[l].y-cc.cy;
if (dx*dx+dy*dy < cc.r*cc.r - 1e-6) { valid=false; break; }
}
if (valid) triangles.push([i,j,k,cc]);
}
// Voronoi edges are duals of Delaunay edges:
// For each Delaunay edge (shared by two triangles), draw line between the two circumcenters
// Build adjacency map
const edgeMap = new Map();
const key = (a,b) => a<b?`${a}-${b}`:`${b}-${a}`;
triangles.forEach(([i,j,k,cc],ti) => {
[[i,j],[j,k],[i,k]].forEach(([a,b]) => {
const ek = key(a,b);
if (!edgeMap.has(ek)) edgeMap.set(ek,[]);
edgeMap.get(ek).push(ti);
});
});
// Clip circumcenter to canvas bounds
function clip(x,y) {
return { x: Math.max(0, Math.min(W, x)), y: Math.max(0, Math.min(H, y)) };
}
edgeMap.forEach((tris, ek) => {
if (tris.length === 2) {
const cc1 = triangles[tris[0]][3];
const cc2 = triangles[tris[1]][3];
result.push({x1:cc1.cx,y1:cc1.cy,x2:cc2.cx,y2:cc2.cy});
} else if (tris.length === 1) {
// Boundary edge โ extend to canvas edge
const cc1 = triangles[tris[0]][3];
const [a,b] = ek.split('-').map(Number);
const sa=sites[a], sb=sites[b];
// Perpendicular bisector direction
const mx=(sa.x+sb.x)/2, my=(sa.y+sb.y)/2;
const dx=sb.x-sa.x, dy=sb.y-sa.y;
const px=-dy, py=dx; // perpendicular
// Extend in the direction away from circumcenter
const len = 1000;
// Pick direction so the edge goes "outward"
const dot = (cc1.cx-mx)*px + (cc1.cy-my)*py;
const sign = dot > 0 ? -1 : 1;
const ex=cc1.cx+sign*px*len/Math.sqrt(px*px+py*py);
const ey=cc1.cy+sign*py*len/Math.sqrt(px*px+py*py);
result.push({x1:cc1.cx,y1:cc1.cy,x2:ex,y2:ey});
}
});
return result;
}
// โโ Rendering โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function draw(d) {
ctx.clearRect(0, 0, W, H);
// Flood fill Voronoi regions
if (sites.length > 0) {
const imgData = ctx.createImageData(W, H);
const colorList = C_REGION.map(hex => {
const r=parseInt(hex.slice(1,3),16);
const g=parseInt(hex.slice(3,5),16);
const b=parseInt(hex.slice(5,7),16);
return [r,g,b];
});
for (let py=0; py<H; py++) {
for (let px=0; px<W; px++) {
let minD=Infinity, idx=0;
for (let i=0; i<sites.length; i++) {
const dx=px-sites[i].x, dy=py-sites[i].y;
const dd=dx*dx+dy*dy;
if (dd<minD) { minD=dd; idx=i; }
}
const col = colorList[idx % colorList.length];
const off = (py*W+px)*4;
imgData.data[off]=col[0]; imgData.data[off+1]=col[1];
imgData.data[off+2]=col[2]; imgData.data[off+3]=160;
}
}
ctx.putImageData(imgData, 0, 0);
}
// Draw Voronoi edges (completed)
ctx.save();
ctx.strokeStyle = C_EDGE;
ctx.lineWidth = 1.5;
edges.forEach(e => {
ctx.beginPath();
ctx.moveTo(e.x1, e.y1);
ctx.lineTo(e.x2, e.y2);
ctx.stroke();
});
ctx.restore();
// Draw beach line (parabolic arcs) up to sweep line
if (d >= 0 && sites.length > 0) {
const processed = sites.filter(s => s.x <= d);
if (processed.length > 0) {
const arcs = beachLineArcs(processed, d);
ctx.save();
ctx.strokeStyle = C_BEACH;
ctx.lineWidth = 2;
arcs.forEach(arc => {
const s = arc.site;
ctx.beginPath();
let first = true;
for (let y = arc.startY; y <= arc.endY; y += 1.5) {
const x = parabolaX(s.y, s.x, d, y);
if (x < 0 || x > W) continue;
if (first) { ctx.moveTo(x, y); first = false; }
else ctx.lineTo(x, y);
}
ctx.stroke();
});
ctx.restore();
}
// Draw sweep line
ctx.save();
ctx.strokeStyle = C_SWEEP;
ctx.lineWidth = 2;
ctx.setLineDash([6,4]);
ctx.beginPath();
ctx.moveTo(d, 0);
ctx.lineTo(d, H);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
// Draw site points
sites.forEach((s, i) => {
ctx.save();
ctx.fillStyle = d >= 0 && s.x <= d ? C_SITE : '#aaa';
ctx.beginPath();
ctx.arc(s.x, s.y, 5, 0, Math.PI*2);
ctx.fill();
ctx.restore();
});
}
// โโ Animation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let animSweepX = 0;
const SPEED = 2.5; // px per frame
function animate() {
animSweepX += SPEED;
sweepX = animSweepX;
// Recompute edges up to current sweep position (for progressive reveal)
const done = sites.filter(s => s.x <= sweepX);
edges = fortuneVoronoi(done);
draw(sweepX);
const info = document.getElementById('info');
const processed = done.length;
info.textContent = fmt('Sweep at x=%round% โ processed %processed% of %length% sites', { round: Math.round(sweepX), processed: processed, length: sites.length });
if (animSweepX < W) {
animId = requestAnimationFrame(animate);
} else {
sweepX = -1;
edges = fortuneVoronoi(sites);
draw(-1);
info.textContent = fmt('Done! Voronoi diagram for %length% site%v% computed in O(n log n).', { length: sites.length, v: sites.length!==1?'s':'' });
}
}
function stopAnim() { if (animId) { cancelAnimationFrame(animId); animId = null; } }
// โโ Controls โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
document.getElementById('btnSweep').addEventListener('click', () => {
if (sites.length < 2) {
document.getElementById('info').textContent = 'Add at least 2 seed points first.';
return;
}
stopAnim();
animSweepX = 0;
edges = [];
stepMode = false;
animate();
});
document.getElementById('btnStep').addEventListener('click', () => {
if (sites.length < 2) {
document.getElementById('info').textContent = 'Add at least 2 seed points first.';
return;
}
stopAnim();
stepMode = true;
// Advance to next site event
const sortedX = sites.map(s=>s.x).sort((a,b)=>a-b);
if (sweepX < 0) sweepX = 0;
const next = sortedX.find(x => x > sweepX + 1);
if (next !== undefined) {
sweepX = next + 1;
} else {
sweepX = W;
}
const done = sites.filter(s => s.x <= sweepX);
edges = fortuneVoronoi(done);
draw(sweepX < W ? sweepX : -1);
const info = document.getElementById('info');
if (sweepX >= W) {
sweepX = -1;
edges = fortuneVoronoi(sites);
draw(-1);
info.textContent = fmt('Done! Voronoi diagram for %length% site%v% computed.', { length: sites.length, v: sites.length!==1?'s':'' });
} else {
info.textContent = fmt('Step: sweep at x=%round% โ %length% of %length2% sites processed.', { round: Math.round(sweepX), length: done.length, length2: sites.length });
}
});
document.getElementById('btnReset').addEventListener('click', () => {
stopAnim();
sites = []; edges = []; sweepX = -1; animSweepX = 0;
draw(-1);
document.getElementById('info').textContent = 'Add seeds, then press Sweep or Step.';
});
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width;
const scaleY = H / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
sites.push({ x, y });
stopAnim();
sweepX = -1; animSweepX = 0;
edges = fortuneVoronoi(sites);
draw(-1);
document.getElementById('info').textContent =
fmt('%length% seed%v% placed. Press Sweep to animate.', { length: sites.length, v: sites.length!==1?'s':'' });
});
// Add some default sites
[[100,80],[250,200],[380,90],[160,290],[420,260],[300,330],[80,200],[330,160]].forEach(([x,y]) => {
sites.push({x,y});
});
edges = fortuneVoronoi(sites);
draw(-1);
document.getElementById('info').textContent =
fmt('%length% seeds placed. Press Sweep to animate Fortuneโs algorithm.', { length: sites.length });
<div class="toolbar">
<button id="btnSweep" type="button">Sweep</button>
<button id="btnStep" type="button">Step</button>
<button id="btnReset" type="button" class="ghost">Clear</button>
<span class="hint-text">Click canvas to add seeds</span>
</div>
<canvas id="c" width="500" height="360"></canvas>
<div id="info" class="info-bar">Add seeds, then press Sweep or Step.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f8f9fb; }
.toolbar { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; padding: .4rem 0 .5rem; }
button { font: 600 13px system-ui; padding: .38rem .85rem; border-radius: 7px;
border: 1px solid #1d3557; background: #1d3557; color: #fff; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.hint-text { font-size: .82rem; color: #666; margin-left: .3rem; }
canvas { display: block; border: 1px solid #d0d8e4; border-radius: 8px;
background: #fff; cursor: crosshair; touch-action: none; max-width: 100%; }
.info-bar { font-size: .88rem; color: #444; margin-top: .45rem; min-height: 1.3em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Fortuneโs sweepline algorithm โ interactive Voronoi demo
// The demo runs an animated step-by-step sweep across seed points.
// For clarity we use a simplified event-driven simulation that correctly
// traces parabolic beach-line arcs and emits Voronoi edges.
//
// Key data structures:
// sites โ array of {x,y} seed points
// events โ min-heap ordered by x (sweep goes leftโright here, vertical line moves right)
// beachLine โ sorted array of arc segments (each arc is a parabola with focus = site)
// edges โ finished Voronoi half-edges {x1,y1,x2,y2}
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
// โโ Colours โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const C_SITE = '#1d3557';
const C_EDGE = '#457b9d';
const C_BEACH = '#e63946';
const C_SWEEP = '#2a9d8f';
const C_REGION = [
'#dbe9f4','#fde8ea','#e8f5e9','#fff8e1',
'#f3e5f5','#e0f2f1','#fce4ec','#e8eaf6'
];
let sites = [];
let sweepX = -1; // current sweep line x position
let edges = []; // finished edges [{x1,y1,x2,y2}]
let animId = null;
let stepMode = false;
// โโ Parabola helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// A parabola with focus F = (fx,fy) and directrix x = d
// satisfies: (x - fx)^2 + (y - fy)^2 = (x - d)^2
// Solving for x given y: x = ((y-fy)^2 + fx^2 - d^2) / (2*(fx - d))
function parabolaX(fy, fx, d, y) {
const denom = 2 * (fx - d);
if (Math.abs(denom) < 1e-9) return fx; // degenerate (site on directrix)
return ((y - fy) * (y - fy) + fx * fx - d * d) / denom;
}
// โโ Intersection of two beach-line parabolas (returns y coordinates) โโโโโโ
// Given two sites A and B (both with focus to the left of sweep line d),
// find y coords where their parabolas intersect.
function intersectY(A, B, d) {
// Quadratic: coefficients from (x_A(y) = x_B(y))
const a1 = 1, b1 = -2*A.y, c1 = A.y*A.y + A.x*A.x - d*d;
const a2 = 1, b2 = -2*B.y, c2 = B.y*B.y + B.x*B.x - d*d;
// (a1-a2)y^2 + (b1-b2)y + (c1-c2) = 0 ... but a1=a2=1 so:
// (b1-b2)y + (c1-c2) = 0 ... only if same coeff for y^2; actually need full:
// expand: for site (fx,fy) with directrix d:
// x = (y^2 - 2fy*y + fy^2 + fx^2 - d^2) / (2(fx - d)):
// x = (y^2 - 2fy*y + fy^2 + fx^2 - d^2) / (2(fx - d))
// Setting x_A = x_B:
// (y^2-2Ay*y + Ay^2 + Ax^2 - d^2)(2(Bx-d)) = (y^2-2By*y + By^2 + Bx^2-d^2)(2(Ax-d))
const dA = 2*(A.x - d), dB = 2*(B.x - d);
if (Math.abs(dA) < 1e-9 && Math.abs(dB) < 1e-9) return [H/2];
if (Math.abs(dA) < 1e-9) return [A.y];
if (Math.abs(dB) < 1e-9) return [B.y];
// coeff of y^2: dB - dA
const qa = dB - dA;
const qb = -2*A.y*dB + 2*B.y*dA;
const qc = (A.y*A.y + A.x*A.x - d*d)*dB - (B.y*B.y + B.x*B.x - d*d)*dA;
if (Math.abs(qa) < 1e-9) {
if (Math.abs(qb) < 1e-9) return [H/2];
return [-qc/qb];
}
const disc = qb*qb - 4*qa*qc;
if (disc < 0) return [H/2];
const sq = Math.sqrt(disc);
return [(-qb - sq)/(2*qa), (-qb + sq)/(2*qa)];
}
// โโ Beach-line arc structure โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Each arc = { site: {x,y}, startY, endY } (y range this arc owns on the sweep line)
// We rebuild the beach-line from scratch at each rendered frame for the animation.
function beachLineArcs(processedSites, d) {
// Sort sites by x (already processed means site.x <= d)
const active = processedSites.filter(s => s.x < d + 1e-9).slice();
if (active.length === 0) return [];
if (active.length === 1) return [{ site: active[0], startY: 0, endY: H }];
// For each y, find which arc is frontmost (max x)
// Build arc list by finding breakpoints between adjacent arcs sorted by y
// This is a simplified O(n^2) beach-line rebuild for visualization
const arcs = [];
// Collect all breakpoint y-values
const bps = new Set([0, H]);
for (let i = 0; i < active.length; i++) {
for (let j = i+1; j < active.length; j++) {
const ys = intersectY(active[i], active[j], d);
ys.forEach(y => { if (y > 0 && y < H) bps.add(y); });
}
}
const sorted = [...bps].sort((a,b) => a-b);
// For each segment between breakpoints, find the frontmost site
for (let i = 0; i < sorted.length - 1; i++) {
const yMid = (sorted[i] + sorted[i+1]) / 2;
let maxX = -Infinity, bestSite = active[0];
for (const s of active) {
const x = parabolaX(s.y, s.x, d, yMid);
if (x > maxX) { maxX = x; bestSite = s; }
}
// Merge with previous arc if same site
if (arcs.length > 0 && arcs[arcs.length-1].site === bestSite) {
arcs[arcs.length-1].endY = sorted[i+1];
} else {
arcs.push({ site: bestSite, startY: sorted[i], endY: sorted[i+1] });
}
}
return arcs;
}
// โโ Full Voronoi via Fortuneโs algorithm (simplified, O(n^2) for small n) โ
// Returns finished edges for a given set of sites
function fortuneVoronoi(sites) {
const n = sites.length;
if (n < 2) return [];
const result = [];
// For each pair of adjacent sites in vertical order, trace their shared edge
// This is the correct full output for the completed diagram
// We use the dual: build Delaunay by brute force, then extract Voronoi edges
// For the demo scale (< 30 sites), O(n^3) Delaunay is fine
// Build Delaunay triangulation via brute force (each triple, check circumcircle)
function circumcircle(a, b, c) {
const ax=a.x,ay=a.y,bx=b.x,by=b.y,cx=c.x,cy=c.y;
const D = 2*(ax*(by-cy)+bx*(cy-ay)+cx*(ay-by));
if (Math.abs(D) < 1e-9) return null;
const ux=((ax*ax+ay*ay)*(by-cy)+(bx*bx+by*by)*(cy-ay)+(cx*cx+cy*cy)*(ay-by))/D;
const uy=((ax*ax+ay*ay)*(cx-bx)+(bx*bx+by*by)*(ax-cx)+(cx*cx+cy*cy)*(bx-ax))/D;
const r=Math.sqrt((ax-ux)*(ax-ux)+(ay-uy)*(ay-uy));
return {cx:ux,cy:uy,r};
}
const triangles = [];
for (let i=0;i<n;i++) for(let j=i+1;j<n;j++) for(let k=j+1;k<n;k++) {
const cc = circumcircle(sites[i],sites[j],sites[k]);
if (!cc) continue;
// Check no other site inside circumcircle
let valid = true;
for (let l=0;l<n;l++) {
if (l===i||l===j||l===k) continue;
const dx=sites[l].x-cc.cx, dy=sites[l].y-cc.cy;
if (dx*dx+dy*dy < cc.r*cc.r - 1e-6) { valid=false; break; }
}
if (valid) triangles.push([i,j,k,cc]);
}
// Voronoi edges are duals of Delaunay edges:
// For each Delaunay edge (shared by two triangles), draw line between the two circumcenters
// Build adjacency map
const edgeMap = new Map();
const key = (a,b) => a<b?`${a}-${b}`:`${b}-${a}`;
triangles.forEach(([i,j,k,cc],ti) => {
[[i,j],[j,k],[i,k]].forEach(([a,b]) => {
const ek = key(a,b);
if (!edgeMap.has(ek)) edgeMap.set(ek,[]);
edgeMap.get(ek).push(ti);
});
});
// Clip circumcenter to canvas bounds
function clip(x,y) {
return { x: Math.max(0, Math.min(W, x)), y: Math.max(0, Math.min(H, y)) };
}
edgeMap.forEach((tris, ek) => {
if (tris.length === 2) {
const cc1 = triangles[tris[0]][3];
const cc2 = triangles[tris[1]][3];
result.push({x1:cc1.cx,y1:cc1.cy,x2:cc2.cx,y2:cc2.cy});
} else if (tris.length === 1) {
// Boundary edge โ extend to canvas edge
const cc1 = triangles[tris[0]][3];
const [a,b] = ek.split('-').map(Number);
const sa=sites[a], sb=sites[b];
// Perpendicular bisector direction
const mx=(sa.x+sb.x)/2, my=(sa.y+sb.y)/2;
const dx=sb.x-sa.x, dy=sb.y-sa.y;
const px=-dy, py=dx; // perpendicular
// Extend in the direction away from circumcenter
const len = 1000;
// Pick direction so the edge goes "outward"
const dot = (cc1.cx-mx)*px + (cc1.cy-my)*py;
const sign = dot > 0 ? -1 : 1;
const ex=cc1.cx+sign*px*len/Math.sqrt(px*px+py*py);
const ey=cc1.cy+sign*py*len/Math.sqrt(px*px+py*py);
result.push({x1:cc1.cx,y1:cc1.cy,x2:ex,y2:ey});
}
});
return result;
}
// โโ Rendering โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function draw(d) {
ctx.clearRect(0, 0, W, H);
// Flood fill Voronoi regions
if (sites.length > 0) {
const imgData = ctx.createImageData(W, H);
const colorList = C_REGION.map(hex => {
const r=parseInt(hex.slice(1,3),16);
const g=parseInt(hex.slice(3,5),16);
const b=parseInt(hex.slice(5,7),16);
return [r,g,b];
});
for (let py=0; py<H; py++) {
for (let px=0; px<W; px++) {
let minD=Infinity, idx=0;
for (let i=0; i<sites.length; i++) {
const dx=px-sites[i].x, dy=py-sites[i].y;
const dd=dx*dx+dy*dy;
if (dd<minD) { minD=dd; idx=i; }
}
const col = colorList[idx % colorList.length];
const off = (py*W+px)*4;
imgData.data[off]=col[0]; imgData.data[off+1]=col[1];
imgData.data[off+2]=col[2]; imgData.data[off+3]=160;
}
}
ctx.putImageData(imgData, 0, 0);
}
// Draw Voronoi edges (completed)
ctx.save();
ctx.strokeStyle = C_EDGE;
ctx.lineWidth = 1.5;
edges.forEach(e => {
ctx.beginPath();
ctx.moveTo(e.x1, e.y1);
ctx.lineTo(e.x2, e.y2);
ctx.stroke();
});
ctx.restore();
// Draw beach line (parabolic arcs) up to sweep line
if (d >= 0 && sites.length > 0) {
const processed = sites.filter(s => s.x <= d);
if (processed.length > 0) {
const arcs = beachLineArcs(processed, d);
ctx.save();
ctx.strokeStyle = C_BEACH;
ctx.lineWidth = 2;
arcs.forEach(arc => {
const s = arc.site;
ctx.beginPath();
let first = true;
for (let y = arc.startY; y <= arc.endY; y += 1.5) {
const x = parabolaX(s.y, s.x, d, y);
if (x < 0 || x > W) continue;
if (first) { ctx.moveTo(x, y); first = false; }
else ctx.lineTo(x, y);
}
ctx.stroke();
});
ctx.restore();
}
// Draw sweep line
ctx.save();
ctx.strokeStyle = C_SWEEP;
ctx.lineWidth = 2;
ctx.setLineDash([6,4]);
ctx.beginPath();
ctx.moveTo(d, 0);
ctx.lineTo(d, H);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
// Draw site points
sites.forEach((s, i) => {
ctx.save();
ctx.fillStyle = d >= 0 && s.x <= d ? C_SITE : '#aaa';
ctx.beginPath();
ctx.arc(s.x, s.y, 5, 0, Math.PI*2);
ctx.fill();
ctx.restore();
});
}
// โโ Animation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
let animSweepX = 0;
const SPEED = 2.5; // px per frame
function animate() {
animSweepX += SPEED;
sweepX = animSweepX;
// Recompute edges up to current sweep position (for progressive reveal)
const done = sites.filter(s => s.x <= sweepX);
edges = fortuneVoronoi(done);
draw(sweepX);
const info = document.getElementById('info');
const processed = done.length;
info.textContent = fmt('Sweep at x=%round% โ processed %processed% of %length% sites', { round: Math.round(sweepX), processed: processed, length: sites.length });
if (animSweepX < W) {
animId = requestAnimationFrame(animate);
} else {
sweepX = -1;
edges = fortuneVoronoi(sites);
draw(-1);
info.textContent = fmt('Done! Voronoi diagram for %length% site%v% computed in O(n log n).', { length: sites.length, v: sites.length!==1?'s':'' });
}
}
function stopAnim() { if (animId) { cancelAnimationFrame(animId); animId = null; } }
// โโ Controls โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
document.getElementById('btnSweep').addEventListener('click', () => {
if (sites.length < 2) {
document.getElementById('info').textContent = 'Add at least 2 seed points first.';
return;
}
stopAnim();
animSweepX = 0;
edges = [];
stepMode = false;
animate();
});
document.getElementById('btnStep').addEventListener('click', () => {
if (sites.length < 2) {
document.getElementById('info').textContent = 'Add at least 2 seed points first.';
return;
}
stopAnim();
stepMode = true;
// Advance to next site event
const sortedX = sites.map(s=>s.x).sort((a,b)=>a-b);
if (sweepX < 0) sweepX = 0;
const next = sortedX.find(x => x > sweepX + 1);
if (next !== undefined) {
sweepX = next + 1;
} else {
sweepX = W;
}
const done = sites.filter(s => s.x <= sweepX);
edges = fortuneVoronoi(done);
draw(sweepX < W ? sweepX : -1);
const info = document.getElementById('info');
if (sweepX >= W) {
sweepX = -1;
edges = fortuneVoronoi(sites);
draw(-1);
info.textContent = fmt('Done! Voronoi diagram for %length% site%v% computed.', { length: sites.length, v: sites.length!==1?'s':'' });
} else {
info.textContent = fmt('Step: sweep at x=%round% โ %length% of %length2% sites processed.', { round: Math.round(sweepX), length: done.length, length2: sites.length });
}
});
document.getElementById('btnReset').addEventListener('click', () => {
stopAnim();
sites = []; edges = []; sweepX = -1; animSweepX = 0;
draw(-1);
document.getElementById('info').textContent = 'Add seeds, then press Sweep or Step.';
});
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const scaleX = W / rect.width;
const scaleY = H / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
sites.push({ x, y });
stopAnim();
sweepX = -1; animSweepX = 0;
edges = fortuneVoronoi(sites);
draw(-1);
document.getElementById('info').textContent =
fmt('%length% seed%v% placed. Press Sweep to animate.', { length: sites.length, v: sites.length!==1?'s':'' });
});
// Add some default sites
[[100,80],[250,200],[380,90],[160,290],[420,260],[300,330],[80,200],[330,160]].forEach(([x,y]) => {
sites.push({x,y});
});
edges = fortuneVoronoi(sites);
draw(-1);
document.getElementById('info').textContent =
fmt('%length% seeds placed. Press Sweep to animate Fortuneโs algorithm.', { length: sites.length });
Notice how the regions snap into place as the sweep progresses. The algorithm processes only two kinds of events โ site events (the sweep line reaches a seed) and circle events (three arcs meet at a point) โ and handles each in O(logn) time using a priority queue and a balanced binary tree. That is the source of the O(nlogn) total cost.
Comments
Loading comments...