النقاط الرئيسية

  • تولّد مكتبة D3 مسارًا سلسًا انطلاقًا من بياناتك.
  • تعرض مكتبة react-native-svg هذا المسار كرسم SVG.
  • تضيف مكتبة react-native-reanimated حركة إلى المسار لتحقيق انتقالات سلسة.
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>
 );
};

شرح الكود:

  1. الاستيرادات: استيراد جميع الوحدات اللازمة من React وreact-native وreact-native-svg وd3-shape وd3-scale وreact-native-reanimated.
  2. أنواع البيانات: تعريف أنواع لنقاط البيانات (LineChartDataPoint) ولمجموعة البيانات الكاملة (LineChartData).
  3. الدالة makeGraph: إنشاء الدالة makeGraph التي: تُحجّم البيانات باستخدام d3-scale لتلائم أبعاد الرسم البياني. تولّد مسار الخط باستخدام d3-shape.line وcurveLinear للحصول على خطوط مستقيمة.
  4. المكوّن LineChartPath: يتولى هذا المكوّن العرض المتحرك للرسم البياني الخطي: يستقبل البيانات ويولّد مسار الرسم البياني. يستخدم useSharedValue وuseAnimatedProps من react-native-reanimated لتحريك مسار الرسم بكفاءة.
  5. الدالة fillMissingDates: تُستخدم هذه الدالة لملء التواريخ المفقودة في البيانات بقيمة افتراضية (0).
  6. الدالة getRandomColor: تُعيد لونًا عشوائيًا للخطوط.
  7. المكوّن LineChart: يُهيّئ عنصر SVG. يمرّ على خطوط الشبكة لرسمها. يمرّ على البيانات المُمرَّرة لعرض كل خط باستخدام المكوّن LineChartPath.

أبرز التحسينات والتوضيحات:

  • استخدام D3 لحساب مسار SVG:
    • تستخدم الدالة makeGraph مقاييس D3 (scaleLinear وscaleTime) لتحويل قيم البيانات إلى مواضع بالبكسل.
    • تُستخدم الدالة d3.line لتوليد نص مسار SVG، مع .curve(curveLinear) للحصول على مقاطع خطية مستقيمة.
  • استخدام react-native-reanimated للحركة:
    • يستخدم المكوّن LineChartPath كلًّا من useSharedValue وuseAnimatedProps من react-native-reanimated لتفعيل حركات سلسة.
    • تحتفظ القيمة المشتركة selectedGraph بمسار SVG المحسوب، وأي تغيير في هذه القيمة يتحرك بسلاسة.
  • التعامل المتجاوب:
    • يستخدم المكوّن الحدث onLayout لضبط عرضه ديناميكيًا وفقًا للمساحة المتاحة.

يوفّر هذا الكود أساسًا متينًا لإنشاء رسم بياني خطي متحرك. ويمكنك تخصيصه أكثر بإضافة ميزات مثل التلميحات (tooltips) والتكبير وأنواع مختلفة من المنحنيات.