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 { graphStyles } from "../chart-styles";
import ChartView from "../chart-view";
import { chartDefaults } from "../graph-config";
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
// 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") {
return null;
} // TODO:#38
const yAxisProps = computeYAxisConfig(data, props.yAxisProps || {});
const {
xValues,
yData,
yAxisLeftLabels,
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
// eslint-disable-next-line react-hooks/rules-of-hooks
} = useGraphData(data, {
} = useGraphData(data, yAxisProps, {
includeColors: ChartComponent.chartType === "bar" ? false : true,
...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 = {
yData,
xValues,
lineStrokeWidth,
min,
contentInset,
numberOfTicks,
spacingInner,
spacingOuter,
barColors,
lineStrokeWidth: chartDefaults.lineStrokeWidth,
min: chartDefaults.min,
contentInset: chartDefaults.contentInset,
numberOfTicks: chartDefaults.numberOfTicks,
spacingInner: chartDefaults.spacingInner,
spacingOuter: chartDefaults.spacingOuter,
barColors: chartDefaults.barColors,
};
return (
<ChartView>
<View
style={[graphStyles.rowContainer, { height: height }]}
style={[graphStyles.rowContainer, { height: chartDefaults.height }]}
testID={`chart-container-${testID}`}
>
<YAxis
data={yAxisLeftLabels.values}
contentInset={contentInset}
contentInset={chartDefaults.contentInset}
svg={graphStyles.yAxisFontStyle}
style={graphStyles.yAxisLeftPadding}
min={min}
max={maxLeftYAxisValue}
numberOfTicks={numberOfTicks}
formatLabel={formatLeftYAxisLabel}
min={chartDefaults.min}
max={yAxisProps.maxLeftYAxisValue}
numberOfTicks={chartDefaults.numberOfTicks}
formatLabel={yAxisProps.formatLeftYAxisLabel}
/>
<View style={graphStyles.flex}>
<ChartComponent {...chartComponentProps} />
@ -87,23 +76,23 @@ const ChartContainer: React.FC<ChartContainerProps> = ({
scale: scale.scaleBand,
})}
{...(ChartComponent.chartType === "line" && {
numberOfTicks: numberOfTicks,
numberOfTicks: chartDefaults.numberOfTicks,
contentInset: graphStyles.horizontalInset,
})}
/>
</View>
<YAxis
data={yAxisRightLabels?.values ?? yAxisLeftLabels.values}
contentInset={contentInset}
contentInset={chartDefaults.contentInset}
svg={graphStyles.yAxisFontStyle}
style={[
graphStyles.yAxisRightPadding,
{ height: useCommonScale ? 0 : "auto" },
]}
min={min}
max={maxRightYAxisValue}
numberOfTicks={numberOfTicks}
formatLabel={formatRightYAxisLabel}
min={chartDefaults.min}
max={yAxisProps.maxRightYAxisValue}
numberOfTicks={chartDefaults.numberOfTicks}
formatLabel={yAxisProps.formatRightYAxisLabel}
/>
</View>
</ChartView>

View File

@ -15,7 +15,7 @@ export interface YAxisData {
export type XValue = string | number;
export interface GraphData {
export interface XYValues {
xValues: Array<XValue>;
yValues: Array<YAxisData>;
}
@ -32,8 +32,15 @@ export interface YAxisProps {
// eslint-disable-next-line no-unused-vars
formatLeftYAxisLabel?: (value: string) => string;
}
export interface ScaledAndStyledYDataItem {
data: { value: number }[];
svg: { fill: string; stroke: string };
}
export type ScaledAndStyledYData = ScaledAndStyledYDataItem[];
export interface GraphProps {
data: GraphData;
data: XYValues;
includeColors?: boolean;
height?: number;
spacingInner?: number;
@ -90,7 +97,7 @@ type ChartComponentWithStatic = React.ComponentType<CommonProps> & {
*/
export interface ChartContainerProps extends CommonProps {
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 = (
graphData: GraphData,
options: {
selectedLeftYAxisLabel?: string;
selectedRightYAxisLabel?: string;
maxRightYAxisValue: number;
maxLeftYAxisValue: number;
includeColors: boolean;
barColors: Array<string>;
},
interface PrepareGraphDataReturn {
yData: ScaledAndStyledYData;
yAxisLeftLabels: YAxisData;
yAxisRightLabels: YAxisData;
xValues: XValue[];
}
export const prepareGraphData = (
xyData: XYValues,
yAxisProps: YAxisProps,
includeColors: boolean,
barColors: string[],
): PrepareGraphDataReturn => {
const { leftAxisIndex, rightAxisIndex } = findAxisIndices(
xyData.yValues,
yAxisProps.selectedLeftYAxisLabel,
yAxisProps.selectedRightYAxisLabel,
);
const yData = createYData(
xyData.yValues,
rightAxisIndex,
yAxisProps.maxRightYAxisValue,
yAxisProps.maxLeftYAxisValue,
includeColors,
barColors,
);
// Ensure yAxisLeftLabels and yAxisRightLabels are correctly defined
const yAxisLeftLabels =
leftAxisIndex >= 0
? xyData.yValues[leftAxisIndex]
: { key: "", values: [] };
const yAxisRightLabels =
rightAxisIndex >= 0
? xyData.yValues[rightAxisIndex]
: { key: "", values: [] };
return {
yData,
yAxisLeftLabels,
yAxisRightLabels,
xValues: xyData.xValues,
};
};
const scaleValues = (
yAxis,
index,
rightAxisIndex,
maxRightYAxisValue,
maxLeftYAxisValue,
) => {
const xValues = graphData.xValues;
const leftAxisIndex = graphData.yValues.findIndex(
(y) => y.key === options.selectedLeftYAxisLabel,
);
const rightAxisIndex = graphData.yValues.findIndex(
(y) => y.key === options.selectedRightYAxisLabel,
);
const maxValue =
index === rightAxisIndex ? maxRightYAxisValue : maxLeftYAxisValue;
return yAxis.values.map((value) => (value / maxValue) * 100);
};
// scale data points according to max value
const yData = graphData.yValues.map((yAxis, index) => {
const maxValue =
index === rightAxisIndex
? options.maxRightYAxisValue
: options.maxLeftYAxisValue;
const getStrokeColor = (index, includeColors, barColors) =>
includeColors ? barColors[index % barColors.length] : "transparent";
// scale values as a percentage of the max value
const scaledValues = yAxis.values.map((value) => (value / maxValue) * 100);
const strokeColor =
options.includeColors && options.barColors
? options.barColors[index % options.barColors.length]
: "transparent";
const mappedData = scaledValues.map((scaledValue) => ({
value: scaledValue,
}));
/**
* 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: mappedData,
svg: { fill: "transparent", stroke: strokeColor }, // Apply the stroke color here
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 { chartDefaults } from "./graph-config";
import { GraphData, GraphProps, XValue, YAxisData } from "./graph-types";
import { convertToGraphData } from "./graph-utils";
import {
GraphProps,
ScaledAndStyledYData,
XValue,
XYValues,
YAxisData,
YAxisProps,
} from "./graph-types";
import { prepareGraphData } from "./graph-utils";
interface useGraphDataInterface {
export interface UseGraphDataReturn {
xValues: Array<XValue>;
yData: {
data: {
value: number;
}[];
svg: {
fill: string;
};
}[];
yData: ScaledAndStyledYData;
yAxisLeftLabels: 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 = (
graphData: GraphData,
xyData: XYValues,
yAxisProps: YAxisProps,
props: Partial<GraphProps>,
): useGraphDataInterface => {
const { yAxisProps = {}, ...otherProps } = props;
const defaultProps = {
...chartDefaults,
...otherProps,
// assign default values for yAxisProps + spread to override with values coming from props
yAxisProps: {
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,
],
);
): UseGraphDataReturn => {
const { yData, yAxisLeftLabels, yAxisRightLabels, xValues } = useMemo(() => {
return prepareGraphData(
xyData,
yAxisProps,
chartDefaults.includeColors,
chartDefaults.barColors,
);
}, [xyData]);
return {
xValues,
yData,
yAxisLeftLabels,
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 { 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 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() {
return (
<View style={StyleSheet.absoluteFill}>
<ChartContainer data={line_chart_two_y_data} ChartComponent={LineGraph} />
<ChartContainer
data={graph_data_two_measures}
ChartComponent={BarGraph}
/>
</View>
);
}

View File

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