Key Points
- D3 generates a smooth path from your data.
- react-native-svg renders the path as an SVG.
- react-native-reanimated animates the path for smooth transitions.
import React, {FC, useEffect, useState} from 'react';
import {G, Line, Path, Rect, Svg} from 'react-native-svg';
import {line, curveBasis, area, curveLinear} from 'd3-shape';
import {scaleLinear, scaleTime} from 'd3-scale';
import Animated, {
useAnimatedProps,
useSharedValue,
} from 'react-native-reanimated';
import {
Dimensions,
LayoutChangeEvent,
View,
Text,
ProcessedColorValue,
ColorValue,
} from 'react-native';
const AnimatedPath = Animated.createAnimatedComponent(Path);
export type LineChartData = LineChartDataPoint[];
export type LineChartDataPoint = {
date: Date;
value: number;
};
const makeGraph = (
data: LineChartDataPoint[],
height: number,
width: number,
max: number,
) => {
const y = scaleLinear().domain([0, max]).range([height, 0]);
const minDate = Math.min(...data.map(val => val.date.getTime()));
const maxDate = Math.max(...data.map(val => val.date.getTime()));
const x = scaleTime()
.domain([new Date(minDate), new Date(maxDate)])
.range([0, width]);
const curvedLine = line<LineChartDataPoint>()
.x(d => x(new Date(d.date)))
.y(d => y(d.value))
.curve(curveLinear)(data);
return {
curve: curvedLine!,
// curve: curvedArea!,
};
};
type LineChartProps = {
height: number;
options: {
grid: {
x: {
stroke: string | ColorValue;
strokeWidth: number;
color: string | ColorValue;
format: (value: number) => string;
};
y: {
stroke: string | ColorValue;
strokeWidth: number;
color: string | ColorValue;
format: (value: number) => string;
};
};
};
lineColor: (index: number) => string;
width?: number;
data: LineChartData[];
leftPadding: number;
bottomPadding: number;
gridLines?:
| [xAxis: number | 'auto', yAxis: number | 'auto']
| ((
max: number,
height: number,
) => [xAxis: number | 'auto', yAxis: number | 'auto']);
};
type LineChartPathProps = {
height: number;
width: number;
max: number;
data: LineChartDataPoint[];
strokeColor: string;
};
const getRandomColor = (): string => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};
const LineChartPath: FC<LineChartPathProps> = ({
height,
width,
data,
max,
strokeColor,
}: LineChartPathProps) => {
const selectedGraph = useSharedValue({curve: ''});
useEffect(() => {
const graphData = makeGraph(data, height, width, max);
selectedGraph.value = graphData;
}, [data, height, width, max]);
const animatedProps = useAnimatedProps(() => {
return {
d: selectedGraph.value.curve,
};
});
return (
<AnimatedPath
fill={'transparent'}
animatedProps={animatedProps}
strokeWidth={1}
stroke={strokeColor}
/>
);
};
export const fillMissingDates = (
data: LineChartData[],
minDate: Date,
maxDate: Date,
): LineChartData[] => {
// Helper function to normalize a date (set time to 00:00:00)
const normalizeDate = (date: Date): Date => {
const normalized = new Date(date);
normalized.setHours(0, 0, 0, 0); // Set time to midnight
return normalized;
};
// Normalize minDate and maxDate
const normalizedMinDate = normalizeDate(minDate);
const normalizedMaxDate = normalizeDate(maxDate);
return data.map(dataset => {
const dateMap = new Map(
dataset.map(item => [normalizeDate(item.date).getTime(), item]),
);
const filledData: LineChartDataPoint[] = [];
for (
let d = new Date(normalizedMinDate);
d <= normalizedMaxDate;
d.setDate(d.getDate() + 1)
) {
const time = d.getTime();
if (dateMap.has(time)) {
filledData.push(dateMap.get(time)!);
} else {
filledData.push({date: new Date(time), value: 0}); // Default value for missing dates
}
}
return filledData;
});
};
export const LineChart: FC<LineChartProps> = ({
options,
height,
width,
data,
bottomPadding,
leftPadding,
gridLines = ['auto', 'auto'],
lineColor = getRandomColor,
}) => {
const [parentWidth, setParentWidth] = useState(0);
const parsedColors = options;
const handleLayout = (event: LayoutChangeEvent) => {
const {width} = event.nativeEvent.layout;
setParentWidth(width);
};
let chartWidth = width || parentWidth || Dimensions.get('window').width;
if (!chartWidth) {
chartWidth = 100;
}
let maxCount = Math.max(
...data.flatMap(dataset => dataset.map(point => point.value)),
);
let gridLineXCount = 0;
let gridLineXSpacing = 0;
let gridLineYCount = 0;
let gridLineYSpacing = 0;
const legendWidth = 30;
const gridGroupWidth = chartWidth - legendWidth;
const chartGroupWidth = chartWidth - legendWidth;
const xStrokeWidth = parsedColors.grid.x.strokeWidth || 0;
const yStrokeWidth = parsedColors.grid.y.strokeWidth || 0;
if (Array.isArray(gridLines) && gridLines.length === 2) {
if (xStrokeWidth) {
if (gridLines[0] == 'auto') {
gridLineXCount = maxCount + 1;
} else {
gridLineXCount = gridLines[0] || 0;
}
gridLineXSpacing =
(height - xStrokeWidth * gridLineXCount) / (gridLineXCount - 1 || 1);
}
if (yStrokeWidth) {
if (gridLines[1] == 'auto') {
gridLineYCount = maxCount + 1;
} else {
gridLineYCount = gridLines[1] || 0;
}
gridLineYSpacing =
(gridGroupWidth - yStrokeWidth * gridLineYCount) /
(gridLineYCount - 1 || 1);
}
} else if (typeof gridLines === 'function') {
const gridLinesValue = gridLines(maxCount, height);
if (xStrokeWidth) {
if (gridLinesValue[0] == 'auto') {
gridLineXCount = maxCount + 1;
} else {
gridLineXCount = gridLinesValue[0] || 0;
}
gridLineXSpacing =
(height - xStrokeWidth * gridLineXCount) / (gridLineXCount - 1 || 1);
}
if (yStrokeWidth) {
if (gridLinesValue[1] == 'auto') {
gridLineYCount = maxCount + 1;
} else {
gridLineYCount = gridLinesValue[1] || 0;
}
const spacingWidth = gridGroupWidth - yStrokeWidth * gridLineYCount;
gridLineYSpacing = spacingWidth / (gridLineYCount - 1 || 1);
}
}
return (
<View onLayout={handleLayout} style={{flex: 1, position: 'relative'}}>
<Svg
width={chartWidth}
viewBox={`0 0 ${chartWidth} ${height}`}
height={height}>
<G y={-bottomPadding}>
<G>
{gridLineXCount > 0
? Array.from({length: gridLineXCount}).map((_, i) => (
<Line
key={i}
x1={0}
y1={
i == 0
? xStrokeWidth
: i * gridLineXSpacing + i * xStrokeWidth
}
x2={gridGroupWidth}
y2={
i == 0
? xStrokeWidth
: i * gridLineXSpacing + i * xStrokeWidth
}
stroke={parsedColors.grid.x.stroke}
strokeWidth={xStrokeWidth}
/>
))
: null}
</G>
<G width={chartGroupWidth}>
{gridLineYCount > 0
? Array.from({length: gridLineYCount}).map((_, i) => (
<Line
key={i}
x1={
i == 0
? yStrokeWidth
: i * gridLineYSpacing + i * yStrokeWidth
}
y1={0}
x2={
i == 0
? yStrokeWidth
: i * gridLineYSpacing + i * yStrokeWidth
}
y2={height}
stroke={parsedColors.grid.y.stroke}
strokeWidth={yStrokeWidth}
/>
))
: null}
</G>
<G>
{data.map((d, i) => {
return (
<LineChartPath
key={i}
height={height}
width={chartGroupWidth}
data={d}
max={maxCount}
strokeColor={lineColor(i)}
/>
);
})}
</G>
</G>
</Svg>
<View
style={{
position: 'absolute',
top: 0,
left: 0,
width: chartWidth,
height: height,
backgroundColor: 'transparent',
zIndex: 1,
}}>
{gridLineXCount > 0
? Array.from({length: gridLineXCount}).map((_, i) => (
<View
key={i}
style={{
position: 'absolute',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
right: 0,
width: 30,
height: gridLineXSpacing,
bottom:
i * gridLineXSpacing +
i * xStrokeWidth -
gridLineXSpacing / 2,
}}>
<Text
style={{
fontSize: 10,
color: parsedColors.grid.x.color,
}}>
{i == 0
? 0
: options.grid.x.format(
maxCount / ((gridLineXCount - 1) / i),
)}
</Text>
</View>
))
: null}
</View>
</View>
);
};
Code Explanation:
- Imports: Import all necessary modules from React, react-native, react-native-svg, d3-shape, d3-scale, and react-native-reanimated.
- Data Types: Define types for the data points (
LineChartDataPoint
) and the entire dataset (LineChartData
). - makeGraph Function: Create the makeGraph function that: Scales the data using d3-scale to fit the chart dimensions. Generates the line path using d3-shape.line and curveLinear for straight lines.
- LineChartPath Component: This component handles the animated rendering of the line chart: It takes the data and generates the chart path. It uses react-native-reanimated's useSharedValue and useAnimatedProps to animate the chart path efficiently.
- fillMissingDates Function: This function is used to fill missing dates in the data with a default value (0).
- getRandomColor Function: returns random color for lines
- LineChart Component: It sets up the SVG element. It iterates over the grid lines to draw them. It maps over the provided data to render each line using the LineChartPath component.
Key Improvements and Explanations:
- Use D3 for SVG Path Calculation:
- The makeGraph function uses D3 scales (scaleLinear and scaleTime) to map data values to pixel positions.
- The d3.line function is used to generate the SVG path string, with .curve(curveLinear) for linear (straight) line segments.
- react-native-reanimated for Animation:
- The LineChartPath component uses useSharedValue and useAnimatedProps from react-native-reanimated to enable smooth animations.
- The selectedGraph shared value holds the calculated SVG path, and any changes to this value are animated smoothly.
- Responsive Handling:
- The component uses the onLayout event to dynamically adjust its width based on the available space.
This code provides a solid foundation for creating an animated line chart. You can further customize it by adding features like tooltips, zooming, and different curve types.