diff --git a/component/charts/graph-config.ts b/component/charts/graph-config.ts new file mode 100644 index 0000000..072f1bc --- /dev/null +++ b/component/charts/graph-config.ts @@ -0,0 +1,10 @@ +// if values in chartDefaults should not be accessible as props & are a style, move to graphStyles +export const chartDefaults = { + height: 300, + spacingInner: 0.3, + spacingOuter: 0.2, + contentInset: { top: 30, bottom: 30 }, + numberOfTicks: 6, + min: 0, + barColors: ['#598EBB', '#F2D4BC', '#DB7878'] +}; \ No newline at end of file diff --git a/component/charts/graph-types.ts b/component/charts/graph-types.ts index 345312a..35aaa49 100644 --- a/component/charts/graph-types.ts +++ b/component/charts/graph-types.ts @@ -1,3 +1,44 @@ import { ScaleLinear } from 'd3-scale' -export type ScaleFunction = ScaleLinear; \ No newline at end of file +export type ScaleFunction = ScaleLinear; + +export interface YAxisData { + key: string; // string value for ChartLabel and useGraphData + values: Array; + // including this code only for review -- + // do we prefer the idea of passing label formatting from the data or in the component? + // generic function type, specific usage of value varies + // eslint-disable-next-line no-unused-vars + formatLabel?: (value: number) => string; +} + +export type XValue = string | number + +export interface GraphData { + xValues: Array; + yValues: Array; +} + +interface YAxisProps { + maxLeftYAxisValue?: number; + maxRightYAxisValue?: number; + selectedLeftYAxisLabel?: string; + selectedRightYAxisLabel?: string; + // generic function type, specific usage of value varies + // eslint-disable-next-line no-unused-vars + formatRightYAxisLabel?: (value: string) => string; + // generic function type, specific usage of value varies + // eslint-disable-next-line no-unused-vars + formatLeftYAxisLabel?: (value: string) => string; +} +export interface GraphProps { + data: GraphData; + height?: number; + spacingInner?: number; + spacingOuter?: number; + contentInset?: { top: number; bottom: number }; + min?: number; + numberOfTicks?: number; + barColors?: Array; + yAxisProps?: YAxisProps; +} \ No newline at end of file diff --git a/component/charts/graph-utils.tsx b/component/charts/graph-utils.tsx new file mode 100644 index 0000000..b114f03 --- /dev/null +++ b/component/charts/graph-utils.tsx @@ -0,0 +1,40 @@ +import { GraphData } from "./graph-types"; + + +export const convertToGraphData = ( + graphData: GraphData, + options: { + selectedLeftYAxisLabel?: string; + selectedRightYAxisLabel?: string; + maxRightYAxisValue: number; + maxLeftYAxisValue: number; + } +) => { + const xValues = graphData.xValues; + const leftAxisIndex = graphData.yValues.findIndex( + (y) => y.key === options.selectedLeftYAxisLabel + ); + const rightAxisIndex = graphData.yValues.findIndex( + (y) => y.key === options.selectedRightYAxisLabel + ); + + // scale data points according to max value + const yData = graphData.yValues.map((yAxis, index) => { + const maxValue = + index === rightAxisIndex ? options.maxRightYAxisValue : options.maxLeftYAxisValue; + + // scale values as a percentage of the max value + const scaledValues = yAxis.values.map((value) => (value / maxValue) * 100); + + const mappedData = scaledValues.map((scaledValue) => ({ value: scaledValue })); + + return { + data: mappedData, + svg: { fill: 'transparent' } // fill handled in CustomBars + }; + }); + const yAxisLeftLabels = leftAxisIndex !== -1 ? graphData.yValues[leftAxisIndex] : null; + const yAxisRightLabels = rightAxisIndex !== -1 ? graphData.yValues[rightAxisIndex] : undefined; + + return { yData, yAxisLeftLabels, yAxisRightLabels, xValues }; +}; diff --git a/component/charts/use-graph-data.ts b/component/charts/use-graph-data.ts new file mode 100644 index 0000000..892e93d --- /dev/null +++ b/component/charts/use-graph-data.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; + +import { convertToGraphData } from './graph-utils'; +import { chartDefaults } from './graph-config'; +import { GraphData, GraphProps, XValue, YAxisData } from './graph-types'; + + + +interface useGraphDataInterface { + xValues: Array; + yData: { + data: { + value: number; + }[]; + svg: { + fill: string; + }; + }[]; + yAxisLeftLabels: YAxisData; + yAxisRightLabels: YAxisData; + defaultProps: Partial; +} + +// 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, props: Partial): 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), + [graphData, defaultProps.yAxisProps] + ); + + return { + xValues, + yData, + yAxisLeftLabels, + yAxisRightLabels, + defaultProps + }; +}; diff --git a/test/component/use-graph-data.test.tsx b/test/component/use-graph-data.test.tsx new file mode 100644 index 0000000..81f03a6 --- /dev/null +++ b/test/component/use-graph-data.test.tsx @@ -0,0 +1,58 @@ +import { renderHook } from '@testing-library/react-native'; +import { useGraphData } from '../../component/charts/use-graph-data'; +import { GraphData, GraphProps } from '../../component/charts/graph-types'; + +describe('useGraphData', () => { + it('should return correctly processed data from convertToGraphData', () => { + // mock values + const mockGraphData: GraphData = { + xValues: ['full hit', '3/4 ball ball', '1/2 ball'], + yValues: [ + { key: 'left', values: [10, 20, 30] }, + { key: 'right', values: [40, 50, 60] } + ] + }; + + const mockProps: Partial = { + yAxisProps: { + maxLeftYAxisValue: 30, + maxRightYAxisValue: 60, + selectedLeftYAxisLabel: 'left', + selectedRightYAxisLabel: 'right', + formatRightYAxisLabel: (value) => `${value}%`, + formatLeftYAxisLabel: (value) => `${value}%` + } + }; + + + const { result } = renderHook(() => useGraphData(mockGraphData, mockProps)); + // values expected + const expectedYData = [ + { + data: [{ value: 33.33 }, { value: 66.67 }, { value: 100 }], + svg: { fill: 'transparent' }, + }, + { + data: [{ value: 66.67 }, { value: 83.33 }, { value: 100 }], + svg: { fill: 'transparent' }, + }, + ]; + const expectedLeftLabels = { key: 'left', values: [10, 20, 30] }; + const expectedRightLabels = { key: 'right', values: [40, 50, 60] }; + + + expect(result.current).toBeDefined(); + expect(result.current.xValues).toEqual(['full hit', '3/4 ball ball', '1/2 ball']); + result.current.yData.forEach((yDataItem, index) => { + yDataItem.data.forEach((dataItem, dataIndex) => { + expect(dataItem.value).toBeCloseTo(expectedYData[index].data[dataIndex].value, 2); + }); + }); + 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(); + }); +}); +