kat/refactor-use-graph-data (#121)

Reviewed-on: railbird/railbird-mobile#121
Reviewed-by: Ivan Malison <ivanmalison@gmail.com>
This commit is contained in:
Kat Huang 2024-02-18 23:14:07 -07:00
parent 762351f76e
commit b05e354459
6 changed files with 237 additions and 151 deletions

View File

@ -4,8 +4,10 @@ import { View } from "react-native";
import { XAxis, YAxis } from "react-native-svg-charts"; import { XAxis, YAxis } from "react-native-svg-charts";
import { graphStyles } from "../chart-styles"; import { graphStyles } from "../chart-styles";
import ChartView from "../chart-view"; import ChartView from "../chart-view";
import { chartDefaults } from "../graph-config";
import { ChartContainerProps } from "../graph-types"; import { ChartContainerProps } from "../graph-types";
import { useGraphData } from "../use-graph-data"; import { computeYAxisConfig, useGraphData } from "../use-graph-data";
computeYAxisConfig;
// TODO: #43 #31 separate PR will update useGraphData to take into account useCommonScale // TODO: #43 #31 separate PR will update useGraphData to take into account useCommonScale
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
@ -19,62 +21,49 @@ const ChartContainer: React.FC<ChartContainerProps> = ({
if (!data || typeof data !== "object") { if (!data || typeof data !== "object") {
return null; return null;
} // TODO:#38 } // TODO:#38
const yAxisProps = computeYAxisConfig(data, props.yAxisProps || {});
const { const {
xValues, xValues,
yData, yData,
yAxisLeftLabels, yAxisLeftLabels,
yAxisRightLabels, yAxisRightLabels,
defaultProps: {
height,
contentInset,
min,
numberOfTicks,
lineStrokeWidth,
spacingInner,
spacingOuter,
barColors,
yAxisProps: {
maxLeftYAxisValue,
maxRightYAxisValue,
formatLeftYAxisLabel,
formatRightYAxisLabel,
},
},
// Proper error/loading handling from useQueryHandler can work with this rule #38 // Proper error/loading handling from useQueryHandler can work with this rule #38
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
} = useGraphData(data, { } = useGraphData(data, yAxisProps, {
includeColors: ChartComponent.chartType === "bar" ? false : true, includeColors: ChartComponent.chartType === "bar" ? false : true,
...props, ...props,
}); });
// TODO: #31 This could be done by the hook since it already handles spreading props. // TODO: #31 This could be done by the hook since it already handles spreading props.
// Depending on if we want a context provider for Charts, that could be another way to handle it // Depiending on if we want a context pr}}ovider for Charts, that could be another way to handle it
const chartComponentProps = { const chartComponentProps = {
yData, yData,
xValues, xValues,
lineStrokeWidth, lineStrokeWidth: chartDefaults.lineStrokeWidth,
min, min: chartDefaults.min,
contentInset, contentInset: chartDefaults.contentInset,
numberOfTicks, numberOfTicks: chartDefaults.numberOfTicks,
spacingInner, spacingInner: chartDefaults.spacingInner,
spacingOuter, spacingOuter: chartDefaults.spacingOuter,
barColors, barColors: chartDefaults.barColors,
}; };
return ( return (
<ChartView> <ChartView>
<View <View
style={[graphStyles.rowContainer, { height: height }]} style={[graphStyles.rowContainer, { height: chartDefaults.height }]}
testID={`chart-container-${testID}`} testID={`chart-container-${testID}`}
> >
<YAxis <YAxis
data={yAxisLeftLabels.values} data={yAxisLeftLabels.values}
contentInset={contentInset} contentInset={chartDefaults.contentInset}
svg={graphStyles.yAxisFontStyle} svg={graphStyles.yAxisFontStyle}
style={graphStyles.yAxisLeftPadding} style={graphStyles.yAxisLeftPadding}
min={min} min={chartDefaults.min}
max={maxLeftYAxisValue} max={yAxisProps.maxLeftYAxisValue}
numberOfTicks={numberOfTicks} numberOfTicks={chartDefaults.numberOfTicks}
formatLabel={formatLeftYAxisLabel} formatLabel={yAxisProps.formatLeftYAxisLabel}
/> />
<View style={graphStyles.flex}> <View style={graphStyles.flex}>
<ChartComponent {...chartComponentProps} /> <ChartComponent {...chartComponentProps} />
@ -87,23 +76,23 @@ const ChartContainer: React.FC<ChartContainerProps> = ({
scale: scale.scaleBand, scale: scale.scaleBand,
})} })}
{...(ChartComponent.chartType === "line" && { {...(ChartComponent.chartType === "line" && {
numberOfTicks: numberOfTicks, numberOfTicks: chartDefaults.numberOfTicks,
contentInset: graphStyles.horizontalInset, contentInset: graphStyles.horizontalInset,
})} })}
/> />
</View> </View>
<YAxis <YAxis
data={yAxisRightLabels?.values ?? yAxisLeftLabels.values} data={yAxisRightLabels?.values ?? yAxisLeftLabels.values}
contentInset={contentInset} contentInset={chartDefaults.contentInset}
svg={graphStyles.yAxisFontStyle} svg={graphStyles.yAxisFontStyle}
style={[ style={[
graphStyles.yAxisRightPadding, graphStyles.yAxisRightPadding,
{ height: useCommonScale ? 0 : "auto" }, { height: useCommonScale ? 0 : "auto" },
]} ]}
min={min} min={chartDefaults.min}
max={maxRightYAxisValue} max={yAxisProps.maxRightYAxisValue}
numberOfTicks={numberOfTicks} numberOfTicks={chartDefaults.numberOfTicks}
formatLabel={formatRightYAxisLabel} formatLabel={yAxisProps.formatRightYAxisLabel}
/> />
</View> </View>
</ChartView> </ChartView>

View File

@ -15,7 +15,7 @@ export interface YAxisData {
export type XValue = string | number; export type XValue = string | number;
export interface GraphData { export interface XYValues {
xValues: Array<XValue>; xValues: Array<XValue>;
yValues: Array<YAxisData>; yValues: Array<YAxisData>;
} }
@ -32,8 +32,15 @@ export interface YAxisProps {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
formatLeftYAxisLabel?: (value: string) => string; formatLeftYAxisLabel?: (value: string) => string;
} }
export interface ScaledAndStyledYDataItem {
data: { value: number }[];
svg: { fill: string; stroke: string };
}
export type ScaledAndStyledYData = ScaledAndStyledYDataItem[];
export interface GraphProps { export interface GraphProps {
data: GraphData; data: XYValues;
includeColors?: boolean; includeColors?: boolean;
height?: number; height?: number;
spacingInner?: number; spacingInner?: number;
@ -90,7 +97,7 @@ type ChartComponentWithStatic = React.ComponentType<CommonProps> & {
*/ */
export interface ChartContainerProps extends CommonProps { export interface ChartContainerProps extends CommonProps {
ChartComponent: ChartComponentWithStatic; ChartComponent: ChartComponentWithStatic;
data: GraphData; data: XYValues;
} }
/** /**

View File

@ -1,52 +1,115 @@
import { GraphData } from "./graph-types"; import {
ScaledAndStyledYData,
XValue,
XYValues,
YAxisData,
YAxisProps,
} from "./graph-types";
export const convertToGraphData = ( interface PrepareGraphDataReturn {
graphData: GraphData, yData: ScaledAndStyledYData;
options: { yAxisLeftLabels: YAxisData;
selectedLeftYAxisLabel?: string; yAxisRightLabels: YAxisData;
selectedRightYAxisLabel?: string; xValues: XValue[];
maxRightYAxisValue: number; }
maxLeftYAxisValue: number;
includeColors: boolean; export const prepareGraphData = (
barColors: Array<string>; xyData: XYValues,
}, yAxisProps: YAxisProps,
) => { includeColors: boolean,
const xValues = graphData.xValues; barColors: string[],
const leftAxisIndex = graphData.yValues.findIndex( ): PrepareGraphDataReturn => {
(y) => y.key === options.selectedLeftYAxisLabel, const { leftAxisIndex, rightAxisIndex } = findAxisIndices(
); xyData.yValues,
const rightAxisIndex = graphData.yValues.findIndex( yAxisProps.selectedLeftYAxisLabel,
(y) => y.key === options.selectedRightYAxisLabel, yAxisProps.selectedRightYAxisLabel,
); );
// scale data points according to max value const yData = createYData(
const yData = graphData.yValues.map((yAxis, index) => { xyData.yValues,
const maxValue = rightAxisIndex,
index === rightAxisIndex yAxisProps.maxRightYAxisValue,
? options.maxRightYAxisValue yAxisProps.maxLeftYAxisValue,
: options.maxLeftYAxisValue; includeColors,
barColors,
);
// scale values as a percentage of the max value // Ensure yAxisLeftLabels and yAxisRightLabels are correctly defined
const scaledValues = yAxis.values.map((value) => (value / maxValue) * 100); const yAxisLeftLabels =
leftAxisIndex >= 0
const strokeColor = ? xyData.yValues[leftAxisIndex]
options.includeColors && options.barColors : { key: "", values: [] };
? options.barColors[index % options.barColors.length] const yAxisRightLabels =
: "transparent"; rightAxisIndex >= 0
? xyData.yValues[rightAxisIndex]
const mappedData = scaledValues.map((scaledValue) => ({ : { key: "", values: [] };
value: scaledValue,
}));
return { return {
data: mappedData, yData,
svg: { fill: "transparent", stroke: strokeColor }, // Apply the stroke color here yAxisLeftLabels,
yAxisRightLabels,
xValues: xyData.xValues,
};
};
const scaleValues = (
yAxis,
index,
rightAxisIndex,
maxRightYAxisValue,
maxLeftYAxisValue,
) => {
const maxValue =
index === rightAxisIndex ? maxRightYAxisValue : maxLeftYAxisValue;
return yAxis.values.map((value) => (value / maxValue) * 100);
};
const getStrokeColor = (index, includeColors, barColors) =>
includeColors ? barColors[index % barColors.length] : "transparent";
/**
* Finds the indices of the datasets in `yValues` that match the specified left and right Y-axis labels.
*
* Iterates through the `yValues` , searching for objects whose `key` matches
* `selectedLeftYAxisLabel` and `selectedRightYAxisLabel`. It's used to identify which datasets
* should be plotted against the left and right Y-axes in a graph. If a match is not found for a label,
* the corresponding axis index is set to `-1`.
*/
const findAxisIndices = (
yValues: Array<{ key: string; values: Array<number> }>,
selectedLeftYAxisLabel?: string,
selectedRightYAxisLabel?: string,
): { leftAxisIndex: number; rightAxisIndex: number } => ({
leftAxisIndex: yValues.findIndex((y) => y.key === selectedLeftYAxisLabel),
rightAxisIndex: yValues.findIndex((y) => y.key === selectedRightYAxisLabel),
});
/**
* Transforms Y-axis data by scaling values and applying stroke colors for visualization.
*
* This function takes an array of Y-axis data objects, each with a key and an array of numeric values.
* It scales these values relative to specified maximum values for the left and right Y-axes and
* applies coloring based on the includeColors flag and the provided barColors array.
*/
const createYData = (
yValues: YAxisData[],
rightAxisIndex: number,
maxRightYAxisValue: number,
maxLeftYAxisValue: number,
includeColors: boolean,
barColors: string[],
): ScaledAndStyledYData =>
yValues.map((yAxis, index) => {
const scaledValues = scaleValues(
yAxis,
index,
rightAxisIndex,
maxRightYAxisValue,
maxLeftYAxisValue,
);
const strokeColor = getStrokeColor(index, includeColors, barColors);
return {
data: scaledValues.map((value) => ({ value })),
svg: { fill: "transparent", stroke: strokeColor },
}; };
}); });
const yAxisLeftLabels =
leftAxisIndex !== -1 ? graphData.yValues[leftAxisIndex] : null;
const yAxisRightLabels =
rightAxisIndex !== -1 ? graphData.yValues[rightAxisIndex] : undefined;
return { yData, yAxisLeftLabels, yAxisRightLabels, xValues };
};

View File

@ -1,70 +1,89 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { chartDefaults } from "./graph-config"; import { chartDefaults } from "./graph-config";
import { GraphData, GraphProps, XValue, YAxisData } from "./graph-types"; import {
import { convertToGraphData } from "./graph-utils"; GraphProps,
ScaledAndStyledYData,
XValue,
XYValues,
YAxisData,
YAxisProps,
} from "./graph-types";
import { prepareGraphData } from "./graph-utils";
interface useGraphDataInterface { export interface UseGraphDataReturn {
xValues: Array<XValue>; xValues: Array<XValue>;
yData: { yData: ScaledAndStyledYData;
data: {
value: number;
}[];
svg: {
fill: string;
};
}[];
yAxisLeftLabels: YAxisData; yAxisLeftLabels: YAxisData;
yAxisRightLabels: YAxisData; yAxisRightLabels: YAxisData;
defaultProps: Partial<GraphProps>;
} }
// this version assumes string values for X, this isn't necessarily the case
// convertToGraphData is specifically tailored to bar/group bar graphs
// ultimately this component could be used by any x & y axis graph types (line/bar/scatter)
export const useGraphData = ( export const useGraphData = (
graphData: GraphData, xyData: XYValues,
yAxisProps: YAxisProps,
props: Partial<GraphProps>, props: Partial<GraphProps>,
): useGraphDataInterface => { ): UseGraphDataReturn => {
const { yAxisProps = {}, ...otherProps } = props; const { yData, yAxisLeftLabels, yAxisRightLabels, xValues } = useMemo(() => {
const defaultProps = { return prepareGraphData(
...chartDefaults, xyData,
...otherProps, yAxisProps,
// assign default values for yAxisProps + spread to override with values coming from props chartDefaults.includeColors,
yAxisProps: { chartDefaults.barColors,
maxLeftYAxisValue: Math.max(...(graphData.yValues[0]?.values ?? [0])),
maxRightYAxisValue:
graphData.yValues.length > 1
? Math.max(...graphData.yValues[1]?.values)
: undefined,
formatRightYAxisLabel: yAxisProps.formatRightYAxisLabel,
formatLeftYAxisLabel: yAxisProps.formatLeftYAxisLabel,
selectedLeftYAxisLabel: graphData.yValues[0]?.key,
selectedRightYAxisLabel: graphData.yValues[1]?.key,
...yAxisProps,
},
};
const { yData, yAxisLeftLabels, yAxisRightLabels, xValues } = useMemo(
() =>
convertToGraphData(graphData, {
...defaultProps.yAxisProps,
includeColors: defaultProps.includeColors,
barColors: defaultProps.barColors,
}),
[
graphData,
defaultProps.yAxisProps,
defaultProps.includeColors,
defaultProps.barColors,
],
); );
}, [xyData]);
return { return {
xValues, xValues,
yData, yData,
yAxisLeftLabels, yAxisLeftLabels,
yAxisRightLabels, yAxisRightLabels,
defaultProps,
}; };
}; };
function computeAxisMaxValues(xyData: XYValues): {
maxLeft: number;
maxRight: number | undefined;
} {
const maxLeft = Math.max(...(xyData.yValues[0]?.values ?? [0]));
const maxRight =
xyData.yValues.length > 1
? Math.max(...(xyData.yValues[1]?.values ?? []))
: undefined;
return { maxLeft, maxRight };
}
function selectAxisLabels(xyData: XYValues): {
leftLabel: string;
rightLabel: string | undefined;
} {
const leftLabel = xyData.yValues[0]?.key;
const rightLabel =
xyData.yValues.length > 1 ? xyData.yValues[1]?.key : undefined;
return { leftLabel, rightLabel };
}
function applyDefaultYAxisProps(
defaultProps: Partial<GraphProps["yAxisProps"]>,
): Partial<YAxisProps> {
return {
formatRightYAxisLabel: defaultProps.formatRightYAxisLabel,
formatLeftYAxisLabel: defaultProps.formatLeftYAxisLabel,
};
}
export function computeYAxisConfig(
xyData: XYValues,
yAxisPropsDefault: Partial<GraphProps["yAxisProps"]>,
): YAxisProps {
const { maxLeft, maxRight } = computeAxisMaxValues(xyData);
const { leftLabel, rightLabel } = selectAxisLabels(xyData);
const defaultYAxisProps = applyDefaultYAxisProps(yAxisPropsDefault);
return {
maxLeftYAxisValue: maxLeft,
maxRightYAxisValue: maxRight,
selectedLeftYAxisLabel: leftLabel,
selectedRightYAxisLabel: rightLabel,
...defaultYAxisProps,
...yAxisPropsDefault,
};
}

View File

@ -1,15 +1,21 @@
import React from "react"; import React from "react";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { line_chart_two_y_data } from "../../test/mock/charts/mock-data"; import {
graph_data_two_measures,
line_chart_two_y_data,
} from "../../test/mock/charts/mock-data";
import BarGraph from "../component/charts/bar-graph/bar-graph";
import ChartContainer from "../component/charts/container/chart-container"; import ChartContainer from "../component/charts/container/chart-container";
import LineGraph from "../component/charts/line-graph/line-graph"; import LineGraph from "../component/charts/line-graph/line-graph";
// Session Mock - can be used for session summary screen using a query handler component
// BarGraph component using mocked data currently
export default function SessionScreen() { export default function SessionScreen() {
return ( return (
<View style={StyleSheet.absoluteFill}> <View style={StyleSheet.absoluteFill}>
<ChartContainer data={line_chart_two_y_data} ChartComponent={LineGraph} /> <ChartContainer data={line_chart_two_y_data} ChartComponent={LineGraph} />
<ChartContainer
data={graph_data_two_measures}
ChartComponent={BarGraph}
/>
</View> </View>
); );
} }

View File

@ -1,11 +1,14 @@
import { renderHook } from "@testing-library/react-native"; import { renderHook } from "@testing-library/react-native";
import { GraphData, GraphProps } from "../../src/component/charts/graph-types"; import { GraphProps, XYValues } from "../../src/component/charts/graph-types";
import { useGraphData } from "../../src/component/charts/use-graph-data"; import {
computeYAxisConfig,
useGraphData,
} from "../../src/component/charts/use-graph-data";
describe("useGraphData", () => { describe("useGraphData", () => {
it("should return correctly processed data from convertToGraphData", () => { it("should return correctly processed data from convertToGraphData", () => {
// mock values // mock values
const mockGraphData: GraphData = { const mockGraphData: XYValues = {
xValues: ["full hit", "3/4 ball ball", "1/2 ball"], xValues: ["full hit", "3/4 ball ball", "1/2 ball"],
yValues: [ yValues: [
{ key: "left", values: [10, 20, 30] }, { key: "left", values: [10, 20, 30] },
@ -23,8 +26,14 @@ describe("useGraphData", () => {
formatLeftYAxisLabel: (value) => `${value}%`, formatLeftYAxisLabel: (value) => `${value}%`,
}, },
}; };
const yAxisProps = computeYAxisConfig(
mockGraphData,
mockProps.yAxisProps || {},
);
const { result } = renderHook(() => useGraphData(mockGraphData, mockProps)); const { result } = renderHook(() =>
useGraphData(mockGraphData, yAxisProps, mockProps),
);
// values expected // values expected
const expectedYData = [ const expectedYData = [
{ {
@ -55,12 +64,5 @@ describe("useGraphData", () => {
}); });
expect(result.current.yAxisLeftLabels).toEqual(expectedLeftLabels); expect(result.current.yAxisLeftLabels).toEqual(expectedLeftLabels);
expect(result.current.yAxisRightLabels).toEqual(expectedRightLabels); expect(result.current.yAxisRightLabels).toEqual(expectedRightLabels);
expect(
result.current.defaultProps.yAxisProps.selectedLeftYAxisLabel,
).toEqual("left");
expect(
result.current.defaultProps.yAxisProps.selectedRightYAxisLabel,
).toEqual("right");
expect(result.current.defaultProps).toBeDefined();
}); });
}); });