React Native 鸿蒙跨平台开发:react-native-svg 矢量图形 - 数据可视化图表

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

一、核心知识点

数据可视化是将抽象的数据转换为视觉表现形式的过程,让用户能够直观地理解和分析数据。在鸿蒙端,使用 react-native-svg(CAPI) 可以创建各种精美的数据可视化图表,包括折线图、柱状图、饼图、雷达图等。

1.1 数据可视化的核心价值
  • 直观展示:将复杂的数据转换为易于理解的图形
  • 趋势分析:通过图表识别数据的变化趋势
  • 对比分析:比较不同数据集之间的差异
  • 决策支持:基于数据可视化做出更明智的决策
  • 交互性:通过交互增强用户的探索体验
1.2 SVG 图表的技术优势
typescript 复制代码
import Svg, { Path, Rect, Circle, Line, G, Text as SvgText, TSpan } from 'react-native-svg';

// 基础折线图示例
const BasicLineChart = ({ data }: { data: number[] }) => {
  const width = 300;
  const height = 200;
  const padding = 20;
  
  // 计算每个点的坐标
  const points = data.map((value, index) => {
    const x = padding + (index / (data.length - 1)) * (width - 2 * padding);
    const y = height - padding - (value / Math.max(...data)) * (height - 2 * padding);
    return `${x},${y}`;
  }).join(' ');
  
  return (
    <Svg width={width} height={height}>
      {/* 坐标轴 */}
      <Line x1={padding} y1={padding} x2={padding} y2={height - padding} stroke="#E5E6EB" strokeWidth={1} />
      <Line x1={padding} y1={height - padding} x2={width - padding} y2={height - padding} stroke="#E5E6EB" strokeWidth={1} />
      
      {/* 折线 */}
      <Polyline
        points={points}
        fill="none"
        stroke="#409EFF"
        strokeWidth={2}
        strokeLinejoin="round"
      />
    </Svg>
  );
};

SVG 图表的优势

  1. 矢量渲染:在任何分辨率下保持清晰
  2. 高度定制:完全控制每个元素的样式
  3. 性能优异:GPU 加速渲染,流畅的交互
  4. 响应式设计:自动适应不同屏幕尺寸
  5. 跨平台兼容:iOS、Android、鸿蒙三端统一

二、实战核心代码深度解析

2.1 折线图深度解析
typescript 复制代码
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, SafeAreaView } from 'react-native';
import Svg, { Path, Circle, Line, G, Defs, LinearGradient, Stop } from 'react-native-svg';

interface DataPoint {
  label: string;
  value: number;
}

const LineChartDemo = () => {
  const [selectedPoint, setSelectedPoint] = useState<number | null>(null);
  
  const data: DataPoint[] = [
    { label: '1月', value: 65 },
    { label: '2月', value: 59 },
    { label: '3月', value: 80 },
    { label: '4月', value: 81 },
    { label: '5月', value: 56 },
    { label: '6月', value: 55 },
    { label: '7月', value: 40 },
  ];
  
  const chartWidth = 320;
  const chartHeight = 200;
  const padding = { top: 20, right: 20, bottom: 40, left: 50 };
  
  // 计算 Y 轴范围
  const maxValue = Math.max(...data.map(d => d.value));
  const minValue = Math.min(...data.map(d => d.value));
  const yRange = maxValue - minValue || 1;
  
  // 计算数据点坐标
  const dataPoints = data.map((point, index) => {
    const x = padding.left + (index / (data.length - 1)) * (chartWidth - padding.left - padding.right);
    const y = padding.top + (1 - (point.value - minValue) / yRange) * (chartHeight - padding.top - padding.bottom);
    return { x, y, ...point };
  });
  
  // 生成平滑曲线(贝塞尔曲线)
  const generateSmoothPath = (points: typeof dataPoints) => {
    if (points.length < 2) return '';
    
    let path = `M ${points[0].x} ${points[0].y}`;
    
    for (let i = 1; i < points.length - 1; i++) {
      const p0 = points[i - 1];
      const p1 = points[i];
      const p2 = points[i + 1];
      
      const cp1x = p0.x + (p1.x - p0.x) / 2;
      const cp1y = p0.y;
      const cp2x = p1.x - (p2.x - p1.x) / 2;
      const cp2y = p1.y;
      
      path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`;
    }
    
    const lastPoint = points[points.length - 1];
    const secondLastPoint = points[points.length - 2];
    const cp1x = secondLastPoint.x + (lastPoint.x - secondLastPoint.x) / 2;
    const cp1y = secondLastPoint.y;
    
    path += ` C ${cp1x} ${cp1y}, ${lastPoint.x} ${lastPoint.y}, ${lastPoint.x} ${lastPoint.y}`;
    
    return path;
  };
  
  // 生成渐变填充路径
  const generateAreaPath = (points: typeof dataPoints) => {
    const linePath = generateSmoothPath(points);
    const bottomRightX = points[points.length - 1].x;
    const bottomRightY = chartHeight - padding.bottom;
    const bottomLeftX = points[0].x;
    const bottomLeftY = chartHeight - padding.bottom;
    
    return `${linePath} L ${bottomRightX} ${bottomRightY} L ${bottomLeftX} ${bottomLeftY} Z`;
  };
  
  return (
    <View style={styles.container}>
      <Text style={styles.title}>折线图示例</Text>
      
      <View style={styles.chartContainer}>
        <Svg width={chartWidth} height={chartHeight}>
          <Defs>
            <LinearGradient id="lineGradient" x1="0%" y1="0%" x2="0%" y2="100%">
              <Stop offset="0%" stopColor="#409EFF" stopOpacity={0.3} />
              <Stop offset="100%" stopColor="#409EFF" stopOpacity={0.05} />
            </LinearGradient>
          </Defs>
          
          {/* 网格线 */}
          {Array.from({ length: 5 }).map((_, index) => {
            const y = padding.top + (index / 4) * (chartHeight - padding.top - padding.bottom);
            return (
              <Line
                key={index}
                x1={padding.left}
                y1={y}
                x2={chartWidth - padding.right}
                y2={y}
                stroke="#E5E6EB"
                strokeWidth={1}
                strokeDasharray={[5, 5]}
              />
            );
          })}
          
          {/* Y 轴标签 */}
          {Array.from({ length: 5 }).map((_, index) => {
            const value = Math.round(maxValue - (index / 4) * yRange);
            const y = padding.top + (index / 4) * (chartHeight - padding.top - padding.bottom);
            return (
              <SvgText
                key={index}
                x={padding.left - 10}
                y={y + 5}
                fontSize={12}
                fill="#909399"
                textAnchor="end"
              >
                {value}
              </SvgText>
            );
          })}
          
          {/* 渐变填充区域 */}
          <Path
            d={generateAreaPath(dataPoints)}
            fill="url(#lineGradient)"
          />
          
          {/* 折线 */}
          <Path
            d={generateSmoothPath(dataPoints)}
            fill="none"
            stroke="#409EFF"
            strokeWidth={2}
            strokeLinecap="round"
            strokeLinejoin="round"
          />
          
          {/* 数据点 */}
          {dataPoints.map((point, index) => (
            <Circle
              key={index}
              cx={point.x}
              cy={point.y}
              r={selectedPoint === index ? 6 : 4}
              fill="#FFFFFF"
              stroke={selectedPoint === index ? '#409EFF' : '#E5E6EB'}
              strokeWidth={2}
            />
          ))}
          
          {/* X 轴标签 */}
          {dataPoints.map((point, index) => (
            <SvgText
              key={index}
              x={point.x}
              y={chartHeight - padding.bottom + 20}
              fontSize={12}
              fill="#606266"
              textAnchor="middle"
            >
              {point.label}
            </SvgText>
          ))}
        </Svg>
      </View>
      
      {/* 数据点信息 */}
      {selectedPoint !== null && (
        <View style={styles.tooltip}>
          <Text style={styles.tooltipLabel}>{dataPoints[selectedPoint].label}</Text>
          <Text style={styles.tooltipValue}>{dataPoints[selectedPoint].value}</Text>
        </View>
      )}
    </View>
  );
};

技术深度解析

  1. 坐标系统的转换

    typescript 复制代码
    const dataPoints = data.map((point, index) => {
      const x = padding.left + (index / (data.length - 1)) * (chartWidth - padding.left - padding.right);
      const y = padding.top + (1 - (point.value - minValue) / yRange) * (chartHeight - padding.top - padding.bottom);
      return { x, y, ...point };
    });
    • X 轴计算:均匀分布,基于索引位置
    • Y 轴计算:线性映射数据值到图表高度
    • padding 的作用:为标签和边距预留空间
    • 坐标归一化:将数据值映射到 0-1 范围,再映射到实际像素
  2. 贝塞尔曲线的平滑算法

    typescript 复制代码
    const generateSmoothPath = (points: typeof dataPoints) => {
      // ... 使用三次贝塞尔曲线 (C 命令)
      // cp1x = p0.x + (p1.x - p0.x) / 2  // 第一个控制点
      // cp1y = p0.y                        // 保持 Y 坐标不变
      // cp2x = p1.x - (p2.x - p1.x) / 2  // 第二个控制点
      // cp2y = p1.y                        // 保持 Y 坐标不变
      // path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`;
    };
    • 控制点计算:使用相邻点的中点作为控制点
    • 曲线平滑度:控制点距离端点越远,曲线越平滑
    • C 命令:三次贝塞尔曲线,需要两个控制点
    • 连续性:确保相邻曲线段在连接点处平滑
  3. 渐变填充的实现

    typescript 复制代码
    const generateAreaPath = (points: typeof dataPoints) => {
      const linePath = generateSmoothPath(points);
      const bottomRightX = points[points.length - 1].x;
      const bottomRightY = chartHeight - padding.bottom;
      const bottomLeftX = points[0].x;
      const bottomLeftY = chartHeight - padding.bottom;
      
      return `${linePath} L ${bottomRightX} ${bottomRightY} L ${bottomLeftX} ${bottomLeftY} Z`;
    };
    • 闭合路径:使用 L 命令连接到底边,Z 命令闭合
    • 渐变应用:从上到下,透明度从 0.3 到 0.05
    • 视觉效果:增强图表的立体感和层次感
  4. 交互式数据点

    typescript 复制代码
    <Circle
      r={selectedPoint === index ? 6 : 4}
      fill="#FFFFFF"
      stroke={selectedPoint === index ? '#409EFF' : '#E5E6EB'}
      strokeWidth={2}
    />
    • 选中状态:增大半径,改变描边颜色
    • 视觉反馈:清晰标识当前选中的数据点
    • 用户体验:点击数据点显示详细信息
2.2 柱状图深度解析
typescript 复制代码
const BarChartDemo = () => {
  const [selectedBar, setSelectedBar] = useState<number | null>(null);
  
  const data: DataPoint[] = [
    { label: '周一', value: 120 },
    { label: '周二', value: 200 },
    { label: '周三', value: 150 },
    { label: '周四', value: 80 },
    { label: '周五', value: 70 },
    { label: '周六', value: 110 },
    { label: '周日', value: 130 },
  ];
  
  const chartWidth = 320;
  const chartHeight = 200;
  const padding = { top: 20, right: 20, bottom: 40, left: 50 };
  
  const maxValue = Math.max(...data.map(d => d.value));
  const barWidth = (chartWidth - padding.left - padding.right) / data.length - 10;
  
  return (
    <View style={styles.container}>
      <Text style={styles.title}>柱状图示例</Text>
      
      <View style={styles.chartContainer}>
        <Svg width={chartWidth} height={chartHeight}>
          {/* Y 轴网格线 */}
          {Array.from({ length: 5 }).map((_, index) => {
            const y = padding.top + (index / 4) * (chartHeight - padding.top - padding.bottom);
            const value = Math.round(maxValue * (1 - index / 4));
            return (
              <G key={index}>
                <Line
                  x1={padding.left}
                  y1={y}
                  x2={chartWidth - padding.right}
                  y2={y}
                  stroke="#E5E6EB"
                  strokeWidth={1}
                  strokeDasharray={[5, 5]}
                />
                <SvgText
                  x={padding.left - 10}
                  y={y + 5}
                  fontSize={12}
                  fill="#909399"
                  textAnchor="end"
                >
                  {value}
                </SvgText>
              </G>
            );
          })}
          
          {/* 柱状图 */}
          {data.map((item, index) => {
            const x = padding.left + index * ((chartWidth - padding.left - padding.right) / data.length) + 5;
            const barHeight = (item.value / maxValue) * (chartHeight - padding.top - padding.bottom);
            const y = chartHeight - padding.bottom - barHeight;
            
            return (
              <G key={index}>
                <Rect
                  x={x}
                  y={y}
                  width={barWidth}
                  height={barHeight}
                  fill={selectedBar === index ? '#409EFF' : '#67C23A'}
                  rx={4}
                  ry={4}
                />
                
                {/* 柱状图数值 */}
                <SvgText
                  x={x + barWidth / 2}
                  y={y - 5}
                  fontSize={12}
                  fill={selectedBar === index ? '#409EFF' : '#606266'}
                  fontWeight={selectedBar === index ? '600' : '400'}
                  textAnchor="middle"
                >
                  {item.value}
                </SvgText>
                
                {/* X 轴标签 */}
                <SvgText
                  x={x + barWidth / 2}
                  y={chartHeight - padding.bottom + 20}
                  fontSize={12}
                  fill="#606266"
                  textAnchor="middle"
                >
                  {item.label}
                </SvgText>
              </G>
            );
          })}
        </Svg>
      </View>
    </View>
  );
};

技术深度解析

  1. 柱状图的布局计算

    typescript 复制代码
    const barWidth = (chartWidth - padding.left - padding.right) / data.length - 10;
    const x = padding.left + index * ((chartWidth - padding.left - padding.right) / data.length) + 5;
    const barHeight = (item.value / maxValue) * (chartHeight - padding.top - padding.bottom);
    const y = chartHeight - padding.bottom - barHeight;
    • 柱子宽度:总宽度除以数据量,减去间距
    • X 轴位置:均匀分布,每个柱子占一个"槽位"
    • 柱子高度:基于数据值与最大值的比例
    • Y 轴位置:从底部向上,所以需要用总高度减去柱子高度
  2. 圆角柱状图的实现

    typescript 复制代码
    <Rect
      rx={4}
      ry={4}
    />
    • rx 和 ry:分别设置 X 和 Y 方向的圆角半径
    • 视觉效果:让柱状图更加现代和柔和
    • 兼容性:鸿蒙端完美支持 SVG 的圆角属性
  3. 数值标签的动态位置

    typescript 复制代码
    <SvgText
      x={x + barWidth / 2}
      y={y - 5}
      textAnchor="middle"
    >
      {item.value}
    </SvgText>
    • 水平居中x + barWidth / 2 确保文本在柱子中心
    • 垂直对齐y - 5 让文本显示在柱子顶部上方
    • textAnchormiddle 让文本水平居中对齐
2.3 饼图深度解析
typescript 复制代码
const PieChartDemo = () => {
  const [selectedSlice, setSelectedSlice] = useState<number | null>(null);
  
  const data: { label: string; value: number; color: string }[] = [
    { label: '完成', value: 40, color: '#4CAF50' },
    { label: '进行中', value: 30, color: '#2196F3' },
    { label: '未开始', value: 20, color: '#E5E6EB' },
    { label: '取消', value: 10, color: '#F44336' },
  ];
  
  const total = data.reduce((sum, item) => sum + item.value, 0);
  const chartSize = 200;
  const radius = 80;
  const centerX = chartSize / 2;
  const centerY = chartSize / 2;
  
  // 计算每个扇形的路径
  const calculateSlicePath = (startAngle: number, endAngle: number, isSelected: boolean) => {
    const offset = isSelected ? 5 : 0;
    const r = radius + offset;
    
    const x1 = centerX + r * Math.cos((startAngle - 90) * Math.PI / 180);
    const y1 = centerY + r * Math.sin((startAngle - 90) * Math.PI / 180);
    const x2 = centerX + r * Math.cos((endAngle - 90) * Math.PI / 180);
    const y2 = centerY + r * Math.sin((endAngle - 90) * Math.PI / 180);
    
    const largeArc = endAngle - startAngle > 180 ? 1 : 0;
    
    return `M ${centerX} ${centerY} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
  };
  
  let startAngle = 0;
  
  return (
    <View style={styles.container}>
      <Text style={styles.title}>饼图示例</Text>
      
      <View style={styles.chartContainer}>
        <Svg width={chartSize} height={chartSize}>
          {data.map((item, index) => {
            const angle = (item.value / total) * 360;
            const endAngle = startAngle + angle;
            const isSelected = selectedSlice === index;
            
            const path = calculateSlicePath(startAngle, endAngle, isSelected);
            
            startAngle = endAngle;
            
            return (
              <G key={index}>
                <Path
                  d={path}
                  fill={item.color}
                  onPress={() => setSelectedSlice(selectedSlice === index ? null : index)}
                />
                
                {/* 标签(仅在扇形足够大时显示) */}
                {angle > 30 && (
                  <SvgText
                    x={centerX + (radius / 2) * Math.cos((startAngle - angle / 2 - 90) * Math.PI / 180)}
                    y={centerY + (radius / 2) * Math.sin((startAngle - angle / 2 - 90) * Math.PI / 180)}
                    fontSize={12}
                    fill="#FFFFFF"
                    fontWeight="600"
                    textAnchor="middle"
                  >
                    {item.label}
                  </SvgText>
                )}
              </G>
            );
          })}
        </Svg>
      </View>
      
      {/* 图例 */}
      <View style={styles.legend}>
        {data.map((item, index) => (
          <View key={index} style={styles.legendItem}>
            <View style={[styles.legendColor, { backgroundColor: item.color }]} />
            <Text style={styles.legendLabel}>{item.label}</Text>
            <Text style={styles.legendValue}>{item.value}%</Text>
          </View>
        ))}
      </View>
    </View>
  );
};

技术深度解析

  1. 扇形路径的数学计算

    typescript 复制代码
    const calculateSlicePath = (startAngle: number, endAngle: number, isSelected: boolean) => {
      const x1 = centerX + r * Math.cos((startAngle - 90) * Math.PI / 180);
      const y1 = centerY + r * Math.sin((startAngle - 90) * Math.PI / 180);
      const x2 = centerX + r * Math.cos((endAngle - 90) * Math.PI / 180);
      const y2 = centerY + r * Math.sin((endAngle - 90) * Math.PI / 180);
      
      const largeArc = endAngle - startAngle > 180 ? 1 : 0;
      
      return `M ${centerX} ${centerY} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
    };
    • 角度转弧度(angle - 90) * Math.PI / 180,-90 度从 12 点钟方向开始
    • 极坐标转换x = cx + r * cos(θ), y = cy + r * sin(θ)
    • A 命令 :圆弧命令,格式为 A rx ry x-axis-rotation large-arc-flag sweep-flag x y
      • large-arc-flag:1 表示大于 180 度,0 表示小于 180 度
      • sweep-flag:1 表示顺时针,0 表示逆时针
  2. 选中状态的视觉反馈

    typescript 复制代码
    const offset = isSelected ? 5 : 0;
    const r = radius + offset;
    • 偏移量:选中时扇形半径增加 5 像素
    • 视觉分离:使选中的扇形从饼图中"弹出"
    • 用户体验:清晰的选中状态反馈
  3. 标签位置的智能计算

    typescript 复制代码
    {angle > 30 && (
      <SvgText
        x={centerX + (radius / 2) * Math.cos((startAngle - angle / 2 - 90) * Math.PI / 180)}
        y={centerY + (radius / 2) * Math.sin((startAngle - angle / 2 - 90) * Math.PI / 180)}
      >
        {item.label}
      </SvgText>
    )}
    • 条件渲染:只在扇形角度大于 30 度时显示标签
    • 标签位置 :扇形中心点的位置(startAngle - angle / 2
    • 距离控制radius / 2 确保标签在扇形中心
2.4 雷达图深度解析
typescript 复制代码
const RadarChartDemo = () => {
  const data: { label: string; values: number[]; color: string }[] = [
    { label: '能力A', values: [80, 90, 70, 85, 75], color: '#4CAF50' },
    { label: '能力B', values: [60, 75, 85, 70, 80], color: '#2196F3' },
  ];
  
  const dimensions = ['速度', '力量', '耐力', '技巧', '战术'];
  const chartSize = 200;
  const radius = 80;
  const centerX = chartSize / 2;
  const centerY = chartSize / 2;
  
  // 计算顶点坐标
  const getVertexPosition = (index: number, total: number, r: number) => {
    const angle = (index / total) * 360 - 90;
    const x = centerX + r * Math.cos(angle * Math.PI / 180);
    const y = centerY + r * Math.sin(angle * Math.PI / 180);
    return { x, y };
  };
  
  // 生成数据多边形路径
  const generateDataPath = (values: number[], maxValue: number) => {
    const points = values.map((value, index) => {
      const position = getVertexPosition(index, values.length, radius * (value / maxValue));
      return `${position.x},${position.y}`;
    }).join(' ');
    
    return `M ${points.replace(/,/g, ' ')} Z`;
  };
  
  const maxValue = 100;
  
  return (
    <View style={styles.container}>
      <Text style={styles.title}>雷达图示例</Text>
      
      <View style={styles.chartContainer}>
        <Svg width={chartSize} height={chartSize}>
          {/* 背景网格(多边形) */}
          {[0.2, 0.4, 0.6, 0.8, 1].map((scale, index) => (
            <Path
              key={index}
              d={Array.from({ length: dimensions.length }).map((_, i) => {
                const pos = getVertexPosition(i, dimensions.length, radius * scale);
                return i === 0 ? `M ${pos.x} ${pos.y}` : `L ${pos.x} ${pos.y}`;
              }).join(' ') + ' Z'}
              fill="none"
              stroke="#E5E6EB"
              strokeWidth={1}
            />
          ))}
          
          {/* 轴线 */}
          {dimensions.map((_, index) => {
            const pos = getVertexPosition(index, dimensions.length, radius);
            return (
              <Line
                key={index}
                x1={centerX}
                y1={centerY}
                x2={pos.x}
                y2={pos.y}
                stroke="#E5E6EB"
                strokeWidth={1}
              />
            );
          })}
          
          {/* 数据多边形 */}
          {data.map((item, index) => (
            <Path
              key={index}
              d={generateDataPath(item.values, maxValue)}
              fill={item.color}
              fillOpacity={0.3}
              stroke={item.color}
              strokeWidth={2}
            />
          ))}
          
          {/* 数据点 */}
          {data.map((item, itemIndex) => (
            item.values.map((value, valueIndex) => {
              const pos = getVertexPosition(valueIndex, dimensions.length, radius * (value / maxValue));
              return (
                <Circle
                  key={`${itemIndex}-${valueIndex}`}
                  cx={pos.x}
                  cy={pos.y}
                  r={3}
                  fill={item.color}
                />
              );
            })
          ))}
          
          {/* 维度标签 */}
          {dimensions.map((label, index) => {
            const pos = getVertexPosition(index, dimensions.length, radius + 20);
            return (
              <SvgText
                key={index}
                x={pos.x}
                y={pos.y}
                fontSize={12}
                fill="#606266"
                textAnchor="middle"
              >
                {label}
              </SvgText>
            );
          })}
        </Svg>
      </View>
      
      {/* 图例 */}
      <View style={styles.legend}>
        {data.map((item, index) => (
          <View key={index} style={styles.legendItem}>
            <View style={[styles.legendColor, { backgroundColor: item.color }]} />
            <Text style={styles.legendLabel}>{item.label}</Text>
          </View>
        ))}
      </View>
    </View>
  );
};

技术深度解析

  1. 雷达图的几何结构

    typescript 复制代码
    const getVertexPosition = (index: number, total: number, r: number) => {
      const angle = (index / total) * 360 - 90;
      const x = centerX + r * Math.cos(angle * Math.PI / 180);
      const y = centerY + r * Math.sin(angle * Math.PI / 180);
      return { x, y };
    };
    • 等边多边形:顶点均匀分布在圆周上
    • 角度计算(index / total) * 360 - 90,确保第一个顶点在顶部
    • 可扩展性:支持任意数量的维度
  2. 背景网格的层次结构

    typescript 复制代码
    {[0.2, 0.4, 0.6, 0.8, 1].map((scale, index) => (
      <Path
        d={Array.from({ length: dimensions.length }).map((_, i) => {
          const pos = getVertexPosition(i, dimensions.length, radius * scale);
          return i === 0 ? `M ${pos.x} ${pos.y}` : `L ${pos.x} ${pos.y}`;
        }).join(' ') + ' Z'}
        fill="none"
        stroke="#E5E6EB"
        strokeWidth={1}
      />
    ))}
    • 多层级网格:使用不同比例的同心多边形
    • 视觉引导:帮助用户理解数据的相对大小
    • 可读性:从外到内,清晰展示数据层级
  3. 数据多边形的生成

    typescript 复制代码
    const generateDataPath = (values: number[], maxValue: number) => {
      const points = values.map((value, index) => {
        const position = getVertexPosition(index, values.length, radius * (value / maxValue));
        return `${position.x},${position.y}`;
      }).join(' ');
      
      return `M ${points.replace(/,/g, ' ')} Z`;
    };
    • 数据归一化:将数据值映射到半径范围
    • 路径构建:依次连接所有顶点,最后闭合
    • 透明度填充 :使用 fillOpacity 让重叠区域可见

三、实战完整版:综合数据可视化系统

typescript 复制代码
import React, { useState } from 'react';
import {
  StyleSheet,
  View,
  Text,
  SafeAreaView,
  ScrollView,
  TouchableOpacity,
  StatusBar,
} from 'react-native';
import Svg, { Path, Circle, Rect, Line, G, Defs, LinearGradient, Stop } from 'react-native-svg';

const DataVisualizationScreen = () => {
  const [chartType, setChartType] = useState<'line' | 'bar' | 'pie' | 'radar'>('line');
  const [selectedDataPoint, setSelectedDataPoint] = useState<number | null>(null);
  
  // 折线图数据
  const lineChartData = [
    { label: '1月', value: 65 },
    { label: '2月', value: 59 },
    { label: '3月', value: 80 },
    { label: '4月', value: 81 },
    { label: '5月', value: 56 },
    { label: '6月', value: 55 },
    { label: '7月', value: 40 },
  ];
  
  // 柱状图数据
  const barChartData = [
    { label: '周一', value: 120 },
    { label: '周二', value: 200 },
    { label: '周三', value: 150 },
    { label: '周四', value: 80 },
    { label: '周五', value: 70 },
    { label: '周六', value: 110 },
    { label: '周日', value: 130 },
  ];
  
  // 饼图数据
  const pieChartData = [
    { label: '完成', value: 40, color: '#4CAF50' },
    { label: '进行中', value: 30, color: '#2196F3' },
    { label: '未开始', value: 20, color: '#E5E6EB' },
    { label: '取消', value: 10, color: '#F44336' },
  ];
  
  // 雷达图数据
  const radarChartData = [
    { label: '能力A', values: [80, 90, 70, 85, 75], color: '#4CAF50' },
    { label: '能力B', values: [60, 75, 85, 70, 80], color: '#2196F3' },
  ];
  const radarDimensions = ['速度', '力量', '耐力', '技巧', '战术'];
  
  const renderLineChart = () => {
    const chartWidth = 320;
    const chartHeight = 200;
    const padding = { top: 20, right: 20, bottom: 40, left: 50 };
    
    const maxValue = Math.max(...lineChartData.map(d => d.value));
    const minValue = Math.min(...lineChartData.map(d => d.value));
    const yRange = maxValue - minValue || 1;
    
    const dataPoints = lineChartData.map((point, index) => {
      const x = padding.left + (index / (lineChartData.length - 1)) * (chartWidth - padding.left - padding.right);
      const y = padding.top + (1 - (point.value - minValue) / yRange) * (chartHeight - padding.top - padding.bottom);
      return { x, y, ...point };
    });
    
    const generateSmoothPath = (points: typeof dataPoints) => {
      if (points.length < 2) return '';
      let path = `M ${points[0].x} ${points[0].y}`;
      for (let i = 1; i < points.length - 1; i++) {
        const p0 = points[i - 1];
        const p1 = points[i];
        const p2 = points[i + 1];
        const cp1x = p0.x + (p1.x - p0.x) / 2;
        const cp1y = p0.y;
        const cp2x = p1.x - (p2.x - p1.x) / 2;
        const cp2y = p1.y;
        path += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p1.x} ${p1.y}`;
      }
      const lastPoint = points[points.length - 1];
      const secondLastPoint = points[points.length - 2];
      const cp1x = secondLastPoint.x + (lastPoint.x - secondLastPoint.x) / 2;
      const cp1y = secondLastPoint.y;
      path += ` C ${cp1x} ${cp1y}, ${lastPoint.x} ${lastPoint.y}, ${lastPoint.x} ${lastPoint.y}`;
      return path;
    };
    
    return (
      <Svg width={chartWidth} height={chartHeight}>
        <Defs>
          <LinearGradient id="lineGradient" x1="0%" y1="0%" x2="0%" y2="100%">
            <Stop offset="0%" stopColor="#409EFF" stopOpacity={0.3} />
            <Stop offset="100%" stopColor="#409EFF" stopOpacity={0.05} />
          </LinearGradient>
        </Defs>
        {Array.from({ length: 5 }).map((_, index) => {
          const y = padding.top + (index / 4) * (chartHeight - padding.top - padding.bottom);
          return (
            <Line
              key={index}
              x1={padding.left}
              y1={y}
              x2={chartWidth - padding.right}
              y2={y}
              stroke="#E5E6EB"
              strokeWidth={1}
              strokeDasharray={[5, 5]}
            />
          );
        })}
        <Path
          d={generateSmoothPath(dataPoints)}
          fill="url(#lineGradient)"
        />
        <Path
          d={generateSmoothPath(dataPoints)}
          fill="none"
          stroke="#409EFF"
          strokeWidth={2}
          strokeLinecap="round"
          strokeLinejoin="round"
        />
        {dataPoints.map((point, index) => (
          <Circle
            key={index}
            cx={point.x}
            cy={point.y}
            r={selectedDataPoint === index ? 6 : 4}
            fill="#FFFFFF"
            stroke={selectedDataPoint === index ? '#409EFF' : '#E5E6EB'}
            strokeWidth={2}
          />
        ))}
        {dataPoints.map((point, index) => (
          <Text
            key={`label-${index}`}
            x={point.x}
            y={chartHeight - padding.bottom + 20}
            fontSize={12}
            fill="#606266"
            textAnchor="middle"
          >
            {point.label}
          </Text>
        ))}
      </Svg>
    );
  };
  
  const renderBarChart = () => {
    const chartWidth = 320;
    const chartHeight = 200;
    const padding = { top: 20, right: 20, bottom: 40, left: 50 };
    
    const maxValue = Math.max(...barChartData.map(d => d.value));
    const barWidth = (chartWidth - padding.left - padding.right) / barChartData.length - 10;
    
    return (
      <Svg width={chartWidth} height={chartHeight}>
        {barChartData.map((item, index) => {
          const x = padding.left + index * ((chartWidth - padding.left - padding.right) / barChartData.length) + 5;
          const barHeight = (item.value / maxValue) * (chartHeight - padding.top - padding.bottom);
          const y = chartHeight - padding.bottom - barHeight;
          
          return (
            <G key={index}>
              <Rect
                x={x}
                y={y}
                width={barWidth}
                height={barHeight}
                fill={selectedDataPoint === index ? '#409EFF' : '#67C23A'}
                rx={4}
                ry={4}
              />
              <Text
                x={x + barWidth / 2}
                y={y - 5}
                fontSize={12}
                fill={selectedDataPoint === index ? '#409EFF' : '#606266'}
                fontWeight={selectedDataPoint === index ? '600' : '400'}
                textAnchor="middle"
              >
                {item.value}
              </Text>
              <Text
                x={x + barWidth / 2}
                y={chartHeight - padding.bottom + 20}
                fontSize={12}
                fill="#606266"
                textAnchor="middle"
              >
                {item.label}
              </Text>
            </G>
          );
        })}
      </Svg>
    );
  };
  
  const renderPieChart = () => {
    const chartSize = 200;
    const radius = 80;
    const centerX = chartSize / 2;
    const centerY = chartSize / 2;
    
    const total = pieChartData.reduce((sum, item) => sum + item.value, 0);
    
    const calculateSlicePath = (startAngle: number, endAngle: number, isSelected: boolean) => {
      const offset = isSelected ? 5 : 0;
      const r = radius + offset;
      
      const x1 = centerX + r * Math.cos((startAngle - 90) * Math.PI / 180);
      const y1 = centerY + r * Math.sin((startAngle - 90) * Math.PI / 180);
      const x2 = centerX + r * Math.cos((endAngle - 90) * Math.PI / 180);
      const y2 = centerY + r * Math.sin((endAngle - 90) * Math.PI / 180);
      
      const largeArc = endAngle - startAngle > 180 ? 1 : 0;
      
      return `M ${centerX} ${centerY} L ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} Z`;
    };
    
    let startAngle = 0;
    
    return (
      <Svg width={chartSize} height={chartSize}>
        {pieChartData.map((item, index) => {
          const angle = (item.value / total) * 360;
          const endAngle = startAngle + angle;
          const isSelected = selectedDataPoint === index;
          
          const path = calculateSlicePath(startAngle, endAngle, isSelected);
          
          startAngle = endAngle;
          
          return (
            <Path
              key={index}
              d={path}
              fill={item.color}
              onPress={() => setSelectedDataPoint(selectedDataPoint === index ? null : index)}
            />
          );
        })}
      </Svg>
    );
  };
  
  const renderRadarChart = () => {
    const chartSize = 200;
    const radius = 80;
    const centerX = chartSize / 2;
    const centerY = chartSize / 2;
    
    const getVertexPosition = (index: number, total: number, r: number) => {
      const angle = (index / total) * 360 - 90;
      const x = centerX + r * Math.cos(angle * Math.PI / 180);
      const y = centerY + r * Math.sin(angle * Math.PI / 180);
      return { x, y };
    };
    
    const generateDataPath = (values: number[], maxValue: number) => {
      const points = values.map((value, index) => {
        const position = getVertexPosition(index, values.length, radius * (value / maxValue));
        return `${position.x},${position.y}`;
      }).join(' ');
      
      return `M ${points.replace(/,/g, ' ')} Z`;
    };
    
    return (
      <Svg width={chartSize} height={chartSize}>
        {[0.2, 0.4, 0.6, 0.8, 1].map((scale, index) => (
          <Path
            key={index}
            d={Array.from({ length: radarDimensions.length }).map((_, i) => {
              const pos = getVertexPosition(i, radarDimensions.length, radius * scale);
              return i === 0 ? `M ${pos.x} ${pos.y}` : `L ${pos.x} ${pos.y}`;
            }).join(' ') + ' Z'}
            fill="none"
            stroke="#E5E6EB"
            strokeWidth={1}
          />
        ))}
        {radarChartData.map((item, index) => (
          <Path
            key={index}
            d={generateDataPath(item.values, 100)}
            fill={item.color}
            fillOpacity={0.3}
            stroke={item.color}
            strokeWidth={2}
          />
        ))}
        {radarChartData.map((item, itemIndex) => (
          item.values.map((value, valueIndex) => {
            const pos = getVertexPosition(valueIndex, radarDimensions.length, radius * (value / 100));
            return (
              <Circle
                key={`${itemIndex}-${valueIndex}`}
                cx={pos.x}
                cy={pos.y}
                r={3}
                fill={item.color}
              />
            );
          })
        ))}
      </Svg>
    );
  };
  
  return (
    <SafeAreaView style={styles.container}>
      <StatusBar barStyle="dark-content" />
      
      <View style={styles.header}>
        <Text style={styles.headerTitle}>📊 数据可视化</Text>
        <Text style={styles.headerSubtitle}>react-native-svg(CAPI)</Text>
      </View>
      
      <ScrollView style={styles.content}>
        {/* 图表类型选择 */}
        <View style={styles.card}>
          <Text style={styles.cardTitle}>图表类型</Text>
          
          <View style={styles.chartTypeButtons}>
            <TouchableOpacity
              style={[styles.chartTypeButton, chartType === 'line' && styles.chartTypeButtonActive]}
              onPress={() => setChartType('line')}
            >
              <Text style={styles.chartTypeButtonText}>折线图</Text>
            </TouchableOpacity>
            
            <TouchableOpacity
              style={[styles.chartTypeButton, chartType === 'bar' && styles.chartTypeButtonActive]}
              onPress={() => setChartType('bar')}
            >
              <Text style={styles.chartTypeButtonText}>柱状图</Text>
            </TouchableOpacity>
            
            <TouchableOpacity
              style={[styles.chartTypeButton, chartType === 'pie' && styles.chartTypeButtonActive]}
              onPress={() => setChartType('pie')}
            >
              <Text style={styles.chartTypeButtonText}>饼图</Text>
            </TouchableOpacity>
            
            <TouchableOpacity
              style={[styles.chartTypeButton, chartType === 'radar' && styles.chartTypeButtonActive]}
              onPress={() => setChartType('radar')}
            >
              <Text style={styles.chartTypeButtonText}>雷达图</Text>
            </TouchableOpacity>
          </View>
        </View>
        
        {/* 图表展示 */}
        <View style={styles.card}>
          <Text style={styles.cardTitle}>图表展示</Text>
          
          <View style={styles.chartDisplay}>
            {chartType === 'line' && renderLineChart()}
            {chartType === 'bar' && renderBarChart()}
            {chartType === 'pie' && renderPieChart()}
            {chartType === 'radar' && renderRadarChart()}
          </View>
        </View>
        
        {/* 使用说明 */}
        <View style={styles.card}>
          <Text style={styles.cardTitle}>💡 使用说明</Text>
          <Text style={styles.instructionText}>
            • 选择不同的图表类型查看对应的数据可视化效果
          </Text>
          <Text style={styles.instructionText}>
            • 折线图:展示数据的变化趋势
          </Text>
          <Text style={styles.instructionText}>
            • 柱状图:比较不同类别的数据大小
          </Text>
          <Text style={styles.instructionText}>
            • 饼图:展示各部分占总体的比例
          </Text>
          <Text style={styles.instructionText}>
            • 雷达图:比较多个维度的数据特征
          </Text>
          <Text style={[styles.instructionText, { color: '#F44336', fontWeight: '600' }]}>
            ⚠️ 注意: 鸿蒙端使用 CAPI 版本,性能更优
          </Text>
          <Text style={[styles.instructionText, { color: '#4CAF50', fontWeight: '600' }]}>
            💡 提示: 所有图表都支持交互点击
          </Text>
          <Text style={[styles.instructionText, { color: '#2196F3', fontWeight: '600' }]}>
            💡 提示: 坐标系统自动适配不同屏幕尺寸
          </Text>
        </View>
      </ScrollView>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F5F7FA',
  },
  header: {
    padding: 20,
    backgroundColor: '#FFFFFF',
    borderBottomWidth: 1,
    borderBottomColor: '#EBEEF5',
  },
  headerTitle: {
    fontSize: 24,
    fontWeight: '700',
    color: '#303133',
    marginBottom: 8,
  },
  headerSubtitle: {
    fontSize: 16,
    fontWeight: '500',
    color: '#909399',
  },
  content: {
    flex: 1,
    padding: 16,
  },
  card: {
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    marginBottom: 16,
    padding: 16,
    shadowColor: '#000000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.08,
    shadowRadius: 8,
    elevation: 4,
  },
  cardTitle: {
    fontSize: 18,
    fontWeight: '600',
    color: '#303133',
    marginBottom: 16,
  },
  chartTypeButtons: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 12,
  },
  chartTypeButton: {
    flex: 1,
    minWidth: 70,
    backgroundColor: '#E5E6EB',
    borderRadius: 8,
    padding: 12,
    alignItems: 'center',
  },
  chartTypeButtonActive: {
    backgroundColor: '#409EFF',
  },
  chartTypeButtonText: {
    fontSize: 14,
    color: '#303133',
    fontWeight: '600',
  },
  chartDisplay: {
    alignItems: 'center',
    padding: 20,
    backgroundColor: '#F5F7FA',
    borderRadius: 12,
  },
  instructionText: {
    fontSize: 14,
    lineHeight: 22,
    marginBottom: 8,
    color: '#606266',
  },
});

export default DataVisualizationScreen;

四、技术深度解析:数据可视化最佳实践

1. 颜色方案的选择
typescript 复制代码
// ✅ 好的做法:使用语义化的颜色方案
const colorSchemes = {
  primary: '#409EFF',
  success: '#4CAF50',
  warning: '#FF9800',
  danger: '#F44336',
  neutral: '#909399',
};

// ✅ 好的做法:使用对比度高的颜色组合
const chartColors = [
  '#4CAF50', // 绿色 - 成功
  '#2196F3', // 蓝色 - 信息
  '#FF9800', // 橙色 - 警告
  '#F44336', // 红色 - 危险
  '#9C27B0', // 紫色 - 特殊
];

// ❌ 不好的做法:使用颜色过于相似
const badColors = [
  '#4CAF50', // 绿色
  '#45A049', // 深绿色(太相似)
  '#3E8E41', // 更深绿色(太相似)
];
2. 响应式图表设计
typescript 复制代码
const ResponsiveChart = ({ data }: { data: DataPoint[] }) => {
  const [dimensions, setDimensions] = useState({ width: 320, height: 200 });
  
  const handleLayout = (event: LayoutChangeEvent) => {
    const { width } = event.nativeEvent.layout;
    setDimensions({
      width: Math.min(width, 600), // 限制最大宽度
      height: width * 0.625, // 保持 16:10 比例
    });
  };
  
  return (
    <View onLayout={handleLayout}>
      <Svg width={dimensions.width} height={dimensions.height}>
        {/* 图表内容 */}
      </Svg>
    </View>
  );
};
相关推荐
胖鱼罐头17 小时前
RNGH:指令式 vs JSX 形式深度对比
前端·react native
BD17 小时前
Umi 项目核心库升级踩坑(Umi 3→4、React 16→18、Antd 3→4、涉及 Qiankun、MicroApp 微前端)
前端·react.js
麟听科技18 小时前
HarmonyOS 6.0+ APP智能种植监测系统开发实战:农业传感器联动与AI种植指导落地
人工智能·分布式·学习·华为·harmonyos
前端不太难18 小时前
HarmonyOS PC 焦点系统重建
华为·状态模式·harmonyos
空白诗19 小时前
基础入门 Flutter for Harmony:Text 组件详解
javascript·flutter·harmonyos
Oscarzhang19 小时前
React 核心原理完全解析:从组件化、虚拟DOM到声明式编程
react.js
光影少年19 小时前
react中的filble架构和diffes算法如何实现的
前端·react.js·掘金·金石计划
lbb 小魔仙20 小时前
【HarmonyOS】React Native实战+Popover内容自适应
react native·华为·harmonyos
motosheep20 小时前
鸿蒙开发(四)播放 Lottie 动画实战(Canvas 渲染 + 资源加载踩坑总结)
华为·harmonyos
左手厨刀右手茼蒿21 小时前
Flutter for OpenHarmony 实战:Barcode — 纯 Dart 条形码与二维码生成全指南
android·flutter·ui·华为·harmonyos