Data Visualization with Recharts: Dashboard Patterns

Build production-ready dashboards with Recharts in React. Covers bar, line, area, pie charts, responsive containers, custom tooltips, real-time data, and dark mode support.

E
ECOSIRE Research and Development Team
|19 مارس 20269 دقائق قراءة2.0k كلمات|

تصور البيانات باستخدام الرسوم البيانية: أنماط لوحة المعلومات

لوحة المعلومات الرائعة ليست مجرد أرقام في جدول، بل هي أنماط مرئية تكشف عن الاتجاهات والحالات الشاذة والفرص في لمحة واحدة. Recharts هي مكتبة الرسوم البيانية الأكثر استخدامًا على نطاق واسع في نظام React البيئي: مبنية على أساسيات D3.js، مكتوبة بالكامل باستخدام TypeScript، وقابلة للتركيب، وسريعة الاستجابة. إنه يتعامل مع تعقيد SVG حتى تتمكن من التركيز على قصة البيانات.

يغطي هذا الدليل أنماط Recharts للوحات معلومات الإنتاج - اتجاهات الإيرادات، ومسارات التحويل، ومقاييس السلاسل الزمنية، والتوزيع الجغرافي، وبيانات التدفق في الوقت الفعلي - مع دعم الوضع المظلم، ولوحات الألوان التي يمكن الوصول إليها، والتخطيطات سريعة الاستجابة.

الوجبات الرئيسية

  • قم دائمًا بلف المخططات في ResponsiveContainer باستخدام width="100%" وheight ثابت - لا تقم مطلقًا بترميز عرض البكسل بشكل ثابت
  • استخدم مكونات CustomTooltip بدلاً من المكونات الافتراضية — تحكم كامل في التنسيق والتصميم
  • لا ينبغي أبدًا كتابة معلمات رد الاتصال الخاصة بإعادة التخطيط formatter بشكل صريح - استخدم Number(value) للتحويل
  • يتطلب الوضع الداكن قيم ألوان ديناميكية - يمكن قراءتها من متغيرات CSS، وليس الألوان السداسية المضمنة
  • بالنسبة لبيانات السلاسل الزمنية، استخدم tickFormatter على المحور X لتنسيق الطوابع الزمنية بشكل متسق
  • إمكانية الوصول: أضف aria-label إلى حاوية المخطط واستخدم tabIndex على عناصر وسيلة الإيضاح
  • الأداء: حفظ بيانات المخطط باستخدام useMemo — إعادة تخطيط عمليات إعادة العرض في كل عرض أصلي
  • بالنسبة للبيانات في الوقت الفعلي، قم بتحديث مرجع مجموعة البيانات - يكتشف Recharts التغييرات حسب المرجع

الإعداد

pnpm add recharts
pnpm add -D @types/recharts  # Usually not needed — recharts ships its own types

يقوم Recharts 2.x بشحن أنواع TypeScript. لا يلزم وجود حزمة @types منفصلة.


مخطط منطقة الإيرادات

// src/components/charts/revenue-area-chart.tsx
'use client';

import { useMemo } from 'react';
import {
  AreaChart, Area, XAxis, YAxis, CartesianGrid,
  Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import { format, parseISO } from 'date-fns';

interface RevenueDataPoint {
  date: string;       // ISO date string
  revenue: number;
  refunds: number;
  net: number;
}

interface CustomTooltipProps {
  active?: boolean;
  payload?: Array<{ name: string; value: number; color: string }>;
  label?: string;
}

function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
  if (!active || !payload || !label) return null;

  return (
    <div className="rounded-lg border bg-background p-3 shadow-md">
      <p className="mb-2 text-sm font-medium text-muted-foreground">
        {format(parseISO(label), 'MMM d, yyyy')}
      </p>
      {payload.map((entry) => (
        <div key={entry.name} className="flex items-center gap-2 text-sm">
          <span
            className="inline-block h-2 w-2 rounded-full"
            style={{ backgroundColor: entry.color }}
          />
          <span className="text-muted-foreground">{entry.name}:</span>
          <span className="font-medium">
            {new Intl.NumberFormat('en-US', {
              style: 'currency',
              currency: 'USD',
              maximumFractionDigits: 0,
            }).format(entry.value)}
          </span>
        </div>
      ))}
    </div>
  );
}

interface RevenueAreaChartProps {
  data: RevenueDataPoint[];
  loading?: boolean;
}

export function RevenueAreaChart({ data, loading }: RevenueAreaChartProps) {
  // CRITICAL: memoize chart data — Recharts re-renders on every reference change
  const chartData = useMemo(() => data, [data]);

  if (loading) {
    return <div className="h-80 animate-pulse rounded-lg bg-muted" />;
  }

  return (
    <div role="img" aria-label="Monthly revenue area chart">
      <ResponsiveContainer width="100%" height={320}>
        <AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
          <defs>
            <linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%"  stopColor="#f59e0b" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
            </linearGradient>
            <linearGradient id="colorNet" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%"  stopColor="#22c55e" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
            </linearGradient>
          </defs>

          <CartesianGrid
            strokeDasharray="3 3"
            stroke="hsl(var(--border))"
            vertical={false}
          />

          <XAxis
            dataKey="date"
            tickFormatter={(value: string) => format(parseISO(value), 'MMM d')}
            tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
            axisLine={false}
            tickLine={false}
          />

          <YAxis
            tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
            tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
            axisLine={false}
            tickLine={false}
            width={60}
          />

          <Tooltip content={<CustomTooltip />} />

          <Legend
            wrapperStyle={{ fontSize: '14px', paddingTop: '16px' }}
            formatter={(value: string) =>
              <span className="text-foreground">{value}</span>
            }
          />

          <Area
            type="monotone"
            dataKey="revenue"
            name="Gross Revenue"
            stroke="#f59e0b"
            strokeWidth={2}
            fill="url(#colorRevenue)"
          />
          <Area
            type="monotone"
            dataKey="net"
            name="Net Revenue"
            stroke="#22c55e"
            strokeWidth={2}
            fill="url(#colorNet)"
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
}

الرسم البياني الشريطي: المقارنة الشهرية

// src/components/charts/monthly-bar-chart.tsx
'use client';

import {
  BarChart, Bar, XAxis, YAxis, CartesianGrid,
  Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts';

interface MonthlyData {
  month: string;
  current: number;
  previous: number;
  target: number;
}

function CustomBarTooltip({ active, payload, label }: any) {
  if (!active || !payload) return null;

  return (
    <div className="rounded-lg border bg-background p-3 shadow-md">
      <p className="mb-2 font-medium">{label}</p>
      {payload.map((entry: { name: string; value: number; fill: string }) => (
        <div key={entry.name} className="flex items-center gap-2 text-sm">
          <span
            className="inline-block h-2 w-2 rounded-full"
            style={{ backgroundColor: entry.fill }}
          />
          <span className="text-muted-foreground">{entry.name}:</span>
          <span className="font-medium">
            {new Intl.NumberFormat('en-US', {
              style: 'currency',
              currency: 'USD',
              maximumFractionDigits: 0,
            }).format(entry.value)}
          </span>
        </div>
      ))}
    </div>
  );
}

export function MonthlyBarChart({ data }: { data: MonthlyData[] }) {
  return (
    <ResponsiveContainer width="100%" height={300}>
      <BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
        <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
        <XAxis
          dataKey="month"
          tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
          tickLine={false}
        />
        <YAxis
          tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
          tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
          tickLine={false}
        />
        <Tooltip content={<CustomBarTooltip />} />
        <Legend />
        <Bar dataKey="previous" name="Previous Year" fill="hsl(var(--muted))" radius={[4, 4, 0, 0]} />
        <Bar dataKey="current"  name="Current Year"  fill="#f59e0b"            radius={[4, 4, 0, 0]} />
        <Bar dataKey="target"   name="Target"        fill="#22c55e" opacity={0.4} radius={[4, 4, 0, 0]} />
      </BarChart>
    </ResponsiveContainer>
  );
}

دونات / مخطط دائري

// src/components/charts/platform-pie-chart.tsx
'use client';

import { useState } from 'react';
import {
  PieChart, Pie, Cell, Sector,
  ResponsiveContainer, Legend, Tooltip,
} from 'recharts';

interface PieDataPoint {
  name: string;
  value: number;
  color: string;
}

const PLATFORM_COLORS: Record<string, string> = {
  'Odoo':         '#714b67',
  'Shopify':      '#96bf48',
  'GoHighLevel':  '#f59e0b',
  'Power BI':     '#f2c811',
  'OpenClaw':     '#0ea5e9',
  'Other':        '#94a3b8',
};

function renderActiveShape(props: any) {
  const {
    cx, cy, innerRadius, outerRadius, startAngle, endAngle,
    fill, payload, percent, value,
  } = props;

  return (
    <g>
      <text x={cx} y={cy - 12} textAnchor="middle" className="text-base font-bold fill-foreground">
        {payload.name}
      </text>
      <text x={cx} y={cy + 12} textAnchor="middle" className="text-sm fill-muted-foreground">
        {`${(percent * 100).toFixed(1)}%`}
      </text>
      <text x={cx} y={cy + 32} textAnchor="middle" className="text-sm fill-muted-foreground">
        {`$${(Number(value) / 1000).toFixed(0)}k`}
      </text>
      <Sector
        cx={cx} cy={cy}
        innerRadius={innerRadius}
        outerRadius={outerRadius + 8}
        startAngle={startAngle}
        endAngle={endAngle}
        fill={fill}
      />
      <Sector
        cx={cx} cy={cy}
        innerRadius={outerRadius + 10}
        outerRadius={outerRadius + 12}
        startAngle={startAngle}
        endAngle={endAngle}
        fill={fill}
      />
    </g>
  );
}

export function PlatformPieChart({ data }: { data: PieDataPoint[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const chartData = data.map((item) => ({
    ...item,
    color: PLATFORM_COLORS[item.name] ?? '#94a3b8',
  }));

  return (
    <ResponsiveContainer width="100%" height={300}>
      <PieChart>
        <Pie
          activeIndex={activeIndex}
          activeShape={renderActiveShape}
          data={chartData}
          cx="50%"
          cy="50%"
          innerRadius={70}
          outerRadius={100}
          dataKey="value"
          onMouseEnter={(_, index) => setActiveIndex(index)}
        >
          {chartData.map((entry, index) => (
            <Cell key={`cell-${index}`} fill={entry.color} />
          ))}
        </Pie>
        <Legend
          formatter={(value: string) =>
            <span className="text-sm text-foreground">{value}</span>
          }
        />
      </PieChart>
    </ResponsiveContainer>
  );
}

الرسم البياني الخطي في الوقت الحقيقي

// src/components/charts/realtime-chart.tsx
'use client';

import { useState, useEffect, useRef } from 'react';
import {
  LineChart, Line, XAxis, YAxis, CartesianGrid,
  Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';

interface MetricPoint {
  time: string;
  requests: number;
  errors: number;
  p95: number;
}

const MAX_DATA_POINTS = 60; // 60 seconds of data

export function RealtimeChart({ endpoint }: { endpoint: string }) {
  const [data, setData] = useState<MetricPoint[]>([]);
  const intervalRef = useRef<ReturnType<typeof setInterval>>();

  useEffect(() => {
    const fetchMetrics = async () => {
      try {
        const response = await fetch(endpoint);
        const point: MetricPoint = await response.json();

        setData((prev) => {
          const updated = [
            ...prev,
            { ...point, time: new Date().toLocaleTimeString() },
          ];
          // Keep only the last MAX_DATA_POINTS — sliding window
          return updated.slice(-MAX_DATA_POINTS);
        });
      } catch {
        // Metrics fetch failure is non-critical — log but do not crash
      }
    };

    fetchMetrics();
    intervalRef.current = setInterval(fetchMetrics, 1000);

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [endpoint]);

  return (
    <ResponsiveContainer width="100%" height={200}>
      <LineChart data={data}>
        <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
        <XAxis
          dataKey="time"
          tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
          interval="preserveStartEnd"
        />
        <YAxis
          tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
        />
        <Tooltip />
        {/* Reference line for SLO threshold */}
        <ReferenceLine
          y={200}
          label={{ value: 'SLO 200ms', position: 'right', fontSize: 10 }}
          stroke="#ef4444"
          strokeDasharray="4 4"
        />
        <Line
          type="monotone"
          dataKey="p95"
          name="P95 Latency (ms)"
          stroke="#f59e0b"
          strokeWidth={2}
          dot={false}
          isAnimationActive={false} // Disable animation for real-time data
        />
        <Line
          type="monotone"
          dataKey="errors"
          name="Errors/s"
          stroke="#ef4444"
          strokeWidth={2}
          dot={false}
          isAnimationActive={false}
        />
      </LineChart>
    </ResponsiveContainer>
  );
}

تخطيط لوحة المعلومات مع بطاقات الإحصائيات

// src/components/dashboard/metrics-dashboard.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { RevenueAreaChart } from './revenue-area-chart';
import { MonthlyBarChart } from './monthly-bar-chart';
import { PlatformPieChart } from './platform-pie-chart';

export function MetricsDashboard() {
  const { data: metrics, isLoading } = useQuery({
    queryKey: ['dashboard-metrics'],
    queryFn: () => apiFetch<DashboardMetrics>('/analytics/dashboard'),
    refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
  });

  return (
    <div className="space-y-6">
      {/* KPI Cards */}
      <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
        {[
          { label: 'MRR', value: metrics?.mrr, format: 'currency', trend: '+12%' },
          { label: 'ARR',  value: metrics?.arr,  format: 'currency', trend: '+8%'  },
          { label: 'Customers', value: metrics?.customers, format: 'number', trend: '+23' },
          { label: 'Churn', value: metrics?.churnRate, format: 'percent', trend: '-0.3%' },
        ].map(({ label, value, format, trend }) => (
          <div key={label} className="rounded-lg border bg-card p-4">
            <p className="text-sm text-muted-foreground">{label}</p>
            <p className="mt-1 text-2xl font-bold">
              {isLoading ? (
                <span className="inline-block h-8 w-24 animate-pulse rounded bg-muted" />
              ) : (
                formatMetric(value, format)
              )}
            </p>
            <p className="mt-1 text-sm text-green-500">{trend} this month</p>
          </div>
        ))}
      </div>

      {/* Charts */}
      <div className="grid gap-6 lg:grid-cols-3">
        <div className="lg:col-span-2 rounded-lg border bg-card p-6">
          <h3 className="mb-4 text-lg font-semibold">Revenue Trend</h3>
          <RevenueAreaChart data={metrics?.revenueByDay ?? []} loading={isLoading} />
        </div>
        <div className="rounded-lg border bg-card p-6">
          <h3 className="mb-4 text-lg font-semibold">Revenue by Platform</h3>
          <PlatformPieChart data={metrics?.revenueByPlatform ?? []} />
        </div>
      </div>

      <div className="rounded-lg border bg-card p-6">
        <h3 className="mb-4 text-lg font-semibold">Monthly Comparison</h3>
        <MonthlyBarChart data={metrics?.monthlyComparison ?? []} />
      </div>
    </div>
  );
}

function formatMetric(value: number | undefined, format: string): string {
  if (value === undefined) return '—';
  const fmt = new Intl.NumberFormat('en-US');
  if (format === 'currency') return `$${fmt.format(value)}`;
  if (format === 'percent') return `${value.toFixed(1)}%`;
  return fmt.format(value);
}

الأسئلة المتداولة

لماذا يجب ألا تقوم عمليات الاسترجاعات الخاصة بمنسق Recharts Tooltip أبدًا بكتابة المعلمات الخاصة بها بشكل صريح؟

يتم تحميل أنواع TypeScript الخاصة بـ Recharts للخاصية formatter بشكل زائد بطرق تسبب تعارضات في النوع عند إضافة تعليقات توضيحية إلى المعلمات. النمط الصحيح هو استخدام Number(value) لتحويل القيمة بأمان دون إضافة تعليق توضيحي لنوع value. إذا قمت بتعليقه كـ (value: number)، فقد يحدث خطأ في TypeScript بسبب نوع الاتحاد في التوقيع الداخلي لـ Recharts. اسمح لـ TypeScript باستنتاج النوع والتحويل باستخدام Number(value).

كيف أجعل مخططات Recharts تعمل في الوضع المظلم؟

استخدم خصائص CSS المخصصة (المتغيرات) لجميع الألوان بدلاً من القيم السداسية المضمنة. قم بالإشارة إليها باستخدام hsl(var(--border)) لخطوط الشبكة، وhsl(var(--muted-foreground)) لنص المحور، وhsl(var(--background)) لخلفيات تلميحات الأدوات. يتم تعريف هذه المتغيرات في سمة Tailwind الخاصة بك ويتم تبديل القيم تلقائيًا بين الوضع الفاتح والداكن عبر فئة dark على <html>. لا تستخدم الكود الثابت #374151 مطلقًا — فلن يتم تحديثه باستخدام السمة.

كيف أتعامل مع الحالات الفارغة أو حالات التحميل للمخططات؟

للتحميل: استبدل المخطط بالهيكل العظمي (animate-pulse bg-muted rounded-lg h-80). بالنسبة للبيانات الفارغة: قم بعرض رسالة حالة فارغة مركزية داخل ResponsiveContainer. بالنسبة للبيانات الجزئية: تتعامل ميزة Recharts مع البيانات المتفرقة بأمان - ما عليك سوى تمرير ما لديك وسيقوم بتوصيل النقاط (أو إظهار الفجوات للقيم الخالية مع connectNulls={false}).

هل يجب أن أستخدم Recharts أو Chart.js أو D3؟

إعادة التخطيط للوحات معلومات React الأصلية - فهي تعريفية وقابلة للتركيب وتتكامل بشكل طبيعي مع نموذج عرض React. Chart.js لحالات الاستخدام الأبسط حيث تريد عارضًا يستند إلى Canvas (أداء أفضل لأكثر من 10000 نقطة بيانات). D3 عندما تحتاج إلى أنواع مخططات مخصصة وغير قياسية لا تغطيها أي مكتبة - توقع المزيد من التعليمات البرمجية بشكل ملحوظ. تجنب خلط مكتبات الرسوم البيانية المتعددة في تطبيق واحد.

كيف يمكنني تصدير المخططات كصور للتقارير؟

استخدم مكتبة html2canvas لالتقاط حاوية المخطط كصورة: const canvas = await html2canvas(chartRef.current); const url = canvas.toDataURL('image/png');. بدلاً من ذلك، استخدم مخرجات SVG المضمنة في Recharts - نظرًا لأن Recharts يعرض SVG، يمكنك إجراء تسلسل لعنصر SVG مباشرة لتصدير المتجهات. بالنسبة لتقارير PDF، قم بعرض المخططات من جانب الخادم باستخدام محرك الدمى أو متصفح بدون رأس.


الخطوات التالية

يقوم تصور البيانات بتحويل المقاييس الأولية إلى ذكاء الأعمال. تمنحك الأنماط الموجودة في هذا الدليل — الحاويات سريعة الاستجابة، وتلميحات الأدوات المخصصة، والبث في الوقت الفعلي، والألوان المرتبطة بالموضوع — الأساس لواجهات مستخدم لوحة المعلومات التي تتواصل بوضوح على أي جهاز وبأي نظام ألوان.

تقوم ECOSIRE ببناء لوحات معلومات تحليلية باستخدام Recharts عبر 8 وحدات لوحة معلومات للمقاييس المالية والتشغيلية ومقاييس الأداء - مع البيانات المباشرة من واجهات برمجة التطبيقات NestJS وPostgreSQL التي تعمل بنظام Drizzle. بالنسبة لتطبيقات ذكاء الأعمال وPower BI، استكشف خدمات Power BI أو اعرض جميع خدماتنا.

E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب