@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.
607 lines (606 loc) • 21.3 kB
JavaScript
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
import * as React from "react";
import { View } from "react-native";
import {
createChartCursorHostSession,
resolveChartFocusStrategy,
resolveFocusPresentation,
resolveMarkStateScene
} from "@tanstack/charts/cursor/host";
import { createChartRuntime } from "@tanstack/charts/runtime";
import { viewportInteractionPoints } from "@tanstack/charts/scene";
import {
adjacentFocusPoint,
createNativeChartFocusModel,
samePointIdentity,
samePointReferences
} from "./interaction.js";
import { resolveNativePaint } from "./paint.js";
import { NativeChartScene } from "./SvgScene.js";
function Chart(props) {
const {
definition,
accessibilityLabel,
accessibilityHint,
width,
height,
aspectRatio,
style,
color = "#111827",
focusFill = "#ffffff",
fontFamily,
fontStyle,
fontStretch,
letterSpacing,
direction,
locale,
fontScale,
idPrefix: idPrefixOption,
testID,
measureText,
resolvePaint = resolveNativePaint,
onFocusChange,
onFocusGroupChange,
onSelect,
onRender,
renderTooltip
} = props;
const generatedId = React.useId();
const idPrefix = idPrefixOption ?? `ts-chart-${generatedId.replaceAll(/[^a-zA-Z0-9_-]/g, "")}`;
const runtime = React.useMemo(
() => createChartRuntime({
defaultTheme: typeof color === "string" ? { foreground: color, muted: color, grid: color } : void 0
}),
[color]
);
const [layout, setLayout] = React.useState(null);
const resolvedAspectRatio = positiveFinite(aspectRatio);
const explicitWidth = positiveFinite(width);
const explicitHeight = positiveFinite(height);
const sceneSize = resolveSceneSize(
explicitWidth,
explicitHeight,
resolvedAspectRatio,
layout
);
const scene = React.useMemo(
() => sceneSize ? runtime.render(definition, sceneSize, {
measureText,
typography: {
fontFamily,
fontStyle,
fontStretch,
letterSpacing,
direction,
locale,
fontScale
}
}) : null,
[
definition,
direction,
fontFamily,
fontScale,
fontStretch,
fontStyle,
letterSpacing,
locale,
measureText,
runtime,
sceneSize?.height,
sceneSize?.width
]
);
const focusModel = React.useMemo(
() => scene ? createNativeChartFocusModel(scene, definition) : null,
[definition, scene]
);
const interactionPoints = React.useMemo(
() => scene ? viewportInteractionPoints(scene) : emptyChartPoints,
[scene]
);
const cursorBinding = definition.cursor;
const cursorMatch = cursorBinding?.mode === "focus" ? cursorBinding.match ?? "xy" : void 0;
const cursorSession = React.useMemo(
() => cursorBinding ? createChartCursorHostSession(cursorBinding) : void 0,
[
cursorBinding?.controller,
cursorBinding?.mode,
cursorBinding?.use,
cursorMatch
]
);
const subscribeCursor = React.useCallback(
(listener) => cursorSession?.subscribe(listener) ?? subscribeEmptyCursor(),
[cursorSession]
);
const getCursorState = React.useCallback(
() => cursorSession?.getState() ?? getEmptyCursorState(),
[cursorSession]
);
const cursorState = React.useSyncExternalStore(subscribeCursor, getCursorState, getCursorState);
const cursorPresentation = React.useMemo(
() => scene && cursorBinding && cursorSession ? cursorSession.resolvePresentation(scene, cursorBinding, cursorState) : null,
[cursorBinding, cursorSession, cursorState, scene]
);
const controlledFocusedPoints = React.useMemo(() => {
if (!scene || cursorBinding?.mode !== "focus" || !cursorSession) {
return emptyChartPoints;
}
return cursorSession.resolveFocus(
interactionPoints,
cursorBinding,
cursorState,
resolveChartFocusStrategy(definition.focus)
);
}, [
cursorBinding,
cursorSession,
cursorState,
definition.focus,
interactionPoints,
scene
]);
const [localFocusedPoints, setLocalFocusedPoints] = React.useState([]);
const localFocusedPointsRef = React.useRef(localFocusedPoints);
const previousCursorBindingRef = React.useRef({
controller: cursorBinding?.controller,
mode: cursorBinding?.mode,
match: cursorMatch,
use: cursorBinding?.use
});
const cursorBindingChanged = previousCursorBindingRef.current.controller !== cursorBinding?.controller || previousCursorBindingRef.current.mode !== cursorBinding?.mode || previousCursorBindingRef.current.match !== cursorMatch || previousCursorBindingRef.current.use !== cursorBinding?.use;
const focusedPoints = cursorBinding ? cursorBinding.mode === "focus" ? controlledFocusedPoints : emptyChartPoints : cursorBindingChanged ? emptyChartPoints : localFocusedPoints;
const focusedPointsRef = React.useRef(focusedPoints);
focusedPointsRef.current = focusedPoints;
const callbackPointsRef = React.useRef(
localFocusedPoints
);
const onFocusChangeRef = React.useRef(onFocusChange);
const onFocusGroupChangeRef = React.useRef(onFocusGroupChange);
const onRenderRef = React.useRef(onRender);
onFocusChangeRef.current = onFocusChange;
onFocusGroupChangeRef.current = onFocusGroupChange;
onRenderRef.current = onRender;
const [pinnedKey, setPinnedKey] = React.useState(null);
const [pointer, setPointer] = React.useState(
null
);
const [focusSource, setFocusSource] = React.useState("programmatic");
const tooltipInput = React.useMemo(
() => resolveNativeTooltipInput(definition.tooltip),
[definition.tooltip]
);
const TooltipComponent = tooltipInput?.component;
const tooltipOptions = tooltipInput?.options;
const sticky = Boolean(tooltipInput) && tooltipOptions?.sticky !== false;
const freeCursor = cursorBinding?.mode === "free";
const datumInteractive = Boolean(focusModel?.navigation.length);
const pointerInteractive = definition.pointer !== false && (datumInteractive || Boolean(scene && freeCursor));
const keyboardInteractive = definition.keyboard !== false && datumInteractive && !freeCursor;
const interactionPinned = cursorBinding ? cursorState?.pinned === true : pinnedKey !== null;
const resolvedFocusSource = cursorBinding ? cursorState?.source ?? focusSource : focusSource;
const resolvedPointer = cursorBindingChanged || cursorBinding && (cursorState?.source !== "pointer" || !cursorSession?.owns(cursorState)) ? null : pointer;
const cursorCurrentlyPinned = () => cursorBinding ? cursorSession?.getState()?.pinned === true : pinnedKey !== null;
const commitLocalFocus = React.useCallback(
(points) => {
const current = localFocusedPointsRef.current;
if (samePointReferences(points, current)) return;
localFocusedPointsRef.current = points;
focusedPointsRef.current = points;
callbackPointsRef.current = points;
setLocalFocusedPoints(points);
onFocusChangeRef.current?.(points[0] ?? null);
onFocusGroupChangeRef.current?.(points);
},
[]
);
React.useEffect(() => () => runtime.destroy(), [runtime]);
React.useEffect(() => () => cursorSession?.destroy(), [cursorSession]);
React.useEffect(() => {
if (scene) onRenderRef.current?.({ scene });
}, [scene]);
React.useEffect(() => {
if (!cursorBindingChanged) return;
previousCursorBindingRef.current = {
controller: cursorBinding?.controller,
mode: cursorBinding?.mode,
match: cursorMatch,
use: cursorBinding?.use
};
localFocusedPointsRef.current = emptyChartPoints;
setLocalFocusedPoints(emptyChartPoints);
setPinnedKey(null);
setPointer(null);
}, [
cursorBinding?.controller,
cursorBinding?.mode,
cursorBinding?.use,
cursorBindingChanged,
cursorMatch
]);
React.useEffect(() => {
const previous = callbackPointsRef.current;
if (sameFocusedPointValues(focusedPoints, previous)) {
callbackPointsRef.current = focusedPoints;
return;
}
callbackPointsRef.current = focusedPoints;
onFocusChangeRef.current?.(focusedPoints[0] ?? null);
onFocusGroupChangeRef.current?.(focusedPoints);
}, [focusedPoints]);
React.useEffect(() => {
if (cursorBinding) return;
const previous = localFocusedPointsRef.current[0];
if (!focusModel || !previous) return;
const restored = focusModel.restore(previous);
if (restored) {
const next = focusModel.group(restored);
const current = localFocusedPointsRef.current;
if (sameFocusedPointValues(next, current)) {
if (!samePointReferences(next, current)) {
localFocusedPointsRef.current = next;
callbackPointsRef.current = next;
setLocalFocusedPoints(next);
}
return;
}
setFocusSource("restored");
commitLocalFocus(next);
} else {
setPinnedKey(null);
commitLocalFocus([]);
}
}, [commitLocalFocus, cursorBinding, focusModel]);
React.useEffect(() => {
if (!cursorBinding && !sticky) setPinnedKey(null);
}, [cursorBinding, sticky]);
React.useEffect(() => {
if (cursorBinding && (!cursorState || cursorState.source !== "pointer")) {
setPointer(null);
}
}, [cursorBinding, cursorState]);
const clearOwnedTransientCursor = React.useCallback(() => {
if (!cursorBinding || !cursorSession) return false;
const cleared = cursorSession.clearOwnedTransient();
if (!cleared) return false;
focusedPointsRef.current = emptyChartPoints;
return true;
}, [cursorBinding, cursorSession]);
const dismiss = React.useCallback(() => {
setPinnedKey(null);
setPointer(null);
if (cursorBinding) {
focusedPointsRef.current = emptyChartPoints;
cursorSession?.clear();
} else commitLocalFocus([]);
}, [commitLocalFocus, cursorBinding, cursorSession]);
const positionAtEvent = React.useCallback(
(event) => {
if (!scene) return null;
const measuredWidth = layout?.width ?? scene.width;
const measuredHeight = layout?.height ?? scene.height;
if (measuredWidth <= 0 || measuredHeight <= 0) return null;
const position = {
x: event.nativeEvent.locationX / measuredWidth * scene.width,
y: event.nativeEvent.locationY / measuredHeight * scene.height
};
setPointer(position);
return position;
},
[layout?.height, layout?.width, scene]
);
const pointsAtEvent = React.useCallback(
(event) => {
const position = positionAtEvent(event);
return position && focusModel ? focusModel.resolve(position.x, position.y) : emptyChartPoints;
},
[focusModel, positionAtEvent]
);
const publishFocusCursor = (points, source, pinned) => {
if (!scene || cursorBinding?.mode !== "focus" || !cursorSession) {
return false;
}
const point = points[0];
if (!point) {
clearOwnedTransientCursor();
return true;
}
const state = cursorSession.createFocusState(scene, cursorBinding, {
primary: point,
group: points,
source,
pinned
});
focusedPointsRef.current = points;
cursorSession.publish(state);
return true;
};
const commitInteractionFocus = (points, source, pinned = cursorCurrentlyPinned()) => {
if (publishFocusCursor(points, source, pinned)) return;
if (cursorBinding) return;
setFocusSource(source);
commitLocalFocus(points);
};
const updateFreeCursor = (event, pinned) => {
if (!scene || cursorBinding?.mode !== "free" || !cursorSession) return false;
const position = positionAtEvent(event);
if (!position || !plotContains(scene, position)) {
setPointer(null);
clearOwnedTransientCursor();
return true;
}
const state = cursorSession.createFreeState(
scene,
cursorBinding,
position,
"pointer",
pinned
);
cursorSession.publish(state);
return true;
};
const handleResponderGrant = (event) => {
if (cursorCurrentlyPinned()) return;
if (updateFreeCursor(event, false)) return;
commitInteractionFocus(pointsAtEvent(event), "pointer", false);
};
const handleResponderMove = (event) => {
if (cursorCurrentlyPinned()) return;
if (updateFreeCursor(event, false)) return;
commitInteractionFocus(pointsAtEvent(event), "pointer", false);
};
const handleResponderRelease = (event) => {
if (cursorBinding?.mode === "free") {
if (cursorSession?.getState()?.pinned) {
if (cursorBinding.pin) dismiss();
} else {
updateFreeCursor(event, cursorBinding.pin === true);
}
onSelect?.(null);
return;
}
const points = pointsAtEvent(event);
const point = points[0] ?? null;
definition.selection?.change(point, "pointer");
if (cursorBinding?.mode === "focus") {
const canPin = cursorBinding.pin === true || sticky;
const currentlyPinned = cursorSession?.getState()?.pinned === true;
if (currentlyPinned && canPin) dismiss();
else
commitInteractionFocus(
points,
"pointer",
currentlyPinned || canPin && point !== null
);
} else {
if (sticky) setPinnedKey(pinnedKey ? null : point?.key ?? null);
commitInteractionFocus(points, "pointer");
}
onSelect?.(point);
};
const handleResponderTerminate = () => {
if (cursorCurrentlyPinned()) return;
setPointer(null);
if (cursorBinding) {
clearOwnedTransientCursor();
} else commitLocalFocus([]);
};
const handleLayout = (event) => {
const next = event.nativeEvent.layout;
if (!positiveFinite(next.width) || !positiveFinite(next.height)) return;
setLayout(
(current) => current?.width === next.width && current.height === next.height ? current : { width: next.width, height: next.height }
);
};
const navigate = (direction2) => {
if (!focusModel || freeCursor) return;
const point = adjacentFocusPoint(
focusModel,
focusedPointsRef.current[0] ?? null,
direction2
);
setPointer(null);
commitInteractionFocus(
point ? focusModel.group(point) : emptyChartPoints,
"keyboard"
);
};
const activate = () => {
const point = focusedPointsRef.current[0] ?? null;
if (!point) return;
definition.selection?.change(point, "keyboard");
if (cursorBinding?.mode === "focus") {
const canPin = cursorBinding.pin === true || sticky;
if (canPin) {
if (cursorSession?.getState()?.pinned) dismiss();
else publishFocusCursor(focusedPointsRef.current, "keyboard", true);
}
} else if (!cursorBinding && sticky) {
setPinnedKey((current) => current ? null : point.key);
}
onSelect?.(point);
};
const handleAccessibilityAction = (event) => {
switch (event.nativeEvent.actionName) {
case "increment":
navigate(1);
break;
case "decrement":
navigate(-1);
break;
case "activate":
activate();
break;
case "escape":
dismiss();
break;
}
};
const handleFocus = () => {
if (!keyboardInteractive || focusedPointsRef.current.length) return;
navigate(1);
};
const focusedPoint = focusedPoints[0] ?? null;
const focus = React.useMemo(
() => focusedPoint ? {
primary: focusedPoint,
group: focusedPoints,
source: resolvedFocusSource,
pinned: interactionPinned
} : null,
[focusedPoint, focusedPoints, interactionPinned, resolvedFocusSource]
);
const presentedScene = React.useMemo(
() => scene ? resolveMarkStateScene(scene, focus, resolvedPointer).scene : null,
[focus, resolvedPointer, scene]
);
const focusPresentation = React.useMemo(
() => presentedScene ? resolveFocusPresentation(
presentedScene,
focus,
resolvedPointer,
cursorPresentation
) : { under: [], over: [] },
[cursorPresentation, focus, presentedScene, resolvedPointer]
);
return /* @__PURE__ */ jsx(
View,
{
accessible: true,
accessibilityActions: !keyboardInteractive ? void 0 : [
{ name: "increment" },
{ name: "decrement" },
{ name: "activate" },
{ name: "escape" }
],
accessibilityHint,
accessibilityLabel,
accessibilityRole: keyboardInteractive ? "adjustable" : "image",
accessibilityValue: focusedPoint ? { text: describePoint(focusedPoint) } : void 0,
focusable: keyboardInteractive,
onAccessibilityAction: handleAccessibilityAction,
onBlur: handleResponderTerminate,
onFocus: handleFocus,
onLayout: handleLayout,
onMoveShouldSetResponder: () => false,
onResponderGrant: handleResponderGrant,
onResponderMove: handleResponderMove,
onResponderRelease: handleResponderRelease,
onResponderTerminate: handleResponderTerminate,
onResponderTerminationRequest: () => true,
onStartShouldSetResponder: () => pointerInteractive,
style: [
{
position: "relative",
overflow: "visible",
width: explicitWidth ?? "100%",
height: explicitHeight ?? (resolvedAspectRatio === void 0 ? 320 : void 0),
aspectRatio: explicitHeight === void 0 ? resolvedAspectRatio : void 0
},
style
],
testID,
children: scene ? /* @__PURE__ */ jsxs(Fragment, { children: [
/* @__PURE__ */ jsx(
NativeChartScene,
{
scene: presentedScene ?? scene,
color,
fontFamily,
fontStyle,
fontStretch,
letterSpacing,
direction,
fontScale,
idPrefix,
resolvePaint,
focusFill,
focusPresentation
}
),
TooltipComponent && focusedPoints.length ? /* @__PURE__ */ jsx(
TooltipComponent,
{
scene,
width: layout?.width ?? scene.width,
height: layout?.height ?? scene.height,
points: focusedPoints,
pointer: resolvedPointer,
focusSource: resolvedFocusSource,
options: tooltipOptions,
pinned: interactionPinned,
color,
resolvePaint,
dismiss,
render: renderTooltip
}
) : null
] }) : null
}
);
}
const emptyChartPoints = [];
function subscribeEmptyCursor() {
return () => {
};
}
function getEmptyCursorState() {
return null;
}
function plotContains(scene, position) {
return position.x >= scene.chart.x && position.x <= scene.chart.x + scene.chart.width && position.y >= scene.chart.y && position.y <= scene.chart.y + scene.chart.height;
}
function sameFocusedPointValues(left, right) {
return left.length === right.length && left.every((point, index) => {
const current = right[index];
return current !== void 0 && samePointIdentity(point, current) && Object.is(point.group, current.group) && point.groupLabel === current.groupLabel && sameChartValue(point.xValue, current.xValue) && sameChartValue(point.yValue, current.yValue) && sameChartValue(point.x1Value, current.x1Value) && sameChartValue(point.x2Value, current.x2Value) && sameChartValue(point.y1Value, current.y1Value) && sameChartValue(point.y2Value, current.y2Value) && point.xInterval === current.xInterval && point.yInterval === current.yInterval && Object.is(point.x, current.x) && Object.is(point.y, current.y) && point.color === current.color;
});
}
function sameChartValue(left, right) {
return left instanceof Date && right instanceof Date ? left.getTime() === right.getTime() : Object.is(left, right);
}
function resolveNativeTooltipInput(input) {
if (!input) return null;
const extension = "create" in input ? input : input.use;
if (!isNativeTooltipExtension(extension)) {
throw new Error(
"React Native charts require a tooltip extension from @tanstack/charts/react-native/tooltip."
);
}
if (!("create" in input) && input.portal) {
throw new Error(
"React Native charts do not support browser tooltip portal extensions."
);
}
return {
component: extension.create(),
options: "create" in input ? void 0 : input
};
}
function isNativeTooltipExtension(extension) {
const candidate = extension;
return candidate.__chartExtensionType === "tooltip" && candidate.__chartTooltipHost === "react-native";
}
function resolveSceneSize(width, height, aspectRatio, layout) {
const resolvedWidth = width ?? layout?.width;
if (!resolvedWidth) return null;
const resolvedHeight = height ?? (aspectRatio === void 0 ? layout?.height ?? (width === void 0 ? void 0 : 320) : resolvedWidth / aspectRatio);
return resolvedHeight ? { width: resolvedWidth, height: resolvedHeight } : null;
}
function positiveFinite(value) {
return value !== void 0 && Number.isFinite(value) && value > 0 ? value : void 0;
}
function describePoint(point) {
return [
point.groupLabel,
formatValue(point.xValue),
formatValue(point.yValue)
].filter(Boolean).join(", ");
}
function formatValue(value) {
return value instanceof Date ? Number.isNaN(+value) ? "Invalid Date" : value.toISOString().replace("T00:00:00.000Z", "") : typeof value === "number" ? value.toLocaleString() : String(value);
}
export {
Chart
};