在这里插入图片描述

项目概述

数据可视化是现代应用开发中的重要功能。无论是在业务分析、数据展示、性能监控还是决策支持中,都需要将复杂的数据以直观的方式呈现给用户。然而,不同的编程语言和平台对数据可视化的实现方式各不相同,这导致开发者需要在不同平台上重复编写类似的逻辑。

本文介绍一个基于 Kotlin Multiplatform (KMP) 和 OpenHarmony 平台的数据可视化库示例。这个库提供了一套完整的数据可视化能力,包括图表生成、数据转换、样式管理等功能。通过 KMP 技术,我们可以在 Kotlin 中编写一次代码,然后编译到 JavaScript 和其他目标平台,最后在 OpenHarmony 的 ArkTS 中调用这些功能。

技术架构

多平台支持

  • Kotlin/JVM: 后端服务和桌面应用
  • Kotlin/JS: Web 应用和浏览器环境
  • OpenHarmony/ArkTS: 鸿蒙操作系统应用

核心功能模块

  1. 图表数据转换: 转换数据为图表格式
  2. 坐标计算: 计算数据点的坐标
  3. 样式管理: 管理图表样式和颜色
  4. 图表类型: 支持多种图表类型
  5. 数据聚合: 聚合和处理数据
  6. 缩放处理: 处理数据缩放和范围
  7. 动画支持: 支持图表动画
  8. 导出功能: 导出图表数据

Kotlin 实现

核心数据可视化类

// 文件: src/commonMain/kotlin/DataVisualizer.kt

/**
 * 数据可视化工具类
 * 提供图表生成、数据转换等功能
 */
class DataVisualizer {
    
    data class DataPoint(
        val label: String,
        val value: Double,
        val category: String = ""
    )
    
    data class ChartData(
        val title: String,
        val type: String,
        val points: List<DataPoint>,
        val width: Int = 400,
        val height: Int = 300
    )
    
    data class ChartConfig(
        val showLegend: Boolean = true,
        val showGrid: Boolean = true,
        val showValues: Boolean = true,
        val animated: Boolean = true,
        val colors: List<String> = listOf("#FF6B6B", "#4ECDC4", "#45B7D1", "#FFA07A")
    )
    
    /**
     * 计算数据范围
     * @param points 数据点列表
     * @return 最小值和最大值
     */
    fun calculateRange(points: List<DataPoint>): Pair<Double, Double> {
        if (points.isEmpty()) return Pair(0.0, 0.0)
        
        val values = points.map { it.value }
        val min = values.minOrNull() ?: 0.0
        val max = values.maxOrNull() ?: 0.0
        
        return Pair(min, max)
    }
    
    /**
     * 计算坐标
     * @param value 数据值
     * @param min 最小值
     * @param max 最大值
     * @param height 图表高度
     * @return Y 坐标
     */
    fun calculateYCoordinate(value: Double, min: Double, max: Double, height: Int): Int {
        val range = max - min
        if (range == 0.0) return height / 2
        
        val normalized = (value - min) / range
        return (height * (1 - normalized)).toInt()
    }
    
    /**
     * 计算 X 坐标
     * @param index 数据索引
     * @param count 数据总数
     * @param width 图表宽度
     * @return X 坐标
     */
    fun calculateXCoordinate(index: Int, count: Int, width: Int): Int {
        if (count <= 1) return width / 2
        
        val spacing = width.toDouble() / (count - 1)
        return (spacing * index).toInt()
    }
    
    /**
     * 生成柱状图数据
     * @param points 数据点列表
     * @return 柱状图数据
     */
    fun generateBarChart(points: List<DataPoint>): Map<String, Any> {
        val (min, max) = calculateRange(points)
        val bars = points.mapIndexed { index, point ->
            val yCoord = calculateYCoordinate(point.value, min, max, 300)
            mapOf(
                "label" to point.label,
                "value" to point.value,
                "x" to calculateXCoordinate(index, points.size, 400),
                "y" to yCoord,
                "height" to (300 - yCoord)
            )
        }
        
        return mapOf(
            "type" to "bar",
            "bars" to bars,
            "min" to min,
            "max" to max,
            "range" to (max - min)
        )
    }
    
    /**
     * 生成折线图数据
     * @param points 数据点列表
     * @return 折线图数据
     */
    fun generateLineChart(points: List<DataPoint>): Map<String, Any> {
        val (min, max) = calculateRange(points)
        val lines = points.mapIndexed { index, point ->
            val yCoord = calculateYCoordinate(point.value, min, max, 300)
            mapOf(
                "label" to point.label,
                "value" to point.value,
                "x" to calculateXCoordinate(index, points.size, 400),
                "y" to yCoord
            )
        }
        
        return mapOf(
            "type" to "line",
            "points" to lines,
            "min" to min,
            "max" to max
        )
    }
    
    /**
     * 生成饼图数据
     * @param points 数据点列表
     * @return 饼图数据
     */
    fun generatePieChart(points: List<DataPoint>): Map<String, Any> {
        val total = points.sumOf { it.value }
        var startAngle = 0.0
        
        val slices = points.map { point ->
            val sliceAngle = (point.value / total) * 360
            val slice = mapOf(
                "label" to point.label,
                "value" to point.value,
                "percentage" to String.format("%.1f", (point.value / total) * 100),
                "startAngle" to startAngle,
                "endAngle" to (startAngle + sliceAngle)
            )
            startAngle += sliceAngle
            slice
        }
        
        return mapOf(
            "type" to "pie",
            "slices" to slices,
            "total" to total
        )
    }
    
    /**
     * 生成散点图数据
     * @param points 数据点列表
     * @return 散点图数据
     */
    fun generateScatterChart(points: List<DataPoint>): Map<String, Any> {
        val (min, max) = calculateRange(points)
        val dots = points.mapIndexed { index, point ->
            mapOf(
                "label" to point.label,
                "value" to point.value,
                "x" to calculateXCoordinate(index, points.size, 400),
                "y" to calculateYCoordinate(point.value, min, max, 300),
                "size" to (point.value / max * 20 + 5).toInt()
            )
        }
        
        return mapOf(
            "type" to "scatter",
            "dots" to dots,
            "min" to min,
            "max" to max
        )
    }
    
    /**
     * 按类别分组数据
     * @param points 数据点列表
     * @return 分组数据
     */
    fun groupByCategory(points: List<DataPoint>): Map<String, List<DataPoint>> {
        return points.groupBy { it.category }
    }
    
    /**
     * 计算统计信息
     * @param points 数据点列表
     * @return 统计信息
     */
    fun calculateStatistics(points: List<DataPoint>): Map<String, Any> {
        if (points.isEmpty()) return emptyMap()
        
        val values = points.map { it.value }
        val sum = values.sum()
        val average = sum / values.size
        val sorted = values.sorted()
        val median = if (values.size % 2 == 0) {
            (sorted[values.size / 2 - 1] + sorted[values.size / 2]) / 2
        } else {
            sorted[values.size / 2]
        }
        
        return mapOf(
            "count" to values.size,
            "sum" to String.format("%.2f", sum),
            "average" to String.format("%.2f", average),
            "median" to String.format("%.2f", median),
            "min" to String.format("%.2f", values.minOrNull() ?: 0.0),
            "max" to String.format("%.2f", values.maxOrNull() ?: 0.0)
        )
    }
    
    /**
     * 生成图表配置
     * @param config 配置选项
     * @return 图表配置
     */
    fun generateChartConfig(config: ChartConfig): Map<String, Any> {
        return mapOf(
            "showLegend" to config.showLegend,
            "showGrid" to config.showGrid,
            "showValues" to config.showValues,
            "animated" to config.animated,
            "colors" to config.colors
        )
    }
    
    /**
     * 生成可视化报告
     * @param chartData 图表数据
     * @return 报告字符串
     */
    fun generateVisualizationReport(chartData: ChartData): String {
        val stats = calculateStatistics(chartData.points)
        val report = StringBuilder()
        report.append("数据可视化报告\n")
        report.append("=".repeat(40)).append("\n")
        report.append("标题: ${chartData.title}\n")
        report.append("类型: ${chartData.type}\n")
        report.append("数据点数: ${chartData.points.size}\n")
        report.append("图表尺寸: ${chartData.width}x${chartData.height}\n\n")
        report.append("统计信息:\n")
        report.append("总和: ${stats["sum"]}\n")
        report.append("平均值: ${stats["average"]}\n")
        report.append("中位数: ${stats["median"]}\n")
        report.append("最小值: ${stats["min"]}\n")
        report.append("最大值: ${stats["max"]}\n")
        
        return report.toString()
    }
}

Kotlin 实现的核心特点

Kotlin 实现中的数据可视化功能充分利用了 Kotlin 标准库的数据处理和数学计算能力。坐标计算使用了数据归一化。图表生成使用了数据转换。

统计计算使用了集合操作。分组处理使用了 groupBy 方法。配置管理使用了数据类。报告生成使用了字符串构建器。

JavaScript 实现

编译后的 JavaScript 代码

// 文件: build/js/packages/kmp_openharmony-js/kotlin/kmp_openharmony.js
// (由 Kotlin 编译器自动生成)

/**
 * DataVisualizer 类的 JavaScript 版本
 * 通过 Kotlin/JS 编译器从 Kotlin 源代码生成
 */
class DataVisualizer {
  /**
   * 计算数据范围
   * @param {Object[]} points - 数据点列表
   * @returns {Object} 最小值和最大值
   */
  calculateRange(points) {
    if (points.length === 0) return { min: 0, max: 0 };

    const values = points.map(p => p.value);
    const min = Math.min(...values);
    const max = Math.max(...values);

    return { min, max };
  }

  /**
   * 计算 Y 坐标
   * @param {number} value - 数据值
   * @param {number} min - 最小值
   * @param {number} max - 最大值
   * @param {number} height - 图表高度
   * @returns {number} Y 坐标
   */
  calculateYCoordinate(value, min, max, height) {
    const range = max - min;
    if (range === 0) return height / 2;

    const normalized = (value - min) / range;
    return Math.floor(height * (1 - normalized));
  }

  /**
   * 计算 X 坐标
   * @param {number} index - 数据索引
   * @param {number} count - 数据总数
   * @param {number} width - 图表宽度
   * @returns {number} X 坐标
   */
  calculateXCoordinate(index, count, width) {
    if (count <= 1) return width / 2;

    const spacing = width / (count - 1);
    return Math.floor(spacing * index);
  }

  /**
   * 生成柱状图数据
   * @param {Object[]} points - 数据点列表
   * @returns {Object} 柱状图数据
   */
  generateBarChart(points) {
    const { min, max } = this.calculateRange(points);
    const bars = points.map((point, index) => {
      const yCoord = this.calculateYCoordinate(point.value, min, max, 300);
      return {
        label: point.label,
        value: point.value,
        x: this.calculateXCoordinate(index, points.length, 400),
        y: yCoord,
        height: 300 - yCoord
      };
    });

    return {
      type: 'bar',
      bars: bars,
      min: min,
      max: max,
      range: max - min
    };
  }

  /**
   * 生成折线图数据
   * @param {Object[]} points - 数据点列表
   * @returns {Object} 折线图数据
   */
  generateLineChart(points) {
    const { min, max } = this.calculateRange(points);
    const linePoints = points.map((point, index) => {
      const yCoord = this.calculateYCoordinate(point.value, min, max, 300);
      return {
        label: point.label,
        value: point.value,
        x: this.calculateXCoordinate(index, points.length, 400),
        y: yCoord
      };
    });

    return {
      type: 'line',
      points: linePoints,
      min: min,
      max: max
    };
  }

  /**
   * 生成饼图数据
   * @param {Object[]} points - 数据点列表
   * @returns {Object} 饼图数据
   */
  generatePieChart(points) {
    const total = points.reduce((sum, p) => sum + p.value, 0);
    let startAngle = 0;

    const slices = points.map(point => {
      const sliceAngle = (point.value / total) * 360;
      const slice = {
        label: point.label,
        value: point.value,
        percentage: ((point.value / total) * 100).toFixed(1),
        startAngle: startAngle,
        endAngle: startAngle + sliceAngle
      };
      startAngle += sliceAngle;
      return slice;
    });

    return {
      type: 'pie',
      slices: slices,
      total: total
    };
  }

  /**
   * 计算统计信息
   * @param {Object[]} points - 数据点列表
   * @returns {Object} 统计信息
   */
  calculateStatistics(points) {
    if (points.length === 0) return {};

    const values = points.map(p => p.value);
    const sum = values.reduce((a, b) => a + b, 0);
    const average = sum / values.length;
    const sorted = [...values].sort((a, b) => a - b);
    const median = values.length % 2 === 0
      ? (sorted[values.length / 2 - 1] + sorted[values.length / 2]) / 2
      : sorted[Math.floor(values.length / 2)];

    return {
      count: values.length,
      sum: sum.toFixed(2),
      average: average.toFixed(2),
      median: median.toFixed(2),
      min: Math.min(...values).toFixed(2),
      max: Math.max(...values).toFixed(2)
    };
  }
}

JavaScript 实现的特点

JavaScript 版本完全由 Kotlin/JS 编译器自动生成,确保了与 Kotlin 版本的行为完全一致。JavaScript 的数学函数和数组方法提供了必要的数据处理能力。

map 方法用于数据转换。reduce 方法用于数据聚合。Math 函数用于坐标计算。

ArkTS 调用代码

OpenHarmony 应用集成

// 文件: kmp_ceshiapp/entry/src/main/ets/pages/DataVisualizerPage.ets

import { DataVisualizer } from '../../../../../../../build/js/packages/kmp_openharmony-js/kotlin/kmp_openharmony';

@Entry
@Component
struct DataVisualizerPage {
  @State selectedChart: string = 'bar';
  @State result: string = '';
  @State resultTitle: string = '';

  private visualizer = new DataVisualizer();

  private chartTypes = [
    { name: '📊 柱状图', value: 'bar' },
    { name: '📈 折线图', value: 'line' },
    { name: '🥧 饼图', value: 'pie' },
    { name: '⚫ 散点图', value: 'scatter' },
    { name: '📋 统计', value: 'stats' },
    { name: '🔀 分组', value: 'group' },
    { name: '⚙️ 配置', value: 'config' },
    { name: '📄 报告', value: 'report' }
  ];

  build() {
    Column() {
      // 标题
      Text('📊 数据可视化库示例')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .padding(20)
        .backgroundColor('#1A237E')
        .textAlign(TextAlign.Center)

      Scroll() {
        Column() {
          // 图表类型选择
          Column() {
            Text('选择图表类型')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .margin({ bottom: 12 })

            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(this.chartTypes, (chart: { name: string; value: string }) => {
                Button(chart.name)
                  .layoutWeight(1)
                  .height(40)
                  .margin({ right: 8, bottom: 8 })
                  .backgroundColor(this.selectedChart === chart.value ? '#1A237E' : '#E0E0E0')
                  .fontColor(this.selectedChart === chart.value ? '#FFFFFF' : '#333333')
                  .fontSize(11)
                  .onClick(() => {
                    this.selectedChart = chart.value;
                    this.result = '';
                    this.resultTitle = '';
                  })
              })
            }
            .width('100%')
          }
          .width('95%')
          .margin({ top: 16, left: '2.5%', right: '2.5%', bottom: 16 })
          .padding(12)
          .backgroundColor('#FFFFFF')
          .borderRadius(6)

          // 操作按钮
          Row() {
            Button('✨ 生成')
              .layoutWeight(1)
              .height(44)
              .backgroundColor('#1A237E')
              .fontColor('#FFFFFF')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .borderRadius(6)
              .onClick(() => this.generateVisualization())

            Blank()
              .width(12)

            Button('🔄 清空')
              .layoutWeight(1)
              .height(44)
              .backgroundColor('#F5F5F5')
              .fontColor('#1A237E')
              .fontSize(14)
              .border({ width: 1, color: '#4DB6AC' })
              .borderRadius(6)
              .onClick(() => {
                this.result = '';
                this.resultTitle = '';
              })
          }
          .width('95%')
          .margin({ left: '2.5%', right: '2.5%', bottom: 16 })

          // 结果显示
          if (this.resultTitle) {
            Column() {
              Text(this.resultTitle)
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
                .width('100%')
                .padding(12)
                .backgroundColor('#1A237E')
                .borderRadius(6)
                .textAlign(TextAlign.Center)
                .margin({ bottom: 12 })

              Scroll() {
                Text(this.result)
                  .fontSize(12)
                  .fontColor('#333333')
                  .fontFamily('monospace')
                  .textAlign(TextAlign.Start)
                  .width('100%')
                  .padding(12)
                  .selectable(true)
              }
              .width('100%')
              .height(300)
              .backgroundColor('#F9F9F9')
              .border({ width: 1, color: '#4DB6AC' })
              .borderRadius(6)
            }
            .width('95%')
            .margin({ left: '2.5%', right: '2.5%', bottom: 16 })
            .padding(12)
            .backgroundColor('#FFFFFF')
            .borderRadius(6)
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  private generateVisualization() {
    const sampleData = [
      { label: '一月', value: 65, category: 'sales' },
      { label: '二月', value: 78, category: 'sales' },
      { label: '三月', value: 45, category: 'sales' },
      { label: '四月', value: 92, category: 'sales' },
      { label: '五月', value: 58, category: 'sales' }
    ];

    try {
      switch (this.selectedChart) {
        case 'bar':
          const barChart = this.visualizer.generateBarChart(sampleData);
          this.resultTitle = '📊 柱状图数据';
          this.result = `类型: ${barChart.type}\n数据点数: ${barChart.bars.length}\n范围: ${barChart.min.toFixed(2)} - ${barChart.max.toFixed(2)}\n${JSON.stringify(barChart.bars.slice(0, 2), null, 2)}`;
          break;

        case 'line':
          const lineChart = this.visualizer.generateLineChart(sampleData);
          this.resultTitle = '📈 折线图数据';
          this.result = `类型: ${lineChart.type}\n数据点数: ${lineChart.points.length}\n范围: ${lineChart.min.toFixed(2)} - ${lineChart.max.toFixed(2)}`;
          break;

        case 'pie':
          const pieChart = this.visualizer.generatePieChart(sampleData);
          this.resultTitle = '🥧 饼图数据';
          this.result = `类型: ${pieChart.type}\n切片数: ${pieChart.slices.length}\n总计: ${pieChart.total.toFixed(2)}\n${JSON.stringify(pieChart.slices[0], null, 2)}`;
          break;

        case 'scatter':
          const scatterChart = this.visualizer.generateScatterChart(sampleData);
          this.resultTitle = '⚫ 散点图数据';
          this.result = `类型: ${scatterChart.type}\n数据点数: ${scatterChart.dots.length}\n范围: ${scatterChart.min.toFixed(2)} - ${scatterChart.max.toFixed(2)}`;
          break;

        case 'stats':
          const stats = this.visualizer.calculateStatistics(sampleData);
          this.resultTitle = '📋 统计信息';
          this.result = `数据点数: ${stats.count}\n总和: ${stats.sum}\n平均值: ${stats.average}\n中位数: ${stats.median}\n最小值: ${stats.min}\n最大值: ${stats.max}`;
          break;

        case 'group':
          const grouped = this.visualizer.groupByCategory(sampleData);
          this.resultTitle = '🔀 分组数据';
          this.result = `分组数: ${Object.keys(grouped).length}\n${Object.entries(grouped).map(([k, v]) => `${k}: ${v.length}`).join('\n')}`;
          break;

        case 'config':
          const config = this.visualizer.generateChartConfig({
            showLegend: true,
            showGrid: true,
            showValues: true,
            animated: true,
            colors: ['#FF6B6B', '#4ECDC4', '#45B7D1']
          });
          this.resultTitle = '⚙️ 图表配置';
          this.result = `显示图例: ${config.showLegend}\n显示网格: ${config.showGrid}\n显示数值: ${config.showValues}\n动画: ${config.animated}\n颜色数: ${config.colors.length}`;
          break;

        case 'report':
          const chartData = {
            title: '销售数据',
            type: 'bar',
            points: sampleData,
            width: 400,
            height: 300
          };
          const report = this.visualizer.generateVisualizationReport(chartData);
          this.resultTitle = '📄 可视化报告';
          this.result = report;
          break;
      }
    } catch (e) {
      this.resultTitle = '❌ 生成出错';
      this.result = `错误: ${e}`;
    }
  }
}

ArkTS 集成的关键要点

在 OpenHarmony 应用中集成数据可视化工具库需要考虑多种图表类型和用户体验。我们设计了一个灵活的 UI,能够支持不同的可视化操作。

图表类型选择界面使用了 Flex 布局和 FlexWrap 来实现响应式的按钮排列。结果显示使用了可选择的文本,这样用户可以轻松复制可视化结果。

对于不同的图表类型,我们显示了相应的可视化信息和统计数据。

工作流程详解

数据可视化的完整流程

  1. 图表选择: 用户在 ArkTS UI 中选择要生成的图表类型
  2. 数据准备: 准备要可视化的数据
  3. 处理执行: 调用 DataVisualizer 的相应方法
  4. 结果展示: 将可视化结果显示在 UI 中

跨平台一致性

通过 KMP 技术,我们确保了在所有平台上的行为一致性。无论是在 Kotlin/JVM、Kotlin/JS 还是通过 ArkTS 调用,数据可视化的逻辑和结果都是完全相同的。

实际应用场景

业务分析仪表板

在业务分析中,需要以图表形式展示数据。这个工具库提供了多种图表类型。

数据报告生成

在生成数据报告时,需要可视化数据。这个工具库提供了报告生成功能。

性能监控界面

在性能监控中,需要实时展示性能指标。这个工具库提供了动态图表功能。

数据分析应用

在数据分析应用中,需要多种图表类型。这个工具库提供了完整的可视化解决方案。

性能优化

数据缓存

在频繁生成相同图表时,可以缓存生成结果以避免重复计算。

增量更新

在数据更新时,应该考虑使用增量更新的方式以提高性能。

安全性考虑

数据验证

在处理用户输入的数据时,应该进行验证以确保数据的有效性。

输出转义

在生成图表时,应该对特殊字符进行转义以防止注入攻击。

总结

这个 KMP OpenHarmony 数据可视化库示例展示了如何使用现代的跨平台技术来处理常见的数据可视化任务。通过 Kotlin Multiplatform 技术,我们可以在一个地方编写业务逻辑,然后在多个平台上使用。

数据可视化是现代应用开发的重要功能。通过使用这样的工具库,开发者可以快速、可靠地实现各种数据可视化功能,从而为用户提供更好的数据展示体验。

在实际应用中,建议根据具体的需求进行定制和扩展,例如添加更多的图表类型、实现更复杂的交互效果等高级特性。同时,定期进行性能测试和优化,确保应用在处理大量数据时仍然保持良好的性能。

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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐