From 6b996e20d38aacaa74121fad36dd02aa5e0e2d13 Mon Sep 17 00:00:00 2001 From: Keith Luchtel Date: Fri, 13 Dec 2024 11:49:01 -0600 Subject: [PATCH] Feat/scroll (#437) --- .changeset/seven-walls-cough.md | 5 + example/app/scroll.tsx | 210 ++++++++++++++++++ example/consts/routes.ts | 5 + example/package.json | 1 + lib/src/cartesian/CartesianChart.tsx | 113 +++++++--- lib/src/cartesian/components/XAxis.tsx | 14 +- lib/src/cartesian/components/YAxis.tsx | 11 +- .../contexts/CartesianTransformContext.tsx | 56 +++-- .../cartesian/hooks/useChartTransformState.ts | 17 +- lib/src/cartesian/utils/makeScale.ts | 9 +- lib/src/cartesian/utils/transformGestures.ts | 48 +++- .../utils/transformInputData.test.ts | 24 ++ lib/src/cartesian/utils/transformInputData.ts | 12 +- lib/src/index.ts | 2 + lib/src/shared/GestureHandler.tsx | 51 +++-- lib/src/types.ts | 10 +- lib/src/utils/boundsToClip.ts | 10 + lib/src/utils/transform.ts | 110 ++++++++- website/docs/cartesian/cartesian-chart.md | 50 +++++ website/docs/pan-zoom.mdx | 28 ++- 20 files changed, 691 insertions(+), 95 deletions(-) create mode 100644 .changeset/seven-walls-cough.md create mode 100644 example/app/scroll.tsx create mode 100644 lib/src/utils/boundsToClip.ts diff --git a/.changeset/seven-walls-cough.md b/.changeset/seven-walls-cough.md new file mode 100644 index 00000000..61e21202 --- /dev/null +++ b/.changeset/seven-walls-cough.md @@ -0,0 +1,5 @@ +--- +"victory-native": minor +--- + +Add ability to scroll chart data diff --git a/example/app/scroll.tsx b/example/app/scroll.tsx new file mode 100644 index 00000000..49ed6e67 --- /dev/null +++ b/example/app/scroll.tsx @@ -0,0 +1,210 @@ +export const PanZoom = () => {}; +import * as React from "react"; +import { StyleSheet, View, SafeAreaView } from "react-native"; +import { + CartesianChart, + invert4, + Line, + useChartTransformState, + type ChartBounds, + type Viewport, +} from "victory-native"; +import { + mapPoint3d, + Matrix4, + Rect, + scale, + useFont, +} from "@shopify/react-native-skia"; +import { + interpolate, + useDerivedValue, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; +import { appColors } from "../consts/colors"; +import inter from "../assets/inter-medium.ttf"; + +export default function HorizontalScrollPage() { + const font = useFont(inter, 12); + const { state } = useChartTransformState({}); + const viewport: Viewport = { + x: [5, 15], + y: [50, 60], + }; + + return ( + + + + {({ points }) => { + return ( + <> + + + ); + }} + + + + + ); +} + +type HightlightedProps = { + viewport: Viewport; + matrix: SharedValue; +}; +const Hightlighted = ({ viewport, matrix }: HightlightedProps) => { + const font = useFont(inter, 12); + const [chartBounds, setChartBounds] = React.useState({ + left: 0, + right: 0, + top: 0, + bottom: 0, + }); + const domainX = useSharedValue<[number, number]>([0, 0]); + const domainY = useSharedValue<[number, number]>([0, 0]); + const box = useDerivedValue(() => { + const vp: Required = { ...{ x: [0, 0], y: [0, 0] }, ...viewport }; + const kx = (domainX.value[1] - domainX.value[0]) / (vp.x[1] - vp.x[0]) || 1; + const ky = (domainY.value[0] - domainY.value[1]) / (vp.y[1] - vp.y[0]) || 1; + + const boundsX = [0, (chartBounds.right - chartBounds.left) * kx]; + const boundsY = [0, (chartBounds.bottom - chartBounds.top) * ky]; + const x1z = interpolate(vp.x[0], domainX.value, boundsX); + const x2z = interpolate(vp.x[1], domainX.value, boundsX); + const y1z = interpolate(vp.y[0], domainY.value, boundsY); + const y2z = interpolate(vp.y[1], domainY.value, boundsY); + + const tl = mapPoint3d(invert4(matrix.value), [x1z, y1z, 1]); + const br = mapPoint3d(invert4(matrix.value), [x2z, y2z, 1]); + + const m = scale(kx, ky, 1); + const x1 = mapPoint3d(invert4(m), tl)[0]; + const x2 = mapPoint3d(invert4(m), br)[0]; + const y1 = mapPoint3d(invert4(m), tl)[1]; + const y2 = mapPoint3d(invert4(m), br)[1]; + + return { x: [x1, x2], y: [y1, y2] }; + }); + const x = useDerivedValue(() => { + return box.value.x[0]! + chartBounds.left; + }); + const w = useDerivedValue(() => { + return box.value.x[1]! - box.value.x[0]!; + }); + const y = useDerivedValue(() => { + return box.value.y[0]! + chartBounds.top; + }); + const h = useDerivedValue(() => { + return box.value.y[1]! - box.value.y[0]!; + }); + + return ( + + { + setChartBounds(() => _chartBounds); + }} + onScaleChange={(_xScale, _yScale) => { + domainX.value = _xScale.domain() as [number, number]; + domainY.value = _yScale.domain() as [number, number]; + }} + > + {({ points }) => { + return ( + <> + + + + ); + }} + + + ); +}; + +const DATA = [ + { day: 0, highTmp: 59.30624201725173 }, + { day: 1, highTmp: 44.25635578608018 }, + { day: 2, highTmp: 68.19738539273173 }, + { day: 3, highTmp: 47.62255457719107 }, + { day: 4, highTmp: 69.36936311145384 }, + { day: 5, highTmp: 50.341333269749946 }, + { day: 6, highTmp: 54.73478765663331 }, + { day: 7, highTmp: 59.65742044241456 }, + { day: 8, highTmp: 48.221495620289595 }, + { day: 9, highTmp: 58.65209092238778 }, + { day: 10, highTmp: 41.03429979716762 }, + { day: 11, highTmp: 41.10630442396717 }, + { day: 12, highTmp: 45.47205847354351 }, + { day: 13, highTmp: 57.634709409230446 }, + { day: 14, highTmp: 65.87827901279721 }, + { day: 15, highTmp: 47.99811346139486 }, + { day: 16, highTmp: 43.29378262397241 }, + { day: 17, highTmp: 65.0593421561084 }, + { day: 18, highTmp: 56.312569508928775 }, + { day: 19, highTmp: 67.7442403533759 }, + { day: 20, highTmp: 62.84831567105093 }, + { day: 21, highTmp: 53.629213794422405 }, + { day: 22, highTmp: 45.06696838558802 }, + { day: 23, highTmp: 47.95068037187096 }, + { day: 24, highTmp: 45.93743256152696 }, + { day: 25, highTmp: 54.075911101211815 }, + { day: 26, highTmp: 43.777537229307036 }, + { day: 27, highTmp: 49.19553019689158 }, + { day: 28, highTmp: 46.771688955924674 }, + { day: 29, highTmp: 47.74835132388989 }, + { day: 30, highTmp: 40.1617262863485 }, +]; + +const styles = StyleSheet.create({ + safeView: { + flex: 1, + backgroundColor: appColors.viewBackground.light, + $dark: { + backgroundColor: appColors.viewBackground.dark, + }, + }, +}); diff --git a/example/consts/routes.ts b/example/consts/routes.ts index 9545e372..abbbb900 100644 --- a/example/consts/routes.ts +++ b/example/consts/routes.ts @@ -143,6 +143,11 @@ export const ChartRoutes: { description: "Basic chart example with a custom tap gesture.", path: "/custom-gesture", }, + { + title: "Scroll", + description: "Show example of scrolling chart data.", + path: "/scroll", + }, ]; if (__DEV__) { diff --git a/example/package.json b/example/package.json index c6bdac89..61161685 100644 --- a/example/package.json +++ b/example/package.json @@ -16,6 +16,7 @@ "@react-native-segmented-control/segmented-control": "2.5.4", "@shopify/react-native-skia": "1.5.0", "canvaskit-wasm": "^0.39.1", + "d3-scale": "^4.0.2", "date-fns": "^2.30.0", "expo": "^52.0.5", "expo-asset": "~11.0.1", diff --git a/lib/src/cartesian/CartesianChart.tsx b/lib/src/cartesian/CartesianChart.tsx index 34025733..746e6c97 100644 --- a/lib/src/cartesian/CartesianChart.tsx +++ b/lib/src/cartesian/CartesianChart.tsx @@ -1,6 +1,6 @@ import * as React from "react"; import { type LayoutChangeEvent } from "react-native"; -import { Canvas, Group, rect } from "@shopify/react-native-skia"; +import { Canvas, Group } from "@shopify/react-native-skia"; import { useSharedValue } from "react-native-reanimated"; import { type ComposedGesture, @@ -10,6 +10,8 @@ import { } from "react-native-gesture-handler"; import { type MutableRefObject } from "react"; import { ZoomTransform } from "d3-zoom"; +import type { ScaleLinear } from "d3-scale"; +import isEqual from "react-fast-compare"; import type { AxisProps, CartesianChartRenderArg, @@ -22,6 +24,7 @@ import type { XAxisInputProps, FrameInputProps, ChartPressPanConfig, + Viewport, } from "../types"; import { transformInputData } from "./utils/transformInputData"; import { findClosestPoint } from "../utils/findClosestPoint"; @@ -42,6 +45,7 @@ import { panTransformGesture, type PanTransformGestureConfig, pinchTransformGesture, + type PinchTransformGestureConfig, } from "./utils/transformGestures"; import { CartesianTransformProvider, @@ -49,6 +53,7 @@ import { } from "./contexts/CartesianTransformContext"; import { downsampleTicks } from "../utils/tickHelpers"; import { GestureHandler } from "../shared/GestureHandler"; +import { boundsToClip } from "../utils/boundsToClip"; import { normalizeYAxisTicks } from "../utils/normalizeYAxisTicks"; export type CartesianActionsHandle = @@ -71,6 +76,7 @@ type CartesianChartProps< padding?: SidedNumber; domainPadding?: SidedNumber; domain?: { x?: [number] | [number, number]; y?: [number] | [number, number] }; + viewport?: Viewport; chartPressState?: | ChartPressState<{ x: InputFields[XK]; y: Record }> | ChartPressState<{ x: InputFields[XK]; y: Record }>[]; @@ -84,6 +90,10 @@ type CartesianChartProps< axisOptions?: Partial, "xScale" | "yScale">>; onChartBoundsChange?: (bounds: ChartBounds) => void; + onScaleChange?: ( + xScale: ScaleLinear, + yScale: ScaleLinear, + ) => void; /** * @deprecated This prop will eventually be replaced by the new `chartPressConfig`. For now it's being kept around for backwards compatibility sake. */ @@ -94,6 +104,7 @@ type CartesianChartProps< transformState?: ChartTransformState; transformConfig?: { pan?: PanTransformGestureConfig; + pinch?: PinchTransformGestureConfig; }; customGestures?: ComposedGesture; actionsRef?: MutableRefObject) { const [size, setSize] = React.useState({ width: 0, height: 0 }); + const chartBoundsRef = React.useRef(undefined); + const xScaleRef = React.useRef | undefined>( + undefined, + ); + const yScaleRef = React.useRef | undefined>( + undefined, + ); const [hasMeasuredLayoutSize, setHasMeasuredLayoutSize] = React.useState(false); const onLayout = React.useCallback( @@ -166,7 +186,14 @@ function CartesianChartContent< // create a d3-zoom transform object based on the current transform state. This // is used for rescaling the X and Y axes. const transform = useCartesianTransformContext(); - const zoom = new ZoomTransform(transform.k, transform.tx, transform.ty); + const zoomX = React.useMemo( + () => new ZoomTransform(transform.k, transform.tx, transform.ty), + [transform.k, transform.tx, transform.ty], + ); + const zoomY = React.useMemo( + () => new ZoomTransform(transform.ky, transform.tx, transform.ty), + [transform.ky, transform.tx, transform.ty], + ); const tData = useSharedValue>({ ix: [], @@ -203,15 +230,20 @@ function CartesianChartContent< domainPadding, xAxis: normalizedAxisProps.xAxis, yAxes: normalizedAxisProps.yAxes, + viewport, }); const primaryYAxis = yAxes[0]; const primaryYScale = primaryYAxis.yScale; const chartBounds = { - left: xScale(xScale.domain().at(0) || 0), - right: xScale(xScale.domain().at(-1) || 0), - top: primaryYScale(primaryYScale.domain().at(0) || 0), - bottom: primaryYScale(primaryYScale.domain().at(-1) || 0), + left: xScale(viewport?.x?.[0] ?? xScale.domain().at(0) ?? 0), + right: xScale(viewport?.x?.[1] ?? xScale.domain().at(-1) ?? 0), + top: primaryYScale( + viewport?.y?.[1] ?? (primaryYScale.domain().at(0) || 0), + ), + bottom: primaryYScale( + viewport?.y?.[0] ?? (primaryYScale.domain().at(-1) || 0), + ), }; return { @@ -232,6 +264,7 @@ function CartesianChartContent< domain, domainPadding, normalizedAxisProps, + viewport, ]); React.useEffect(() => { @@ -503,9 +536,28 @@ function CartesianChartContent< // On bounds change, emit const onChartBoundsRef = useFunctionRef(onChartBoundsChange); React.useEffect(() => { - onChartBoundsRef.current?.(chartBounds); + if (!isEqual(chartBounds, chartBoundsRef.current)) { + chartBoundsRef.current = chartBounds; + onChartBoundsRef.current?.(chartBounds); + } }, [chartBounds, onChartBoundsRef]); + const onScaleRef = useFunctionRef(onScaleChange); + React.useEffect(() => { + const rescaledX = zoomX.rescaleX(xScale); + const rescaledY = zoomY.rescaleY(primaryYScale); + if ( + !isEqual(xScaleRef.current?.domain(), rescaledX.domain()) || + !isEqual(yScaleRef.current?.domain(), rescaledY.domain()) || + !isEqual(xScaleRef.current?.range(), rescaledX.range()) || + !isEqual(yScaleRef.current?.range(), rescaledY.range()) + ) { + xScaleRef.current = xScale; + yScaleRef.current = primaryYScale; + onScaleRef.current?.(rescaledX, rescaledY); + } + }, [onScaleChange, onScaleRef, xScale, zoomX, zoomY, primaryYScale]); + const renderArg: CartesianChartRenderArg = { xScale, xTicks: xTicksNormalized, @@ -516,13 +568,7 @@ function CartesianChartContent< points, }; - const clipRect = rect( - chartBounds.left, - chartBounds.top, - chartBounds.right - chartBounds.left, - chartBounds.bottom - chartBounds.top, - ); - + const clipRect = boundsToClip(chartBounds); const YAxisComponents = hasMeasuredLayoutSize && (axisOptions || yAxes) ? normalizedAxisProps.yAxes?.map((axis, index) => { @@ -531,8 +577,8 @@ function CartesianChartContent< if (!yAxis) return null; const primaryAxisProps = normalizedAxisProps.yAxes[0]!; - const primaryRescaled = zoom.rescaleY(primaryYScale); - const rescaled = zoom.rescaleY(yAxis.yScale); + const primaryRescaled = zoomY.rescaleY(primaryYScale); + const rescaled = zoomY.rescaleY(yAxis.yScale); const rescaledTicks = axis.tickValues ? downsampleTicks(axis.tickValues, axis.tickCount) @@ -553,7 +599,7 @@ function CartesianChartContent< 0 && !axis.tickValues @@ -564,7 +610,7 @@ function CartesianChartContent< ) : rescaledTicks } - chartBounds={clipRect} + chartBounds={chartBounds} /> ); }) @@ -575,11 +621,11 @@ function CartesianChartContent< ) : null; @@ -611,11 +657,18 @@ function CartesianChartContent< let composed = customGestures ?? Gesture.Race(); if (transformState) { - composed = Gesture.Race( - composed, - pinchTransformGesture(transformState), - panTransformGesture(transformState, transformConfig?.pan), - ); + if (transformConfig?.pinch?.enabled ?? true) { + composed = Gesture.Race( + composed, + pinchTransformGesture(transformState, transformConfig?.pinch), + ); + } + if (transformConfig?.pan?.enabled ?? true) { + composed = Gesture.Race( + composed, + panTransformGesture(transformState, transformConfig?.pan), + ); + } } if (chartPressState) { composed = Gesture.Race(composed, panGesture); @@ -627,7 +680,13 @@ function CartesianChartContent< ); diff --git a/lib/src/cartesian/components/XAxis.tsx b/lib/src/cartesian/components/XAxis.tsx index 9dc65aa3..12a13595 100644 --- a/lib/src/cartesian/components/XAxis.tsx +++ b/lib/src/cartesian/components/XAxis.tsx @@ -1,6 +1,7 @@ import React from "react"; import { StyleSheet } from "react-native"; import { Group, Line, Text, vec } from "@shopify/react-native-skia"; +import { boundsToClip } from "lib/src/utils/boundsToClip"; import { DEFAULT_TICK_COUNT, downsampleTicks } from "../../utils/tickHelpers"; import type { InputDatum, @@ -36,7 +37,6 @@ export const XAxis = < }: XAxisProps) => { const xScale = zoom ? zoom.rescaleX(xScaleProp) : xScaleProp; const [y1 = 0, y2 = 0] = yScale.domain(); - const [x1r = 0, x2r = 0] = xScale.range(); const fontSize = font?.getSize() ?? 0; const xTicksNormalized = tickValues ? downsampleTicks(tickValues, tickCount) @@ -54,14 +54,16 @@ export const XAxis = < .reduce((sum, value) => sum + value, 0) ?? 0; const labelX = xScale(tick) - (labelWidth ?? 0) / 2; const canFitLabelContent = - xScale(tick) >= x1r && - xScale(tick) <= x2r && - (yAxisSide === "left" ? labelX + labelWidth < x2r : x1r < labelX); + xScale(tick) >= chartBounds.left && + xScale(tick) <= chartBounds.right && + (yAxisSide === "left" + ? labelX + labelWidth < chartBounds.right + : chartBounds.left < labelX); const labelY = (() => { // bottom, outset if (axisSide === "bottom" && labelPosition === "outset") { - return yScale(y2) + labelOffset + fontSize; + return chartBounds.bottom + labelOffset + fontSize; } // bottom, inset if (axisSide === "bottom" && labelPosition === "inset") { @@ -78,7 +80,7 @@ export const XAxis = < return ( {lineWidth > 0 ? ( - + { // left, outset if (axisSide === "left" && labelPosition === "outset") { - return xScale(x1) - (labelWidth + labelOffset); + return chartBounds.left - (labelWidth + labelOffset); } // left, inset if (axisSide === "left" && labelPosition === "inset") { - return xScale(x1) + labelOffset; + return chartBounds.left + labelOffset; } // right, outset if (axisSide === "right" && labelPosition === "outset") { - return xScale(x2) + labelOffset; + return chartBounds.right + labelOffset; } // right, inset - return xScale(x2) - (labelWidth + labelOffset); + return chartBounds.right - (labelWidth + labelOffset); })(); const canFitLabelContent = labelY > fontSize && labelY < yScale(y2); @@ -59,7 +60,7 @@ export const YAxis = < return ( {lineWidth > 0 ? ( - + { - const getTransformComponents = ( - transformState: ChartTransformState | undefined, - ) => { - "worklet"; - return { - k: transformState?.matrix.value[0] || 1, - tx: transformState?.matrix.value[3] || 0, - ty: transformState?.matrix.value[7] || 0, - }; - }; const [transform, setTransform] = useState<{ k: number; + kx: number; + ky: number; tx: number; ty: number; - }>(getTransformComponents(undefined)); + }>(() => { + const components = getTransformComponents(undefined); + return { + k: components.scaleX, + kx: components.scaleX, + ky: components.scaleY, + tx: components.translateX, + ty: components.translateY, + }; + }); + // This is done in a useEffect to prevent Reanimated warning + // about setting shared value in the render phase useEffect(() => { if (transformState) { - setTransform(getTransformComponents(transformState)); + setTransform(() => { + const components = getTransformComponents(transformState.matrix.value); + return { + k: components.scaleX, + kx: components.scaleX, + ky: components.scaleY, + tx: components.translateX, + ty: components.translateY, + }; + }); } }, []); // eslint-disable-line react-hooks/exhaustive-deps useAnimatedReaction( () => { - return getTransformComponents(transformState); + return getTransformComponents(transformState?.matrix.value); }, (cv, pv) => { - if (cv.k != pv?.k || cv.tx != pv.tx || cv.ty !== pv.ty) { - runOnJS(setTransform)(cv); + if ( + cv.scaleX !== pv?.scaleX || + cv.scaleY !== pv.scaleY || + cv.translateX !== pv.translateX || + cv.translateY !== pv.translateY + ) { + runOnJS(setTransform)({ + k: cv.scaleX, + kx: cv.scaleX, + ky: cv.scaleY, + tx: cv.translateX, + ty: cv.translateY, + }); } }, ); diff --git a/lib/src/cartesian/hooks/useChartTransformState.ts b/lib/src/cartesian/hooks/useChartTransformState.ts index b6c73cf9..aa831e94 100644 --- a/lib/src/cartesian/hooks/useChartTransformState.ts +++ b/lib/src/cartesian/hooks/useChartTransformState.ts @@ -3,7 +3,8 @@ import { type SharedValue, useSharedValue, } from "react-native-reanimated"; -import { type Matrix4 } from "@shopify/react-native-skia"; +import { scale, type Matrix4 } from "@shopify/react-native-skia"; +import { useEffect } from "react"; import { identity4 } from "../../utils/transform"; export type ChartTransformState = { @@ -14,13 +15,25 @@ export type ChartTransformState = { offset: SharedValue; }; -export const useChartTransformState = (): { +type ChartTransformStateConfig = { + scaleX?: number; + scaleY?: number; +}; +export const useChartTransformState = ( + config?: ChartTransformStateConfig, +): { state: ChartTransformState; } => { const origin = useSharedValue({ x: 0, y: 0 }); const matrix = useSharedValue(identity4); const offset = useSharedValue(identity4); + // This is done in a useEffect to prevent Reanimated warning + // about setting shared value in the render phase + useEffect(() => { + matrix.value = scale(config?.scaleX ?? 1, config?.scaleY ?? 1); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return { state: { panActive: makeMutable(false), diff --git a/lib/src/cartesian/utils/makeScale.ts b/lib/src/cartesian/utils/makeScale.ts index 80d7d9a9..0e4dd9a7 100644 --- a/lib/src/cartesian/utils/makeScale.ts +++ b/lib/src/cartesian/utils/makeScale.ts @@ -5,16 +5,23 @@ export const makeScale = ({ outputBounds, padStart, padEnd, + viewport, isNice = false, }: { inputBounds: [number, number]; outputBounds: [number, number]; + viewport?: [number, number]; padStart?: number; padEnd?: number; isNice?: boolean; }): ScaleLinear => { // Linear - const scale = scaleLinear().domain(inputBounds).range(outputBounds); + const viewScale = scaleLinear() + .domain(viewport ?? inputBounds) + .range(outputBounds); + const scale = scaleLinear() + .domain(inputBounds) + .range([viewScale(inputBounds[0]), viewScale(inputBounds[1])]); if (padStart || padEnd) { scale diff --git a/lib/src/cartesian/utils/transformGestures.ts b/lib/src/cartesian/utils/transformGestures.ts index 2248c30c..a74a734e 100644 --- a/lib/src/cartesian/utils/transformGestures.ts +++ b/lib/src/cartesian/utils/transformGestures.ts @@ -7,9 +7,27 @@ import { multiply4, scale, translate } from "@shopify/react-native-skia"; import type { PanGestureConfig } from "react-native-gesture-handler/lib/typescript/handlers/PanGestureHandler"; import { type ChartTransformState } from "../hooks/useChartTransformState"; +type Dimension = "x" | "y"; + +export type PinchTransformGestureConfig = { + enabled?: boolean; + dimensions?: Dimension | Dimension[]; +}; export const pinchTransformGesture = ( state: ChartTransformState, + _config: PinchTransformGestureConfig = {}, ): PinchGesture => { + const defaults: PinchTransformGestureConfig = { + enabled: true, + dimensions: ["x", "y"], + }; + const config = { ...defaults, ..._config }; + const dimensions = Array.isArray(config.dimensions) + ? config.dimensions + : [config.dimensions]; + const scaleX = dimensions.includes("x"); + const scaleY = dimensions.includes("y"); + const pinch = Gesture.Pinch() .onBegin((e) => { state.offset.value = state.matrix.value; @@ -24,7 +42,12 @@ export const pinchTransformGesture = ( .onChange((e) => { state.matrix.value = multiply4( state.offset.value, - scale(e.scale, e.scale, 1, state.origin.value), + scale( + scaleX ? e.scale : 1, + scaleY ? e.scale : 1, + 1, + state.origin.value, + ), ); }) .onEnd(() => { @@ -34,21 +57,32 @@ export const pinchTransformGesture = ( return pinch; }; -export type PanTransformGestureConfig = Pick< - PanGestureConfig, - "activateAfterLongPress" ->; +export type PanTransformGestureConfig = { + enabled?: boolean; + dimensions?: Dimension | Dimension[]; +} & Pick; export const panTransformGesture = ( state: ChartTransformState, - config: PanTransformGestureConfig = {}, + _config: PanTransformGestureConfig = {}, ): PanGesture => { + const defaults: PanTransformGestureConfig = { + enabled: true, + dimensions: ["x", "y"], + }; + const config = { ...defaults, ..._config }; + const dimensions = Array.isArray(config.dimensions) + ? config.dimensions + : [config.dimensions]; + const panX = dimensions.includes("x"); + const panY = dimensions.includes("y"); + const pan = Gesture.Pan() .onStart(() => { state.panActive.value = true; }) .onChange((e) => { state.matrix.value = multiply4( - translate(e.changeX, e.changeY, 0), + translate(panX ? e.changeX : 0, panY ? e.changeY : 0, 0), state.matrix.value, ); }) diff --git a/lib/src/cartesian/utils/transformInputData.test.ts b/lib/src/cartesian/utils/transformInputData.test.ts index 4eb994df..d0692c4a 100644 --- a/lib/src/cartesian/utils/transformInputData.test.ts +++ b/lib/src/cartesian/utils/transformInputData.test.ts @@ -88,6 +88,30 @@ describe("transformInputData", () => { expect(yScale(10)).toEqual(0); }); + it("should handle viewport", () => { + const { xScale, yAxes } = transformInputData({ + data: DATA, + xKey: "x", + yKeys: ["y", "z"], + outputWindow: OUTPUT_WINDOW, + xAxis: axes.xAxis, + yAxes: axes.yAxes, + viewport: { + // Test both x and y viewport handling + x: [0.5, 1.5], + y: [2, 8], + }, + }); + + const yScale = yAxes[0].yScale; + + expect(xScale(0.5)).toEqual(0); + expect(xScale(1.5)).toEqual(500); + + expect(yScale(2)).toEqual(300); // min maps to bottom + expect(yScale(8)).toEqual(0); // max maps to top + }); + it("sorts data by xKey", () => { const { ix, y } = transformInputData({ data: [ diff --git a/lib/src/cartesian/utils/transformInputData.ts b/lib/src/cartesian/utils/transformInputData.ts index d83f5ed6..14e4a544 100644 --- a/lib/src/cartesian/utils/transformInputData.ts +++ b/lib/src/cartesian/utils/transformInputData.ts @@ -42,6 +42,7 @@ export const transformInputData = < domainPadding, xAxis, yAxes, + viewport, }: { data: RawData[]; xKey: XK; @@ -54,6 +55,10 @@ export const transformInputData = < domainPadding?: SidedNumber; xAxis: XAxisPropsWithDefaults; yAxes: YAxisPropsWithDefaults[]; + viewport?: { + x?: [number, number]; + y?: [number, number]; + }; }): TransformedData & { xScale: ScaleLinear; isNumericalData: boolean; @@ -156,6 +161,8 @@ export const transformInputData = < const yScale = makeScale({ inputBounds: yScaleDomain, outputBounds: yScaleRange, + // Reverse viewport y values since canvas coordinates increase downward + viewport: viewport?.y ? [viewport.y[1], viewport.y[0]] : yScaleDomain, isNice: true, padEnd: typeof domainPadding === "number" @@ -270,10 +277,13 @@ export const transformInputData = < const ixMin = asNumber(domain?.x?.[0] ?? tickDomainsX?.[0] ?? ixNum.at(0)), ixMax = asNumber(domain?.x?.[1] ?? tickDomainsX?.[1] ?? ixNum.at(-1)); + const xInputBounds: [number, number] = + ixMin === ixMax ? [ixMin - 1, ixMax + 1] : [ixMin, ixMax]; const xScale = makeScale({ // if single data point, manually add upper & lower bounds so chart renders properly - inputBounds: ixMin === ixMax ? [ixMin - 1, ixMax + 1] : [ixMin, ixMax], + inputBounds: xInputBounds, outputBounds: oRange, + viewport: viewport?.x ?? xInputBounds, padStart: typeof domainPadding === "number" ? domainPadding : domainPadding?.left, padEnd: diff --git a/lib/src/index.ts b/lib/src/index.ts index da210c4d..b5bfb4d6 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -9,6 +9,7 @@ export { export { type InputDatum, type CartesianChartRenderArg, + type Viewport, type ChartBounds, type YAxisSide, type XAxisSide, @@ -32,6 +33,7 @@ export { getTransformComponents, setScale, setTranslate, + invert4, } from "./utils/transform"; // Line diff --git a/lib/src/shared/GestureHandler.tsx b/lib/src/shared/GestureHandler.tsx index 8b8e0541..d2d67335 100644 --- a/lib/src/shared/GestureHandler.tsx +++ b/lib/src/shared/GestureHandler.tsx @@ -4,16 +4,18 @@ import { type GestureType, } from "react-native-gesture-handler"; import { - convertToAffineMatrix, - convertToColumnMajor, - type Matrix4, + // convertToAffineMatrix, + // convertToColumnMajor, + // type Matrix4, type SkRect, } from "@shopify/react-native-skia"; import Animated, { useAnimatedStyle } from "react-native-reanimated"; -import { Platform } from "react-native"; +import { + /*Platform, type TransformsStyle,*/ type ViewStyle, +} from "react-native"; import * as React from "react"; import { type ChartTransformState } from "../cartesian/hooks/useChartTransformState"; -import { identity4 } from "../utils/transform"; +import { getTransformComponents /*identity4*/ } from "../utils/transform"; type GestureHandlerProps = { gesture: ComposedGesture | GestureType; @@ -29,28 +31,41 @@ export const GestureHandler = ({ }: GestureHandlerProps) => { const { x, y, width, height } = dimensions; const style = useAnimatedStyle(() => { - let m4: Matrix4 = identity4; + // let m4: Matrix4 = identity4; + let transforms: ViewStyle["transform"] = []; if (transformState?.matrix.value) { - m4 = convertToColumnMajor(transformState.matrix.value); + const decomposed = getTransformComponents(transformState.matrix.value); + transforms = [ + { translateX: decomposed.translateX }, + { translateY: decomposed.translateY }, + { scaleX: decomposed.scaleX }, + { scaleY: decomposed.scaleY }, + ]; + // m4 = convertToColumnMajor(transformState.matrix.value); } return { position: "absolute", backgroundColor: debug ? "rgba(100, 200, 300, 0.4)" : "transparent", - x, - y, + // x, + // y, + left: x, + top: y, width, height, transform: [ - { translateX: -width / 2 }, + { translateX: -width / 2 - x }, { translateY: -height / 2 }, - { - matrix: m4 - ? Platform.OS === "web" - ? convertToAffineMatrix(m4) - : (m4 as unknown as number[]) - : [], - }, - { translateX: width / 2 }, + // Running into issues using 'matrix' transforms when enabling the new arch: + // https://github.com/facebook/react-native/issues/47467 + // { + // matrix: m4 + // ? Platform.OS === "web" + // ? convertToAffineMatrix(m4) + // : undefined + // : undefined, + // }, + ...transforms, + { translateX: x + width / 2 }, { translateY: height / 2 }, ], }; diff --git a/lib/src/types.ts b/lib/src/types.ts index 735f6381..7b5cec8c 100644 --- a/lib/src/types.ts +++ b/lib/src/types.ts @@ -1,7 +1,6 @@ import { type SharedValue } from "react-native-reanimated"; import { type ScaleLinear } from "d3-scale"; import { - type ClipDef, type Color, type DashPathEffect, type SkFont, @@ -53,6 +52,11 @@ export type SidedNumber = | number | { left?: number; right?: number; top?: number; bottom?: number }; +export type Viewport = { + x?: [number, number]; + y?: [number, number]; +}; + /** * Render arg for our line chart. */ @@ -209,7 +213,7 @@ export type XAxisProps< yScale: Scale; isNumericalData: boolean; ix: InputFields[XK][]; - chartBounds: ClipDef; + chartBounds: ChartBounds; zoom?: ZoomTransform; }; @@ -257,7 +261,7 @@ export type YAxisProps< yScale: Scale; yTicksNormalized: number[]; yKeys: YK[]; - chartBounds: ClipDef; + chartBounds: ChartBounds; }; export type FrameInputProps = { diff --git a/lib/src/utils/boundsToClip.ts b/lib/src/utils/boundsToClip.ts new file mode 100644 index 00000000..6d04750a --- /dev/null +++ b/lib/src/utils/boundsToClip.ts @@ -0,0 +1,10 @@ +import { rect, type ClipDef } from "@shopify/react-native-skia"; +import type { ChartBounds } from "../types"; + +export const boundsToClip = (bounds: ChartBounds): ClipDef => + rect( + bounds.left, + bounds.top, + bounds.right - bounds.left, + bounds.bottom - bounds.top, + ); diff --git a/lib/src/utils/transform.ts b/lib/src/utils/transform.ts index bf1a467b..1a6b0088 100644 --- a/lib/src/utils/transform.ts +++ b/lib/src/utils/transform.ts @@ -1,4 +1,4 @@ -import type { Matrix4 } from "@shopify/react-native-skia"; +import { Matrix4 } from "@shopify/react-native-skia"; export const identity4: Matrix4 = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, @@ -11,14 +11,14 @@ enum MatrixValues { TranslateY = 7, } -export const getTransformComponents = (m: Matrix4) => { +export const getTransformComponents = (m: Matrix4 | undefined) => { "worklet"; return { - scaleX: m[MatrixValues.ScaleX], - scaleY: m[MatrixValues.ScaleY], - translateX: m[MatrixValues.TranslateX], - translateY: m[MatrixValues.TranslateY], + scaleX: m?.[MatrixValues.ScaleX] || 1, + scaleY: m?.[MatrixValues.ScaleY] || 1, + translateX: m?.[MatrixValues.TranslateX] || 0, + translateY: m?.[MatrixValues.TranslateY] || 0, }; }; @@ -45,3 +45,101 @@ export const setTranslate = ( return m as unknown as Matrix4; }; + +/** taken from https://github.com/Shopify/react-native-skia/blob/main/packages/skia/src/skia/types/Matrix4.ts#L378 which was very recently added. + * This is a temporary workaround until the new version of react-native-skia is released and widely used. + */ +const det3x3 = ( + a00: number, + a01: number, + a02: number, + a10: number, + a11: number, + a12: number, + a20: number, + a21: number, + a22: number, +): number => { + "worklet"; + return ( + a00 * (a11 * a22 - a12 * a21) + + a01 * (a12 * a20 - a10 * a22) + + a02 * (a10 * a21 - a11 * a20) + ); +}; + +/** taken from https://github.com/Shopify/react-native-skia/blob/main/packages/skia/src/skia/types/Matrix4.ts#L402 which was very recently added. + * This is a temporary workaround until the new version of react-native-skia is released and widely used. + */ +export const invert4 = (m: Matrix4): Matrix4 => { + "worklet"; + + const a00 = m[0], + a01 = m[1], + a02 = m[2], + a03 = m[3]; + const a10 = m[4], + a11 = m[5], + a12 = m[6], + a13 = m[7]; + const a20 = m[8], + a21 = m[9], + a22 = m[10], + a23 = m[11]; + const a30 = m[12], + a31 = m[13], + a32 = m[14], + a33 = m[15]; + + // Calculate cofactors + const b00 = det3x3(a11, a12, a13, a21, a22, a23, a31, a32, a33); + const b01 = -det3x3(a10, a12, a13, a20, a22, a23, a30, a32, a33); + const b02 = det3x3(a10, a11, a13, a20, a21, a23, a30, a31, a33); + const b03 = -det3x3(a10, a11, a12, a20, a21, a22, a30, a31, a32); + + const b10 = -det3x3(a01, a02, a03, a21, a22, a23, a31, a32, a33); + const b11 = det3x3(a00, a02, a03, a20, a22, a23, a30, a32, a33); + const b12 = -det3x3(a00, a01, a03, a20, a21, a23, a30, a31, a33); + const b13 = det3x3(a00, a01, a02, a20, a21, a22, a30, a31, a32); + + const b20 = det3x3(a01, a02, a03, a11, a12, a13, a31, a32, a33); + const b21 = -det3x3(a00, a02, a03, a10, a12, a13, a30, a32, a33); + const b22 = det3x3(a00, a01, a03, a10, a11, a13, a30, a31, a33); + const b23 = -det3x3(a00, a01, a02, a10, a11, a12, a30, a31, a32); + + const b30 = -det3x3(a01, a02, a03, a11, a12, a13, a21, a22, a23); + const b31 = det3x3(a00, a02, a03, a10, a12, a13, a20, a22, a23); + const b32 = -det3x3(a00, a01, a03, a10, a11, a13, a20, a21, a23); + const b33 = det3x3(a00, a01, a02, a10, a11, a12, a20, a21, a22); + + // Calculate determinant + const det = a00 * b00 + a01 * b01 + a02 * b02 + a03 * b03; + + // Check if matrix is invertible + if (Math.abs(det) < 1e-8) { + // Return identity matrix if not invertible + return Matrix4(); + } + + const invDet = 1.0 / det; + + // Calculate inverse matrix + return [ + b00 * invDet, + b10 * invDet, + b20 * invDet, + b30 * invDet, + b01 * invDet, + b11 * invDet, + b21 * invDet, + b31 * invDet, + b02 * invDet, + b12 * invDet, + b22 * invDet, + b32 * invDet, + b03 * invDet, + b13 * invDet, + b23 * invDet, + b33 * invDet, + ] as Matrix4; +}; diff --git a/website/docs/cartesian/cartesian-chart.md b/website/docs/cartesian/cartesian-chart.md index cf659b6a..40b4827f 100644 --- a/website/docs/cartesian/cartesian-chart.md +++ b/website/docs/cartesian/cartesian-chart.md @@ -88,6 +88,18 @@ An object of shape `{ x?: [number] | [number, number]; y?: [number] | [number, n For example, passing `domain={{y: [-10, 100]}}` will result in a y-axis with a lower bound of `-10` and an upper bound of `100`. For `domain={{x: [1, 4]}}`, will result in an x-axis contained within those bounds. +### `viewport` + +An object of shape `{ x?: [number, number]; y?: [number, number] }` that controls the visible range of the chart. Unlike `domain` which sets the absolute bounds of the data, `viewport` determines what portion of the data is currently visible in the chart window. + +For example, if your data spans from 0-100 on the x-axis, setting `viewport={{ x: [25, 75] }}` will zoom the chart to show only the data between x=25 and x=75. This is particularly useful for implementing features like: + +- Initial zoom level +- Programmatically controlling the visible range +- Creating preset view windows for different data ranges + +The viewport can be combined with `transformState` to allow user interaction (pan/zoom) within the specified range. + ### `domainPadding` A `number` or an object of shape `{ left?: number; right?: number; top?: number; bottom?: number; }` that specifies that padding between the outer bounds of the _charting area_ (e.g. where the axes lie) and where chart elements will be plotted. @@ -246,11 +258,30 @@ An optional configuration object for customizing transform behavior when `transf ```typescript { pan?: { + enabled?: boolean; // Enable/disable panning gesture (defaults to true) + dimensions?: "x" | "y" | ("x" | "y")[]; // Control which dimensions can be panned activateAfterLongPress?: number; // Minimum time to press before pan gesture is activated + }, + pinch?: { + enabled?: boolean; // Enable/disable pinch gesture (defaults to true) + dimensions?: "x" | "y" | ("x" | "y")[]; // Control which dimensions can be zoomed } } ``` +For example, to restrict panning and zooming to only the x-axis: + +```typescript + +``` + ### `customGestures` The `customGestures` prop allows you to provide custom gesture handlers that will work alongside (or instead of) the default chart press gestures. It accepts a `ComposedGesture` from react-native-gesture-handler. @@ -293,6 +324,25 @@ function MyChart() { } ``` +### `onScaleChange` + +A callback function that is called whenever the chart's scales change, either due to data updates or zoom/pan transformations. The function receives two parameters: + +- `xScale`: The current x-axis scale (a d3 linear scale) +- `yScale`: The current y-axis scale (a d3 linear scale) + +This is useful for tracking scale changes and accessing the current domain/range of the chart, especially during zoom and pan interactions. + +```tsx + { + console.log("X domain:", xScale.domain()); + console.log("Y domain:", yScale.domain()); + }} + // ... other props +/> +``` + ## Render Function Fields The `CartesianChart` `children` and `renderOutside` render functions both have a single argument that is an object with the following fields. diff --git a/website/docs/pan-zoom.mdx b/website/docs/pan-zoom.mdx index 51d72180..6573249c 100644 --- a/website/docs/pan-zoom.mdx +++ b/website/docs/pan-zoom.mdx @@ -21,7 +21,22 @@ The `CartesianChart` and `PolarChart` components have opt-in support for "pan/zo ## useChartTransformState -The `useChartTransformState` hook provides the necessary state management for pan and zoom gestures. It returns an object with a `state` property that contains: +The `useChartTransformState` hook provides the necessary state management for pan and zoom gestures. It accepts an optional configuration object and returns an object with a `state` property. + +### Configuration + +The hook accepts a configuration object with the following options: + +```ts +type ChartTransformStateConfig = { + scaleX?: number; // Initial X-axis scale + scaleY?: number; // Initial Y-axis scale +}; +``` + +### Return Value + +The hook returns an object with a `state` property that contains: ```ts { @@ -43,7 +58,10 @@ To enable pan and zoom gestures on a chart, pass the transform state to the char import { CartesianChart, useChartTransformState } from "victory-native"; function MyChart() { - const transformState = useChartTransformState(); + const transformState = useChartTransformState({ + scaleX: 1.5, // Initial X-axis scale + scaleY: 1.0, // Initial Y-axis scale + }); return (