17天跑通交通行业SVG编辑器

0 阅读5分钟

本案例是一个交通行业的图形编辑系统,前后端联调数据,各个子公司组织都可以进入后台编辑自己部门的设备摆放。

技术栈: D3.js + Draggable + Jsplumb + snap.svg 介绍: 公司使用的设备路由器,电力猫,电力路由器,交换机,电力变压器等设备在幕布上拖拽摆放布局,与Pixso和Sketch等纯SVG制作编辑工具不同。

先说说17天都干了什么: 1. —— 读懂编辑器现有的DOM操作和数据绑定逻辑,补齐编辑器功能,抽象工具箱各模块功能,数据独立可单独修改。 原计划一到两个月。

2. —— 基础功能涉及坐标计算、DOM操作、序列化/反序列化,选中设备独立弹窗可编辑参数。

  1. —— 数据结构 单个编辑器画布数据保存,本地数据缓存和基于组织结构的各分支机构根据GroupId读取各自的编辑器数据,跑通整个链路。

为什么是17天,不是15天呢。因为刚开始部门领导定的时间是一个半月并且说不要太着急,因为行业内没有太多类似的产品,有困难是一定的。

但我当时评估系统的数据和DOM结构,加上画布之间还要有拖拽、连接线等效果和被选中icon的节点数据交互,需要按照公司给定的基本框架去完善功能和设计数据。如果15天左右还摸不清楚这套系统的数据逻辑,那很可能接下来两个月内都要在这套数据里面打转,所以突破必须尽快。

一、设计思路:

  1. 左侧设备ICON盒子
  2. 中间幕布
  3. 右侧设备弹窗--可选条件和设备属性信息
  4. 头部---SVG工具条

基本布局已经有了,4的工具条功能需要补充,例如恢复/撤销,标注,保存/导出/删除/重置等功能。

image.png

当时是2021年左右,基于vue2数据交互,vuex存储交互数据,撤销/恢复时与本地data对比核对,取出数据和删除数据。 这个设计有个bug,本地arr数组存储数据和比对,步数多了对页面性能不好,没有做好解耦。这种设计临时可以解决交付。如果想灵活的把toolbox的功能做好,必须重新全局设计数据体系,时间上来不及了。 画布属于当前页面的初始化,虽然也要本地持久一部分数据,后来业务提了一个功能,进入页面或者本地崩溃误刷新时全局恢复布局。这个功能的处理在main.js挂载了一个原型链 Vue.prototype.wholeData替代当前页面的本地arr数组。

二、后端数据 个人profile画布数据上云

公司有遍布全省的多层组织机构,数据不能保存在本地,保存时默认就存数据库了,方便总部调取查看,后台也要根据操作员设计权限。
最后设定数据样式:
key: groupId, data:{ {icon:xxx,x:222,y:123,shape:{svgdraw},{icon:xxx,x:222,y:123,shape:{svgdraw}}
通过画布上已经选中的icon,组装成需要的数据格式,调用接口完成上传。加载画布时获取数据解构恢复。   

三、JSPlumb + D3实例

技术栈核心职责典型应用场景
D3.js数据驱动的布局与视图更新自动布局、数据绑定、画布控制 (zoom)
Snap.svg复杂图形绘制与动画节点图形、路径编辑、元素动画
jsPlumb连线交互与管理拖拽连线、端点管理、连接事件监听

新建svg图形功能本章不讨论。

// --- 4. 选中节点逻辑 ---
        function selectNode(nodeId, d3Group) {
            // 移除之前的选中样式
            if(selectedNodeId) {
                const prevNode = nodesData.get(selectedNodeId);
                if(prevNode) prevNode.d3Group.classed("selected", false);
            }
            selectedNodeId = nodeId;
            d3Group.classed("selected", true);
            updatePropertyPanel(nodeId);
        }
// --- 5. 删除当前选中的节点 ---
        function deleteSelectedNode() {
            if(!selectedNodeId) {
                alert("未选中任何节点");
                return;
            }
            const node = nodesData.get(selectedNodeId);
            if(node) {
                // 移除所有与这个节点相关的连线
                jsPlumbInstance.deleteConnectionsForElement(node.element);
                // 从jsPlumb移除该元素的 source/target
                jsPlumbInstance.unmakeSource(node.element);
                jsPlumbInstance.unmakeTarget(node.element);
                // 移除 SVG DOM 元素
                node.d3Group.remove();
                // 删除数据
                nodesData.delete(selectedNodeId);
                selectedNodeId = null;
// --- 6. 重置全部节点 ---
        function resetAll() {
            // 遍历删除所有节点
            for(let [id, node] of nodesData.entries()) {
                jsPlumbInstance.deleteConnectionsForElement(node.element);
                jsPlumbInstance.unmakeSource(node.element);
                jsPlumbInstance.unmakeTarget(node.element);
                node.d3Group.remove();
            }
            nodesData.clear();
            selectedNodeId = null;
            currentNodeId = 1;
// --- 8. 保存当前拓扑为JSON并导出 (演示)---
        function saveToJSON() {
            let nodesExport = [];
            for(let [id, node] of nodesData.entries()) {
                nodesExport.push({
                    id: id,
                    type: node.type,
                    icon: node.icon,
                    color: node.color,
                    x: node.x,
                    y: node.y
                });
            }
            // 获取所有连接线
            let connections = jsPlumbInstance.getAllConnections();
            let connsExport = connections.map(conn => ({
                sourceId: conn.sourceId,
                targetId: conn.targetId
            }));
            const exportData = { nodes: nodesExport, connections: connsExport };
            console.log("保存数据:", exportData);

Vue2中初始化JSPlumb:

初始化jsPlumb 实例 (this.jsplumb) 以及一个包含节点元素的数组或 Map

methods: {
  // 初始化 jsPlumb
  initJsPlumb() {
    this.jsplumb = jsPlumb.getInstance({
      Container: 'svg-container',  // 画布容器id
      DragOptions: { stop: this.onNodeDragStop }, // 拖拽停止回调
    });

    // 使所有 .node 元素可拖拽
    this.jsplumb.setDraggable('.node', {
      containment: 'parent',
      stop: this.onNodeDragStop
    });
  },

  // 拖拽停止时,获取节点 id 和新坐标
  onNodeDragStop(params) {
    const el = params.el;               // 被拖拽的 DOM 元素
    const nodeId = el.getAttribute('data-node-id');
    // 获取元素当前的 transform 或使用 getBoundingClientRect
    const x = parseFloat(el.getAttribute('data-x')) || 0;
    const y = parseFloat(el.getAttribute('data-y')) || 0;
    // 更新你的数据模型 (Vue data)
    const node = this.nodes.find(n => n.id === nodeId);
    if (node) {
      node.x = x;
      node.y = y;
    }
    // 通知 jsPlumb 重绘连线
    this.jsplumb.repaint(el);
  },

  // 节点选中事件(原生 click)
  onNodeClick(event, nodeId) {
    const el = event.currentTarget;
    // 获取节点位置
    const rect = el.getBoundingClientRect();
    const x = rect.left;
    const y = rect.top;
    console.log(`选中节点 ${nodeId},坐标:${x}, ${y}`);
    // 更新右侧面板显示
    this.selectedNode = this.nodes.find(n => n.id === nodeId);
  }
}

D3.js的作用?

 D3.js:在 SVG 编辑器中还能做什么?

即使 jsPlumb 能画连接线和拖拽,D3 依然有不可替代的作用:

核心作用

  • 数据驱动创建节点:根据 nodes 数组自动生成、更新或删除 <g> 元素。
  • 布局算法:自动计算节点位置(树状图、力导向图等)。
  • 路径 / 复杂图形:绘制自定义的 SVG 图形(如设备图标、流程图形状)。
  • 画布缩放与平移:通过 d3.zoom 实现全局视口控制。
  • 动画过渡:平滑更新节点位置或连线。

本章不讨论具体D3.js + JSPlumb.js的复杂功能结合与实现,部分功能和模块在其他章节中有结合会提到。