Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: separate gesture handling code in cartesian axis #481

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 37 additions & 293 deletions lib/src/cartesian/CartesianChart.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import * as React from "react";
import { type LayoutChangeEvent } from "react-native";
import { StyleSheet, type LayoutChangeEvent } from "react-native";
import { Canvas, Group } from "@shopify/react-native-skia";
import { useSharedValue } from "react-native-reanimated";
import {
type ComposedGesture,
Gesture,
GestureHandlerRootView,
type TouchData,
} from "react-native-gesture-handler";
import { type ComposedGesture } from "react-native-gesture-handler";
import { type MutableRefObject } from "react";
import { ZoomTransform } from "d3-zoom";
import type { ScaleLinear } from "d3-scale";
Expand All @@ -27,7 +22,6 @@ import type {
Viewport,
} from "../types";
import { transformInputData } from "./utils/transformInputData";
import { findClosestPoint } from "../utils/findClosestPoint";
import { valueFromSidedNumber } from "../utils/valueFromSidedNumber";
import { asNumber } from "../utils/asNumber";
import type {
Expand All @@ -42,19 +36,17 @@ import { Frame } from "./components/Frame";
import { useBuildChartAxis } from "./hooks/useBuildChartAxis";
import { type ChartTransformState } from "./hooks/useChartTransformState";
import {
panTransformGesture,
type PanTransformGestureConfig,
pinchTransformGesture,
type PinchTransformGestureConfig,
} from "./utils/transformGestures";
import {
CartesianTransformProvider,
useCartesianTransformContext,
} from "./contexts/CartesianTransformContext";
import { downsampleTicks } from "../utils/tickHelpers";
import { GestureHandler } from "../shared/GestureHandler";
import { boundsToClip } from "../utils/boundsToClip";
import { normalizeYAxisTicks } from "../utils/normalizeYAxisTicks";
import { CartesianGestureHandler } from "./components/CartesianGestureHandler";

export type CartesianActionsHandle<T = undefined> =
T extends ChartPressState<infer S>
Expand Down Expand Up @@ -88,7 +80,6 @@ type CartesianChartProps<
args: CartesianChartRenderArg<RawData, YK>,
) => React.ReactNode;
axisOptions?: Partial<Omit<AxisProps<RawData, XK, YK>, "xScale" | "yScale">>;

onChartBoundsChange?: (bounds: ChartBounds) => void;
onScaleChange?: (
xScale: ScaleLinear<number, number>,
Expand Down Expand Up @@ -275,237 +266,6 @@ function CartesianChartContent<
const primaryYAxis = yAxes[0];
const primaryYScale = primaryYAxis.yScale;

// stacked bar values
const chartHeight = chartBounds.bottom;
const yScaleTop = primaryYAxis.yScale.domain().at(0);
const yScaleBottom = primaryYAxis.yScale.domain().at(-1);
// end stacked bar values

/**
* Pan gesture handling
*/
const lastIdx = useSharedValue(null as null | number);
/**
* Take a "press value" and an x-value and update the shared values accordingly.
*/
const handleTouch = (
v: ChartPressState<{ x: InputFields<RawData>[XK]; y: Record<YK, number> }>,
x: number,
y: number,
) => {
"worklet";
const idx = findClosestPoint(tData.value.ox, x);

if (typeof idx !== "number") return;

const isInYs = (yk: string): yk is YK & string => yKeys.includes(yk as YK);

// begin stacked bar handling:
// store the heights of each bar segment
const barHeights: number[] = [];
for (const yk in v.y) {
if (isInYs(yk)) {
const height = asNumber(tData.value.y[yk].i[idx]);
barHeights.push(height);
}
}

const chartYPressed = chartHeight - y; // Invert y-coordinate, since RNGH gives us the absolute Y, and we want to know where in the chart they clicked
// Calculate the actual yValue of the touch within the domain of the yScale
const yDomainValue =
(chartYPressed / chartHeight) * (yScaleTop! - yScaleBottom!);

// track the cumulative height and the y-index of the touched segment
let cumulativeHeight = 0;
let yIndex = -1;

// loop through the bar heights to find which bar was touched
for (let i = 0; i < barHeights.length; i++) {
// Accumulate the height as we go along
cumulativeHeight += barHeights[i]!;
// Check if the y-value touched falls within the current segment
if (yDomainValue <= cumulativeHeight) {
// If it does, set yIndex to the current segment index and break
yIndex = i;
break;
}
}

// Update the yIndex value in the state or context
v.yIndex.value = yIndex;
// end stacked bar handling

if (v) {
try {
v.matchedIndex.value = idx;
v.x.value.value = tData.value.ix[idx]!;
v.x.position.value = asNumber(tData.value.ox[idx]);
for (const yk in v.y) {
if (isInYs(yk)) {
v.y[yk].value.value = asNumber(tData.value.y[yk].i[idx]);
v.y[yk].position.value = asNumber(tData.value.y[yk].o[idx]);
}
}
} catch (err) {
// no-op
}
}

lastIdx.value = idx;
};

if (actionsRef) {
actionsRef.current = {
handleTouch,
};
}

/**
* Touch gesture is a modified Pan gesture handler that allows for multiple presses:
* - Using Pan Gesture handler effectively _just_ for the .activateAfterLongPress functionality.
* - Tracking the finger is handled with .onTouchesMove instead of .onUpdate, because
* .onTouchesMove gives us access to each individual finger.
* - The activation gets a bit complicated because we want to wait til "start" state before updating Press Value
* which gives time for the gesture to get cancelled before we start updating the shared values.
* Therefore we use gestureState.bootstrap to store some "bootstrap" information if gesture isn't active when finger goes down.
*/
// touch ID -> value index mapping to keep track of which finger updates which value
const touchMap = useSharedValue({} as Record<number, number | undefined>);
const activePressSharedValues = Array.isArray(chartPressState)
? chartPressState
: [chartPressState];
const gestureState = useSharedValue({
isGestureActive: false,
bootstrap: [] as [
ChartPressState<{ x: InputFields<RawData>[XK]; y: Record<YK, number> }>,
TouchData,
][],
});

const panGesture = Gesture.Pan()
/**
* When a finger goes down, either update the state or store in the bootstrap array.
*/
.onTouchesDown((e) => {
const vals = activePressSharedValues || [];
if (!vals.length || e.numberOfTouches === 0) return;

for (let i = 0; i < Math.min(e.allTouches.length, vals.length); i++) {
const touch = e.allTouches[i];
const v = vals[i];
if (!v || !touch) continue;

if (gestureState.value.isGestureActive) {
// Update the mapping
if (typeof touchMap.value[touch.id] !== "number")
touchMap.value[touch.id] = i;

v.isActive.value = true;
handleTouch(v, touch.x, touch.y);
} else {
gestureState.value.bootstrap.push([v, touch]);
}
}
})
/**
* On start, check if we have any bootstraped updates we need to apply.
*/
.onStart(() => {
gestureState.value.isGestureActive = true;

for (let i = 0; i < gestureState.value.bootstrap.length; i++) {
const [v, touch] = gestureState.value.bootstrap[i]!;
// Update the mapping
if (typeof touchMap.value[touch.id] !== "number")
touchMap.value[touch.id] = i;

v.isActive.value = true;
handleTouch(v, touch.x, touch.y);
}
})
/**
* Clear gesture state on gesture end.
*/
.onFinalize(() => {
gestureState.value.isGestureActive = false;
gestureState.value.bootstrap = [];
})
/**
* As fingers move, update the shared values accordingly.
*/
.onTouchesMove((e) => {
const vals = activePressSharedValues || [];
if (!vals.length || e.numberOfTouches === 0) return;

for (let i = 0; i < Math.min(e.allTouches.length, vals.length); i++) {
const touch = e.allTouches[i];
const touchId = touch?.id;
const idx = typeof touchId === "number" && touchMap.value[touchId];
const v = typeof idx === "number" && vals?.[idx];

if (!v || !touch) continue;
if (!v.isActive.value) v.isActive.value = true;
handleTouch(v, touch.x, touch.y);
}
})
/**
* On each finger up, start to update values and "free up" the touch map.
*/
.onTouchesUp((e) => {
for (const touch of e.changedTouches) {
const vals = activePressSharedValues || [];

// Set active state to false
const touchId = touch?.id;
const idx = typeof touchId === "number" && touchMap.value[touchId];
const val = typeof idx === "number" && vals[idx];
if (val) {
val.isActive.value = false;
}

// Free up touch map for this touch
touchMap.value[touch.id] = undefined;
}
})
/**
* Once the gesture ends, ensure all active values are falsified.
*/
.onEnd(() => {
const vals = activePressSharedValues || [];
// Set active state to false for all vals
for (const val of vals) {
if (val) {
val.isActive.value = false;
}
}
});

if (!chartPressConfig?.pan) {
/**
* Activate after a long press, which helps with preventing all touch hijacking.
* This is important if this chart is inside of some sort of scrollable container.
*/
panGesture.activateAfterLongPress(gestureLongPressDelay);
}

if (chartPressConfig?.pan?.activateAfterLongPress) {
panGesture.activateAfterLongPress(
chartPressConfig.pan?.activateAfterLongPress,
);
}
if (chartPressConfig?.pan?.activeOffsetX) {
panGesture.activeOffsetX(chartPressConfig.pan.activeOffsetX);
}
if (chartPressConfig?.pan?.activeOffsetY) {
panGesture.activeOffsetX(chartPressConfig.pan.activeOffsetY);
}
if (chartPressConfig?.pan?.failOffsetX) {
panGesture.failOffsetX(chartPressConfig.pan.failOffsetX);
}
if (chartPressConfig?.pan?.failOffsetY) {
panGesture.failOffsetX(chartPressConfig.pan.failOffsetY);
}

/**
* Allow end-user to request "raw-ish" data for a given yKey.
* Generate this on demand using a proxy.
Expand Down Expand Up @@ -639,56 +399,40 @@ function CartesianChartContent<
/>
) : null;

// Body of the chart.
const body = (
<Canvas style={{ flex: 1 }} onLayout={onLayout}>
{YAxisComponents}
{XAxisComponents}
{FrameComponent}
<CartesianChartProvider yScale={primaryYScale} xScale={xScale}>
<Group clip={clipRect}>
<Group matrix={transformState?.matrix}>
{hasMeasuredLayoutSize && children(renderArg)}
</Group>
</Group>
</CartesianChartProvider>
{hasMeasuredLayoutSize && renderOutside?.(renderArg)}
</Canvas>
);

let composed = customGestures ?? Gesture.Race();
if (transformState) {
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);
}

return (
<GestureHandlerRootView style={{ flex: 1, overflow: "hidden" }}>
{body}
<GestureHandler
gesture={composed}
transformState={transformState}
dimensions={{
x: Math.min(xScale.range()[0]!, 0),
y: Math.min(primaryYScale.range()[0]!, 0),
width: xScale.range()[1]! - Math.min(xScale.range()[0]!, 0),
height:
primaryYScale.range()[1]! - Math.min(primaryYScale.range()[0]!, 0),
}}
/>
</GestureHandlerRootView>
<CartesianGestureHandler<RawData, XK, YK>
actionsRef={actionsRef}
chartBounds={chartBounds}
chartPressConfig={chartPressConfig}
chartPressState={chartPressState}
customGestures={customGestures}
gestureLongPressDelay={gestureLongPressDelay}
primaryYAxis={primaryYAxis}
tData={tData}
transformConfig={transformConfig}
transformState={transformState}
xScale={xScale}
yKeys={yKeys}
>
<Canvas style={styles.canvas} onLayout={onLayout}>
{YAxisComponents}
{XAxisComponents}
{FrameComponent}
<CartesianChartProvider yScale={primaryYScale} xScale={xScale}>
<Group clip={clipRect}>
<Group matrix={transformState?.matrix}>
{hasMeasuredLayoutSize && children(renderArg)}
</Group>
</Group>
</CartesianChartProvider>
{hasMeasuredLayoutSize && renderOutside?.(renderArg)}
</Canvas>
</CartesianGestureHandler>
);
}

const styles = StyleSheet.create({
canvas: {
flex: 1,
},
});
Loading
Loading