Explorar o código

✨ feat(charts): 添加 Original2D 组件并修复 BaseChart 像素比问题

新增组件:
- BaseChartOriginal2D: 使用原始 u-charts.js + Canvas 2D API
- ColumnChartOriginal2D: Original2D 的柱状图包装组件
- 新增类型定义文件 u-charts-original.d.ts

修复问题:
- BaseChart: 修复传入 uCharts 的 width/height 需要乘以 pixelRatio
- 参考 Taro 2D 示例文档正确处理 Canvas 2D 像素比

测试支持:
- Statistics 页面现在支持三个版本的对比测试:
  1. Canvas 2D + 模块化 TS(当前版本)
  2. Canvas 2D + 原始 JS(Original2D,新增)
  3. 旧 Canvas API + 原始 JS(Legacy)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
yourname hai 3 semanas
pai
achega
676e8a3eb0

+ 10 - 0
mini-ui-packages/mini-charts/package.json

@@ -55,6 +55,16 @@
       "types": "./dist/src/components/ColumnChartLegacy.d.ts",
       "import": "./dist/src/components/ColumnChartLegacy.js",
       "require": "./dist/src/components/ColumnChartLegacy.js"
+    },
+    "./components/BaseChartOriginal2D": {
+      "types": "./dist/src/components/BaseChartOriginal2D.d.ts",
+      "import": "./dist/src/components/BaseChartOriginal2D.js",
+      "require": "./dist/src/components/BaseChartOriginal2D.js"
+    },
+    "./components/ColumnChartOriginal2D": {
+      "types": "./dist/src/components/ColumnChartOriginal2D.d.ts",
+      "import": "./dist/src/components/ColumnChartOriginal2D.js",
+      "require": "./dist/src/components/ColumnChartOriginal2D.js"
     }
   },
   "scripts": {

+ 4 - 5
mini-ui-packages/mini-charts/src/components/BaseChart.tsx

@@ -108,17 +108,16 @@ export const BaseChart: React.FC<BaseChartProps> = (props) => {
       // 将 Taro CanvasContext 转换为 uCharts 需要的 CanvasContext
       // 注意:不能使用展开运算符,因为这会复制方法但丢失 this 指向
       const ctx = rawCtx as ExtendedCanvasContext;
-      // 添加 uCharts 需要的 width 和 height 属性
-      ctx.width = cWidth;
-      ctx.height = cHeight;
 
+      // Canvas 2D: 传入 uCharts 的 width/height 需要乘以 pixelRatio
+      // 因为原始 u-charts.js 的绘制坐标基于这些值计算
       const chartConfig: ChartsConfig = {
         type,
         context: ctx,
         categories,
         series,
-        width: cWidth,
-        height: cHeight,
+        width: cWidth * actualPixelRatio,
+        height: cHeight * actualPixelRatio,
         pixelRatio: actualPixelRatio,
         ...config,
       };

+ 193 - 0
mini-ui-packages/mini-charts/src/components/BaseChartOriginal2D.tsx

@@ -0,0 +1,193 @@
+import React, { useEffect, useRef, useMemo } from 'react';
+import Taro from '@tarojs/taro';
+import { Canvas } from '@tarojs/components';
+import uChartsClass from '../lib/u-charts-original.js';
+import type { ChartsConfig, TouchEvent } from '../lib/u-charts-original';
+import type { ExtendedCanvasContext } from '../types';
+
+/**
+ * BaseChartOriginal2D 组件的 Props 接口
+ * 使用原始 u-charts.js + Canvas 2D API
+ */
+export interface BaseChartOriginal2DProps {
+  /** Canvas 元素的 ID,必须唯一 */
+  canvasId: string;
+  /** 图表宽度(像素),默认为屏幕宽度 */
+  width?: number;
+  /** 图表高度(像素),默认根据宽高比计算 */
+  height?: number;
+  /** 设备像素比,默认根据环境自动设置 */
+  pixelRatio?: number;
+  /** 图表类型 */
+  type: ChartsConfig['type'];
+  /** X 轴分类数据 */
+  categories?: string[];
+  /** 系列数据 */
+  series?: ChartsConfig['series'];
+  /** 额外的图表配置 */
+  config?: Partial<ChartsConfig>;
+  /** 触摸开始事件 */
+  onTouchStart?: (e: TouchEvent) => void;
+  /** 触摸移动事件 */
+  onTouchMove?: (e: TouchEvent) => void;
+  /** 触摸结束事件 */
+  onTouchEnd?: (e: TouchEvent) => void;
+}
+
+/**
+ * BaseChartOriginal2D 组件
+ *
+ * 使用原始 u-charts.js + Canvas 2D API
+ * 参考 docs/小程序图表库示例/taro-2d柱状图使用示例.md
+ */
+export const BaseChartOriginal2D: React.FC<BaseChartOriginal2DProps> = (props) => {
+  const {
+    canvasId,
+    width,
+    height,
+    pixelRatio,
+    type,
+    categories = [],
+    series = [],
+    config = {},
+    onTouchStart,
+    onTouchMove,
+    onTouchEnd,
+  } = props;
+
+  const chartRef = useRef<any>(null);
+
+  /**
+   * 计算响应式尺寸和像素比
+   */
+  const { cWidth, cHeight, actualPixelRatio } = useMemo(() => {
+    const sysInfo = Taro.getSystemInfoSync();
+    const pr = pixelRatio ?? sysInfo.pixelRatio;
+    // width 和 height 是逻辑像素(CSS 像素)
+    const cw = width ?? (750 / 750 * sysInfo.windowWidth);
+    const ch = height ?? (500 / 750 * cw);
+    return { cWidth: cw, cHeight: ch, actualPixelRatio: pr };
+  }, [width, height, pixelRatio]);
+
+  /**
+   * Canvas props - Canvas 2D API
+   * - width/height 属性:实际像素尺寸(逻辑像素 * pixelRatio)
+   * - style.width/style.height:CSS 显示尺寸(逻辑像素)
+   */
+  const canvasProps = useMemo(() => ({
+    width: String(cWidth * actualPixelRatio),
+    height: String(cHeight * actualPixelRatio),
+    style: { width: `${cWidth}px`, height: `${cHeight}px` }
+  }), [cWidth, cHeight, actualPixelRatio]);
+
+  /**
+   * 初始化图表实例
+   * 使用 Canvas 2D API + 原始 u-charts.js
+   */
+  useEffect(() => {
+    // 使用 setTimeout 确保 Canvas DOM 元素已渲染完成
+    const timer = setTimeout(() => {
+      // 使用 Canvas 2D API 的方式获取 context
+      const query = Taro.createSelectorQuery();
+      query.select('#' + canvasId).fields({ node: true, size: true }).exec((res) => {
+        if (res[0]) {
+          const canvas = res[0].node;
+          const ctx = canvas.getContext('2d');
+
+          // 设置 canvas 的实际像素尺寸
+          canvas.width = res[0].width * actualPixelRatio;
+          canvas.height = res[0].height * actualPixelRatio;
+
+          // 将 Taro CanvasContext 转换为 uCharts 需要的 CanvasContext
+          const extendedCtx = ctx as ExtendedCanvasContext;
+
+          // Canvas 2D: 传入 uCharts 的 width/height 需要乘以 pixelRatio
+          const chartConfig: ChartsConfig = {
+            type,
+            context: extendedCtx,
+            categories,
+            series,
+            width: cWidth * actualPixelRatio,
+            height: cHeight * actualPixelRatio,
+            pixelRatio: actualPixelRatio,
+            animation: true,
+            background: '#FFFFFF',
+            color: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444'],
+            padding: [15, 15, 0, 5],
+            enableScroll: false,
+            legend: {},
+            xAxis: {
+              disableGrid: true
+            },
+            yAxis: {
+              data: [{ min: 0 }]
+            },
+            extra: {
+              column: {
+                type: 'group',
+                width: 30,
+                activeBgColor: '#000000',
+                activeBgOpacity: 0.08
+              }
+            },
+            ...config,
+          };
+
+          chartRef.current = new uChartsClass(chartConfig);
+          console.log('[BaseChartOriginal2D] 图表初始化完成:', canvasId, {
+            cWidth, cHeight, actualPixelRatio,
+            canvasWidth: canvas.width,
+            canvasHeight: canvas.height
+          });
+        } else {
+          console.error('[BaseChartOriginal2D] 未获取到 canvas node:', canvasId);
+        }
+      });
+    }, 100);
+
+    return () => {
+      clearTimeout(timer);
+      chartRef.current = null;
+    };
+  }, [canvasId, type, categories, series, cWidth, cHeight, actualPixelRatio, config]);
+
+  /**
+   * 触摸事件处理
+   */
+  const handleTouchStart = (e: any) => {
+    if (chartRef.current) {
+      chartRef.current.touchLegend(e);
+      chartRef.current.showToolTip(e);
+    }
+    onTouchStart?.(e as TouchEvent);
+  };
+
+  const handleTouchMove = (e: any) => {
+    if (chartRef.current) {
+      chartRef.current.scroll(e);
+    }
+    onTouchMove?.(e as TouchEvent);
+  };
+
+  const handleTouchEnd = (e: any) => {
+    if (chartRef.current) {
+      chartRef.current.touchLegend(e);
+      chartRef.current.showToolTip(e);
+    }
+    onTouchEnd?.(e as TouchEvent);
+  };
+
+  return (
+    <Canvas
+      canvasId={canvasId}
+      id={canvasId}
+      {...canvasProps}
+      onTouchStart={handleTouchStart}
+      onTouchMove={handleTouchMove}
+      onTouchEnd={handleTouchEnd}
+      type="2d"
+    />
+  );
+};
+
+export default BaseChartOriginal2D;

+ 90 - 0
mini-ui-packages/mini-charts/src/components/ColumnChartOriginal2D.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+import { BaseChartOriginal2D } from './BaseChartOriginal2D';
+import type { ChartsConfig } from '../lib/u-charts-original';
+
+/**
+ * ColumnChartOriginal2D 组件的 Props 接口
+ * 使用原始 u-charts.js + Canvas 2D API
+ */
+export interface ColumnChartOriginal2DProps {
+  /** Canvas 元素的 ID,必须唯一 */
+  canvasId: string;
+  /** 图表宽度(像素) */
+  width?: number;
+  /** 图表高度(像素) */
+  height?: number;
+  /** 设备像素比 */
+  pixelRatio?: number;
+  /** X 轴分类数据 */
+  categories: string[];
+  /** 系列数据 */
+  series: ChartsConfig['series'];
+  /** 额外的图表配置 */
+  config?: Partial<ChartsConfig>;
+  /** Tooltip 格式化函数 */
+  tooltipFormatter?: (item: any, category: string) => string;
+}
+
+/**
+ * ColumnChartOriginal2D 柱状图组件
+ *
+ * 使用原始 u-charts.js + Canvas 2D API
+ * 用于测试对比 Legacy 版本和当前模块化版本
+ */
+export const ColumnChartOriginal2D: React.FC<ColumnChartOriginal2DProps> = (props) => {
+  const {
+    canvasId,
+    width,
+    height,
+    pixelRatio,
+    categories,
+    series,
+    config = {},
+    tooltipFormatter,
+  } = props;
+
+  /**
+   * 合并默认配置
+   */
+  const mergedConfig: Partial<ChartsConfig> = {
+    animation: true,
+    background: '#FFFFFF',
+    color: ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444'],
+    padding: [15, 15, 0, 5],
+    enableScroll: false,
+    legend: {},
+    xAxis: {
+      disableGrid: true,
+      ...config.xAxis,
+    },
+    yAxis: {
+      data: [{ min: 0 }],
+      ...config.yAxis,
+    },
+    extra: {
+      column: {
+        type: 'group',
+        width: 30,
+        activeBgColor: '#000000',
+        activeBgOpacity: 0.08,
+        ...config.extra?.column,
+      }
+    },
+    ...config,
+  };
+
+  return (
+    <BaseChartOriginal2D
+      canvasId={canvasId}
+      width={width}
+      height={height}
+      pixelRatio={pixelRatio}
+      type="column"
+      categories={categories}
+      series={series}
+      config={mergedConfig}
+    />
+  );
+};
+
+export default ColumnChartOriginal2D;

+ 7 - 0
mini-ui-packages/mini-charts/src/lib/u-charts-original.ts

@@ -0,0 +1,7 @@
+/**
+ * u-charts-original.js 类型导出
+ *
+ * 重新导出 u-charts-original.d.ts 中定义的类型
+ */
+
+export type { ChartsConfig, TouchEvent } from '../types/u-charts-original';

+ 69 - 0
mini-ui-packages/mini-charts/src/types/u-charts-original.d.ts

@@ -0,0 +1,69 @@
+/**
+ * u-charts-original.js 类型定义
+ *
+ * 从原始 u-charts.js 文件中提取的核心类型定义
+ * 用于 BaseChartOriginal2D 和 ColumnChartOriginal2D 组件
+ */
+
+/**
+ * 图表配置接口 - 与原始 u-charts.js 兼容
+ */
+export interface ChartsConfig {
+  /** 图表类型 */
+  type: 'pie' | 'ring' | 'line' | 'column' | 'bar' | 'area' | 'radar' | 'candle';
+  /** Canvas 上下文 */
+  context: any;
+  /** 图表宽度(像素) */
+  width: number;
+  /** 图表高度(像素) */
+  height: number;
+  /** 设备像素比 */
+  pixelRatio?: number;
+  /** X 轴分类数据 */
+  categories?: string[];
+  /** 系列数据 */
+  series?: any[];
+  /** 图表动画 */
+  animation?: boolean;
+  /** 背景颜色 */
+  background?: string;
+  /** 颜色数组 */
+  color?: string[];
+  /** 内边距 */
+  padding?: number[];
+  /** 是否启用滚动 */
+  enableScroll?: boolean;
+  /** 图例配置 */
+  legend?: any;
+  /** X 轴配置 */
+  xAxis?: any;
+  /** Y 轴配置 */
+  yAxis?: any;
+  /** 额外配置 */
+  extra?: any;
+  /** 数据更新回调 */
+  update?: boolean;
+}
+
+/**
+ * 触摸事件接口
+ */
+export interface TouchEvent {
+  touchEvent: {
+    x: number;
+    y: number;
+  };
+  [key: string]: any;
+}
+
+declare module '../lib/u-charts-original.js' {
+  import { ChartsConfig } from '../types/u-charts-original';
+
+  const uCharts: new (config: ChartsConfig) => {
+    touchLegend(e: any): void;
+    showToolTip(e: any): void;
+    scroll(e: any): void;
+  };
+
+  export default uCharts;
+}

+ 75 - 4
mini-ui-packages/yongren-statistics-ui/src/pages/Statistics/Statistics.tsx

@@ -3,7 +3,8 @@ import { View, Text, ScrollView, Picker } from '@tarojs/components'
 import { useQuery } from '@tanstack/react-query'
 import { YongrenTabBarLayout } from '@d8d/yongren-shared-ui/components/YongrenTabBarLayout'
 import { Navbar } from '@d8d/mini-shared-ui-components/components/navbar'
-// 使用原始 u-charts.js 的 Legacy 组件进行测试
+import { ColumnChart } from '@d8d/mini-charts/components/ColumnChart'
+import { ColumnChartOriginal2D } from '@d8d/mini-charts/components/ColumnChartOriginal2D'
 import { ColumnChartLegacy } from '@d8d/mini-charts/components/ColumnChartLegacy'
 import { PieChart } from '@d8d/mini-charts/components/PieChart'
 import { BarChart } from '@d8d/mini-charts/components/BarChart'
@@ -251,9 +252,9 @@ const Statistics: React.FC<StatisticsProps> = () => {
           </View>
         </View>
 
-        {/* 残疾类型分布 */}
+        {/* 残疾类型分布 - 当前版本(Canvas 2D + 模块化TS) */}
         <View className="card bg-white p-4 mb-4 rounded-lg shadow-sm flex flex-col">
-          <Text className="font-semibold text-gray-700">残疾类型分布 (Legacy测试)</Text>
+          <Text className="font-semibold text-gray-700">残疾类型分布 (Canvas 2D + 模块化TS)</Text>
           {isLoadingDisability ? (
             <Text className="text-gray-500 text-center py-4">加载中...</Text>
           ) : (
@@ -263,7 +264,7 @@ const Statistics: React.FC<StatisticsProps> = () => {
                 const chartData = convertToColumnData(stats)
                 return (
                   <View className="mt-3">
-                    <ColumnChartLegacy
+                    <ColumnChart
                       canvasId="disability-type-chart"
                       width={650}
                       height={200}
@@ -290,6 +291,76 @@ const Statistics: React.FC<StatisticsProps> = () => {
           )}
         </View>
 
+        {/* 残疾类型分布 - Original2D版本(Canvas 2D + 原始JS) */}
+        {false && (<View className="card bg-white p-4 mb-4 rounded-lg shadow-sm flex flex-col">
+          <Text className="font-semibold text-gray-700">残疾类型分布 (Canvas 2D + 原始JS)</Text>
+          {isLoadingDisability ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : (
+            (() => {
+              const stats = getStats(disabilityData)
+              if (stats.length > 0) {
+                const chartData = convertToColumnData(stats)
+                return (
+                  <View className="mt-3">
+                    <ColumnChartOriginal2D
+                      canvasId="disability-type-chart-orig2d"
+                      width={650}
+                      height={200}
+                      categories={chartData.categories}
+                      series={chartData.series}
+                      config={{
+                        color: ['#3b82f6'],
+                        fontSize: 10,
+                        dataLabel: true,
+                        xAxis: { disableGrid: true },
+                        yAxis: {}
+                      }}
+                    />
+                  </View>
+                )
+              } else {
+                return <Text className="text-gray-500 text-center py-4 mt-3">暂无数据</Text>
+              }
+            })()
+          )}
+        </View>)}
+
+        {/* 残疾类型分布 - Legacy版本(旧Canvas API + 原始JS) */}
+        {false && (<View className="card bg-white p-4 mb-4 rounded-lg shadow-sm flex flex-col">
+          <Text className="font-semibold text-gray-700">残疾类型分布 (旧Canvas API + 原始JS)</Text>
+          {isLoadingDisability ? (
+            <Text className="text-gray-500 text-center py-4">加载中...</Text>
+          ) : (
+            (() => {
+              const stats = getStats(disabilityData)
+              if (stats.length > 0) {
+                const chartData = convertToColumnData(stats)
+                return (
+                  <View className="mt-3">
+                    <ColumnChartLegacy
+                      canvasId="disability-type-chart-legacy"
+                      width={650}
+                      height={200}
+                      categories={chartData.categories}
+                      series={chartData.series}
+                      config={{
+                        color: ['#3b82f6'],
+                        fontSize: 10,
+                        dataLabel: true,
+                        xAxis: { disableGrid: true },
+                        yAxis: {}
+                      }}
+                    />
+                  </View>
+                )
+              } else {
+                return <Text className="text-gray-500 text-center py-4 mt-3">暂无数据</Text>
+              }
+            })()
+          )}
+        </View>)}
+
         {/* 性别分布 */}
         {false && (<View className="card bg-white p-4 mb-4 rounded-lg shadow-sm flex flex-col">
           <Text className="font-semibold text-gray-700">性别分布</Text>