cd ..

Meta2d.js 使用指北

做工业组态、设备监控这类可视化项目时,你大概率会面临一个选择题:是从零用 Canvas 手撸一套,还是找个现成的框架?如果你选了后者,Meta2d.js 值得认真看看。

Meta2d.js 是一个开源的 2D 可视化引擎,支持实时数据展示、动态交互和数据管理,基本覆盖了组态应用的核心需求。我在实际项目中用了一段时间,踩了不少坑,这篇文章就把上手过程和关键知识点梳理一遍,希望能帮你少走些弯路。

从安装到跑起来

安装依赖

Meta2d.js 采用了模块化的包结构,核心库必装,其他图元库按需引入:

安装指南
# 安装核心库
pnpm install @meta2d/core --save

# 可选功能模块
pnpm install @meta2d/activity-diagram --save
pnpm install @meta2d/chart-diagram --save
pnpm install @meta2d/class-diagram --save
pnpm install @meta2d/flow-diagram --save
pnpm install @meta2d/fta-diagram --save
pnpm install @meta2d/form-diagram --save
pnpm install @meta2d/sequence-diagram --save
pnpm install @meta2d/le5le-charts --save
pnpm install @meta2d/svg --save

这些图元库分别对应活动图、类图、流程图、时序图等常见图形,不用的就别装,减小打包体积。

在 Vue3 中跑通第一个 Demo

注意: 本文主要介绍 Meta2d.js 在 Vue3 中的使用方法,其他框架的使用方式类似。

要让 Meta2d.js 在 Vue3 项目中正常工作,首先得在 index.html 里引入一些外部资源。这一步容易被忽略,但跳过了后面会出各种奇怪的问题:

在 index.html 中引入资源
// index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Meta2d Vue3</title>
    <!-- Meta2d 图标库 -->
    <script src="//at.alicdn.com/t/c/font_4042197_vr5c62twlzh.js"></script>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
    <!-- Meta2d 依赖 -->
    <script src="https://assets.le5lecdn.com/2d/canvas2svg.js"></script>
    <script defer src="/meta2d/marked.min.js"></script>
  </body>
</html>

准备工作做好了,接下来创建 Meta2d 实例。这里有个小细节:Meta2d 在创建时会在全局挂载一个 meta2d 对象,后续可以直接通过它来操作画布。组件卸载时记得调用 destroy() 释放资源,不然会有内存泄漏的风险。

基础 Meta2d 组件
<script setup lang="ts">
import { Meta2d } from '@meta2d/core';
import { onMounted, onUnmounted } from 'vue';

// Meta2d 配置选项
const meta2dOptions = {};

// 右键菜单处理函数
function contextmenu(e) {
  console.log('右键菜单事件', e);
}

// 点击事件处理函数
function click(e) {
  console.log('点击事件', e);
}

onMounted(() => {
  // 创建 Meta2d 实例
  // eslint-disable-next-line no-new
  new Meta2d('meta2d', meta2dOptions);

  // 注册事件监听
  meta2d.on('contextmenu', contextmenu);
  meta2d.on('click', click);

  // 打开已有画布数据(如果有)
  // meta2d.open(json);
});

onUnmounted(() => {
  // 销毁实例,释放资源
  meta2d.destroy();
});
</script>

<template>
  <div id="meta2d" class="w-full h-full" />
</template>

<style scoped>
#meta2d {
  width: 100%;
  height: 100%;
}
</style>

到这里,一个最简单的 Meta2d 画布就跑起来了。接下来的重头戏是让它真正可用——支持拖拽图元、编辑属性、接收实时数据。

核心功能:拖拽与图元

组态应用的核心交互就是”拖拽”:用户从图元库里选中一个图元,拖到画布上,调整位置和大小。Meta2d.js 对这一流程提供了开箱即用的支持。

注册图元库

在创建 Meta2d 实例之后,需要把用到的图元库注册进去。这一步决定了画布上能使用哪些预置图形:

注册图元
<script setup lang="ts">
import { activityDiagram } from '@meta2d/activity-diagram';
import { classPens } from '@meta2d/class-diagram';
import { Meta2d } from '@meta2d/core';
import { flowPens } from '@meta2d/flow-diagram';
import { formPens } from '@meta2d/form-diagram';
import { sequencePens, sequencePensbyCtx } from '@meta2d/sequence-diagram';
import { onMounted, onUnmounted } from 'vue';

const meta2dOptions = {};

onMounted(() => {
  // 创建 Meta2d 实例
  // eslint-disable-next-line no-new
  new Meta2d('meta2d', meta2dOptions);

  // 注册各类图元
  meta2d.register(flowPens());
  meta2d.register(activityDiagram());
  meta2d.register(classPens());
  meta2d.register(sequencePens());
  meta2d.registerCanvasDraw(sequencePensbyCtx());
  meta2d.registerCanvasDraw(formPens());

  // 注册事件监听
  meta2d.on('contextmenu', contextmenu);
  meta2d.on('click', click);

  // 打开已有画布数据(如果有)
  // meta2d.open(json);
});

onUnmounted(() => {
  // 销毁实例
  meta2d.destroy();
});
</script>

<template>
  <div id="meta2d" class="w-full h-full" />
</template>

搭建图元库面板

光注册了图元还不够,还需要一个侧边栏让用户能看到并拖拽它们。下面是一个图元库面板的实现,关键在于 dragStart 函数——它通过 HTML5 的 Drag API 把图元数据传递给 Meta2d 画布:

图元库组件
<script setup lang="ts">
import { ref } from 'vue';

// 图元分组数据
const graphicGroups = [
  {
    name: '基本形状',
    show: true,
    list: [
      {
        name: '正方形',
        icon: 'l-rect',
        id: 1,
        data: {
          width: 100,
          height: 100,
          name: 'square',
        },
      },
      {
        name: '矩形',
        icon: 'l-rectangle',
        id: 2,
        data: {
          width: 200,
          height: 50,
          borderRadius: 0.1,
          name: 'rectangle',
        },
      },
      // 更多图元...
    ],
  },
  // 更多分组...
];

// 拖拽开始事件处理
function dragStart(e, elem) {
  if (!elem)
    return;

  e.stopPropagation();

  if (e instanceof DragEvent) {
    // 设置拖拽数据
    e.dataTransfer?.setData('Meta2d', JSON.stringify(elem.data));
  } else {
    // 平板模式:支持点击添加图元
    meta2d.canvas.addCaches = [elem.data];
  }
}
</script>

<template>
  <div class="graphics-panel">
    <div class="panel-title">
      图元库
    </div>

    <div v-for="group in graphicGroups" :key="group.name" class="graphic-group">
      <div class="group-header">
        {{ group.name }}
      </div>

      <div class="group-content">
        <div
          v-for="elem in group.list"
          :key="elem.id"
          class="graphic-item"
          :draggable="true"
          @dragstart="dragStart($event, elem)"
          @click.prevent="dragStart($event, elem)"
        >
          <svg class="icon" aria-hidden="true">
            <use :xlink:href="`#${elem.icon}`" />
          </svg>
          <div class="graphic-name" :title="elem.name">
            {{ elem.name }}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.graphics-panel {
  width: 220px;
  height: 100%;
  border-right: 1px solid #e0e0e0;
  overflow-y: auto;
}

.panel-title {
  padding: 12px 16px;
  font-size: 16px;
  font-weight: 500;
  border-bottom: 1px solid #e0e0e0;
}

.graphic-group {
  margin-bottom: 8px;
}

.group-header {
  padding: 8px 16px;
  font-size: 14px;
  font-weight: 500;
  background-color: #f5f5f5;
  cursor: pointer;
}

.group-content {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  padding: 8px;
  gap: 8px;
}

.graphic-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 8px 4px;
  border: 1px solid transparent;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.2s;
}

.graphic-item:hover {
  border-color: #d9d9d9;
  background-color: #f9f9f9;
}

.icon {
  width: 24px;
  height: 24px;
  margin-bottom: 4px;
}

.graphic-name {
  font-size: 12px;
  text-align: center;
  width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>

拖拽事件的两种模式

值得注意的是,dragStart 函数里做了两种处理。在桌面端,它通过 DragEventdataTransfer 传递数据;在平板端(没有拖拽 API 的场景),则直接把数据塞到 meta2d.canvas.addCaches 里,实现点击添加。这个细节在做移动端适配时很有用。

定义拖拽事件
function dragStart(e: any, elem: any) {
  if (!elem)
    return;

  e.stopPropagation();

  // 拖拽事件
  if (e instanceof DragEvent) {
    // 设置拖拽数据
    e.dataTransfer?.setData('Meta2d', JSON.stringify(elem.data));
  }
  else {
    // 支持单击添加图元。平板模式
    meta2d.canvas.addCaches = [elem.data];
  }
}
绑定事件

在模板中,每个图元同时绑定了 @dragstart@click.prevent,确保两种交互模式都能正常工作:

<template>
  <div
    class="graphic"
    :draggable="true"
    @dragstart="dragStart($event, elem)"
    @click.prevent="dragStart($event, elem)"
  >
    <svg class="l-icon" aria-hidden="true">
      <use :xlink:href="`#${elem.icon}`" />
    </svg>
    <p :title="elem.name">
      {{ elem.name }}
    </p>
  </div>
</template>

核心功能:属性编辑

光能把图元拖到画布上还不够,真正的组态应用需要让用户能编辑每个图元的属性——位置、大小、颜色、文字等等。这就需要监听图元的选中/取消选中事件,然后把属性显示到一个表单里。

管理图元选中状态

为了让属性面板知道当前选中的是什么,我封装了一个 useMeta2dSelection 组合式函数。它会根据选中图元的类型(普通图元、设备、测点、连线)自动判断并设置不同的编辑模式:

图元选择辅助函数
import type { Pen } from '@meta2d/core';

export enum SelectionMode {
  File = 'file',
  Pen = 'pen',
  Equip = 'equip',
  Point = 'point',
  Line = 'line',
}

const selections = reactive<{
  mode: SelectionMode
  pen?: Pen
  pens?: Pen[]
}>({
  mode: SelectionMode.File,
  pen: undefined,
  pens: [],
});

export function useMeta2dSelection() {
  const select = (pens?: Pen[]) => {
    if (!pens || pens.length !== 1) {
      selections.mode = SelectionMode.File;
      selections.pen = undefined;

      if (pens?.length)
        selections.pens = pens;
      else
        selections.pens = [];
      return;
    }

    // name 为 'image' 默认为设备
    // name 以 'point-combine' 开头 默认为测点
    // 其余为基础图元
    const pen = pens[0];
    const penName = pen.name || '';
    if (penName === 'image')
      selections.mode = SelectionMode.Equip;
    else if (penName.startsWith('point-combine'))
      selections.mode = SelectionMode.Point;
    else if (pen.type === 1)
      selections.mode = SelectionMode.Line;
    else
      selections.mode = SelectionMode.Pen;

    selections.pen = pen;
    selections.pens = pens;
  };

  return {
    selections,
    select,
  };
}

属性面板与双向绑定

有了选中状态管理,就可以构建属性面板了。核心逻辑是:选中图元时读取其属性填入表单,修改表单时通过 meta2d.setValue() 回写到图元上。这里用 watch 监听选中图元的变化,自动刷新表单数据:

绑定选择事件并修改属性
<script setup lang="ts">
import { Meta2d } from '@meta2d/core';
import { onMounted, onUnmounted, reactive, watch } from 'vue';
import { useMeta2dSelection } from './useMeta2dSelection';

const { selections, select } = useMeta2dSelection();

// 属性表单
const form = reactive({
  id: '',
  x: 0,
  y: 0,
  width: 0,
  height: 0,
  text: '',
  color: '', // 边框颜色
  background: '', // 背景色
  fontSize: 12,
  fontFamily: 'Arial',
  lineWidth: 1,
  // 更多属性...
});

// 图元激活事件处理
function active(pens) {
  select(pens);
}

// 图元取消激活事件处理
function inactive() {
  select();
}

// 获取当前选中图元的属性
function getPenProperties() {
  const pen = selections.pen;
  if (!pen)
    return;

  // 基本属性
  form.id = pen.id || '';
  form.text = pen.text || '';
  form.color = pen.color || '';
  form.background = pen.background || '';
  form.fontSize = pen.fontSize || 12;
  form.fontFamily = pen.fontFamily || 'Arial';
  form.lineWidth = pen.lineWidth || 1;

  // 位置和大小
  const rect = meta2d.getPenRect(pen);
  if (rect) {
    form.x = Math.round(rect.x) || 0;
    form.y = Math.round(rect.y) || 0;
    form.width = Math.round(rect.width) || 0;
    form.height = Math.round(rect.height) || 0;
  }
}

// 更新图元属性
function updateProperty(prop) {
  if (!selections.pen)
    return;

  const update = { id: form.id };
  update[prop] = form[prop];

  meta2d.setValue(update, { render: true });
}

// 监听选中图元变化
watch(() => selections.pen?.id, getPenProperties);

onMounted(() => {
  // 创建 Meta2d 实例
  // eslint-disable-next-line no-new
  new Meta2d('meta2d', {});

  // 注册图元选择事件
  meta2d.on('active', active);
  meta2d.on('inactive', inactive);
});

onUnmounted(() => {
  meta2d.destroy();
});
</script>

<template>
  <div class="editor-container">
    <!-- 图元库 -->
    <div class="sidebar">
      <GraphicsPanel />
    </div>

    <!-- 画布 -->
    <div class="canvas-container">
      <div id="meta2d" class="canvas" />
    </div>

    <!-- 属性面板 -->
    <div v-if="selections.pen" class="properties-panel">
      <div class="panel-title">
        属性设置
      </div>

      <div class="property-group">
        <div class="group-title">
          位置和大小
        </div>

        <div class="property-row">
          <div class="property-label">
            X 坐标
          </div>
          <input
            v-model.number="form.x"
            type="number"
            class="property-input"
            @change="updateProperty('x')"
          >
        </div>

        <div class="property-row">
          <div class="property-label">
            Y 坐标
          </div>
          <input
            v-model.number="form.y"
            type="number"
            class="property-input"
            @change="updateProperty('y')"
          >
        </div>

        <div class="property-row">
          <div class="property-label">
            宽度
          </div>
          <input
            v-model.number="form.width"
            type="number"
            class="property-input"
            @change="updateProperty('width')"
          >
        </div>

        <div class="property-row">
          <div class="property-label">
            高度
          </div>
          <input
            v-model.number="form.height"
            type="number"
            class="property-input"
            @change="updateProperty('height')"
          >
        </div>
      </div>

      <div class="property-group">
        <div class="group-title">
          样式
        </div>

        <div class="property-row">
          <div class="property-label">
            边框颜色
          </div>
          <input
            v-model="form.color"
            type="color"
            class="property-color"
            @change="updateProperty('color')"
          >
        </div>

        <div class="property-row">
          <div class="property-label">
            背景颜色
          </div>
          <input
            v-model="form.background"
            type="color"
            class="property-color"
            @change="updateProperty('background')"
          >
        </div>

        <div class="property-row">
          <div class="property-label">
            边框宽度
          </div>
          <input
            v-model.number="form.lineWidth"
            type="number"
            class="property-input"
            @change="updateProperty('lineWidth')"
          >
        </div>
      </div>

      <div class="property-group">
        <div class="group-title">
          文本
        </div>

        <div class="property-row">
          <div class="property-label">
            内容
          </div>
          <input
            v-model="form.text"
            type="text"
            class="property-input"
            @change="updateProperty('text')"
          >
        </div>

        <div class="property-row">
          <div class="property-label">
            字体大小
          </div>
          <input
            v-model.number="form.fontSize"
            type="number"
            class="property-input"
            @change="updateProperty('fontSize')"
          >
        </div>

        <div class="property-row">
          <div class="property-label">
            字体
          </div>
          <select
            v-model="form.fontFamily"
            class="property-select"
            @change="updateProperty('fontFamily')"
          >
            <option value="Arial">
              Arial
            </option>
            <option value="Verdana">
              Verdana
            </option>
            <option value="Helvetica">
              Helvetica
            </option>
            <option value="Times New Roman">
              Times New Roman
            </option>
            <option value="Courier New">
              Courier New
            </option>
          </select>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.editor-container {
  display: flex;
  width: 100%;
  height: 100vh;
}

.sidebar {
  width: 220px;
  height: 100%;
  border-right: 1px solid #e0e0e0;
}

.canvas-container {
  flex: 1;
  height: 100%;
  position: relative;
}

.canvas {
  width: 100%;
  height: 100%;
}

.properties-panel {
  width: 280px;
  height: 100%;
  border-left: 1px solid #e0e0e0;
  overflow-y: auto;
  padding: 0 16px;
}

.panel-title {
  padding: 12px 0;
  font-size: 16px;
  font-weight: 500;
  border-bottom: 1px solid #e0e0e0;
  margin-bottom: 16px;
}

.property-group {
  margin-bottom: 16px;
}

.group-title {
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 8px;
}

.property-row {
  display: flex;
  align-items: center;
  margin-bottom: 8px;
}

.property-label {
  width: 80px;
  font-size: 14px;
}

.property-input,
.property-select {
  flex: 1;
  height: 32px;
  padding: 0 8px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
}

.property-color {
  width: 32px;
  height: 32px;
  border: none;
  padding: 0;
  background: none;
}
</style>

进阶:多画布与实时数据

当基础功能搭建完成后,你很可能会遇到两个进阶需求:同一页面管理多个画布,以及接入实时数据让画布”活”起来。

多画布的那个坑

Meta2d.js 有个设计上的特点:每次 new Meta2d() 都会把实例挂到全局 meta2d 对象上,后创建的会覆盖先创建的。这意味着如果你在主画布的弹窗里又开了一个次画布,全局的 meta2d 就指向次画布了,主画布的操作全都会出问题。

解决方法是在创建次画布后,手动把全局引用恢复回来:

多画布管理辅助函数
/**
 * 全局 meta2d 对象保存的是最后一次创建的 meta2d 对象
 *
 * 如果次画布在主画布后面实例化(比如主画布页面弹窗一个次画布),则我们需要转换一下
 *
 * https://doc.le5le.com/document/119752831
 */

import type { Options } from '@meta2d/core';
import { Meta2d } from '@meta2d/core';

export function getExtraMeta2d(id: string, options?: Options) {
  const mainMeta2d = (globalThis as any).meta2d;
  const newMeta2d = new Meta2d(id, options)
  ;(globalThis as any).meta2d = mainMeta2d;
  return newMeta2d;
}

用法很简单:主画布正常创建,次画布通过 getExtraMeta2d() 创建。函数内部会在创建完次画布后立即把全局 meta2d 恢复成主画布的引用,两个画布互不干扰。

三种实时数据通信方式

组态应用的灵魂在于”实时”——设备状态变了,画布上的图元要跟着变。Meta2d.js 支持三种数据通信方式,适用于不同的场景。

MQTT 通信适合物联网场景,设备数量多、数据推送频繁。需要注意的是,浏览器环境只能用 ws(s):// 协议,不能用 mqtt://

使用 Meta2d 内置 MQTT 功能
import type { Meta2d } from '@meta2d/core';

/**
 * Meta2d MQTT 通信辅助函数
 */
export function useMeta2dMqtt() {
  /**
   * 初始化 MQTT 连接
   */
  function init() {
    const params = {
      mqtt: 'ws://broker.example.com:8083/mqtt', // MQTT Broker WebSocket 地址
      mqttTopics: 'topic1/#,topic2', // 多个主题用逗号分隔
      mqttOptions: {
        clientId: `meta2d_${Date.now()}`, // 客户端ID
        username: 'username', // 用户名
        password: 'password', // 密码
        // 如果 clientId 不为空,默认会随机重新生成一个 clientId,避免连接冲突
        // 设置 customClientId = true 可使用用户自定义的固定 clientId
        customClientId: false,
      },
    };

    // 连接 MQTT
    meta2d.connectMqtt(params);

    // 自定义数据处理函数
    meta2d.socketFn = (data: string, context?: {
      meta2d?: Meta2d
      type?: string
      topic?: string
      url?: string
    }) => {
      try {
        // 解析数据
        const message = JSON.parse(data);

        // 处理数据,更新图元
        if (message.penId && message.value !== undefined) {
          meta2d.setValue({
            id: message.penId,
            value: message.value,
            // 其他需要更新的属性...
          });
        }

        // return false 表示仅执行自定义回调函数
        // return true 表示除了执行自定义回调外,还会执行核心库方法
        return false;
      } catch (e) {
        console.error('处理 MQTT 消息出错:', e);
        return false;
      }
    };
  }

  /**
   * 关闭 MQTT 连接
   */
  function close() {
    meta2d.closeMqtt();
  }

  return {
    init,
    close,
  };
}

如果内置的 MQTT 功能无法满足需求(比如需要更精细的连接管理),也可以自己引入 mqtt 库来实现:

自定义 MQTT 客户端
import mqtt from 'mqtt';
import { ref } from 'vue';

// MQTT 配置
const MQTT_URL = 'ws://broker.example.com:8083/mqtt';
const MQTT_TOPICS = ['device/+/data', 'system/status'];
const MQTT_OPTIONS = {
  username: 'username',
  password: 'password',
  clientId: `meta2d_client_${Date.now()}`,
};

/**
 * 自定义 MQTT 客户端辅助函数
 */
export function useCustomMqtt() {
  const client = ref(null);
  const connectionStatus = ref('disconnected');

  /**
   * 连接 MQTT 并处理消息
   * @param messageHandler 消息处理回调函数
   */
  function connect(messageHandler) {
    // 创建 MQTT 客户端
    client.value = mqtt.connect(MQTT_URL, MQTT_OPTIONS);

    // 连接成功事件
    client.value.on('connect', () => {
      connectionStatus.value = 'connected';
      console.log('MQTT 连接成功');

      // 订阅主题
      client.value.subscribe(MQTT_TOPICS, (err) => {
        if (err) {
          console.error(`MQTT 订阅主题 [${MQTT_TOPICS.join(', ')}] 失败:`, err);
        } else {
          console.log(`MQTT 已订阅主题: ${MQTT_TOPICS.join(', ')}`);
        }
      });
    });

    // 错误事件
    client.value.on('error', (err) => {
      console.error('MQTT 连接错误:', err);
      connectionStatus.value = 'error';
    });

    // 断开连接事件
    client.value.on('close', () => {
      console.log('MQTT 连接已关闭');
      connectionStatus.value = 'disconnected';
    });

    // 重连事件
    client.value.on('reconnect', () => {
      console.log('MQTT 正在重连...');
      connectionStatus.value = 'reconnecting';
    });

    // 消息事件
    client.value.on('message', (topic, payload) => {
      try {
        const message = JSON.parse(payload.toString());
        console.log(`收到 MQTT 消息,主题: ${topic}`, message);

        // 调用消息处理函数
        messageHandler(topic, message);
      } catch (e) {
        console.error('处理 MQTT 消息失败:', e);
      }
    });
  }

  /**
   * 断开 MQTT 连接
   */
  function disconnect() {
    if (client.value && client.value.connected) {
      client.value.end(false, () => {
        console.log('MQTT 连接已断开');
        connectionStatus.value = 'disconnected';
      });
    }
  }

  /**
   * 发布消息到指定主题
   * @param topic 主题
   * @param message 消息内容
   * @param options 发布选项
   */
  function publish(topic, message, options = {}) {
    if (!client.value || !client.value.connected) {
      console.error('MQTT 客户端未连接,无法发布消息');
      return false;
    }

    try {
      const payload = typeof message === 'object' ? JSON.stringify(message) : message;
      client.value.publish(topic, payload, options, (err) => {
        if (err) {
          console.error(`发布消息到主题 ${topic} 失败:`, err);
        }
      });
      return true;
    } catch (e) {
      console.error('发布 MQTT 消息失败:', e);
      return false;
    }
  }

  return {
    client,
    connectionStatus,
    connect,
    disconnect,
    publish,
  };
}

WebSocket 通信是最常见的方案,适合大多数 Web 应用场景:

WebSocket 连接方法
/**
 * Meta2d WebSocket 通信
 */
export function useMeta2dWebsocket() {
  /**
   * 初始化 WebSocket 连接
   * @param url WebSocket 服务器地址
   */
  function connect(url: string) {
    // 方式一:直接连接
    meta2d.connectWebsocket(url);

    // 方式二:先设置地址再连接
    // meta2d.store.data.websocket = url;
    // meta2d.connectWebsocket();

    // 自定义数据处理函数
    meta2d.socketFn = (data: string, context?: {
      meta2d?: Meta2d
      type?: string
      topic?: string
      url?: string
    }) => {
      try {
        const message = JSON.parse(data);
        console.log('收到 WebSocket 消息:', message);

        // 处理数据,更新图元
        if (message.penId && message.value !== undefined) {
          meta2d.setValue({
            id: message.penId,
            value: message.value,
            // 其他需要更新的属性...
          });
        }

        return false; // 仅执行自定义回调
      } catch (e) {
        console.error('处理 WebSocket 消息出错:', e);
        return false;
      }
    };
  }

  /**
   * 关闭 WebSocket 连接
   */
  function disconnect() {
    meta2d.closeWebsocket();
  }

  return {
    connect,
    disconnect
  };
}

HTTP 轮询虽然实时性不如前两者,但胜在简单可靠,不需要额外的服务端支持。特别适合数据更新频率不高的场景:

HTTP 轮询配置
/**
 * Meta2d HTTP 轮询
 */
export function useMeta2dHttp() {
  /**
   * 配置单个 HTTP 轮询
   * @param url API 地址
   * @param interval 轮询间隔(毫秒),默认 1000ms
   * @param headers 请求头
   */
  function setupSingleHttp(url: string, interval: number = 1000, headers: Record<string, string> = {}) {
    // 配置 HTTP 轮询
    meta2d.store.data.http = url;
    meta2d.store.data.httpTimeInterval = interval;
    meta2d.store.data.httpHeaders = headers;

    // 启动轮询
    meta2d.connectHttp();
  }

  /**
   * 配置多个 HTTP 轮询(1.0.26 版本以上支持)
   * @param configs HTTP 配置数组
   */
  function setupMultiHttp(configs: Array<{
    url: string
    method?: 'GET' | 'POST'
    interval?: number
    headers?: Record<string, string>
  }>) {
    // 确保 https 数组存在
    if (!meta2d.store.data.https) {
      meta2d.store.data.https = [];
    }

    // 配置多个 HTTP 轮询
    configs.forEach((config, index) => {
      // 确保数组长度足够
      while (meta2d.store.data.https.length <= index) {
        meta2d.store.data.https.push({});
      }

      // 设置配置
      meta2d.store.data.https[index].http = config.url;
      meta2d.store.data.https[index].method = config.method || 'GET';
      meta2d.store.data.https[index].httpTimeInterval = config.interval || 1000;
      meta2d.store.data.https[index].httpHeaders = config.headers || {};
    });

    // 启动轮询
    meta2d.connectHttp();
  }

  /**
   * 停止 HTTP 轮询
   */
  function stopHttp() {
    meta2d.closeHttp();
  }

  return {
    setupSingleHttp,
    setupMultiHttp,
    stopHttp
  };
}

工程化实践

当项目复杂度上来之后,把 Meta2d 的各个功能模块拆分清楚就变得很重要了。下面分享几个在实际项目中总结出的实践。

组件化封装

与其让 Meta2d 的初始化代码散落在各个页面,不如封装成一个通用的 Vue 组件。通过 props 接收配置和初始数据,通过 emit 暴露事件,父组件用起来会清爽很多:

Meta2d 核心组件
<!-- Meta2dCanvas.vue -->
<script setup lang="ts">
import type { Pen } from '@meta2d/core';
import { Meta2d } from '@meta2d/core';
import { onMounted, onUnmounted, ref, watch } from 'vue';

const props = defineProps({
  // 画布配置选项
  options: {
    type: Object,
    default: () => ({})
  },
  // 初始数据
  initialData: {
    type: Object,
    default: null
  },
  // 画布ID
  canvasId: {
    type: String,
    default: 'meta2d'
  }
});

const emit = defineEmits([
  'active',
  'inactive',
  'click',
  'contextmenu',
  'mousedown',
  'mouseup',
  'mousemove'
]);

// Meta2d 实例引用
const meta2dInstance = ref(null);

// 初始化 Meta2d
function initMeta2d() {
  // 创建实例
  meta2dInstance.value = new Meta2d(props.canvasId, props.options);

  // 注册事件
  meta2dInstance.value.on('active', (pens: Pen[]) => {
    emit('active', pens);
  });

  meta2dInstance.value.on('inactive', () => {
    emit('inactive');
  });

  meta2dInstance.value.on('click', (e: MouseEvent) => {
    emit('click', e);
  });

  meta2dInstance.value.on('contextmenu', (e: MouseEvent) => {
    emit('contextmenu', e);
  });

  meta2dInstance.value.on('mousedown', (e: MouseEvent) => {
    emit('mousedown', e);
  });

  meta2dInstance.value.on('mouseup', (e: MouseEvent) => {
    emit('mouseup', e);
  });

  meta2dInstance.value.on('mousemove', (e: MouseEvent) => {
    emit('mousemove', e);
  });

  // 加载初始数据
  if (props.initialData) {
    meta2dInstance.value.open(props.initialData);
  }
}

// 监听初始数据变化
watch(() => props.initialData, (newData) => {
  if (meta2dInstance.value && newData) {
    meta2dInstance.value.open(newData);
  }
}, { deep: true });

// 暴露方法给父组件
defineExpose({
  getInstance: () => meta2dInstance.value
});

onMounted(() => {
  initMeta2d();
});

onUnmounted(() => {
  if (meta2dInstance.value) {
    meta2dInstance.value.destroy();
  }
});
</script>

<template>
  <div :id="canvasId" class="meta2d-canvas" />
</template>

<style scoped>
.meta2d-canvas {
  width: 100%;
  height: 100%;
}
</style>

数据持久化

画布上精心搭建的组态画面,当然需要保存下来。下面这个工具函数集合涵盖了导出 JSON、从文件加载、导出为图片等常见操作:

数据保存与加载
/**
 * Meta2d 数据持久化辅助函数
 */
export function useMeta2dData() {
  /**
   * 保存画布数据
   * @param filename 文件名
   */
  function saveData(filename: string = 'meta2d-data.json') {
    try {
      // 获取画布数据
      const data = meta2d.data();

      // 创建下载链接
      const dataStr = JSON.stringify(data, null, 2);
      const dataBlob = new Blob([dataStr], { type: 'application/json' });
      const url = URL.createObjectURL(dataBlob);

      // 创建下载元素
      const link = document.createElement('a');
      link.href = url;
      link.download = filename;
      link.click();

      // 清理
      URL.revokeObjectURL(url);

      return true;
    } catch (e) {
      console.error('保存画布数据失败:', e);
      return false;
    }
  }

  /**
   * 加载画布数据
   * @param data 画布数据对象
   */
  function loadData(data: any) {
    try {
      meta2d.open(data);
      return true;
    } catch (e) {
      console.error('加载画布数据失败:', e);
      return false;
    }
  }

  /**
   * 从文件加载画布数据
   * @param file 文件对象
   * @returns Promise
   */
  function loadFromFile(file: File): Promise<boolean> {
    return new Promise((resolve, reject) => {
      if (!file) {
        reject(new Error('未提供文件'));
        return;
      }

      const reader = new FileReader();

      reader.onload = (e) => {
        try {
          const data = JSON.parse(e.target.result as string);
          meta2d.open(data);
          resolve(true);
        } catch (err) {
          console.error('解析文件数据失败:', err);
          reject(err);
        }
      };

      reader.onerror = (e) => {
        console.error('读取文件失败:', e);
        reject(e);
      };

      reader.readAsText(file);
    });
  }

  /**
   * 导出为图片
   * @param filename 文件名
   * @param type 图片类型 'png' | 'jpeg' | 'svg'
   */
  function exportImage(filename: string = 'meta2d-canvas', type: 'png' | 'jpeg' | 'svg' = 'png') {
    try {
      if (type === 'svg') {
        meta2d.toSVG(filename);
      } else {
        meta2d.toPng(filename, type);
      }
      return true;
    } catch (e) {
      console.error(`导出${type}图片失败:`, e);
      return false;
    }
  }

  return {
    saveData,
    loadData,
    loadFromFile,
    exportImage
  };
}

自定义图元

内置图元满足不了业务需求时,就需要自己造了。Meta2d.js 支持通过自定义渲染函数来绘制任意图形,下面是一个包含设备图元和指标图元的示例:

自定义图元示例
/**
 * 创建自定义图元
 */
export function createCustomPens() {
  // 自定义设备图元
  const customDevicePen = {
    name: 'customDevice',
    icon: 'l-custom-device',
    data: {
      text: '自定义设备',
      width: 120,
      height: 80,
      name: 'customDevice',
      // 自定义属性
      deviceId: '',
      status: 'normal',
      // 自定义图形
      path: [
        ['M', 0, 0],
        ['L', 120, 0],
        ['L', 120, 80],
        ['L', 0, 80],
        ['Z'],
        ['M', 10, 20],
        ['L', 110, 20],
        ['L', 110, 60],
        ['L', 10, 60],
        ['Z']
      ],
      // 自定义样式
      lineWidth: 2,
      color: '#1890ff',
      background: '#e6f7ff',
      // 状态样式映射
      statusStyles: {
        normal: { background: '#e6f7ff', color: '#1890ff' },
        warning: { background: '#fff7e6', color: '#fa8c16' },
        error: { background: '#fff1f0', color: '#f5222d' }
      }
    }
  };

  // 自定义指标图元
  const customMetricPen = {
    name: 'customMetric',
    icon: 'l-custom-metric',
    data: {
      text: '0',
      width: 80,
      height: 40,
      name: 'customMetric',
      // 自定义属性
      metricId: '',
      unit: '',
      min: 0,
      max: 100,
      // 自定义样式
      textAlign: 'center',
      textBaseline: 'middle',
      fontSize: 16,
      fontWeight: 'bold',
      // 自定义渲染
      onRender: (ctx, pen) => {
        // 绘制背景
        ctx.fillStyle = pen.background || '#f0f0f0';
        ctx.strokeStyle = pen.color || '#d9d9d9';
        ctx.lineWidth = pen.lineWidth || 1;

        const x = pen.calculative.worldRect.x;
        const y = pen.calculative.worldRect.y;
        const w = pen.calculative.worldRect.width;
        const h = pen.calculative.worldRect.height;

        // 绘制圆角矩形
        const r = 4; // 圆角半径
        ctx.beginPath();
        ctx.moveTo(x + r, y);
        ctx.lineTo(x + w - r, y);
        ctx.arcTo(x + w, y, x + w, y + r, r);
        ctx.lineTo(x + w, y + h - r);
        ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
        ctx.lineTo(x + r, y + h);
        ctx.arcTo(x, y + h, x, y + h - r, r);
        ctx.lineTo(x, y + r);
        ctx.arcTo(x, y, x + r, y, r);
        ctx.closePath();

        ctx.fill();
        ctx.stroke();

        // 绘制文本
        ctx.fillStyle = pen.fontColor || '#000';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.font = `${pen.fontWeight || ''} ${pen.fontSize || 16}px ${pen.fontFamily || 'Arial'}`;

        let text = pen.text || '0';
        if (pen.unit) {
          text += ` ${pen.unit}`;
        }

        ctx.fillText(text, x + w / 2, y + h / 2);

        return true; // 返回 true 表示自定义渲染
      }
    }
  };

  return [customDevicePen, customMetricPen];
}

// 注册自定义图元
export function registerCustomPens() {
  const customPens = createCustomPens();
  meta2d.register(customPens);
}

Demo 项目与总结

在学习和实践 Meta2d.js 的过程中,我搭了一个 Vue3 的演示项目,把上面提到的功能都集成了进去,有兴趣的话可以看看:

回顾整个使用过程,Meta2d.js 给我的感觉是”够用且灵活”。它不像一些重量级框架那样大而全,但在 2D 组态这个垂直领域,该有的功能基本都有——图元拖拽、属性编辑、多种数据通信方式、自定义图元、多画布管理。上手成本不算高,但要用好确实需要花些时间去理解它的设计思路,特别是全局 meta2d 对象的管理和 socketFn 回调的工作机制。

如果你正在做组态监控、流程图编辑器或者其他 2D 可视化项目,Meta2d.js 是一个值得尝试的选择。