Merge pull request 'loewy/custom-bars' (#18) from loewy/custom-bars into master
Reviewed-on: billnerds/rn-playground#18 Reviewed-by: Ivan Malison <ivanmalison@gmail.com> Reviewed-by: Kat Huang <kkathuang@gmail.com>
This commit is contained in:
commit
28a0426096
48
component/bar.tsx
Normal file
48
component/bar.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Path } from 'react-native-svg';
|
||||||
|
|
||||||
|
import { calculateBarOrigin, drawBarPath } from './custom-bar-utils';
|
||||||
|
import { ScaleFunction } from './graph-types';
|
||||||
|
|
||||||
|
interface BarProps {
|
||||||
|
scaleX: ScaleFunction;
|
||||||
|
scaleY: ScaleFunction;
|
||||||
|
data: { value: number };
|
||||||
|
barNumber: number;
|
||||||
|
index: number;
|
||||||
|
fill: string;
|
||||||
|
barWidth: number;
|
||||||
|
gap: number;
|
||||||
|
roundedRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Bar: React.FC<BarProps> = ({
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
data,
|
||||||
|
barNumber,
|
||||||
|
index,
|
||||||
|
fill,
|
||||||
|
barWidth,
|
||||||
|
gap,
|
||||||
|
roundedRadius
|
||||||
|
}) => {
|
||||||
|
const { xOrigin, yOrigin, height } = calculateBarOrigin({
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
index,
|
||||||
|
data,
|
||||||
|
barNumber,
|
||||||
|
barWidth,
|
||||||
|
gap
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Path
|
||||||
|
key={`bar-path-${barNumber}-${index}`}
|
||||||
|
d={drawBarPath(xOrigin, yOrigin, barWidth, height, roundedRadius)}
|
||||||
|
fill={fill}
|
||||||
|
testID={`bar-${barNumber}-${index}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
59
component/custom-bar-utils.ts
Normal file
59
component/custom-bar-utils.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { path as d3 } from 'd3-path';
|
||||||
|
|
||||||
|
import { ScaleFunction } from './graph-types';
|
||||||
|
|
||||||
|
type BarCalculationProps = {
|
||||||
|
scaleX: ScaleFunction;
|
||||||
|
scaleY: ScaleFunction;
|
||||||
|
data: { value: number };
|
||||||
|
barNumber: number;
|
||||||
|
index: number;
|
||||||
|
barWidth: number;
|
||||||
|
gap: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculateBarOrigin({
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
barWidth,
|
||||||
|
data,
|
||||||
|
index,
|
||||||
|
barNumber,
|
||||||
|
gap
|
||||||
|
}: BarCalculationProps): { xOrigin: number; yOrigin: number; height: number } {
|
||||||
|
const firstBar = barNumber === 0;
|
||||||
|
const xOrigin = scaleX(index) + (firstBar ? 0 : barWidth * barNumber + gap * barNumber);
|
||||||
|
const yOrigin = scaleY(data.value);
|
||||||
|
const height = scaleY(0) - yOrigin;
|
||||||
|
|
||||||
|
return { xOrigin, yOrigin, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function drawBarPath(
|
||||||
|
xOrigin: number,
|
||||||
|
yOrigin: number,
|
||||||
|
barWidth: number,
|
||||||
|
height: number,
|
||||||
|
roundedRadius: number
|
||||||
|
): string {
|
||||||
|
const path = d3();
|
||||||
|
path.moveTo(xOrigin, yOrigin + height);
|
||||||
|
path.lineTo(xOrigin, yOrigin + roundedRadius);
|
||||||
|
path.arcTo(xOrigin, yOrigin, xOrigin + roundedRadius, yOrigin, roundedRadius);
|
||||||
|
path.lineTo(xOrigin + barWidth - roundedRadius, yOrigin);
|
||||||
|
path.arcTo(
|
||||||
|
xOrigin + barWidth,
|
||||||
|
yOrigin,
|
||||||
|
xOrigin + barWidth,
|
||||||
|
yOrigin + roundedRadius,
|
||||||
|
roundedRadius
|
||||||
|
);
|
||||||
|
path.lineTo(xOrigin + barWidth, yOrigin + height);
|
||||||
|
path.lineTo(xOrigin, yOrigin + height);
|
||||||
|
path.closePath();
|
||||||
|
|
||||||
|
return path.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateBarWidth = (bandwidth: number, combinedDataLength: number, gap: number) =>
|
||||||
|
(bandwidth - gap * (combinedDataLength - 1)) / combinedDataLength;
|
54
component/custom-bars.tsx
Normal file
54
component/custom-bars.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
import { Svg } from 'react-native-svg';
|
||||||
|
|
||||||
|
import { Bar } from './bar';
|
||||||
|
import { ScaleFunction } from './graph-types';
|
||||||
|
import { calculateBarWidth } from './custom-bar-utils';
|
||||||
|
|
||||||
|
export interface CombinedData {
|
||||||
|
data: { value: number }[];
|
||||||
|
svg: { fill: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomBarsProps {
|
||||||
|
x: ScaleFunction;
|
||||||
|
y: ScaleFunction;
|
||||||
|
bandwidth: number;
|
||||||
|
barColors: string[];
|
||||||
|
rawData: unknown[]; // TODO: update this value when this data type is defined
|
||||||
|
combinedData: CombinedData[];
|
||||||
|
gap: number;
|
||||||
|
roundedRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomBars: React.FC<CustomBarsProps> = ({
|
||||||
|
x: scaleX,
|
||||||
|
y: scaleY,
|
||||||
|
bandwidth,
|
||||||
|
combinedData,
|
||||||
|
rawData,
|
||||||
|
barColors,
|
||||||
|
gap = 2,
|
||||||
|
roundedRadius = 4
|
||||||
|
}) => {
|
||||||
|
const barWidth = calculateBarWidth(bandwidth, combinedData.length, gap);
|
||||||
|
|
||||||
|
return rawData.map((_, index) => (
|
||||||
|
<Svg key={`group-${index}`} testID={`svg-${index}`}>
|
||||||
|
{combinedData.map((item, i) => (
|
||||||
|
<Bar
|
||||||
|
key={`bar-${i}-${index}`}
|
||||||
|
scaleX={scaleX}
|
||||||
|
scaleY={scaleY}
|
||||||
|
data={item.data[index]}
|
||||||
|
barNumber={i} // index of bar
|
||||||
|
index={index} // index of group
|
||||||
|
fill={barColors[i]}
|
||||||
|
barWidth={barWidth}
|
||||||
|
gap={gap}
|
||||||
|
roundedRadius={roundedRadius}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
));
|
||||||
|
};
|
@ -1,12 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { G, Line } from 'react-native-svg';
|
import { G, Line } from 'react-native-svg';
|
||||||
import { ScaleLinear } from 'd3-scale'
|
|
||||||
import { colors } from '../styles';
|
import { colors } from '../styles';
|
||||||
|
import { ScaleFunction } from './graph-types';
|
||||||
type scaleFunction = ScaleLinear<number, number>;
|
|
||||||
|
|
||||||
interface CustomGridProps {
|
interface CustomGridProps {
|
||||||
y: scaleFunction;
|
y: ScaleFunction;
|
||||||
ticks: Array<number>;
|
ticks: Array<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
3
component/graph-types.ts
Normal file
3
component/graph-types.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { ScaleLinear } from 'd3-scale'
|
||||||
|
|
||||||
|
export type ScaleFunction = ScaleLinear<number, number>;
|
@ -14,7 +14,7 @@
|
|||||||
"jest": {
|
"jest": {
|
||||||
"preset": "jest-expo",
|
"preset": "jest-expo",
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg)"
|
"node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|d3-path)/)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -23,6 +23,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"@typescript-eslint/parser": "^6.17.0",
|
||||||
"babel-plugin-inline-dotenv": "^1.7.0",
|
"babel-plugin-inline-dotenv": "^1.7.0",
|
||||||
|
"d3-path": "^3.1.0",
|
||||||
"d3-scale": "1.0.6",
|
"d3-scale": "1.0.6",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
@ -43,6 +44,7 @@
|
|||||||
"@babel/core": "^7.20.0",
|
"@babel/core": "^7.20.0",
|
||||||
"@testing-library/jest-native": "^5.4.3",
|
"@testing-library/jest-native": "^5.4.3",
|
||||||
"@testing-library/react-native": "^12.4.3",
|
"@testing-library/react-native": "^12.4.3",
|
||||||
|
"@types/d3-path": "^3.0.2",
|
||||||
"@types/jest": "^29.5.11",
|
"@types/jest": "^29.5.11",
|
||||||
"eslint-config-prettier": "^9.1.0"
|
"eslint-config-prettier": "^9.1.0"
|
||||||
},
|
},
|
||||||
|
89
test/component/custom-bars.test.tsx
Normal file
89
test/component/custom-bars.test.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react-native';
|
||||||
|
import "@testing-library/jest-native/extend-expect";
|
||||||
|
import * as scale from 'd3-scale'
|
||||||
|
|
||||||
|
import { CustomBars } from '../../component/custom-bars';
|
||||||
|
import { calculateBarOrigin, drawBarPath } from '../../component/custom-bar-utils';
|
||||||
|
|
||||||
|
|
||||||
|
const mockYScaleFunction = scale.scaleLinear();
|
||||||
|
const mockXScaleFunction = scale.scaleBand();
|
||||||
|
const mockBandwidth = 100;
|
||||||
|
const mockCombinedData = [
|
||||||
|
{
|
||||||
|
data: [{ value: 10 }, { value: 20 }],
|
||||||
|
svg: { fill: 'red' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const mockRawData = [
|
||||||
|
{ label: 'Full ball', shotsTaken: 10, makePercentage: 20 },
|
||||||
|
];
|
||||||
|
const mockBarColors = ['red', 'blue'];
|
||||||
|
const mockGap = 2;
|
||||||
|
const mockRoundedRadius = 4;
|
||||||
|
describe('CustomBars Component Tests', () => {
|
||||||
|
it('should render correct number of Svg and Bar components', () => {
|
||||||
|
const { getAllByTestId } = render(
|
||||||
|
<CustomBars
|
||||||
|
x={mockXScaleFunction}
|
||||||
|
y={mockYScaleFunction}
|
||||||
|
bandwidth={mockBandwidth}
|
||||||
|
combinedData={mockCombinedData}
|
||||||
|
rawData={mockRawData}
|
||||||
|
barColors={mockBarColors}
|
||||||
|
gap={mockGap}
|
||||||
|
roundedRadius={mockRoundedRadius}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const svgs = getAllByTestId(/svg-/);
|
||||||
|
const bars = getAllByTestId(/bar-/);
|
||||||
|
|
||||||
|
expect(svgs.length).toBe(mockRawData.length);
|
||||||
|
expect(bars.length).toBe(mockCombinedData.length * mockRawData.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bar utility functions', () => {
|
||||||
|
describe('calculateBarOrigin', () => {
|
||||||
|
it('calculates properties correctly', () => {
|
||||||
|
const mockData = { value: 10 };
|
||||||
|
const mockIndex = 1;
|
||||||
|
const mockBarNumber = 2;
|
||||||
|
const mockBarWidth = 20;
|
||||||
|
const mockGap = 5;
|
||||||
|
|
||||||
|
const result = calculateBarOrigin({
|
||||||
|
scaleX: mockXScaleFunction,
|
||||||
|
scaleY: mockYScaleFunction,
|
||||||
|
data: mockData,
|
||||||
|
index: mockIndex,
|
||||||
|
barNumber: mockBarNumber,
|
||||||
|
barWidth: mockBarWidth,
|
||||||
|
gap: mockGap
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
xOrigin: expect.any(Number),
|
||||||
|
yOrigin: expect.any(Number),
|
||||||
|
height: expect.any(Number)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('drawBarPath', () => {
|
||||||
|
it('generates a correct SVG path string', () => {
|
||||||
|
const xOrigin = 50;
|
||||||
|
const yOrigin = 100;
|
||||||
|
const barWidth = 20;
|
||||||
|
const height = 60;
|
||||||
|
const roundedRadius = 10;
|
||||||
|
|
||||||
|
const path = drawBarPath(xOrigin, yOrigin, barWidth, height, roundedRadius);
|
||||||
|
const expectedPath = 'M50,160L50,110A10,10,0,0,1,60,100L60,100A10,10,0,0,1,70,110L70,160L50,160Z'
|
||||||
|
|
||||||
|
expect(path).toBe(expectedPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
10
yarn.lock
10
yarn.lock
@ -2296,6 +2296,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.20.7"
|
"@babel/types" "^7.20.7"
|
||||||
|
|
||||||
|
"@types/d3-path@^3.0.2":
|
||||||
|
version "3.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.2.tgz#4327f4a05d475cf9be46a93fc2e0f8d23380805a"
|
||||||
|
integrity sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==
|
||||||
|
|
||||||
"@types/graceful-fs@^4.1.3":
|
"@types/graceful-fs@^4.1.3":
|
||||||
version "4.1.9"
|
version "4.1.9"
|
||||||
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4"
|
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4"
|
||||||
@ -3683,6 +3688,11 @@ d3-interpolate@1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
d3-color "1"
|
d3-color "1"
|
||||||
|
|
||||||
|
d3-path@^3.1.0:
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526"
|
||||||
|
integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==
|
||||||
|
|
||||||
d3-scale@1.0.6:
|
d3-scale@1.0.6:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.6.tgz#bce19da80d3a0cf422c9543ae3322086220b34ed"
|
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.6.tgz#bce19da80d3a0cf422c9543ae3322086220b34ed"
|
||||||
|
Loading…
Reference in New Issue
Block a user