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.

406 lines (405 loc) 15 kB
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 };