ECharts 没实现,使用 SVG 实现的仪表盘效果

1,463 阅读9分钟

背景

之前接到一个设计稿,实现以下的效果:

image.png

我立马就先入为主了,使用 ECharts 来实现,因为 ECharts 的仪表盘效果和这个很类似,配置一下 API 应该很容易实现,如下所示的 ECharts 仪表盘效果:

image.png

于是乎,我就开始翻 ECharts 的配置项手册,示例 Demo,但最终实现并没有想象的那么轻松,实现效果差强人意,最后也没有能够实现,最终放弃了 ECharts,我开始思考如何使用 SVG 来实现。

SVG(Scalable Vector Graphics)是一种用于在网页上绘制图形的 XML 格式。它允许开发者创建复杂的图形和动画,而不需要依赖外部图像文件。因此,使用 SVG 实现这种效果具备可行性。

实现

SVG 实现基础效果

首先,我需要思考如何使用 SVG 来实现这个效果,我思考了一下,使用 SVG 来实现这个效果,设计稿有几个设计点需要注意:

  1. 有三个圆环
  2. 圆环内部有数值标识,最大值、最小值、当前值
  3. 圆环有颜色变化
  4. 当前值显示

所以我准备借助 Cursor AI 的思路来实现,是否会更容易实现?

先给 AI 投喂下这个设计稿图片,让它帮我生成这些圆环的 SVG 代码,如下所示:

image.png

image.png

参考了 AI 返回的代码,取一个 SVG 源代码及渲染:

<svg viewBox="0 0 100 50">
  <path
    class="dial"
    d="M 10 50 A 40 40 0 0 1 90 50"
    fill="none"
    stroke="#DDD"
    stroke-width="2"
  ></path>
  <g class="text-container">
    <text
      class="value-text"
      dominant-baseline="central"
      fill="#999"
      font-family="sans-serif"
      font-size="100%"
      font-weight="normal"
      text-anchor="middle"
      x="50"
      y="50"
    ></text>
  </g>
  <path
    d="M 10 50 A 40 40 0 0 1 50 10"
    class="value"
    fill="none"
    stroke-width="8"
    style="stroke: rgb(7, 177, 130)"
  ></path>
  <path
    d="M 18 50 A 32 32 0 0 1 18.158 46.824"
    class="band"
    fill="none"
    stroke="#d1433f"
    stroke-width="2"
  ></path>
  <text
    x="20"
    y="47"
    class="bandLabel"
    dominant-baseline="middle"
    fill="#999"
    font-family="sans-serif"
    font-size="50%"
    text-anchor="start"
  >
    -100.0
  </text>
  <path
    d="M 81.842 46.824 A 32 32 0 0 1 82 50"
    class="band"
    fill="none"
    stroke="#d1433f"
    stroke-width="2"
  ></path>
  <text
    x="78"
    y="47"
    class="bandLabel"
    dominant-baseline="middle"
    fill="#999"
    font-family="sans-serif"
    font-size="50%"
    text-anchor="end"
  >
    -60.0
  </text>
</svg>

上述 SVG 代码渲染的图表如下图所示,效果与设计图一致:

image.png

由以上代码分析一下 SVG 的构成和实现思路:

分析 SVG 的构成

  1. 基本元素

    • <svg>:SVG 的根元素,定义了画布的大小和视口。
    • <path>:用于绘制复杂的形状,如曲线、直线等。通过 d 属性定义路径。
    • <text>:用于在 SVG 中添加文本,可以设置字体、大小、颜色等属性。
    • <g>:用于分组其他 SVG 元素,便于管理和操作。
  2. 属性

    • viewBox:定义 SVG 的视口,控制图形的缩放和位置。
    • fill:设置填充颜色。
    • stroke:设置描边颜色。
    • stroke-width:设置描边的宽度。
    • stroke-dasharraystroke-dashoffset:用于创建虚线效果,常用于表示进度或值。
  3. 样式和动画

    • 可以使用 CSS 样式来设置 SVG 元素的样式。
    • 通过 JavaScript 可以动态修改 SVG 元素的属性,实现动画效果。

分析 SVG 实现图表的思路

  1. 设计布局

    • 确定图表的整体布局和尺寸,使用 viewBox 属性来定义视口。
  2. 绘制基础图形

    • 使用 <path> 元素绘制图表的基础结构,如仪表的弧形部分。
    • 使用 <text> 元素添加刻度和标签。
  3. 动态数据展示

    • 通过 JavaScript 动态计算和更新 <path>stroke-dasharraystroke-dashoffset 属性,以反映当前值。
  4. 交互和动画

    • 添加事件监听器,响应用户交互。
    • 使用 CSS 过渡或 JavaScript 动画库来实现平滑的动画效果。

通过以上步骤,可以创建出动态、交互性强的 SVG 图表,适用于数据可视化和用户界面设计。

提取为组件

使用 SVG 实现效果图并不是最终目的,这只是验证能够实现猜想的第一步。由于在实际项目中,图表是根据数据动态渲染的,所以我们要将它拆分为公用组件,根据实际数据动态渲染。

因此,实现这个组件必须要做的是:

  1. 接收三个必需的 props:

    • min: 最小值
    • max: 最大值
    • value: 当前值
  2. 实现动态渲染:

    • 动态计算并显示圆弧的位置和长度
    • 根据值(value)是否在范围内(min~max)切换颜色(红色表示超出范围,绿色表示在范围内)
    • 平滑的动画过渡效果
    • 自适应的标签位置

再次借助 Cursor AI 来帮助我们

image.png

AI 并不能一次问答就能输出较为成熟的代码,需要我们一步步进行调校优化,描述准确尤为重要,加之我们修改一些显而易见的错误,最终能够输出我们想要的效果:

image.png

image.png

image.png

最终经过不断的修改优化,经过测试后完整的组件如下:

<script setup lang="ts">
  import { computed } from 'vue'

  // 定义组件的 Props 接口
  interface Props {
    min: number // 最小值
    max: number // 最大值
    value: number // 当前值
  }

  // 定义组件名称
  defineOptions({
    name: 'GaugeSVG'
  })

  // 获取组件的 props
  const props = defineProps<Props>()

  // 计算实际显示范围
  const effectiveRange = computed(() => {
    let effectiveMin = props.min
    let effectiveMax = props.max

    // 如果当前值小于最小值,调整最小值
    if (props.value < props.min) {
      effectiveMin = props.value
    }
    // 如果当前值大于最大值,调整最大值
    if (props.value > props.max) {
      effectiveMax = props.value
    }

    return { min: effectiveMin, max: effectiveMax }
  })

  // 计算圆弧上的坐标的工具函数
  const calculatePoint = (angle: number): { x: number; y: number } => {
    const radius = 40 // 圆的半径
    const centerX = 50 // 圆心 X 坐标
    const centerY = 50 // 圆心 Y 坐标
    const radians = ((angle - 180) * Math.PI) / 180 // 将角度转换为弧度
    return {
      x: centerX + radius * Math.cos(radians), // 计算 X 坐标
      y: centerY + radius * Math.sin(radians) // 计算 Y 坐标
    }
  }

  // 计算值的圆弧
  const getValueArc = computed(() => {
    const percentage = Math.min(
      Math.max(
        (props.value - effectiveRange.value.min) /
          (effectiveRange.value.max - effectiveRange.value.min),
        0
      ),
      1
    )
    const angle = 180 * percentage // 计算角度
    const point = calculatePoint(angle) // 计算圆弧终点坐标
    const largeArcFlag = angle > 180 ? 1 : 0 // 判断是否为大弧

    return `M 10 50 A 40 40 0 ${largeArcFlag} 1 ${point.x} ${point.y}` // 返回 SVG 路径
  })

  // 计算最小值和最大值标记的圆弧
  const getMinBandArc = computed(() => 'M 18 50 A 32 32 0 0 1 18.158 46.824')
  const getMaxBandArc = computed(() => 'M 81.842 46.824 A 32 32 0 0 1 82 50')

  // 计算值的颜色
  const getValueColor = computed(
    () =>
      props.value < props.min || props.value > props.max
        ? 'rgb(209, 67, 63)' // 红色 - 超出范围
        : 'rgb(7, 177, 130)' // 绿色 - 正常范围
  )

  // 标签位置
  const minLabelPos = computed(() => ({
    x: 20,
    y: 47
  }))

  const maxLabelPos = computed(() => ({
    x: 78,
    y: 47
  }))
</script>

<template>
  <div class="gauge-svg">
    <svg viewBox="0 0 100 50">
      <!-- 背景圆弧 -->
      <path
        class="dial"
        d="M 10 50 A 40 40 0 0 1 90 50"
        fill="none"
        stroke="#DDD"
        stroke-width="2"
      />

      <!-- 值文本 -->
      <g class="text-container">
        <text
          class="value-text"
          dominant-baseline="central"
          fill="#999"
          font-family="sans-serif"
          font-size="100%"
          font-weight="normal"
          text-anchor="middle"
          x="50"
          y="50"
        ></text>
      </g>

      <!-- 值的圆弧 -->
      <path
        :d="getValueArc"
        :style="{ stroke: getValueColor }"
        class="value"
        fill="none"
        stroke-width="8"
      />

      <!-- 最小值标记 -->
      <path
        :d="getMinBandArc"
        class="band"
        fill="none"
        stroke="#d1433f"
        stroke-width="2"
      />
      <text
        :x="minLabelPos.x"
        :y="minLabelPos.y"
        class="bandLabel"
        dominant-baseline="middle"
        fill="#999"
        font-family="sans-serif"
        font-size="70%"
        text-anchor="start"
      >
        {{ min.toFixed(1) }}
        <!-- 显示最小值 -->
      </text>

      <!-- 最大值标记 -->
      <path
        :d="getMaxBandArc"
        class="band"
        fill="none"
        stroke="#d1433f"
        stroke-width="2"
      />
      <text
        :x="maxLabelPos.x"
        :y="maxLabelPos.y"
        class="bandLabel"
        dominant-baseline="middle"
        fill="#999"
        font-family="sans-serif"
        font-size="70%"
        text-anchor="end"
      >
        {{ max.toFixed(1) }}
        <!-- 显示最大值 -->
      </text>
    </svg>
    <p class="gauge-svg-text">{{ value.toFixed(1) }}</p>
  </div>
</template>

<style scoped>
  .gauge-svg {
    text-align: center;
  }

  .gauge-svg-text {
    margin-top: 10px;
    font-size: 25px;
    color: #999;
  }

  .value {
    transition: all 0.3s ease; /* 添加过渡效果 */
  }
</style>

如何使用组件

<template>
  <!-- 超出范围 -->
  <GaugeSVG :max="-60" :min="-100" :value="20" />
  <!-- 在范围内 -->
  <GaugeSVG :max="-20" :min="-40" :value="-55" />
  <!-- 低于范围 -->
  <GaugeSVG :max="-60" :min="-100" :value="-80" />
</template>

分析实现原理

接下来,我们分析一下这个 GaugeSVG 组件的实现原理。这是一个基于 SVG 实现的仪表盘组件,主要用于显示一个数值在指定范围内的可视化表示。

主要设计实现原理如下:

  1. 组件接口设计
interface Props {
  min: number // 最小值
  max: number // 最大值
  value: number // 当前值
}

组件接收三个基本参数:最小值、最大值和当前值,用于定义仪表盘的显示范围。

  1. 动态范围计算
const effectiveRange = computed(() => {
  let effectiveMin = props.min
  let effectiveMax = props.max

  // 动态调整显示范围,确保当前值始终在可视范围内
  if (props.value < props.min) {
    effectiveMin = props.value
  }
  if (props.value > props.max) {
    effectiveMax = props.value
  }

  return { min: effectiveMin, max: effectiveMax }
})

这个计算属性确保即使当前值超出预设范围,也能在仪表盘上正确显示。

  1. SVG 坐标计算
const calculatePoint = (angle: number): { x: number; y: number } => {
  const radius = 40 // 圆的半径
  const centerX = 50 // 圆心 X 坐标
  const centerY = 50 // 圆心 Y 坐标
  const radians = ((angle - 180) * Math.PI) / 180 // 角度转弧度
  return {
    x: centerX + radius * Math.cos(radians),
    y: centerY + radius * Math.sin(radians)
  }
}

这个工具函数用于计算圆弧上的点坐标,使用三角函数将角度转换为具体的 x、y 坐标。

  1. 值弧计算
const percentage = Math.min(
  Math.max(
    (props.value - effectiveRange.value.min) /
      (effectiveRange.value.max - effectiveRange.value.min),
    0
  ),
  1
)
const angle = 180 * percentage
const point = calculatePoint(angle)
const largeArcFlag = angle > 180 ? 1 : 0

以上的这些计算用于生成表示当前值的 SVG 路径的必要参数:

  • percentage 为计算当前值在范围内的百分比
  • 将百分比转换为角度(0-180 度)angle
  • 计算终点坐标 point
  • largeArcFlag 用于判断是否为大弧
  1. 颜色逻辑
const getValueColor = computed(
  () =>
    props.value < props.min || props.value > props.max
      ? 'rgb(209, 67, 63)' // 红色 - 超出范围
      : 'rgb(7, 177, 130)' // 绿色 - 正常范围
)

根据当前值是否在范围内,动态改变显示颜色:

  • 超出范围显示红色
  • 在范围内显示绿色
  1. SVG 结构

组件使用 SVG 元素构建仪表盘:

  • 背景圆弧:显示整个范围
  • 值弧:显示当前值
  • 最小/最大值标记:显示范围边界
  • 文本标签:显示具体数值
  1. 样式处理
.value {
  transition: all 0.3s ease; /* 添加过渡效果 */
}

使用 CSS 过渡效果使数值变化时的动画更平滑。

这个组件通过 SVG 实现了精确的仪表盘显示,并且:

  • 支持动态范围调整
  • 提供颜色变化
  • 具有平滑的动画效果
  • 显示清晰的数值标记

总结

虽然刚开始我尝试使用 ECharts 实现设计稿中的效果,但经过简单尝试最终实现不理想,因此转向 SVG 的实现。

但由于我之前对 SVG 实现图表并没有研究,因此借助 Cursor AI 来逐步实现一个 SVG 版的动态仪表盘组件,以替代传统的 ECharts 方案,结果是可行的。

最终,将 SVG 实现拆分为一个可复用的 Vue 组件,接收最小值、最大值和当前值作为 props,并动态计算圆弧的位置和长度,根据值是否在范围内切换颜色(红色表示超出范围,绿色表示在范围内),同时添加平滑的动画过渡效果。