UNPKG

@tanstack/charts

Version:

A chart grammar for TypeScript and JavaScript. Marks consume your data directly, channels describe visual encodings, and the engine compiles them into a renderer-neutral keyed scene. TanStack's compact scales cover common numeric and categorical mappings.

348 lines (347 loc) 13.3 kB
import { angularDelaunayNeighborIndexes, createDelaunay } from "./spatial-delaunay-internal.js"; function voronoiCellPolygons(positions, bounds) { if (!Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || !Number.isFinite(bounds.width) || !Number.isFinite(bounds.height)) { throw new TypeError("voronoi: chart bounds must be finite"); } if (positions.length === 0 || bounds.width <= 0 || bounds.height <= 0) { return []; } const xMaximum = bounds.x + bounds.width; const yMaximum = bounds.y + bounds.height; if (!Number.isFinite(xMaximum) || !Number.isFinite(yMaximum) || xMaximum <= bounds.x || yMaximum <= bounds.y) { throw new TypeError("voronoi: chart bounds must have representable extents"); } if (positions.some(({ x, y }) => !Number.isFinite(x) || !Number.isFinite(y))) { throw new TypeError("voronoi: site positions must be finite"); } const offsets = positions.map(({ x, y }) => ({ x: x - bounds.x, y: y - bounds.y })); if (offsets.some(({ x, y }) => !Number.isFinite(x) || !Number.isFinite(y))) { throw new TypeError( "voronoi: site offsets from chart bounds must be finite" ); } const indexed = uniquePositions(offsets); if (indexed.length === 1) { return [ { pointIndex: indexed[0].pointIndex, points: [ [xMaximum, bounds.y], [xMaximum, yMaximum], [bounds.x, yMaximum], [bounds.x, bounds.y] ] } ]; } const first = indexed[0].position; let xMinimum = first.x; let xMaximumPosition = first.x; let yMinimum = first.y; let yMaximumPosition = first.y; for (const { position } of indexed.slice(1)) { xMinimum = Math.min(xMinimum, position.x); xMaximumPosition = Math.max(xMaximumPosition, position.x); yMinimum = Math.min(yMinimum, position.y); yMaximumPosition = Math.max(yMaximumPosition, position.y); } const xOrigin = xMinimum / 2 + xMaximumPosition / 2; const yOrigin = yMinimum / 2 + yMaximumPosition / 2; const dataExtent = Math.max( xMaximumPosition - xMinimum, yMaximumPosition - yMinimum ); if (!Number.isFinite(dataExtent)) { throw new TypeError("voronoi: site extent exceeds numeric range"); } const coordinateScale = dataExtent > 0 ? 2 ** Math.min(1023, Math.floor(Math.log2(dataExtent))) : 1; const localBounds = { x: -xOrigin / coordinateScale, y: -yOrigin / coordinateScale, width: bounds.width / coordinateScale, height: bounds.height / coordinateScale }; const localWidth = localBounds.x + localBounds.width - localBounds.x; const localHeight = localBounds.y + localBounds.height - localBounds.y; const hasRepresentableLocalBounds = !(!Number.isFinite(localBounds.x) || !Number.isFinite(localBounds.y) || !Number.isFinite(localBounds.width) || !Number.isFinite(localBounds.height) || localBounds.width === 0 || localBounds.height === 0 || localWidth === 0 || localHeight === 0 || Math.abs(localWidth - localBounds.width) > localBounds.width * 1e-8 || Math.abs(localHeight - localBounds.height) > localBounds.height * 1e-8); const unique = indexed.map((entry) => ({ ...entry, position: { x: (entry.position.x - xOrigin) / coordinateScale, y: (entry.position.y - yOrigin) / coordinateScale } })); const delaunay = createDelaunay(unique.map((entry) => entry.position)); const neighbors = angularDelaunayNeighborIndexes( delaunay, unique.length, true ); if (hasRepresentableLocalBounds) { const diagram = delaunay.voronoi([ localBounds.x, localBounds.y, localBounds.x + localBounds.width, localBounds.y + localBounds.height ]); let cells = readD3Cells(diagram, unique); if (!isValidPartition(cells, localBounds, unique, neighbors)) { cells = buildCells(unique, localBounds, neighbors); } if (!isValidPartition(cells, localBounds, unique, neighbors)) { cells = buildCells(unique, localBounds, neighbors, true); } if (isValidPartition(cells, localBounds, unique, neighbors)) { return translateCells( cells, bounds.x + xOrigin, bounds.y + yOrigin, coordinateScale, bounds, offsets ); } } const plotBounds = { x: 0, y: 0, width: bounds.width, height: bounds.height }; let resolved = buildCells(indexed, plotBounds, neighbors); if (!isValidPartition(resolved, plotBounds, indexed, neighbors)) { resolved = buildCells(indexed, plotBounds, neighbors, true); } if (!isValidPartition(resolved, plotBounds, indexed, neighbors)) { throw new TypeError("voronoi: could not generate a valid cell partition"); } return translateCells(resolved, bounds.x, bounds.y, 1, bounds, offsets); } function translateCells(cells, xOffset, yOffset, coordinateScale, bounds, sourceOffsets) { const xMaximum = bounds.x + bounds.width; const yMaximum = bounds.y + bounds.height; return cells.flatMap((cell) => { const points = normalizePolygon( cell.points.map( ([x, y]) => [ Math.max( bounds.x, Math.min(xMaximum, x * coordinateScale + xOffset) ), Math.max( bounds.y, Math.min(yMaximum, y * coordinateScale + yOffset) ) ] ) ); if (points.length < 3 || polygonArea(points) === 0) { const site = sourceOffsets[cell.pointIndex]; if (site.x < 0 || site.x > bounds.width || site.y < 0 || site.y > bounds.height) { return []; } throw new TypeError( "voronoi: cell boundaries are not representable in chart coordinates" ); } return [{ pointIndex: cell.pointIndex, points }]; }); } function uniquePositions(positions) { const seen = /* @__PURE__ */ new Map(); return positions.flatMap((position, pointIndex) => { const yValues = seen.get(position.x); if (yValues?.has(position.y)) return []; if (yValues) yValues.add(position.y); else seen.set(position.x, /* @__PURE__ */ new Set([position.y])); return [{ position, pointIndex }]; }); } function readD3Cells(diagram, positions) { return positions.flatMap(({ pointIndex }, diagramIndex) => { const closed = diagram.cellPolygon(diagramIndex); const points = normalizePolygon(closed ?? []); return points.length >= 3 && polygonArea(points) !== 0 ? [{ pointIndex, points }] : []; }); } function buildCells(positions, bounds, neighbors, allCompetitors = false) { const xMaximum = bounds.x + bounds.width; const yMaximum = bounds.y + bounds.height; const rectangle = [ [xMaximum, bounds.y], [xMaximum, yMaximum], [bounds.x, yMaximum], [bounds.x, bounds.y] ]; return positions.flatMap((entry, index) => { let points = rectangle.map(([x, y]) => [x, y]); const competitors = allCompetitors ? positions.keys() : neighbors[index]; for (const competitorIndex of competitors) { if (competitorIndex === index) continue; if (competitorIndex < 0 || competitorIndex >= positions.length) continue; points = clipToCloserHalfPlane( points, entry.position, positions[competitorIndex].position ); if (points.length < 3) return []; } const normalized = normalizePolygon( points.map( ([x, y]) => [ Math.max(bounds.x, Math.min(xMaximum, x)), Math.max(bounds.y, Math.min(yMaximum, y)) ] ) ); return normalized.length >= 3 && polygonArea(normalized) !== 0 ? [{ pointIndex: entry.pointIndex, points: normalized }] : []; }); } function clipToCloserHalfPlane(polygon, site, competitor) { if (polygon.length === 0) return []; const xDelta = competitor.x - site.x; const yDelta = competitor.y - site.y; if (xDelta === 0 && yDelta === 0) return [...polygon]; const output = []; let previous = polygon.at(-1); let previousDistance = signedBisectorDistance(previous, site, competitor); for (const current of polygon) { const currentDistance = signedBisectorDistance(current, site, competitor); const previousInside = previousDistance <= 0; const currentInside = currentDistance <= 0; if (previousInside !== currentInside) { const ratio = previousDistance / (previousDistance - currentDistance); output.push([ previous[0] + ratio * (current[0] - previous[0]), previous[1] + ratio * (current[1] - previous[1]) ]); } if (currentInside) output.push(current); previous = current; previousDistance = currentDistance; } return output; } function signedBisectorDistance([x, y], site, competitor) { const xDelta = competitor.x - site.x; const yDelta = competitor.y - site.y; const xMidpoint = site.x / 2 + competitor.x / 2; const yMidpoint = site.y / 2 + competitor.y / 2; return (x - xMidpoint) * xDelta + (y - yMidpoint) * yDelta; } function normalizePolygon(polygon) { const points = []; const seen = /* @__PURE__ */ new Set(); for (const point of polygon) { const key = JSON.stringify(point); if (seen.has(key)) continue; seen.add(key); points.push([point[0], point[1]]); } return points; } function isValidPartition(cells, bounds, positions, neighbors) { if (cells.length === 0) return false; const positionIndexes = new Map( positions.map(({ pointIndex }, index) => [pointIndex, index]) ); const cellIndexes = /* @__PURE__ */ new Set(); const expectedArea = bounds.width * bounds.height; const crossTolerance = expectedArea * Number.EPSILON * 4096; const ownershipTolerance = Math.max(bounds.width, bounds.height) * 1e-8; let totalArea = 0; for (const cell of cells) { const positionIndex = positionIndexes.get(cell.pointIndex); if (positionIndex === void 0 || cellIndexes.has(positionIndex) || cell.points.length < 3 || !polygonIsConvex(cell.points, crossTolerance) || cell.points.some( ([x, y]) => !Number.isFinite(x) || !Number.isFinite(y) || x < bounds.x || x > bounds.x + bounds.width || y < bounds.y || y > bounds.y + bounds.height )) { return false; } const site = positions[positionIndex].position; if (violatesNeighborOwnership( cell.points, site, neighbors[positionIndex], positions, ownershipTolerance )) { return false; } const area = Math.abs(polygonArea(cell.points)); if (area === 0 || !Number.isFinite(area)) return false; totalArea += area; cellIndexes.add(positionIndex); } for (let index = 0; index < positions.length; index += 1) { const { x, y } = positions[index].position; if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height && !cellIndexes.has(index)) { return false; } } return Math.abs(totalArea - expectedArea) <= expectedArea * 1e-8; } function violatesNeighborOwnership(points, site, competitorIndexes, positions, tolerance) { if (points.length * competitorIndexes.length <= 4096) { return competitorIndexes.some((competitorIndex) => { const competitor = positions[competitorIndex].position; const distance = Math.hypot(competitor.x - site.x, competitor.y - site.y); return points.some( (point) => signedBisectorDistance(point, site, competitor) > distance * tolerance ); }); } const step = polygonArea(points) >= 0 ? 1 : -1; let vertexIndex = 0; let initialized = false; for (const competitorIndex of competitorIndexes) { const competitor = positions[competitorIndex].position; const xDelta = competitor.x - site.x; const yDelta = competitor.y - site.y; const projection = (index) => points[index][0] * xDelta + points[index][1] * yDelta; if (!initialized) { for (let index = 1; index < points.length; index += 1) { if (projection(index) > projection(vertexIndex)) vertexIndex = index; } initialized = true; } else { for (let advances = 0; advances < points.length; advances += 1) { const next = (vertexIndex + step + points.length) % points.length; if (projection(next) <= projection(vertexIndex)) break; vertexIndex = next; } } if (signedBisectorDistance(points[vertexIndex], site, competitor) > Math.hypot(xDelta, yDelta) * tolerance) { return true; } } return false; } function polygonIsConvex(points, tolerance) { let direction = 0; for (let index = 0; index < points.length; index += 1) { const first = points[index]; const second = points[(index + 1) % points.length]; const third = points[(index + 2) % points.length]; const cross = (second[0] - first[0]) * (third[1] - second[1]) - (second[1] - first[1]) * (third[0] - second[0]); if (Math.abs(cross) <= tolerance) continue; const nextDirection = Math.sign(cross); if (direction && direction !== nextDirection) return false; direction = nextDirection; } return true; } function polygonArea(points) { const origin = points[0]; if (!origin) return 0; let area = 0; for (let index = 1; index < points.length - 1; index += 1) { const current = points[index]; const next = points[index + 1]; area += (current[0] - origin[0]) * (next[1] - origin[1]) - (current[1] - origin[1]) * (next[0] - origin[0]); } return area / 2; } export { voronoiCellPolygons };