billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
120 lines (101 loc) • 3.6 kB
text/typescript
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*
* Bounded Voronoi cells, computed directly by half-plane clipping instead of
* going through a Delaunay triangulation.
*
* A cell is the set of points closer to its site than to any other site, which
* is exactly the intersection of the half-planes bounded by the perpendicular
* bisectors between that site and every other one. Starting from the clip
* rectangle and clipping it against each bisector (Sutherland-Hodgman) yields
* the same polygons d3-delaunay's `voronoi().cellPolygon()` returns, without
* the triangulation payload.
*
* The trade-off is O(n²) rather than O(n log n). That is the right trade for
* the label-placement use case, which runs on tens to hundreds of points.
*
* Parity with d3-delaunay is asserted by test/module/voronoi-parity-spec.ts.
*/
type Point = [number, number];
// distance below which two sites are treated as coincident
const EPSILON = 1e-9;
/**
* Clip a convex polygon by the half-plane of points at least as close to `a` as
* to `b`, i.e. the side of the perpendicular bisector of `ab` that contains `a`.
* @param {Array} polygon Convex polygon as an open ring of [x, y]
* @param {Array} a Site kept by the half-plane
* @param {Array} b Opposing site
* @returns {Array} Clipped polygon, empty when fully outside
* @private
*/
function clipByBisector(polygon: Point[], a: Point, b: Point): Point[] {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
// bisector as dx*x + dy*y = c, keeping the side where the expression is below c
const c = (dx * (a[0] + b[0]) + dy * (a[1] + b[1])) / 2;
const output: Point[] = [];
const n = polygon.length;
let prev = polygon[n - 1];
let prevDist = dx * prev[0] + dy * prev[1] - c;
for (let i = 0; i < n; i++) {
const curr = polygon[i];
const currDist = dx * curr[0] + dy * curr[1] - c;
// the edge straddles the bisector: emit the crossing point
if ((prevDist > 0) !== (currDist > 0)) {
const t = prevDist / (prevDist - currDist);
output.push([
prev[0] + t * (curr[0] - prev[0]),
prev[1] + t * (curr[1] - prev[1])
]);
}
if (currDist <= 0) {
output.push(curr);
}
prev = curr;
prevDist = currDist;
}
return output;
}
/**
* Compute bounded Voronoi cells for the given sites.
*
* Cells are returned in site order, closed (first vertex repeated last) as
* d3-delaunay does. A site yields `null` when it has no cell: it lies outside
* the bounds, or it coincides with an earlier site.
* @param {Array} points Sites as [x, y]
* @param {Array} bounds Clip extent as [xmin, ymin, xmax, ymax]
* @returns {Array} Cell polygons in site order
* @private
*/
export function voronoiCells(points: Point[], bounds: number[]): (Point[] | null)[] {
const [x0, y0, x1, y1] = [
Math.min(bounds[0], bounds[2]),
Math.min(bounds[1], bounds[3]),
Math.max(bounds[0], bounds[2]),
Math.max(bounds[1], bounds[3])
];
const rect: Point[] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
const n = points.length;
return points.map((site, i) => {
let cell = rect;
for (let j = 0; j < n && cell.length > 2; j++) {
if (j === i) {
continue;
}
const other = points[j];
if (
Math.abs(other[0] - site[0]) < EPSILON &&
Math.abs(other[1] - site[1]) < EPSILON
) {
// coincident sites: the first one keeps the cell, as in d3-delaunay
if (j < i) {
return null;
}
continue;
}
cell = clipByBisector(cell, site, other);
}
return cell.length > 2 ? [...cell, cell[0]] as Point[] : null;
});
}