21 ROS2 数据 Web 端实时展示

ROS2 数据 Web 端实时展示

关联:索引

要解决的问题

章节内容(本讲核心):

与前置知识衔接(避免重复):

项目工坊:开发 ROS2 数据实时监控面板

学生任务:

大模型任务:

作业:


说明:

1) 视觉检测结果(String 携带 JSON)

建议话题名:/vision/detections_json,消息类型:std_msgs/msg/String

示例 payload(这是 String 的 data 字段内部 JSON):

{
  "frame_id": "camera_front",
  "stamp_ms": 1760000000123,
  "detections": [
    { "label": "person", "score": 0.92, "bbox": { "x": 120, "y": 40, "w": 80, "h": 160 } },
    { "label": "forklift", "score": 0.81, "bbox": { "x": 260, "y": 90, "w": 140, "h": 110 } }
  ]
}

2) 设备状态(String 携带 JSON)

建议话题名:/device/state_json,消息类型:std_msgs/msg/String

{
  "device_id": "agv-01",
  "online": true,
  "mode": "AUTO",
  "battery": 76.3,
  "temperature": 41.2,
  "alarm_level": "WARN",
  "stamp_ms": 1760000000456
}

3) 参数数据(String 携带 JSON)

建议话题名:/device/params_json,消息类型:std_msgs/msg/String

{
  "stamp_ms": 1760000000789,
  "params": {
    "max_speed": 1.5,
    "kp": 0.8,
    "ki": 0.02,
    "kd": 0.01,
    "enable_vision_stop": true
  }
}

4) ROS2 侧发布命令(用于模拟数据流)

以下命令用于在 ROS2 侧持续发布“JSON 字符串”,让前端订阅到数据。

说明:命令参数最后一段是 std_msgs/msg/String 的 YAML 入参;本给出的写法在 Bash 与 PowerShell 下都可用(如果你在 zsh 等环境遇到引号问题,优先把外层单引号保留,并减少不必要的嵌套引号)。

发布视觉检测模拟数据(1Hz):

# 发布 /vision/detections_json(1Hz),消息类型为 std_msgs/msg/String
# 注意:外层单引号包住 YAML;data 内层双引号包住 JSON;JSON 内的 \" 只是 YAML 转义,进入 data 后不会保留反斜杠
ros2 topic pub -r 1 /vision/detections_json std_msgs/msg/String '{data: "{\"frame_id\":\"camera_front\",\"stamp_ms\":1775784807437,\"detections\":[{\"label\":\"person\",\"score\":0.92,\"bbox\":{\"x\":120,\"y\":40,\"w\":80,\"h\":160}}]}"}'

发布设备状态模拟数据(5Hz):

# 发布 /device/state_json(5Hz),用于驱动状态卡片与实时曲线(battery/temperature)
# stamp_ms 推荐为 epoch(ms);如果你传的是秒/微秒/纳秒,前端需要做单位归一化
ros2 topic pub -r 5 /device/state_json std_msgs/msg/String '{data: "{\"device_id\":\"agv-01\",\"online\":true,\"mode\":\"AUTO\",\"battery\":76.3,\"temperature\":41.2,\"alarm_level\":\"WARN\",\"stamp_ms\":1775784807437}"}'

发布参数快照(0.5Hz):

# 发布 /device/params_json(0.5Hz),模拟“参数快照”(通常低频或按变更发布)
# params 允许混合类型:number / boolean / string / object(前端展示层需要统一格式化)
ros2 topic pub -r 0.5 /device/params_json std_msgs/msg/String '{data: "{\"stamp_ms\":1775784807437,\"params\":{\"max_speed\":1.5,\"kp\":0.8,\"ki\":0.02,\"kd\":0.01,\"enable_vision_stop\":true}}"}'

工程准备:从 07 复制出 08 项目(本讲代码落位)

本讲默认你已经完成上节课工程 07_rosbridge_topic_subscriber。为了避免“边改边坏”,本讲统一在一个新工程里做实时面板开发:

1) 复制工程(PowerShell)

0410 目录执行:

# 从上一讲工程复制出 08 工程(避免“边改边坏”,也便于课堂回滚)
Copy-Item .\07_rosbridge_topic_subscriber .\08_ros2_realtime_dashboard -Recurse
# 进入 08 工程目录
cd .\08_ros2_realtime_dashboard
# 安装依赖(首次运行必须)
npm install
# 启动开发服务器(浏览器打开终端提示的地址)
npm run dev

Windows(PowerShell)常见提示:如果遇到 “禁止运行脚本 npm.ps1”,优先用 npm.cmd 执行同等命令:

# PowerShell 执行策略可能禁止运行 npm.ps1:用 npm.cmd 可以绕过该限制
npm.cmd install
npm.cmd run dev

2) 本讲新增/修改的文件(以 08 工程为准)

建议最终结构如下(相对路径):

08_ros2_realtime_dashboard/
  src/
    App.vue
    components/
      Ros2RealtimeDashboard.vue
      LineChartECharts.vue
    utils/
      rosbridge.ts
      ros-parse.ts
      ros-vm.ts
      ring-buffer.ts

推荐分层(从内到外):

这样做的直接收益:

以下代码建议放在 src/utils/ros-vm.ts(命名仅作建议)。

// src/utils/ros-vm.ts
// 目标:把“业务层数据(Payload)”与“展示层数据(VM)”分开,组件只拿 VM 去渲染

// bbox:可选,因为不同视觉节点/模型可能只给 label/score,不给 bbox
export type VisionBBox = {
  x: number
  y: number
  w: number
  h: number
}

// 单个检测结果(score 建议在 0~1 之间,展示层可转成百分比)
export type VisionDetection = {
  label: string
  score: number
  bbox?: VisionBBox
}

// 一帧检测结果快照(stamp_ms 用于“更新时间/延迟/曲线横轴”)
export type VisionDetectionsPayload = {
  frame_id: string
  stamp_ms: number
  detections: VisionDetection[]
}

// 告警等级:用联合类型约束字段,避免“随便一个字符串”导致 UI 状态不可控
export type DeviceAlarmLevel = 'OK' | 'WARN' | 'ERROR'

// 设备状态快照(字段较固定,适合做强校验)
export type DeviceStatePayload = {
  device_id: string
  online: boolean
  mode: string
  battery: number
  temperature: number
  alarm_level: DeviceAlarmLevel
  stamp_ms: number
}

// 参数快照:params 是 key->value;value 类型不固定,所以用 unknown 承接
export type ParamsPayload = {
  stamp_ms: number
  params: Record<string, unknown>
}

// 视觉检测的“表格行”VM:全部转成易渲染的字符串
export type VisionDetectionRowVM = {
  label: string
  scoreText: string
  bboxText: string
}

// 设备状态卡片 VM:把数值/状态码格式化为人类可读文本
export type DeviceStateVM = {
  deviceId: string
  onlineText: string
  alarmText: string
  modeText: string
  batteryText: string
  temperatureText: string
  // delayText:数据延迟(只有当 stamp_ms 可信时才展示;不可信可显示 “—”)
  delayText: string
  updatedAtText: string
}

// 参数表格行 VM:valueText 统一为字符串,避免模板里大量 typeof 判断
export type ParamRowVM = {
  key: string
  valueText: string
  updatedAtText: string
}

以下代码建议放在 src/utils/ros-parse.ts

import type {
  DeviceAlarmLevel,
  DeviceStatePayload,
  ParamsPayload,
  VisionDetectionsPayload
} from './ros-vm'

// src/utils/ros-parse.ts
// 目标:把 rosbridge 推过来的 unknown msg 安全解析为 Payload
// 注意:本讲的三个话题都是 std_msgs/msg/String,真正的 JSON 在 msg.data 里

export function safeJsonParse(text: string): unknown {
  try {
    return JSON.parse(text)
  } catch {
    return null
  }
}

// unknown -> number(并保证是有限数),用于过滤 NaN/Infinity/字符串数字等脏数据
function toNumber(x: unknown): number | null {
  return typeof x === 'number' && Number.isFinite(x) ? x : null
}

// unknown -> string
function toString(x: unknown): string | null {
  return typeof x === 'string' ? x : null
}

// unknown -> boolean
function toBoolean(x: unknown): boolean | null {
  return typeof x === 'boolean' ? x : null
}

// unknown -> DeviceAlarmLevel(不在枚举内就判为无效)
function toAlarmLevel(x: unknown): DeviceAlarmLevel | null {
  return x === 'OK' || x === 'WARN' || x === 'ERROR' ? x : null
}

// 从 rosbridge 的 msg 中提取 std_msgs/msg/String 的 data 字段
function parseStdStringMsgData(msg: unknown): string | null {
  if (!msg || typeof msg !== 'object' || !('data' in msg)) return null
  const m = msg as { data?: unknown }
  return toString(m.data)
}

// 视觉检测:msg.data(JSON字符串) -> VisionDetectionsPayload
export function parseVisionDetectionsJsonStringMsg(msg: unknown): VisionDetectionsPayload | null {
  const text = parseStdStringMsgData(msg)
  if (!text) return null

  const data = safeJsonParse(text)
  if (!data || typeof data !== 'object') return null

  // 只取我们关心的字段;未知字段忽略,保证兼容后续扩展
  const d = data as {
    frame_id?: unknown
    stamp_ms?: unknown
    detections?: unknown
  }

  const frame_id = toString(d.frame_id)
  const stamp_ms = toNumber(d.stamp_ms)
  const detectionsRaw = Array.isArray(d.detections) ? d.detections : null
  if (!frame_id || stamp_ms === null || !detectionsRaw) return null

  const detections = detectionsRaw
    .map((item) => {
      if (!item || typeof item !== 'object') return null
      const it = item as {
        label?: unknown
        score?: unknown
        bbox?: unknown
      }
      const label = toString(it.label)
      const score = toNumber(it.score)
      if (!label || score === null) return null

      let bbox: { x: number; y: number; w: number; h: number } | undefined
      if (it.bbox && typeof it.bbox === 'object') {
        const b = it.bbox as { x?: unknown; y?: unknown; w?: unknown; h?: unknown }
        const x = toNumber(b.x)
        const y = toNumber(b.y)
        const w = toNumber(b.w)
        const h = toNumber(b.h)
        if (x !== null && y !== null && w !== null && h !== null) bbox = { x, y, w, h }
      }

      return { label, score, bbox }
    })
    .filter((x): x is NonNullable<typeof x> => x !== null)

  return { frame_id, stamp_ms, detections }
}

// 设备状态:msg.data(JSON字符串) -> DeviceStatePayload
export function parseDeviceStateJsonStringMsg(msg: unknown): DeviceStatePayload | null {
  const text = parseStdStringMsgData(msg)
  if (!text) return null

  const data = safeJsonParse(text)
  if (!data || typeof data !== 'object') return null

  const d = data as {
    device_id?: unknown
    online?: unknown
    mode?: unknown
    battery?: unknown
    temperature?: unknown
    alarm_level?: unknown
    stamp_ms?: unknown
  }

  const device_id = toString(d.device_id)
  const online = toBoolean(d.online)
  const mode = toString(d.mode)
  const battery = toNumber(d.battery)
  const temperature = toNumber(d.temperature)
  const alarm_level = toAlarmLevel(d.alarm_level)
  const stamp_ms = toNumber(d.stamp_ms)
  if (
    !device_id ||
    online === null ||
    !mode ||
    battery === null ||
    temperature === null ||
    !alarm_level ||
    stamp_ms === null
  ) {
    return null
  }

  return { device_id, online, mode, battery, temperature, alarm_level, stamp_ms }
}

// 参数快照:msg.data(JSON字符串) -> ParamsPayload(params 允许混合类型)
export function parseParamsJsonStringMsg(msg: unknown): ParamsPayload | null {
  const text = parseStdStringMsgData(msg)
  if (!text) return null

  const data = safeJsonParse(text)
  if (!data || typeof data !== 'object') return null

  const d = data as { stamp_ms?: unknown; params?: unknown }
  const stamp_ms = toNumber(d.stamp_ms)
  const params = d.params && typeof d.params === 'object' ? (d.params as Record<string, unknown>) : null
  if (stamp_ms === null || !params) return null

  return { stamp_ms, params }
}

下面示例展示“一个面板组件”如何接入三类 Parser 并实时展示。

说明:

建议文件:src/components/Ros2RealtimeDashboard.vue(08 工程)

<template>
  <div class="wrap">
    <h2>ROS2 数据实时展示</h2>

    <!-- 连接区:用输入框配置 rosbridge 地址,按钮触发 connect/disconnect -->
    <div class="row">
      <label class="label" for="url">rosbridge URL</label>
      <input id="url" v-model="url" class="input" type="text" />
      <button class="btn" type="button" :disabled="status === 'OPEN' || status === 'CONNECTING'" @click="connect">
        连接
      </button>
      <button class="btn" type="button" :disabled="status !== 'OPEN'" @click="disconnect">断开</button>
      <span class="status">状态:{{ status }}</span>
    </div>

    <div class="grid">
      <!-- 状态卡片:展示 /device/state_json 解析后的 VM -->
      <section class="card">
        <h3>设备状态</h3>
        <div class="kv"><span class="k">设备</span><span class="v">{{ deviceVm?.deviceId ?? '—' }}</span></div>
        <div class="kv"><span class="k">在线</span><span class="v">{{ deviceVm?.onlineText ?? '—' }}</span></div>
        <div class="kv"><span class="k">模式</span><span class="v">{{ deviceVm?.modeText ?? '—' }}</span></div>
        <div class="kv"><span class="k">告警</span><span class="v">{{ deviceVm?.alarmText ?? '—' }}</span></div>
        <div class="kv"><span class="k">电量</span><span class="v">{{ deviceVm?.batteryText ?? '—' }}</span></div>
        <div class="kv"><span class="k">温度</span><span class="v">{{ deviceVm?.temperatureText ?? '—' }}</span></div>
        <div class="kv"><span class="k">延迟</span><span class="v">{{ deviceVm?.delayText ?? '—' }}</span></div>
        <div class="kv"><span class="k">更新时间</span><span class="v">{{ deviceVm?.updatedAtText ?? '—' }}</span></div>
      </section>

      <!-- 检测列表:展示 /vision/detections_json 的最新一帧 -->
      <section class="card">
        <h3>视觉检测(最新一帧)</h3>
        <div class="muted">frame: {{ visionFrameId ?? '—' }} | time: {{ visionUpdatedAtText ?? '—' }}</div>
        <table class="table">
          <thead>
            <tr>
              <th>label</th>
              <th>score</th>
              <th>bbox</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="(row, idx) in visionRows" :key="idx">
              <td>{{ row.label }}</td>
              <td>{{ row.scoreText }}</td>
              <td class="muted">{{ row.bboxText }}</td>
            </tr>
            <tr v-if="visionRows.length === 0">
              <td class="muted" colspan="3">(暂无数据)</td>
            </tr>
          </tbody>
        </table>
      </section>

      <!-- 参数表格:展示 /device/params_json 的 key/value 快照 -->
      <section class="card">
        <h3>参数快照(最新)</h3>
        <div class="muted">time: {{ paramsUpdatedAtText ?? '—' }}</div>
        <table class="table">
          <thead>
            <tr>
              <th>key</th>
              <th>value</th>
              <th>updated</th>
            </tr>
          </thead>
          <tbody>
            <tr v-for="row in paramRows" :key="row.key">
              <td>{{ row.key }}</td>
              <td>{{ row.valueText }}</td>
              <td class="muted">{{ row.updatedAtText }}</td>
            </tr>
            <tr v-if="paramRows.length === 0">
              <td class="muted" colspan="3">(暂无数据)</td>
            </tr>
          </tbody>
        </table>
      </section>
    </div>

    <div v-if="lastError" class="error">错误:{{ lastError }}</div>
  </div>
</template>

<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { RosbridgeClient } from '../utils/rosbridge'
import { parseDeviceStateJsonStringMsg, parseParamsJsonStringMsg, parseVisionDetectionsJsonStringMsg } from '../utils/ros-parse'
import type { DeviceStateVM, ParamRowVM, VisionDetectionRowVM } from '../utils/ros-vm'

// 连接状态:用来控制按钮 disabled 与页面提示
type Status = 'IDLE' | 'CONNECTING' | 'OPEN' | 'CLOSED'

// rosbridge ws 地址(默认本机 9090)
const url = ref('ws://localhost:9090')
const status = ref<Status>('IDLE')
const lastError = ref('')

// RosbridgeClient:负责把 publish 消息按 topic 路由到 handler(组件只写 subscribe)
let client: RosbridgeClient | null = null

// /vision/detections_json:frame_id / stamp_ms / detections[](转成表格行 VM)
const visionFrameId = ref<string | null>(null)
const visionUpdatedAt = ref<number | null>(null)
const visionRows = ref<VisionDetectionRowVM[]>([])

// /device/state_json:设备状态(转成卡片 VM)
const deviceVm = ref<DeviceStateVM | null>(null)

// /device/params_json:参数快照(转成表格行 VM)
const paramsUpdatedAt = ref<number | null>(null)
const paramRows = ref<ParamRowVM[]>([])

// 把毫秒时间戳格式化为 HH:mm:ss.SSS,用于“最近更新时间”
const visionUpdatedAtText = computed(() => (visionUpdatedAt.value ? formatTime(visionUpdatedAt.value) : null))
const paramsUpdatedAtText = computed(() => (paramsUpdatedAt.value ? formatTime(paramsUpdatedAt.value) : null))

function formatTime(tsMs: number): string {
  const d = new Date(tsMs)
  const hh = String(d.getHours()).padStart(2, '0')
  const mm = String(d.getMinutes()).padStart(2, '0')
  const ss = String(d.getSeconds()).padStart(2, '0')
  const ms = String(d.getMilliseconds()).padStart(3, '0')
  return `${hh}:${mm}:${ss}.${ms}`
}

function formatPercent(x: number): string {
  const v = Math.max(0, Math.min(1, x))
  return `${(v * 100).toFixed(1)}%`
}

function formatAlarmText(level: 'OK' | 'WARN' | 'ERROR'): string {
  return level === 'OK' ? '正常' : level === 'WARN' ? '预警' : '故障'
}

function formatAny(v: unknown): string {
  if (typeof v === 'string') return v
  if (typeof v === 'number') return Number.isFinite(v) ? String(v) : 'NaN'
  if (typeof v === 'boolean') return v ? 'true' : 'false'
  if (v === null) return 'null'
  if (v === undefined) return 'undefined'
  try {
    return JSON.stringify(v)
  } catch {
    return String(v)
  }
}

function keepLatestN<T>(arr: T[], n: number): T[] {
  if (arr.length <= n) return arr
  return arr.slice(arr.length - n)
}

// connect() 做两件事:
// 1) 建立 WebSocket 并等待 OPEN(waitForOpen)
// 2) 注册三个 topic 的 handler:每个 handler 内做 parse -> vm -> 写入响应式状态
async function connect(): Promise<void> {
  lastError.value = ''
  status.value = 'CONNECTING'

  client = new RosbridgeClient(url.value)
  client.connect()

  try {
    await client.waitForOpen(5000)
    status.value = 'OPEN'
  } catch (e) {
    status.value = 'CLOSED'
    lastError.value = e instanceof Error ? e.message : 'connect failed'
    client?.disconnect()
    client = null
    return
  }

  client.subscribe('/vision/detections_json', 'std_msgs/msg/String', (msg) => {
    // msg 是 rosbridge 的 msg 字段(unknown),对 std_msgs/msg/String 来说就是 { data: "<json string>" }
    const p = parseVisionDetectionsJsonStringMsg(msg)
    if (!p) return
    visionFrameId.value = p.frame_id
    visionUpdatedAt.value = p.stamp_ms
    const rows = p.detections.map((d) => ({
      label: d.label,
      scoreText: formatPercent(d.score),
      bboxText: d.bbox ? `${d.bbox.x},${d.bbox.y},${d.bbox.w},${d.bbox.h}` : '—'
    }))
    visionRows.value = keepLatestN(rows, 30)
  })

  client.subscribe('/device/state_json', 'std_msgs/msg/String', (msg) => {
    const p = parseDeviceStateJsonStringMsg(msg)
    if (!p) return
    // 用“本机时间 - 上游时间戳”计算延迟(后续可升级为 epoch(ms) 归一化与超时判定)
    const delayMs = Math.max(0, Date.now() - p.stamp_ms)
    deviceVm.value = {
      deviceId: p.device_id,
      onlineText: p.online ? '在线' : '离线',
      alarmText: formatAlarmText(p.alarm_level),
      modeText: p.mode,
      batteryText: `${p.battery.toFixed(1)}%`,
      temperatureText: `${p.temperature.toFixed(1)}℃`,
      delayText: `${delayMs}ms`,
      updatedAtText: formatTime(p.stamp_ms)
    }
  })

  client.subscribe('/device/params_json', 'std_msgs/msg/String', (msg) => {
    const p = parseParamsJsonStringMsg(msg)
    if (!p) return
    paramsUpdatedAt.value = p.stamp_ms
    // 参数表格:按 key 排序,保证每次刷新行顺序稳定,减少视觉抖动
    paramRows.value = Object.entries(p.params)
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([key, value]) => ({
        key,
        valueText: formatAny(value),
        updatedAtText: formatTime(p.stamp_ms)
      }))
  })
}

// 断开连接:释放 WebSocket + 清掉 client 引用(避免热更新重复连接)
function disconnect(): void {
  client?.disconnect()
  client = null
  status.value = 'CLOSED'
}

// 组件卸载时自动断开,避免切换页面/热更新造成“重复订阅、重复数据”
onUnmounted(() => {
  disconnect()
})
</script>

<style scoped>
.wrap {
  max-width: 1100px;
  margin: 0 auto;
  padding: 16px;
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
}
.row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 10px 0;
}
.label {
  min-width: 110px;
  color: #555;
}
.input {
  flex: 1;
  padding: 8px 10px;
  border: 1px solid #ddd;
  border-radius: 8px;
}
.btn {
  padding: 8px 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background: #fff;
  cursor: pointer;
}
.status {
  color: #555;
}
.grid {
  display: grid;
  gap: 12px;
  grid-template-columns: repeat(3, minmax(0, 1fr));
}
.card {
  border: 1px solid #eee;
  border-radius: 12px;
  padding: 12px;
  background: #fff;
}
.kv {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  padding: 4px 0;
}
.k {
  color: #666;
}
.v {
  color: #111;
}
.muted {
  color: #888;
  font-size: 12px;
}
.table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 8px;
}
.table th,
.table td {
  border-top: 1px solid #f0f0f0;
  padding: 6px 8px;
  text-align: left;
  vertical-align: top;
}
.error {
  margin-top: 12px;
  padding: 10px 12px;
  background: #fff1f1;
  border: 1px solid #ffd0d0;
  color: #b00020;
  border-radius: 10px;
}
</style>

六、练习(至少 2 题)

  1. 把设备状态卡片增加一个“数据延迟”字段:Date.now() - stamp_ms,并把延迟超过 2000ms 的状态显示为“可能掉线/延迟高”。
  2. 把视觉检测列表增加“只显示 score > 0.5”的过滤,并在页面上显示“过滤前/过滤后数量”。

八、学生任务(提交物与标准)

对高频 topic(例如 30Hz 的状态/传感器),建议在 subscribe 中加节流参数:

{
  "op": "subscribe",
  "topic": "/device/state_json",
  "type": "std_msgs/msg/String",
  "id": "sub-device",
  "throttle_rate": 200,
  "queue_length": 1
}

如果你复用了上一讲的 RosbridgeClient,推荐在 08 工程里把 subscribe 扩展为支持可选参数(不影响 07 工程),这样组件侧写法会更工程化:

client.subscribe(
  '/device/state_json',
  'std_msgs/msg/String',
  (msg) => {
    // handler:只处理“这一条 topic 的 msg”
    // 推荐流程:parse(unknown) -> payload(可信结构) -> VM(展示结构) -> 写入响应式状态
    // 这样组件模板里就不会出现一堆 msg.xxx?.yyy 的脆弱访问
  },
  { throttle_rate: 200, queue_length: 1 }
)

建议新建 src/utils/ring-buffer.ts,用于保存电量/温度等曲线数据。

export type Point = { t: number; v: number }

// src/utils/ring-buffer.ts
// 目标:固定容量缓存,保存“最近 N 个点”,避免数组无限增长导致卡顿
export class RingBuffer {
  private readonly cap: number
  private data: Point[]
  private head = 0
  private size = 0

  constructor(capacity: number) {
    // capacity 强制为 >=1 的整数
    this.cap = Math.max(1, Math.floor(capacity))
    this.data = new Array<Point>(this.cap)
  }

  push(p: Point): void {
    // 写入当前位置(覆盖最旧点)
    this.data[this.head] = p
    this.head = (this.head + 1) % this.cap
    this.size = Math.min(this.size + 1, this.cap)
  }

  toArray(): Point[] {
    if (this.size === 0) return []
    // 从最旧元素开始按时间顺序展开
    const start = (this.head - this.size + this.cap) % this.cap
    const out: Point[] = []
    for (let i = 0; i < this.size; i += 1) {
      out.push(this.data[(start + i) % this.cap])
    }
    return out
  }
}

说明:

1) 安装依赖(在 08 工程执行)

如果你在 PowerShell 遇到 npm.ps1 执行策略限制,优先用 npm.cmd

# 在 08 工程安装 ECharts(只装主包即可)
cd .\08_ros2_realtime_dashboard
npm.cmd i echarts

2) 新建图表组件:src/components/LineChartECharts.vue

<template>
  <div ref="elRef" class="chart"></div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import * as echarts from 'echarts'

type Point = { t: number; v: number }

const props = defineProps<{
  points: Point[]
  yMin?: number
  yMax?: number
  label?: string
}>()

const elRef = ref<HTMLDivElement | null>(null)

// chart:ECharts 实例(组件卸载时必须 dispose)
let chart: echarts.ECharts | null = null
// ro:容器大小变化监听(比 window resize 更准确,适配卡片布局变化)
let ro: ResizeObserver | null = null
// raf:合帧更新用的 requestAnimationFrame id
let raf = 0

function formatAxisTime(value: unknown): string {
  const ts = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN
  if (!Number.isFinite(ts)) return ''
  const d = new Date(ts)
  const hh = String(d.getHours()).padStart(2, '0')
  const mm = String(d.getMinutes()).padStart(2, '0')
  const ss = String(d.getSeconds()).padStart(2, '0')
  return `${hh}:${mm}:${ss}`
}

function buildOption(): echarts.EChartsOption {
  const seriesData = props.points.map((p) => [p.t, p.v])
  const shouldScale = props.yMin === undefined || props.yMax === undefined

  return {
    // 高频更新时建议关掉动画,避免抖动与掉帧
    animation: false,
    grid: { left: 40, right: 14, top: 30, bottom: 34 },
    title: props.label
      ? { text: props.label, left: 10, top: 6, textStyle: { fontSize: 12, fontWeight: 'normal', color: '#666' } }
      : undefined,
    tooltip: { trigger: 'axis', axisPointer: { type: 'line' } },
    xAxis: {
      type: 'time',
      // splitNumber 只是“建议分段数”,最终还会受容器宽度影响
      splitNumber: 4,
      axisLabel: { color: '#888', hideOverlap: true, margin: 10, formatter: formatAxisTime },
      axisLine: { lineStyle: { color: '#eee' } }
    },
    yAxis: {
      type: 'value',
      min: props.yMin,
      max: props.yMax,
      scale: shouldScale,
      axisLabel: { color: '#888' },
      splitLine: { lineStyle: { color: '#f2f2f2' } },
      axisLine: { lineStyle: { color: '#eee' } }
    },
    series: [{ type: 'line', showSymbol: false, data: seriesData, lineStyle: { width: 2, color: '#1677ff' } }]
  }
}

function render(): void {
  if (!chart) return
  // notMerge: true 让每次 option 都以当前构建为准,避免残留;lazyUpdate: true 允许内部做延后渲染优化
  chart.setOption(buildOption(), { notMerge: true, lazyUpdate: true })
}

function scheduleRender(): void {
  if (raf) cancelAnimationFrame(raf)
  // 合并短时间内多次 points 更新(例如 5Hz~几十 Hz 推送),最多每帧渲染一次
  raf = requestAnimationFrame(() => render())
}

function onResize(): void {
  chart?.resize()
}

watch(
  () => props.points,
  () => scheduleRender()
)

watch(
  () => [props.yMin, props.yMax, props.label],
  () => scheduleRender()
)

onMounted(() => {
  const el = elRef.value
  if (!el) return

  // 初始化图表实例:容器必须已有尺寸,否则会出现 0x0 图表
  chart = echarts.init(el, undefined, { renderer: 'canvas' })
  scheduleRender()

  if (typeof ResizeObserver !== 'undefined') {
    ro = new ResizeObserver(() => {
      chart?.resize()
    })
    ro.observe(el)
  } else {
    // 退化方案:只监听窗口 resize(容器内部布局变化可能捕捉不到)
    window.addEventListener('resize', onResize)
  }
})

onUnmounted(() => {
  if (raf) cancelAnimationFrame(raf)

  if (ro && elRef.value) ro.unobserve(elRef.value)
  ro = null

  window.removeEventListener('resize', onResize)

  // 释放 ECharts 持有的 DOM/事件/内存
  chart?.dispose()
  chart = null
})
</script>

<style scoped>
.chart {
  width: 100%;
  height: 180px;
  border-radius: 10px;
  background: #fff;
}
</style>

核心目标:

import { ref } from 'vue'
import { RingBuffer } from '../utils/ring-buffer'

const batterySeries = new RingBuffer(120)
const batteryPoints = ref<{ t: number; v: number }[]>([])

// 目的:把上游 stamp_ms 统一成“epoch(ms)”:
// - raw 若本身就是 ms:直接通过
// - raw 若是秒:raw*1000
// - raw 若是微秒:raw/1000
// - raw 若是纳秒:raw/1_000_000
// 如果都不像一个合理的日期,就用 receiveMs 回退(保证曲线始终能画)
function resolveStampMs(raw: number, receiveMs: number): number {
  const minMs = Date.UTC(2000, 0, 1)
  const maxMs = Date.UTC(2100, 0, 1)
  const candidates = [raw, raw * 1000, raw / 1000, raw / 1_000_000]
  for (const c of candidates) {
    if (Number.isFinite(c) && c >= minMs && c <= maxMs) return Math.floor(c)
  }
  return receiveMs
}

// lastT:记录上一点的时间戳,保证时间严格递增(ECharts time 轴更稳定)
let lastT: number | null = null

function onDeviceState(stamp_ms: number, battery: number): void {
  // receiveMs:前端收到消息的时刻(用于回退与乱序保护)
  const receiveMs = Date.now()
  let t = resolveStampMs(stamp_ms, receiveMs)

  // 如果点乱序/重复:强制让 t 递增,否则曲线可能出现“来回跳/不连线/看起来不更新”
  if (lastT !== null && t <= lastT) t = Math.max(receiveMs, lastT + 1)
  lastT = t

  batterySeries.push({ t, v: battery })
  batteryPoints.value = batterySeries.toArray()
}

function keepLatestN<T>(arr: T[], n: number): T[] {
  if (arr.length <= n) return arr
  return arr.slice(arr.length - n)
}

把图表渲染到模板中(示例):

<LineChartECharts :points="batteryPoints" :y-min="0" :y-max="100" label="battery(%)" />

六、练习(至少 2 题)

  1. 给设备状态卡片增加“离线判定”:如果 Date.now() - stamp_ms > 3000,则在线显示为“离线(超时)”。
  2. 实现“检测数量曲线”:每次收到视觉检测 payload,把 detections.length push 进 ring buffer,画出最近 60 个点的曲线。

课后作业

参考与延伸

Markdown 与代码自检清单(本出稿自检)