@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.
406 lines (405 loc) • 15 kB
JavaScript
const sceneInteractionCache = /* @__PURE__ */ new WeakMap();
function nearestPoint(points, x, y, maxDistance) {
let result;
let resultDistance = Infinity;
for (let index = points.length; index--; ) {
const point = points[index];
const dx = point.x - x;
const dy = point.y - y;
const distance = dx * dx + dy * dy;
if (distance <= resultDistance) {
result = point;
resultDistance = distance;
}
}
return result && resultDistance <= Math.max(0, maxDistance) ** 2 ? result : null;
}
function nearestScenePoint(scene, x, y, maxDistance, points = scene.points) {
const index = interactionIndex(scene);
const allowed = points === scene.points ? void 0 : new Set(points);
if (!index.targets.length && !index.attachedPoints.size) {
return nearestPoint(points, x, y, maxDistance);
}
const contained = findContainingScenePoint(scene, x, y, points);
if (contained) return contained.point;
let resultPoint;
let resultInteraction;
let resultPrimaryDistance = Infinity;
let resultGeometryDistance = Infinity;
for (const target of index.targets) {
const interaction = target.node.interaction;
if (!hasAllowedInteractionPoint(interaction, allowed)) continue;
const affinity = interaction.affinity ?? "xy";
if (affinity === "geometry") continue;
const axis = affinity === "x" ? "x" : affinity === "y" ? "y" : void 0;
const primaryDistance = axis ? squaredAxisDistance(target.bounds, axis === "x" ? x : y, axis) : distanceToTarget(target, x, y);
if (primaryDistance > resultPrimaryDistance) continue;
const geometryDistance = axis ? distanceToTarget(target, x, y) : primaryDistance;
if (primaryDistance < resultPrimaryDistance || primaryDistance === resultPrimaryDistance && geometryDistance < resultGeometryDistance) {
resultInteraction = interaction;
resultPoint = void 0;
resultPrimaryDistance = primaryDistance;
resultGeometryDistance = geometryDistance;
}
}
if (resultPrimaryDistance !== 0) {
for (const point of points) {
if (index.attachedPoints.has(point)) continue;
const dx = point.x - x;
const dy = point.y - y;
const distance = dx * dx + dy * dy;
if (distance < resultPrimaryDistance) {
resultPoint = point;
resultInteraction = void 0;
resultPrimaryDistance = distance;
resultGeometryDistance = distance;
}
}
}
if (resultPrimaryDistance > Math.max(0, maxDistance) ** 2) return null;
const result = resultPoint ?? (resultInteraction ? bestInteractionPoint(resultInteraction, x, y, allowed) : void 0);
return result ?? null;
}
function findContainingScenePoint(scene, x, y, points = scene.points) {
const index = interactionIndex(scene);
const allowed = points === scene.points ? void 0 : new Set(points);
for (let targetIndex = index.targets.length; targetIndex--; ) {
const target = index.targets[targetIndex];
if (containsBounds(target.bounds, x, y) && containsTarget(target, x, y)) {
const interaction = target.node.interaction;
const point = bestInteractionPoint(interaction, x, y, allowed);
const hasSemanticPoint = interaction.point ? true : interaction.points.length > 0;
if (point || !allowed || !hasSemanticPoint) {
return {
point
};
}
}
}
return null;
}
function interactionIndex(scene) {
const cached = sceneInteractionCache.get(scene);
if (cached) return cached;
const targets = [];
const attachedPoints = /* @__PURE__ */ new Set();
collectTargets(scene.nodes, 0, 0, void 0, targets, attachedPoints);
const index = { targets, attachedPoints };
sceneInteractionCache.set(scene, index);
return index;
}
function collectTargets(nodes, offsetX, offsetY, clip, targets, attachedPoints) {
for (const node of nodes) {
if (node.kind === "group") {
if (node.focus) continue;
const nextOffsetX = offsetX + (node.translateX ?? 0);
const nextOffsetY = offsetY + (node.translateY ?? 0);
const groupClip = node.clip ? translateBounds(node.clip, nextOffsetX, nextOffsetY) : void 0;
const nextClip = clip === null ? null : intersectBounds(clip, groupClip);
collectTargets(
node.children,
nextOffsetX,
nextOffsetY,
nextClip,
targets,
attachedPoints
);
continue;
}
if (node.kind === "label" || !node.interaction) continue;
if (node.interaction.point) attachedPoints.add(node.interaction.point);
else {
for (const point of node.interaction.points) attachedPoints.add(point);
}
if (clip === null) continue;
const localBounds = boundsForNode(node);
if (!localBounds) continue;
const paintedBounds = translateBounds(localBounds, offsetX, offsetY);
const visibleBounds = clip ? intersectBounds(paintedBounds, clip) : paintedBounds;
if (visibleBounds == null) continue;
targets.push({
node,
offsetX,
offsetY,
bounds: visibleBounds,
clip
});
}
}
function bestInteractionPoint(interaction, x, y, allowed) {
if (interaction.point) {
return !allowed || allowed.has(interaction.point) ? interaction.point : null;
}
const affinity = interaction.affinity ?? "xy";
let result;
let primaryDistance = Infinity;
let secondaryDistance = Infinity;
for (const point of interaction.points) {
if (allowed && !allowed.has(point)) continue;
const dx = point.x - x;
const dy = point.y - y;
const fullDistance = dx * dx + dy * dy;
const nextPrimary = affinity === "x" ? dx * dx : affinity === "y" ? dy * dy : fullDistance;
if (nextPrimary < primaryDistance || nextPrimary === primaryDistance && fullDistance < secondaryDistance) {
result = point;
primaryDistance = nextPrimary;
secondaryDistance = fullDistance;
}
}
return result ?? null;
}
function hasAllowedInteractionPoint(interaction, allowed) {
if (!allowed) return true;
return interaction.point ? allowed.has(interaction.point) : interaction.points.some((point) => allowed.has(point));
}
function containsTarget(target, x, y) {
const localX = x - target.offsetX;
const localY = y - target.offsetY;
const { node } = target;
switch (node.kind) {
case "rect":
return containsRoundedRect(node, localX, localY);
case "dot": {
const dx = localX - node.x;
const dy = localY - node.y;
const radius = Math.max(0, node.radius);
return dx * dx + dy * dy <= radius * radius;
}
case "area":
return node.polygons === void 0 ? containsPolygon(node.points, localX, localY) : containsPolygons(node.polygons, localX, localY);
case "polyline":
return squaredDistanceToPolyline(node.points, localX, localY, false) <= strokeRadius(node) ** 2;
case "rule":
return squaredDistanceToSegment(
node.x1,
node.y1,
node.x2,
node.y2,
localX,
localY
) <= strokeRadius(node) ** 2;
}
}
function distanceToTarget(target, x, y) {
const localX = x - target.offsetX;
const localY = y - target.offsetY;
const { node } = target;
let distance;
switch (node.kind) {
case "rect":
distance = node.radius ? squaredDistanceToRoundedRect(node, localX, localY) : squaredDistanceToBounds(node, localX, localY);
break;
case "dot": {
const dx = localX - node.x;
const dy = localY - node.y;
const amount = Math.max(
0,
Math.sqrt(dx * dx + dy * dy) - Math.max(0, node.radius)
);
distance = amount * amount;
break;
}
case "area":
distance = node.polygons === void 0 ? squaredDistanceToPolyline(node.points, localX, localY, true) : squaredDistanceToPolygons(node.polygons, localX, localY);
break;
case "polyline": {
const raw = squaredDistanceToPolyline(node.points, localX, localY, false);
const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node));
distance = amount * amount;
break;
}
case "rule": {
const raw = squaredDistanceToSegment(
node.x1,
node.y1,
node.x2,
node.y2,
localX,
localY
);
const amount = Math.max(0, Math.sqrt(raw) - strokeRadius(node));
distance = amount * amount;
break;
}
}
return target.clip ? Math.max(distance, squaredDistanceToBounds(target.clip, x, y)) : distance;
}
function boundsForNode(node) {
switch (node.kind) {
case "rect":
return normalizeRect(node);
case "dot": {
const radius = Math.max(0, node.radius);
return {
x: node.x - radius,
y: node.y - radius,
width: radius * 2,
height: radius * 2
};
}
case "area":
return node.polygons === void 0 ? boundsFromPoints(node.points) : boundsFromPolygons(node.polygons);
case "polyline": {
const bounds = boundsFromPoints(node.points);
return bounds ? expandBounds(bounds, strokeRadius(node)) : null;
}
case "rule":
return expandBounds(
{
x: Math.min(node.x1, node.x2),
y: Math.min(node.y1, node.y2),
width: Math.abs(node.x2 - node.x1),
height: Math.abs(node.y2 - node.y1)
},
strokeRadius(node)
);
}
}
function containsRoundedRect(node, x, y) {
const bounds = normalizeRect(node);
if (!containsBounds(bounds, x, y)) return false;
const radius = Math.max(
0,
Math.min(node.radius ?? 0, bounds.width / 2, bounds.height / 2)
);
if (radius === 0 || x >= bounds.x + radius && x <= bounds.x + bounds.width - radius || y >= bounds.y + radius && y <= bounds.y + bounds.height - radius) {
return true;
}
const cornerX = x < bounds.x + radius ? bounds.x + radius : bounds.x + bounds.width - radius;
const cornerY = y < bounds.y + radius ? bounds.y + radius : bounds.y + bounds.height - radius;
const dx = x - cornerX;
const dy = y - cornerY;
return dx * dx + dy * dy <= radius * radius;
}
function squaredDistanceToRoundedRect(node, x, y) {
const bounds = normalizeRect(node);
const halfWidth = bounds.width / 2;
const halfHeight = bounds.height / 2;
const radius = Math.max(0, Math.min(node.radius ?? 0, halfWidth, halfHeight));
const offsetX = Math.abs(x - (bounds.x + halfWidth)) - (halfWidth - radius);
const offsetY = Math.abs(y - (bounds.y + halfHeight)) - (halfHeight - radius);
const outside = Math.sqrt(Math.max(0, offsetX) ** 2 + Math.max(0, offsetY) ** 2) - radius;
return Math.max(0, outside) ** 2;
}
function containsPolygon(points, x, y) {
let inside = false;
for (let index = 0, previous = points.length - 1; index < points.length; previous = index++) {
const current = points[index];
const prior = points[previous];
if (current[1] > y !== prior[1] > y && x < (prior[0] - current[0]) * (y - current[1]) / (prior[1] - current[1]) + current[0]) {
inside = !inside;
}
}
return inside;
}
function containsPolygons(polygons, x, y) {
return polygons.some(([exterior, ...holes]) => {
if (!exterior || !containsPolygon(exterior, x, y)) return false;
return !holes.some((hole) => containsPolygon(hole, x, y));
});
}
function squaredDistanceToPolygons(polygons, x, y) {
let distance = Infinity;
for (const polygon of polygons) {
for (const ring of polygon) {
distance = Math.min(distance, squaredDistanceToPolyline(ring, x, y, true));
}
}
return distance;
}
function squaredDistanceToPolyline(points, x, y, closed) {
if (!points.length) return Infinity;
if (points.length === 1) {
const point = points[0];
return (point[0] - x) ** 2 + (point[1] - y) ** 2;
}
let distance = Infinity;
const segmentCount = closed ? points.length : Math.max(0, points.length - 1);
for (let index = 0; index < segmentCount; index += 1) {
const start = points[index];
const end = points[(index + 1) % points.length];
distance = Math.min(
distance,
squaredDistanceToSegment(start[0], start[1], end[0], end[1], x, y)
);
}
return distance;
}
function squaredDistanceToSegment(x1, y1, x2, y2, x, y) {
const dx = x2 - x1;
const dy = y2 - y1;
const length = dx * dx + dy * dy;
const amount = length ? Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / length)) : 0;
const offsetX = x - (x1 + amount * dx);
const offsetY = y - (y1 + amount * dy);
return offsetX * offsetX + offsetY * offsetY;
}
function boundsFromPoints(points) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
if (!Number.isFinite(point[0]) || !Number.isFinite(point[1])) continue;
minX = Math.min(minX, point[0]);
minY = Math.min(minY, point[1]);
maxX = Math.max(maxX, point[0]);
maxY = Math.max(maxY, point[1]);
}
return Number.isFinite(minX) ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;
}
function boundsFromPolygons(polygons) {
return boundsFromPoints(polygons.flatMap((polygon) => polygon.flat()));
}
function normalizeRect(rect) {
return {
x: Math.min(rect.x, rect.x + rect.width),
y: Math.min(rect.y, rect.y + rect.height),
width: Math.abs(rect.width),
height: Math.abs(rect.height)
};
}
function translateBounds(bounds, x, y) {
const normalized = normalizeRect(bounds);
return { ...normalized, x: normalized.x + x, y: normalized.y + y };
}
function expandBounds(bounds, amount) {
return {
x: bounds.x - amount,
y: bounds.y - amount,
width: bounds.width + amount * 2,
height: bounds.height + amount * 2
};
}
function intersectBounds(left, right) {
if (!left) return right;
if (!right) return left;
const x = Math.max(left.x, right.x);
const y = Math.max(left.y, right.y);
const rightEdge = Math.min(left.x + left.width, right.x + right.width);
const bottomEdge = Math.min(left.y + left.height, right.y + right.height);
return rightEdge < x || bottomEdge < y ? null : { x, y, width: rightEdge - x, height: bottomEdge - y };
}
function containsBounds(bounds, x, y) {
return x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height;
}
function squaredAxisDistance(bounds, value, axis) {
const start = axis === "x" ? bounds.x : bounds.y;
const size = axis === "x" ? bounds.width : bounds.height;
const distance = value < start ? start - value : value > start + size ? value - start - size : 0;
return distance * distance;
}
function squaredDistanceToBounds(bounds, x, y) {
const normalized = normalizeRect(bounds);
const dx = x < normalized.x ? normalized.x - x : x > normalized.x + normalized.width ? x - normalized.x - normalized.width : 0;
const dy = y < normalized.y ? normalized.y - y : y > normalized.y + normalized.height ? y - normalized.y - normalized.height : 0;
return dx * dx + dy * dy;
}
function strokeRadius(node) {
return Math.max(0, node.style?.strokeWidth ?? 1) / 2;
}
export {
findContainingScenePoint,
nearestPoint,
nearestScenePoint
};