在本文档中,我们将探讨如何使用 D3.js,结合 SVG(可缩放矢量图形)和 Canvas,来实现高效、交互性强的路网图效果。D3.js 是一个强大的 JavaScript 数据可视化库,可以基于数据驱动文档对象模型(DOM)的变化,轻松创建复杂的图表和图形;而 SVG 和 Canvas 则提供了不同的图形渲染能力,帮助我们在不同的使用场景下选择最适合的技术方案。
本文详细介绍如何使用这些技术构建一个可交互的路网图,我将从基础的路网图结构入手,逐步引导读者了解如何实现图形渲染、动态更新和用户交互,最终展示一个完整的、可视化的路网系统。
适用场景
D3.js、SVG 和 Canvas 作为图形和数据可视化的关键技术,在不同的场景下具有独特的优势,能够高效地实现路网图效果。以下是这些技术在具体应用中的适用场景:
-
动态交互式地图和图形
在需要对路网进行动态交互和实时更新的场景中,D3.js 和 SVG 提供了强大的数据绑定和 DOM 操作能力,使得用户可以通过交互操作(如缩放、拖动、选择)实时查看路网的变化。例如,智能交通系统中用户希望查看特定区域的实时交通流量或事故信息时,D3.js 和 SVG 能够高效地呈现这些数据。
-
高效展示大规模路网数据
对于大规模路网数据的可视化,Canvas 提供了更高效的渲染能力,能够处理大量元素而不会影响性能。在一些需要展示数千或数万个路段、交叉口等细节的场景中,Canvas 的像素级渲染特性使得它比 SVG 更加适合。例如,城市交通管理平台中,Canvas 可以用来高效展示多个城市或区域的交通流图。
-
复杂路径计算与路网分析
在进行路网分析(如最短路径计算、交通流模拟等)时,D3.js 的数据绑定特性使得用户能够直观地展示计算结果。利用 D3.js 动态绘制路径,并根据分析结果调整颜色、宽度等属性,可以更清晰地展示交通流向、拥堵情况等信息。
-
移动端和低性能设备的兼容性
当需要在低性能设备(如移动设备、嵌入式系统等)上展示路网图时,Canvas 由于其较低的计算和内存消耗,能够在一定程度上确保流畅的显示效果。而 D3.js + SVG 适用于更高性能的设备,能够提供更细致的交互和视觉效果。
技术选型
-
SVG
SVG 是一种基于 XML 的图形格式,用于在网页中绘制矢量图形。它是 D3.js 的主要底层技术,可以非常精确地控制图形的渲染,支持交互性和动画。
优势:
- 矢量图形: 无论放大多少倍,图形不会失真,适用于需要高分辨率和清晰度的场景。
- DOM 控制: 可以直接操作 DOM 元素,通过 CSS 和 JavaScript 来控制图形。
- 易于交互: 可以通过事件绑定(如鼠标点击、悬停等)实现交互。
- 易于调试: 因为是基于 DOM 的,可以通过浏览器的开发者工具轻松调试。
缺点:
- 性能瓶颈:当元素数量增加到几千甚至更多时,浏览器的渲染性能可能下降。
- 不适合渲染动态变化频繁的、复杂的图形(如实时路网图)。
-
Canvas
Canvas 是一种 HTML 元素,可以通过 JavaScript 动态生成图形,适用于高效的像素级图形渲染。Canvas 不像 SVG 那样基于 DOM,每次绘制图形都会覆盖上一次的绘制,适合大量图形的高效渲染。
优势:
- 高效渲染: 适用于渲染大量图形和实时更新。Canvas 能够在每帧中高效地绘制大量元素,性能相较于 SVG 更强。
- 适合游戏和实时渲染: 常用于高效绘制动态场景,如游戏开发、实时数据可视化等。
- 不受元素数量限制: 渲染上没有像 SVG 那样的元素数量限制,能够支持大量图形的绘制。
缺点:
- 缺乏 DOM 支持: 不像 SVG,Canvas 中的图形无法直接通过 DOM 操作,无法像 SVG 一样单独访问每个元素进行修改。
- 交互较为复杂: Canvas 中的交互通常需要手动实现(如鼠标事件绑定),不像 SVG 那样能够直接绑定事件。
上述列出了SVG和Canvas的优缺点对比,关键点还是元素体量的大小,体量较大时建议Canvas,相反则选择SVG
案例展示
案例中的动画效果涉及枢纽点位的,会有误差。原因是枢纽点位暂未沟通好数据格式
-
单条轨迹/多条轨迹
-
点位按照原图比例展示/铺满全屏
-
根据路线,自动生成高速名称点位
-
点位弹窗展示点位信息
-
点位连线(直线和曲线)
-
轨迹动画
-
分段虚线流动效果-直线
-
分段虚线流动效果-曲线
-
路径追踪动画-曲线
-
路径追踪动画-直线
-
跨路段和基于高速名称
跨路段数据是前端用于展示,写死的数据;实际项目中前端可以获取到轨迹点位
-
点位信息展示
-
费用信息展示
-
注意:与后端沟通过,当前无法获取到站点的经纬度,只有站点相对于底图的xy坐标。但是前端mock数据是根据经纬度,因此点位处理经过了两次转换:先将经纬度按照底图的宽高转变为xy坐标,再将依据底图的xy坐标转换为相对于画布大小的xy坐标
踩坑集合
SVG
- 多次绘制点位时,
svg.selectAll(自定义内容).data(pointData).enter().append("text")
,自定义内容不可重复或者相同 - 数据绑定的流程:
.selectAll() → .data() → .enter() → .append()
- 若交互中存在轨迹动效,在初始化绘制点位连线时尽量使用path而不要使用line元素。line元素主要用于绘制直线,path元素绘制任意形状,可以是直线、曲线、封闭图形等,且支持动画
- 为元素添加鼠标移入移出事件,建议使用
mouseenter
和mouseleave
,不要使用mouseout
和mouseover
。目的是避免事件冒泡,造成不必要的交互问题。
Canvas
- 通过图片画点位,需要先对使用图片进行预加载。图片加载完成后,在回调函数中画点,否则会出现点位无法渲染的问题
性能优化
-
点位创建与更新
// 画点(方式1) this.svgInstance.selectAll(".point").data(this.points).enter().append("image").attr("x", (d) => d.x - 10).attr("y", (d) => d.y - 10).attr("width", 20).attr("height", 20).attr("href", require("../../../assets/equipmentIcon.png")).on("click", function (event, d) {});// 画点(方式2) this.svgInstance.selectAll(".point").data(data).join((enter) =>enter.append("image").attr("x", (d) => d.x - 10).attr("y", (d) => d.y - 10).attr("width", 20).attr("height", 20).attr("id", id).attr("href",require("../../../assets/equipmentIcon.png")),(update) =>update.attr("x", (d) => d.x - 10).attr("y", (d) => d.y - 10),(exit) => exit.remove());
方式2
在更新、删除和插入 DOM 元素时更加高效,特别是针对数据量较大的场景,它能够减少不必要的 DOM 操作和提高渲染性能。 -
分层渲染
<template><div class="map-container"><div class="map-test" ref="d3Chart"></div></div> </template><script> import * as d3 from "d3";...,methods: {createChart() {let width = this.$refs.d3Chart.offsetWidth;let height = this.$refs.d3Chart.offsetHeight;this.svgInstance = d3.select(this.$refs.d3Chart).append("svg").attr("width", width).attr("height", height);// 创建连线图层this.footerLayer = this.svgInstance.append("g").attr("class", "footer-layer");// 创建点位图层this.headLayer = this.svgInstance.append("g").attr("class", "head-layer");const dataPoints = this.createPoint(10000);const footerPointLayerData = dataPoints.slice(0, 5000);// const footerLineLayerData = linesData.slice(0, 1);const headPointLayerData = dataPoints.slice(5000, 10000);// const headLineLayerData = linesData.slice(1, 2);// this.drawLine(this.footerLayer, 'footer-line', footerLineLayerData);this.drawPoint(this.footerLayer,"footer-point",footerPointLayerData);// this.drawLine(this.headLayer, 'head-line', headLineLayerData);this.drawPoint(this.headLayer, "head-point", headPointLayerData);},drawPoint(instance, id, data) {instance.selectAll(".point").data(data).join((enter) =>enter.append("image").attr("x", (d) => d.x - 10).attr("y", (d) => d.y - 10).attr("width", 20).attr("height", 20).attr("id", id).attr("href",require("../../../assets/equipmentIcon.png")),(update) =>update.attr("x", (d) => d.x - 10).attr("y", (d) => d.y - 10),(exit) => exit.remove());},drawLine(instance, id, data) {instance.selectAll("line").data(data).enter().append("line").attr("id", id).attr("x1", (d) => d.x1).attr("y1", (d) => d.y1).attr("x2", (d) => d.x2).attr("y2", (d) => d.y2).attr("stroke", "black").attr("stroke-width", 2);},createPoint(total) {const result = [];for (let i = 0; i < total; i++) {result.push({id: i,radius: 10,...this.generateRandomCoordinates(),});}return result;},generateRandomCoordinates() {// 生成 x 在 0 到 1920 范围内的随机数const x = Math.floor(Math.random() * 1921); // 1921 是因为 Math.random() 生成的值是 [0, 1) 区间// 生成 y 在 0 到 945 范围内的随机数const y = Math.floor(Math.random() * 946); // 946 是因为 [0, 945] 范围内包括了 945return { x, y };},},mounted() {this.createChart();},
D3 使用 SVG 元素渲染点位和连线时,如果所有元素都放在一个图层中,浏览器在进行绘制、更新和重绘时,可能会导致性能瓶颈。分图层渲染的基本思路是将不同类型的元素(例如点、连线、文本标签等)分到不同的图层中,这样可以减少对整个图层的重新渲染,只更新需要变动的部分,从而提高性能。
-
开启硬件加速(GPU 加速)
drawPointText(svg,pointData,property,x = 0,y = 0,dx = 0,dy = 0,color = "#000",fontSize = 12,fontWeight = 400 ) {svg.selectAll(`.text-${property}`).data(pointData).enter().append("text").attr("class", `.text-${property}`).attr("x", (d) => d.x + x + dx).attr("y", (d) => d.y + y).attr("text-anchor", "middle").attr("fill", color).attr("font-size", fontSize).attr("font-weight", fontWeight).style("will-change", "transform") // 提示浏览器将应用 GPU 加速.style("transform", "translate3d(0, 0, 0)") // 启用 GPU 加速.append("tspan").attr("x", (d) => d.x + dx).attr("dy", dy).text((d) => {if (property === "code") {return d.type ? d[property] : "";}return d[property];}); },
- 通过为绘制的 点 或 线 添加
transform: translate3d(0, 0, 0);
,你可以将图形渲染转移到 GPU 进行加速处理,而不是由 CPU 执行。这种方法对于优化动画和动态变换非常有效。 - 使用
will-change
属性告诉浏览器某个元素将会变化,这样浏览器可以提前为该元素做优化,避免每次渲染时都重新计算。 - 这会让浏览器将这些元素提升到 GPU 层,减少 CPU 和 GPU 之间的线程竞争,从而提高性能。
- 通过为绘制的 点 或 线 添加
数据源
data.js**:真实数据中没有经纬度,只有相对于底图的xy坐标**
export const pointData = Object.freeze({'申苏浙皖': [{lat: 30.19234,lon: 120.266129,label: "窑上",code: 1,source: 1,target: 2,road: 1,type: "gantry",gsName: "申苏浙皖",// position: 'start',},{lat: 30.1961,lon: 120.2730,label: "常山",code: 2,source: 2,target: 3,type: "gantry",gsName: "申苏浙皖",// position: 'transition',road: 1,},{lat: 30.2001,lon: 120.2810,label: "柯城",code: 3,source: 3,target: 4,road: 1,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2041,lon: 120.2890,label: "衢州东",code: 4,source: 4,target: 5,road: 1,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2081,lon: 120.2950,label: "龙游枢纽",code: 5,road: [1,7],source: 5,target: 6,type: "icon-hub",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2141,lon: 120.3020,label: "游垺",code: 6,source: 6,road: 1,target: 7,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2221,lon: 120.3060,label: "兰溪",code: 7,source: 7,target: 8,road: 1,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2291,lon: 120.3100,label: "金华",code: 8,source: 8,target: 9,road: 1,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2361,lon: 120.3140,label: "上溪",road: 1,code: 9,source: 9,target: 10,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2421,lon: 120.3180,label: "浦江",road: 1,code: 10,source: 10,target: 11,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2531,lon: 120.3280,label: "牌头",code: 11,road: 1,source: 11,target: 12,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2631,lon: 120.3370,label: "次屋",code: 12,road: 1,source: 12,target: 13,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2721,lon: 120.3470,label: "临浦枢纽",code: 13,road: [1,2,5,4,10,5],source: 13,target: 14,type: "icon-hub",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2801,lon: 120.3580,label: "河桥西",code: 14,road: 2,source: 14,target: 15,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2801,lon: 120.3630,label: "萧山东",code: 15,road: 2,source: 15,target: 29,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2861,lon: 120.3680,label: "新街",road: 2,code: 29,source: 29,target: 30,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2891,lon: 120.3730,label: "机场",code: 30,road: 2,source: 30,target: 31,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2931,lon: 120.3780,label: "瓜沥",code: 31,source: 31,road: 2,target: 32,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2971,lon: 120.3820,label: "柯桥",code: 32,source: 32,road: 2,target: 33,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3071,lon: 120.3880,label: "绍兴",code: 33,source: 33,road: 2,target: 34,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3109,lon: 120.3932,label: "孙端",code: 34,source: 34,road: 2,target: 35,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3009,lon: 120.4032,label: "上虞",code: 35,road: 2,source: 35,target: 36,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2909,lon: 120.4092,label: "牟山",code: 36,source: 36,road: 2,target: 37,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2839,lon: 120.4162,label: "余姚西",code: 37,road: 2,source: 37,target: 38,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2929,lon: 120.4192,label: "余姚东",code: 38,road: 2,source: 38,target: 39,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2869,lon: 120.4232,label: "马褚",code: 39,road: 2,source: 39,target: 40,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.2909,lon: 120.4272,label: "阳明",code: 40,road: 2,source: 40,target: 41,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3109,lon: 120.4342,label: "泗门",code: 41,road: 2,source: 41,target: 42,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3169,lon: 120.4442,label: "小曹娥",code: 42,road: 2,source: 42,target: 43,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3209,lon: 120.4502,label: "庵东西",code: 43,road: 2,source: 43,target: 44,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3289,lon: 120.4582,label: "宁波新西",code: 44,road: 2,source: 44,target: 45,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3789,lon: 120.4682,label: "水湾路",code: 45,road: 2,source: 45,target: 46,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.3709,lon: 120.48602,label: "海中平台",code: 46,road: 2,source: 46,target: 47,type: "gantry",gsName: "申苏浙皖",// position: 'transition',},{lat: 30.39120,lon: 120.493281,label: "南浔",code: 47,road: 2,source: 47,target: null,type: "gantry",gsName: "申苏浙皖",// position: 'end',},],'申嘉湖': [{lat: 30.40219,lon: 120.304172,label: "浙苏",code: 100,source: 100,target: 101,road: 3,type: "station",gsName: "申嘉湖",// position: 'start',},{lat: 30.3869,lon: 120.3092,label: "梅山北",code: 101,source: 101,target: 102,road: 3,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.37219,lon: 120.3152,label: "梅山南",code: 102,source: 102,target: 103,road: 3,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.36219,lon: 120.3192,label: "泗安北",code: 103,source: 103,target: 104,road: 3,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.34419,lon: 120.3222,label: "天子湖枢纽",code: 104,road: 3,source: 104,target: 105,type: "icon-hub",gsName: "申嘉湖",// position: 'transition',},{lat: 30.33419,lon: 120.3202,label: "安吉北",code: 105,road: 4,source: 105,target: 106,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.32619,lon: 120.3182,label: "安吉开发区",road: 4,code: 106,source: 106,target: 107,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.31619,lon: 120.3202,label: "安吉",road: 4,code: 107,source: 107,target: 108,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.30519,lon: 120.3202,label: "百丈",road: 4,code: 108,source: 108,target: 109,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.29019,lon: 120.3252,label: "黄湖",road: 4,code: 109,source: 109,target: 110,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.288019,lon: 120.3272,label: "径山",road: 4,code: 110,source: 110,target: 111,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.27819,lon: 120.3322,label: "甄窑",road: 4,code: 111,source: 111,target: 112,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.28019,lon: 120.3422,label: "紫金港",road: 4,code: 112,source: 112,target: 13,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2721,lon: 120.3470,label: "临浦枢纽",code: 13,road: [1,2,5,4,10,5],source: 13,target: 113,type: "icon-hub",gsName: "申嘉湖",// position: 'transition',},{lat: 30.27019,lon: 120.3542,label: "良渚",road: 5,code: 113,source: 113,target: 114,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.26019,lon: 120.3742,label: "半山",road: 5,code: 114,source: 114,target: 115,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.26019,lon: 120.3842,label: "下沙",road: 5,code: 115,source: 115,target: 116,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.25019,lon: 120.3942,label: "河庄",code: 116,source: 116,target: 117,road: 5,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.24019,lon: 120.3992,label: "义蓬",code: 117,source: 117,target: 118,road: 5,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.23019,lon: 120.4092,label: "党湾",code: 118,source: 118,road: 5,target: 119,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.22519,lon: 120.4292,label: "东关",code: 119,source: 119,road: 5,target: 120,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.22519,lon: 120.4492,label: "篙坝",code: 120,source: 120,target: 121,road: 15,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2319,lon: 120.4532,label: "上浦",code: 121,source: 121,target: 122,road: 15,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2449,lon: 120.4632,label: "章镇",code: 122,source: 122,road: 15,target: 123,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2549,lon: 120.4732,label: "三界",code: 123,road: 15,source: 123,target: 124,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2489,lon: 120.4782,label: "嵊州",road: 15,code: 124,source: 124,target: 125,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2419,lon: 120.4822,label: "新昌",road: 15,code: 125,source: 125,target: 126,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2319,lon: 120.4842,label: "新吕南",road: 15,code: 126,source: 126,target: 127,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2219,lon: 120.4842,label: "双彩",road: 15,code: 127,source: 127,target: 128,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.2129,lon: 120.4832,label: "白鹤",road: 15,code: 128,source: 128,target: 129,type: "station",gsName: "申嘉湖",// position: 'transition',},{lat: 30.204234,lon: 120.479129,label: "天台",road: 15,code: 129,source: 129,target: null,type: "station",gsName: "申嘉湖",// position: 'end',},],'京台高速': [{lat: 30.3691,lon: 120.266129,label: "钱江源",road: 6,code: 1000,source: 1000,target: 1001,type: "hub",gsName: "京台高速",// position: 'start',},{lat: 30.34991,lon: 120.276129,label: "马金",road: 6,code: 1001,source: 1001,target: 10003,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.33910,lon: 120.281291,road: [6, 8, 9, 7],label: "湖溪枢纽",code: 10003,source: 10003,target: 1002,type: "icon-hub",gsName: "京台高速",// position: 'transition',},{lat: 30.3291,lon: 120.286129,label: "开化",road: 7,code: 1002,source: 1002,target: 1003,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.3091,lon: 120.289129,road: 7,label: "渊底枢纽",code: 1003,source: 1003,target: 1004,type: "hub",gsName: "京台高速",// position: 'transition',},{road: 7,lat: 30.2891,lon: 120.295929,label: "芳村",code: 1004,source: 1004,target: 1005,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.2741,lon: 120.300029,road: 7,label: "东岸",code: 1005,source: 1005,target: 1006,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.2601,lon: 120.300029,road: 7,label: "五里枢纽",code: 1006,source: 1006,target: 1007,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.2451,lon: 120.299029,road: 7,label: "衢州南",code: 1007,source: 1007,target: 1008,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.2301,lon: 120.296029,label: "江山",road: 7,code: 1008,source: 1008,target: 1009,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.21501,lon: 120.293029,label: "江郎山",code: 1009,road: 7,source: 1009,target: 5,type: "hub",gsName: "京台高速",// position: 'transition',},{lat: 30.2081,lon: 120.2950,label: "龙游枢纽",code: 5,road: [1,7,4,6],source: 5,target: 1010,type: "icon-hub",gsName: "京台高速",// position: 'transition',},{lat: 30.19001,lon: 120.295029,label: "峡口",code: 1010,road: 7,source: 1010,target: null,type: "hub",gsName: "京台高速",// position: 'end',},],'长深高速': [{lat: 30.3111,lon: 120.261291,label: "陈宅",road: 8,code: 10001,source: 10001,target: 10002,type: "hh-station",gsName: "长深高速",// position: 'start',},{lat: 30.32910,lon: 120.271291,road: 8,label: "歌山",code: 10002,source: 10002,target: 10003,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.33910,lon: 120.281291,road: [6, 8, 9],label: "湖溪枢纽",code: 10003,source: 10003,target: 10004,type: "icon-hub",gsName: "长深高速",// position: 'transition',},{lat: 30.34910,lon: 120.291291,label: "横店",road: 9,code: 10004,source: 10004,target: 10005,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.35510,lon: 120.301291,road: 9,label: "马宅",code: 10005,source: 10005,target: 10006,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.35010,lon: 120.311291,road: 9,label: "磐安",code: 10006,source: 10006,target: 104,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.34419,lon: 120.3222,label: "天枢纽",code: 104,road: [3, 9, 10, 4],source: 104,target: 10007,type: "icon-hub",gsName: "长深高速",// position: 'transition',},{lat: 30.34510,lon: 120.329991,road: 10,label: "新市",code: 10007,source: 10007,target: 10008,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.34010,lon: 120.339991,label: "双峰",road: 10,code: 10008,source: 10008,target: 10009,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.33510,lon: 120.349991,label: "埠头",road: 10,code: 10009,source: 10009,target: 10010,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.32010,lon: 120.359991,label: "神仙居",road: 10,code: 10010,source: 10010,target: 10011,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.300010,lon: 120.358991,label: "公盂岩",road: 10,code: 10011,source: 10011,target: 13,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.2721,lon: 120.3470,label: "临浦枢纽",code: 13,road: [1,2,10],source: 13,target: 10012,type: "icon-hub",gsName: "长深高速",// position: 'transition',},{lat: 30.25010,lon: 120.345991,label: "岩坦",code: 10012,road: 10,source: 10012,target: 10013,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.24010,lon: 120.358991,road: 10,label: "枫林",code: 10013,source: 10013,target: 10014,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.22010,lon: 120.368991,road: 10,label: "花坦",code: 10014,source: 10014,target: 10015,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.21010,lon: 120.372991,label: "古庙",road: 10,code: 10015,source: 10015,target: 10016,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.20010,lon: 120.379991,road: 10,label: "永嘉",code: 10016,source: 10016,target: 10017,type: "hh-station",gsName: "长深高速",// position: 'transition',},{lat: 30.20010,lon: 120.389991,label: "温州北",code: 10017,road: 10,source: 10017,target: null,type: "hh-station",gsName: "长深高速",// position: 'end',},],
})export const colorList = ["#409EFF","#67C23A","#E6A23C","#F56C6C",// "#909399",
]
源码分享
**D3 官网:**https://d3js.org/
**D3 API地址:**https://d3js.org/api
SVG API地址:https://developer.mozilla.org/zh-CN/docs/Web/SVG
Canvas API地址:https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API
源码基于Vue2.6.14
,需要安装依赖npm install lodash d3
canvas渲染
<template><div class="canvas-render"><div class="action-panel"><inputtype="button"class="margin-right-6"@click="setPointMode":value="pointMode === 'contain' ? '铺满页面' : '保持比例'"/><inputtype="button"class="margin-right-6"@click="setIsShowGsName":value="isShowGsName ? '隐藏高速名称' : '显示高速名称'"/><inputtype="button"class="margin-right-6"@click="setIsTooltip":value="isTooltip ? '展示弹窗' : '不展示弹窗'"/><inputtype="button"class="margin-right-6"@click="setLineStatus":value="lineStatus ? '点位直线连接' : '点位非直线连接'"/><inputtype="button"class="margin-right-6"@click="setIsAnimate":value="isAnimate ? '关闭动画' : '开启动画'"/><inputv-if="animateType"type="button"class="margin-right-6"@click="setAnimateType":value="animateType === 'linear' ? '图标动画' : '分段虚线流动效果'"/><inputv-if="animateType"type="button"class="margin-right-6"@click="setIsBaseGsName":value="!isBaseGsName ? '基于高速名称' : '基于路段'"/><inputv-if="animateType"type="button"class="margin-right-6"@click="setIsShowPointInfo":value="isShowPointInfo ? '隐藏点位信息' : '显示点位信息'"/><inputv-if="animateType"type="button"class="margin-right-6"@click="setIsShowFeeInfo":value="isShowFeeInfo ? '隐藏费用信息' : '显示费用信息'"/></div><div class="map-test canvas" ref="d3CanvasChart"><div class="tooltip" id="popup-element"><span>{{ text }}</span><i id="close-element" class="el-icon-close"></i><span class="arrow"></span></div></div></div>
</template><script>
import * as d3 from "d3";
import { pointData, colorList } from "../data";
import {setImgUrl,pointFormat,pointCoordinateSwitch,addPositionFields,getAllLinkLineHaveArrowData,getAllLinkLineNoArrowData,addArrowPoint,calculateGSNamePosition,calculateMidPoints,convertImageCoordsToPageCoords,
} from "../utils";
import _ from "lodash";export default {name: "CanvasRender",components: {},data() {return {text: "", // 弹窗文本allData: [], // 全部点位数据allLineData: [], // 全部连线数据gsNamePointData: [], // 高速名称点位数据carMark: null,track: null,hoverPoint: null, // 鼠标悬浮的点位gsKeyToValue: [],offset: 0,activeTrack: [], // 激活轨迹animationId: null,preloadedImages: [],trackColor: null, // 当前轨迹颜色activeGsName: null, // 激活高速名称pointTypeList: [], // 点位类型集合gsNameToColorList: [], // 高速名称颜色集合activeClickPoint: null,animateType: null, // 动画类型:linear(虚线),mark(图标)isBaseGsName: true,canvasHeight: null, // 容器高度canvasWidth: null, // 容器宽度isShowGsName: false, // 是否展示高速名称isTooltip: false, // 是否展示弹窗isAnimate: false, // 是否开启动画lineStatus: false, // 点位连线状态isShowFeeInfo: false, // 是否展示费用信息isShowPointInfo: false, // 是否展示点位信息canvansInstance: null, // 画布元素pointMode: 'contain', // 图片填充模式:contain:保持比例,fill:铺满页面};},computed: {},methods: {// 设置点位模式setPointMode() {this.pointMode = this.pointMode === 'contain' ? 'fill' : 'contain';this.clearSvg();this.createSvgChart();},// 创建canvas元素createCanvasChart() {if (!this.canvasHeight && !this.canvasWidth) {// 设置容器宽高let width = this.$refs.d3CanvasChart.offsetWidth;let height = this.$refs.d3CanvasChart.offsetHeight;this.canvasHeight = height;this.canvasWidth = width;this.canvansInstance = d3.select(this.$refs.d3CanvasChart).append("canvas").attr("width", this.canvasWidth).attr("height", this.canvasHeight).node();this.svgInstance = this.canvansInstance.getContext("2d");}// 是否展示弹窗if (!this.isTooltip) {this.canvansInstance.addEventListener("click",this.handleMouseClick);const closeElement = document.getElementById("close-element");closeElement.addEventListener("click", this.closeElementClick);}// 是否开启动画if (this.isAnimate) {this.canvansInstance.addEventListener("mousemove",this.handleMouseMove);}this.canvasRender();},// 关闭弹窗的点击事件closeElementClick() {const popupEle = document.getElementById("popup-element");popupEle.classList.remove("visible");this.activeClickPoint = null;},// 画canvascanvasRender() {// 添加position属性const _res = addPositionFields(_.cloneDeep(pointData));// 格式化并去重后的点位数据const _pointData = pointFormat(_res);// // 坐标转换后的点位数据// const pointConverted = pointCoordinateSwitch(// _pointData,// this.canvasWidth,// this.canvasHeight// );// 点位数据的xy坐标是相对图片(1988*1892)的坐标,需要转换为页面的坐标// 前端mock点位先将维度按照图片的宽高进行转换const _pointConverted = pointCoordinateSwitch(_pointData,1988,1892);// 然后再将转换后的坐标转换为页面的坐标const pointConverted = convertImageCoordsToPageCoords(_pointConverted,{ width: 1988, height: 1892 },{ width: this.canvasWidth, height: this.canvasHeight },this.pointMode,80,);// 获取点位类型集合this.pointTypeList = [...new Set(pointConverted.map((ele) => ele.type)),"track",];// 获取高速名称集合this.gsKeyToValue = Object.keys(pointData);if (this.isShowGsName) {this.allData = addArrowPoint(pointConverted);this.allLineData = getAllLinkLineHaveArrowData(_.cloneDeep(pointData),this.allData);} else {this.allData = pointConverted;this.allLineData = getAllLinkLineNoArrowData(_.cloneDeep(pointData),this.allData);}// 预加载图片this.preloadImages(this.pointTypeList).then((imageList) => {this.preloadedImages = imageList;this.drawLink();if (this.isShowGsName) {this.calculateGsNameData();}this.drawCanvasPoint(this.allData, 1);});},// 切换是否展示费用信息setIsShowFeeInfo() {this.isShowFeeInfo = !this.isShowFeeInfo;this.clearAllElement();this.createCanvasChart();},// 切换是否展示高速名称setIsShowGsName() {this.isShowGsName = !this.isShowGsName;this.clearAllElement();this.createCanvasChart();},// 设置点位连线状态setLineStatus() {this.lineStatus = !this.lineStatus;this.clearAllElement();this.createCanvasChart();},// 画连接线drawLink() {this.gsKeyToValue.forEach((ele, index) => {this.gsNameToColorList.push({name: ele,color: colorList[index],});const line = this.allLineData.filter((item) => item.gsName === ele);if (line.length > 0) {this.draweCanvasLine(line, colorList[index], 20, 1);}});},// 计算高速名称数据calculateGsNameData() {this.gsKeyToValue.forEach((ele, index) => {if (this.isShowGsName) {const gsPointList = this.allData.filter((item) => item.gsName === ele);const len = gsPointList.length;if (len >= 2) {this.gsNamePointData.push(calculateGSNamePosition(gsPointList[len - 1],gsPointList[len - 2],ele,40,"after","text"));} else if (len == 1) {this.gsNamePointData.push(calculateGSNamePosition(gsPointList[len - 1],gsPointList[len - 1],ele,40,"after","text"));}}});this.drawGsNameByCanvas(this.gsNamePointData, 1);},// 清除canvas所有元素clearAllElement() {this.gsNamePointData = [];this.svgInstance.clearRect(0,0,this.canvasWidth,this.canvasHeight);},// 切换是否展示弹窗setIsTooltip() {this.isTooltip = !this.isTooltip;this.clearAllElement();this.createCanvasChart();const closeElement = document.getElementById("close-element");if (closeElement && this.isTooltip) {closeElement.removeEventListener("click",this.closeElementClick);}},// 切换动画状态setIsAnimate() {this.isAnimate = !this.isAnimate;if (this.isAnimate) {this.animateType = "linear";} else {this.animateType = null;this.canvansInstance.removeEventListener("mousemove",this.handleMouseMove);}this.clearAllElement();this.createCanvasChart();},// 设置是否展示点位信息setIsShowPointInfo() {this.isShowPointInfo = !this.isShowPointInfo;this.clearAllElement();this.createCanvasChart();},// 画高速名称drawGsNameByCanvas(data, opacity) {data.forEach((point) => {this.svgInstance.globalAlpha = opacity;this.svgInstance.font = "bold 16px Arial"; // 设置字体样式this.svgInstance.fillStyle = "black"; // 设置字体颜色// 设置文字在点位上方显示this.svgInstance.textAlign = "center"; // 水平居中对齐this.svgInstance.textBaseline = "center"; // 文字基线对齐底部// 在点位上方绘制文字this.svgInstance.fillText(point.label, point.x, point.y);});},// 绘制点位文本信息drawTextByCanvas(points) {const texts = []; // 记录已绘制的文本位置points.forEach((d) => {const directions = [{ dx: 10, dy: -20 }, // 上方{ dx: 10, dy: 25 }, // 下方{ dx: -30, dy: 0 }, // 左侧{ dx: 30, dy: 0 }, // 右侧];let targetX, targetY;// 尝试不同方向直到找到无碰撞位置for (const dir of directions) {let x = d.x + dir.dx;let y = d.y + dir.dy;// 动态调整 y 坐标(上方方向)if (dir.dy < 0) {y += 4; // 将文本向下移动 4px}// 调整下方时,使文本更靠近目标点if (dir.dy > 0) {y -= 2; // 向上调整,文本更靠近目标点}const bounds = this.getTextBounds(d,x,y,this.svgInstance);if (!this.checkCollision(bounds, points, texts, d)) {targetX = x;targetY = y;texts.push({ ...d, bounds }); // 记录已占用的文本位置break;}}// 绘制文本if (targetX !== undefined) {this.svgInstance.fillStyle = "black";this.svgInstance.font = "10px Arial";this.svgInstance.globalAlpha = 1;if (d.type) {this.svgInstance.fillText(`${d.label}(${d.code})`,targetX,targetY);}}});},// 画点位之间的费用信息drawFee(points) {points.forEach((d) => {this.svgInstance.fillStyle = "black";this.svgInstance.font = "10px Arial";this.svgInstance.fillText(`${d.label}`, d.x, d.y);});},// 绘制点位drawCanvasPoint(data, opacity) {data.forEach((point) => {const imgInfo = this.preloadedImages.find((item) => item.name === point.type);if (imgInfo && point.type) {this.svgInstance.globalAlpha = opacity;this.svgInstance.drawImage(imgInfo.imgElement,point.x - 5,point.y - 5,10,10);}});},// 预加载图片preloadImages(data) {const promises = data.map((type) => {return new Promise((resolve) => {const img = new Image();img.src = setImgUrl(type);img.onload = () => resolve({ name: type, imgElement: img });});});return Promise.all(promises);},// 绘制曲线线drawCurveLine(points, color, opacity) {this.svgInstance.beginPath();this.svgInstance.moveTo(points[0].x, points[0].y);for (let i = 0; i < points.length - 1; i++) {const p0 = i === 0 ? points[0] : points[i - 1]; // 处理起点情况const p1 = points[i];const p2 = points[i + 1];const p3 = i < points.length - 2 ? points[i + 2] : p2; // 处理终点情况// 计算控制点(alpha=0.5的centripetal参数化)const cp1x = p1.x + ((p2.x - p0.x) / 6) * 0.5;const cp1y = p1.y + ((p2.y - p0.y) / 6) * 0.5;const cp2x = p2.x - ((p3.x - p1.x) / 6) * 0.5;const cp2y = p2.y - ((p3.y - p1.y) / 6) * 0.5;// 绘制三次贝塞尔曲线段this.svgInstance.bezierCurveTo(cp1x,cp1y,cp2x,cp2y,p2.x,p2.y);}this.svgInstance.stroke();const lastItem = points[points.length - 1];if (lastItem.target && !lastItem.target.type) {this.drawArrow(points[points.length - 2], // 倒数第二个点points[points.length - 1], // 最后一个点color,opacity);}},// 分段虚线流水动效-折线drawBrokenLine(data) {this.svgInstance.beginPath();this.svgInstance.moveTo(data[0].x, data[0].y); // 起点for (let i = 1; i < data.length; i++) {this.svgInstance.lineTo(data[i].x, data[i].y); // 连接点位}this.svgInstance.stroke();},// 绘制流水线动画drawAnimatedDashedLine() {if (!this.hoverPoint) return;this.clearCanvasContent();this.svgInstance.globalAlpha = 1;this.svgInstance.lineWidth = 4;this.svgInstance.setLineDash([30, 20]);this.svgInstance.lineDashOffset = -this.offset; // 控制虚线运动方向this.svgInstance.strokeStyle = this.isBaseGsName? this.trackColor: "blue"; // 虚线颜色if (this.lineStatus) {this.drawCurveLine(this.activeTrack, null, 1);} else {this.drawBrokenLine(this.activeTrack);}// 更新偏移量this.offset += 1;if (this.offset > 1000) this.offset = 0; // 防止偏移量过大if (this.preloadedImages) {if (!this.isBaseGsName) {// 跨路段// code集合const codeList = new Set(this.activeTrack.map((item) => item.code));// 隐藏非轨迹的连线const hiddenLines = this.allLineData.filter((line) => {return !(codeList.has(line.source.code) &&codeList.has(line.target.code));});hiddenLines.forEach((item) => {let color = this.gsNameToColorList.find((ele) => ele.name === item.gsName).color;this.draweCanvasLine([item], color, 20, 0.3);}); // 隐藏点位集合const hiddenPointList = this.allData.filter((ele) => !codeList.has(ele.code));// 绘制隐藏点位this.drawCanvasPoint(hiddenPointList, 0.3);// 显示点位集合const showPointList = this.allData.filter((ele) =>codeList.has(ele.code));// 绘制显示点位this.drawCanvasPoint(showPointList, 1);if (this.isShowPointInfo) {this.drawTextByCanvas(showPointList);}// 显示点位费用if (this.isShowFeeInfo) {const midPointList = calculateMidPoints(showPointList);this.drawFee(midPointList);}if (this.isShowGsName) {// 显示高速名称this.drawGsNameByCanvas(this.gsNamePointData, 1);}} else {// 不跨路段(根据高速名称)this.gsKeyToValue.forEach((key) => {const res = this.allData.filter((ele) => ele.gsName === key);// 更新点位透明度this.drawCanvasPoint(res,key === this.activeGsName ? 1 : 0.3);if (this.isShowGsName) {// 更新高速名称透明度;const gsNameList = this.gsNamePointData.filter((ele) => ele.gsName === key);this.drawGsNameByCanvas(gsNameList,key === this.activeGsName ? 1 : 0.3);}if (key === this.activeGsName) {if (this.isShowPointInfo)this.drawTextByCanvas(res);if (this.isShowFeeInfo) {const midPointList = calculateMidPoints(res);this.drawFee(midPointList);}}// 更新连线const lineData = this.allLineData.filter((ele) => ele.gsName === key);let color = this.gsNameToColorList.find((ele) => ele.name === key).color;if (key !== this.activeGsName) {this.draweCanvasLine(lineData, color, 20, 0.3);} else {if (this.isShowGsName) {const lastLine = lineData[lineData.length - 1]; // 提取最后一条线段const arrowColor = this.isBaseGsName? this.trackColor: "blue";const arrowSize = 15; // 显式命名箭头尺寸this.drawArrow(lastLine.source,lastLine.target,arrowColor,1, // opacity 参数显式命名arrowSize);}}});}}// 循环动画this.animationId = requestAnimationFrame(this.drawAnimatedDashedLine);},// 绘制点位连线draweCanvasLine(data, color, curveAmount = 20, opacity = 1) {this.svgInstance.setLineDash([]);this.svgInstance.strokeStyle = color;this.svgInstance.globalAlpha = opacity;this.svgInstance.lineWidth = 2;if (this.lineStatus) {const points = [];data.forEach((item, i) => {if (i === 0) points.push(item.source);points.push(item.target);});this.drawCurveLine(points, color, opacity);} else {this.svgInstance.beginPath();data.forEach((ele, index) => {const source = ele.source;const target = ele.target;this.svgInstance.moveTo(source.x, source.y);this.svgInstance.lineTo(target.x, target.y);if (index === data.length - 1 && ele.target.type === null) {this.svgInstance.lineTo(target.x, target.y);this.svgInstance.stroke();this.drawArrow(source, target, color, opacity);} else {this.svgInstance.lineTo(target.x, target.y);}});this.svgInstance.stroke();}},// 绘制箭头的方法drawArrow(source, target, color, opacity, arrowSize = 10) {const angle = Math.atan2(target.y - source.y, target.x - source.x); // 计算箭头的角度// 箭头的两个点const arrowX1 =target.x - arrowSize * Math.cos(angle - Math.PI / 6);const arrowY1 =target.y - arrowSize * Math.sin(angle - Math.PI / 6);const arrowX2 =target.x - arrowSize * Math.cos(angle + Math.PI / 6);const arrowY2 =target.y - arrowSize * Math.sin(angle + Math.PI / 6);this.svgInstance.beginPath();// 绘制箭头三角形this.svgInstance.globalAlpha = opacity; // 设置透明度this.svgInstance.moveTo(target.x, target.y);this.svgInstance.lineTo(arrowX1, arrowY1);this.svgInstance.lineTo(arrowX2, arrowY2);this.svgInstance.closePath();this.svgInstance.fillStyle = color;this.svgInstance.fill();},// 处理鼠标点击事件handleMouseClick(event) {event.stopPropagation(); // 阻止事件冒泡if (this.isTooltip) return;const mouseX = event.offsetX;const mouseY = event.offsetY;// 查找鼠标悬浮的点let hoveredPoint = null;for (let i = 0; i < this.allData.length; i++) {if (this.isMouseOverShape(mouseX, mouseY, this.allData[i])) {hoveredPoint = this.allData[i];break;}}const popupEle = document.getElementById("popup-element");if (hoveredPoint) {if (!this.activeClickPoint) {// 第一次点击点位this.text = hoveredPoint.label;// 设置弹窗位置popupEle.style.left = `${hoveredPoint.x - 100}px`; // 水平居中popupEle.style.top = `${hoveredPoint.y - 60}px`; // 垂直偏移// 添加 visible 类,触发过渡动画popupEle.classList.add("visible");this.activeClickPoint = hoveredPoint;}} else {// 点击的是空白处this.activeClickPoint = null;if (window.getComputedStyle(popupEle).opacity == 1) {popupEle.classList.remove("visible");}}},// 辅助方法:跨路段处理handleCrossRoad(val) {/*** 设置跨高速/路段轨迹* road: 1 -> 5*/if (val.road === 1 || val.road === 5) {let _res = [];this.allData.forEach((ele) => {if (ele.road && ele.road.length) {// 枢纽if (ele.road.includes(1) || ele.road.includes(5)) {_res.push(ele);}}if (ele.road && (ele.road === 1 || ele.road === 5)) {_res.push(ele);}});this.activeTrack = _res;}},// 辅助方法:清除动画clearAnimation() {if (this.animationId) {cancelAnimationFrame(this.animationId);this.animationId = null;}},// 辅助方法:处理悬停结束handleHoverEnd() {this.clearAnimation();this.clearCanvasContent();this.hoverPoint = null;this.resetTrackAndPointVisibility();this.offset = 0;this.activeTrack = [];this.trackColor = null;this.textPositions = [];},// 辅助方法:处理跨路段轨迹handleCrossRoadTrack(hoveredPoint) {if (hoveredPoint.road === 1 || hoveredPoint.road === 5) {this.activeTrack = this.allData.filter((ele) => {if (ele.road && ele.road.length) {return ele.road.includes(1) || ele.road.includes(5);}return ele.road === 1 || ele.road === 5;});}},// 三次贝塞尔曲线公式cubicBezier(p0, p1, p2, p3, t) {const mt = 1 - t;return (mt * mt * mt * p0 +3 * mt * mt * t * p1 +3 * mt * t * t * p2 +t * t * t * p3);},// 计算曲线总长度calculateCurveLength(curvePoints) {let length = 0;for (let i = 1; i < curvePoints.length; i++) {const dx = curvePoints[i].x - curvePoints[i - 1].x;const dy = curvePoints[i].y - curvePoints[i - 1].y;length += Math.sqrt(dx * dx + dy * dy);}return length;},// 根据距离获取曲线上的位置getPositionOnCurve(curvePoints, targetDistance) {let accumulatedLength = 0;for (let i = 1; i < curvePoints.length; i++) {const p1 = curvePoints[i - 1];const p2 = curvePoints[i];const dx = p2.x - p1.x;const dy = p2.y - p1.y;const segmentLength = Math.sqrt(dx * dx + dy * dy);if (accumulatedLength + segmentLength >= targetDistance) {// 在当前线段内const ratio =(targetDistance - accumulatedLength) / segmentLength;return {x: p1.x + dx * ratio,y: p1.y + dy * ratio,};}accumulatedLength += segmentLength;}// 如果超出范围,返回最后一个点return curvePoints[curvePoints.length - 1];},// 辅助方法:计算曲线上的采样点calculateCurvePoints(points) {const curvePoints = [];const segmentCount = 20; // 每段曲线的采样点数for (let i = 0; i < points.length - 1; i++) {const p0 = i === 0 ? points[0] : points[i - 1];const p1 = points[i];const p2 = points[i + 1];const p3 = i < points.length - 2 ? points[i + 2] : p2;// 计算控制点(与drawCurveLine一致)const cp1x = p1.x + ((p2.x - p0.x) / 6) * 0.5;const cp1y = p1.y + ((p2.y - p0.y) / 6) * 0.5;const cp2x = p2.x - ((p3.x - p1.x) / 6) * 0.5;const cp2y = p2.y - ((p3.y - p1.y) / 6) * 0.5;// 采样曲线上的点for (let t = 0; t <= 1; t += 1 / segmentCount) {const x = this.cubicBezier(p1.x, cp1x, cp2x, p2.x, t);const y = this.cubicBezier(p1.y, cp1y, cp2y, p2.y, t);curvePoints.push({ x, y });}}// 确保包含终点curvePoints.push(points[points.length - 1]);return curvePoints;},// 绘制图标沿着曲线轨迹移动animateIconAlongCurvePath(points, onComplete) {// 1. 预计算曲线路径上的所有点const curvePoints = this.calculateCurvePoints(points);const totalLength = this.calculateCurveLength(curvePoints);let currentDistance = 0;const speed = 2; // 移动速度(像素/帧)const animate = () => {if (currentDistance < totalLength) {// 2. 找到当前距离对应的曲线位置const { x, y } = this.getPositionOnCurve(curvePoints,currentDistance);// 清除画布并重绘轨迹(保持原有逻辑)this.clearCanvasContent();if (this.preloadedImages) {// 跨路段if (!this.isBaseGsName) {// code集合const codeList = new Set(this.activeTrack.map((item) => item.code));// 隐藏非轨迹的连线const hiddenLines = this.allLineData.filter((line) => {return !(codeList.has(line.source.code) &&codeList.has(line.target.code));});hiddenLines.forEach((item) => {let color = this.gsNameToColorList.find((ele) => ele.name === item.gsName).color;this.draweCanvasLine([item], color, 20, 0.3);});// 高亮轨迹连线const highlightLines = this.allLineData.filter((line) => {return (codeList.has(line.source.code) &&codeList.has(line.target.code));});highlightLines.forEach((item) => {let color = this.gsNameToColorList.find((ele) => ele.name === item.gsName).color;this.draweCanvasLine([item], color, 20, 1);});// 隐藏点位集合const hiddenPointList = this.allData.filter((ele) => !codeList.has(ele.code));// 绘制隐藏点位this.drawCanvasPoint(hiddenPointList, 0.3);// 显示点位集合const showPointList = this.allData.filter((ele) =>codeList.has(ele.code));// 绘制显示点位this.drawCanvasPoint(showPointList, 1);if (this.isShowPointInfo) {// 显示点位名称this.drawTextByCanvas(showPointList);}if (this.isShowFeeInfo) {// 显示点位费用const midPointList =calculateMidPoints(showPointList);this.drawFee(midPointList);}if (this.isShowGsName) {// 显示高速名称this.drawGsNameByCanvas(this.gsNamePointData,1);}} else {this.gsKeyToValue.forEach((key) => {// 更新连线const lineData = this.allLineData.filter((ele) => ele.gsName === key);let color = this.gsNameToColorList.find((ele) => ele.name === key).color;if (key !== this.activeGsName) {this.draweCanvasLine(lineData,color,20,0.3);} else {this.draweCanvasLine(lineData,color,20,1);if (this.isShowGsName) {// 绘制箭头(最后一个点位)this.drawArrow(lineData[lineData.length - 1].source,lineData[lineData.length - 1].target,this.trackColor,1); // 绘制箭头}}// 鼠标悬浮展示点位const res = this.allData.filter((ele) => ele.gsName === key);// 2.展示整条轨迹的点位文本if (key === this.activeGsName) {if (this.isShowPointInfo)this.drawTextByCanvas(res);// 显示点位费用if (this.isShowFeeInfo) {const midPointList =calculateMidPoints(res);this.drawFee(midPointList);}}// 更新点位透明度this.drawCanvasPoint(res,key === this.activeGsName ? 1 : 0.3);if (this.isShowGsName) {// 更新高速名称透明度const gsNameList =this.gsNamePointData.filter((ele) => ele.gsName === key);this.drawGsNameByCanvas(gsNameList,key === this.activeGsName ? 1 : 0.3);}});}}// 3. 在计算出的位置绘制图标const icon = this.preloadedImages.find((item) => item.name === "track");this.svgInstance.globalAlpha = 1;this.svgInstance.drawImage(icon.imgElement,x - 15, // 图标中心对准轨迹点y - 15,30,30);currentDistance += speed;this.animationId = requestAnimationFrame(animate);} else {onComplete();}};animate();},// 处理鼠标移动事件handleMouseMove(event) {const mouseX = event.offsetX;const mouseY = event.offsetY;// 鼠标悬浮的点let hoveredPoint = this.allData.find((point) =>this.isMouseOverShape(mouseX, mouseY, point));// 过滤枢纽if (hoveredPoint && hoveredPoint.type === "icon-hub") return;if (hoveredPoint) {// 鼠标移入点位图标if (!this.hoverPoint ||hoveredPoint.code !== this.hoverPoint.code) {// 鼠标第一次移入点位或鼠标在同一个图标中移动this.clearAnimation();this.clearCanvasContent();// 记录当前悬浮的点this.hoverPoint = hoveredPoint;if (this.animateType === "linear") {// 动态轨迹this.animationId = requestAnimationFrame(this.drawAnimatedDashedLine);} else {if (!this.lineStatus) {this.animationId = requestAnimationFrame(() => {this.animateIconAlongstraightPath(this.activeTrack,() => {console.log("图标移动完成");});});} else {this.animationId = requestAnimationFrame(() => {this.animateIconAlongCurvePath(this.activeTrack,() => {console.log("图标移动完成");});});}}if (this.isBaseGsName) {// 基于gsNamethis.updateTrackAndPointVisibility(hoveredPoint.gsName);} else {this.handleCrossRoad(this.hoverPoint);}}} else {this.handleHoverEnd();}},// 计算轨迹中两点间的中间坐标集合calculateMidPoints(trackList) {const midPoints = [];for (let i = 0; i < trackList.length - 1; i++) {const currentPoint = trackList[i];const nextPoint = trackList[i + 1];// 检查当前点和下一个点的type和position是否符合条件if (currentPoint.type &&nextPoint.type &¤tPoint.position !== "end") {// 计算中间点const midLat = (currentPoint.x + nextPoint.x) / 2;const midLon = (currentPoint.y + nextPoint.y) / 2;// 将中间点信息存储到数组中midPoints.push({x: midLat,y: midLon,label: `${(currentPoint.code + nextPoint.code) / 2}元`,code: (currentPoint.code + nextPoint.code) / 2,type: "gantry",gsName: (currentPoint.code + nextPoint.code) / 2,position: "mid",});}}return midPoints;},calculateTotalLength(path) {let totalLength = 0;for (let i = 1; i < path.length; i++) {const dx = path[i].x - path[i - 1].x;const dy = path[i].y - path[i - 1].y;totalLength += Math.sqrt(dx * dx + dy * dy); // 计算线段长度}return totalLength;},// 计算多行文本的边界框,用于后续的碰撞检测。// 根据文本内容、位置和画布上下文(Canvas 的 ctx),返回一个包含以下属性的对象:getTextBounds(d, x, y, ctx) {const padding = 0; // 安全边距const labelWidth = ctx.measureText(d.label).width;const codeWidth = ctx.measureText(d.code).width;const width = Math.max(labelWidth, codeWidth);const height = 10; // 两行文本的高度(假设每行10px)return {x: x - padding, // 文本区域左上角的 x 坐标y: y - height - padding, // 文本区域左上角的 y 坐标width: width + 2 * padding, // 文本区域的宽度(含安全边距)height: height + 2 * padding, // 文本区域的高度(含安全边距)};},// 检测两个矩形是否重叠。它通过比较两个矩形的边界坐标,判断它们// 是否存在交集。如果重叠,返回 true;否则返回 false。rectOverlap(a, b) {return (a.x < b.x + b.width &&a.x + a.width > b.x &&a.y < b.y + b.height &&a.y + a.height > b.y);},// 检测一个指定区域是否与其他点或文本发生碰撞checkCollision(bounds, points, texts, currentD) {let collision = false;// 检测点位碰撞: 遍历所有点位,计算当前文本边界框的中心与每个点位的距离// 如果距离小于 点位半径 + 文本边界框的最大边长的一半,则认为发生碰撞collision = points.some((point) => {const pointRadius = 10; // 点位半径const dx = bounds.x + bounds.width / 2 - point.x;const dy = bounds.y + bounds.height / 2 - point.y;const distance = Math.sqrt(dx * dx + dy * dy);return (distance <pointRadius + Math.max(bounds.width, bounds.height) / 2);});if (collision) return true;// 检测文本碰撞(使用四叉树优化): 遍历四叉树中的节点,检查当前文本边界框是否与其他文本边界框重叠// 通过四叉树,可以快速找到与当前文本边界框可能发生碰撞的其他文本const quadtree = d3.quadtree().x((d) => d.x).y((d) => d.y).addAll(texts.filter((t) => t !== currentD));quadtree.visit((node, x1, y1, x2, y2) => {if (node.data) {const otherBounds = node.data.bounds;if (this.rectOverlap(bounds, otherBounds)) {collision = true;return true; // 终止遍历}}return false;});return collision;},// 绘制图标沿着直线轨迹移动animateIconAlongstraightPath(path, onComplete) {const totalLength = this.calculateTotalLength(path); // 计算轨迹总长度let currentDistance = 0; // 当前移动的实际距离const speed = 2; // 速度,每一帧移动的像素距离(可以根据需要调整)const animate = () => {if (currentDistance < totalLength) {// 找到当前距离对应的线段let accumulatedLength = 0;let startPoint, endPoint;for (let i = 1; i < path.length; i++) {const dx = path[i].x - path[i - 1].x;const dy = path[i].y - path[i - 1].y;const segmentLength = Math.sqrt(dx * dx + dy * dy);if (currentDistance <=accumulatedLength + segmentLength) {startPoint = path[i - 1];endPoint = path[i];break;}accumulatedLength += segmentLength;}// 计算当前线段的插值比例const segmentDistance = currentDistance - accumulatedLength;const dx = endPoint.x - startPoint.x;const dy = endPoint.y - startPoint.y;const segmentLength = Math.sqrt(dx * dx + dy * dy);const t = segmentDistance / segmentLength; // 当前线段的比例(0 <= t <= 1)// 计算当前点的位置const x = startPoint.x + dx * t;const y = startPoint.y + dy * t;// 清除画布内容(保留轨迹)this.clearCanvasContent();if (this.preloadedImages) {// 跨路段if (!this.isBaseGsName) {// code集合const codeList = new Set(this.activeTrack.map((item) => item.code));// 隐藏非轨迹的连线const hiddenLines = this.allLineData.filter((line) => {return !(codeList.has(line.source.code) &&codeList.has(line.target.code));});hiddenLines.forEach((item) => {let color = this.gsNameToColorList.find((ele) => ele.name === item.gsName).color;this.draweCanvasLine([item], color, 20, 0.3);});// 高亮轨迹连线const highlightLines = this.allLineData.filter((line) => {return (codeList.has(line.source.code) &&codeList.has(line.target.code));});highlightLines.forEach((item) => {let color = this.gsNameToColorList.find((ele) => ele.name === item.gsName).color;this.draweCanvasLine([item], color, 20, 1);});// 隐藏点位集合const hiddenPointList = this.allData.filter((ele) => !codeList.has(ele.code));// 绘制隐藏点位this.drawCanvasPoint(hiddenPointList, 0.3);// 显示点位集合const showPointList = this.allData.filter((ele) =>codeList.has(ele.code));// 绘制显示点位this.drawCanvasPoint(showPointList, 1);if (this.isShowPointInfo) {// 显示点位名称this.drawTextByCanvas(showPointList);}if (this.isShowFeeInfo) {// 显示点位费用const midPointList =calculateMidPoints(showPointList);this.drawFee(midPointList);}if (this.isShowGsName) {// 显示高速名称this.drawGsNameByCanvas(this.gsNamePointData,1);}} else {this.gsKeyToValue.forEach((key) => {// 更新连线const lineData = this.allLineData.filter((ele) => ele.gsName === key);let color = this.gsNameToColorList.find((ele) => ele.name === key).color;if (key !== this.activeGsName) {this.draweCanvasLine(lineData,color,20,0.3);} else {this.draweCanvasLine(lineData,color,20,1);if (this.isShowGsName) {// 绘制箭头(最后一个点位)this.drawArrow(lineData[lineData.length - 1].source,lineData[lineData.length - 1].target,this.trackColor,1); // 绘制箭头}}// 鼠标悬浮展示点位const res = this.allData.filter((ele) => ele.gsName === key);// 2.展示整条轨迹的点位文本if (key === this.activeGsName) {if (this.isShowPointInfo)this.drawTextByCanvas(res);// 显示点位费用if (this.isShowFeeInfo) {const midPointList =calculateMidPoints(res);this.drawFee(midPointList);}}// 更新点位透明度this.drawCanvasPoint(res,key === this.activeGsName ? 1 : 0.3);if (this.isShowGsName) {// 更新高速名称透明度const gsNameList =this.gsNamePointData.filter((ele) => ele.gsName === key);this.drawGsNameByCanvas(gsNameList,key === this.activeGsName ? 1 : 0.3);}});}}// 绘制图标const icon = this.preloadedImages.find((item) => item.name === "track" // 替换为你的图标名称);this.svgInstance.globalAlpha = 1; // 显式设置透明度为 1this.svgInstance.drawImage(icon.imgElement,x - 15, // 图标中心对准轨迹点y - 15,30,30);currentDistance += speed; // 更新当前距离this.animationId = requestAnimationFrame(animate);} else {onComplete();}};animate();},// 更新轨迹和点位透明度updateTrackAndPointVisibility(gsName) {// 基于gsNamethis.gsKeyToValue.forEach((ele) => {this.trackColor = this.gsNameToColorList.find((ele) => ele.name === gsName).color;const pointData = this.allData.filter((item) => item.gsName === ele);this.activeGsName = gsName;if (gsName === ele) {this.activeTrack = pointData;// this.drawAnimatedDashedLine();}});},// 恢复所有轨迹和点位的正常显示resetTrackAndPointVisibility() {// 恢复所有轨迹的正常显示this.gsKeyToValue.forEach((ele, index) => {const line = this.allLineData.filter((item) => item.gsName === ele);let color = this.gsNameToColorList.find((item) => item.name === ele).color;if (line.length > 0) {this.draweCanvasLine(line, color, 20, 1);}});// 恢复所有点位的显示this.drawCanvasPoint(this.allData, 1);this.drawGsNameByCanvas(this.gsNamePointData, 1);},// 清空 CanvasclearCanvasContent() {let width = this.$refs.d3CanvasChart.offsetWidth;let height = this.$refs.d3CanvasChart.offsetHeight;this.svgInstance.clearRect(0, 0, width, height);},/*** 判断鼠标是否在点位上* @param {Number} mouseX 鼠标横坐标* @param {Number} mouseY 鼠标纵坐标* @param {Object} shape 点位信息* @returns {Boolean} 返回布尔值,true在点位上,false不在点位上*/isMouseOverShape(mouseX, mouseY, shape) {// 如果是圆形if (shape.imgType === "circle") {const distance = Math.sqrt((mouseX - shape.x) ** 2 + (mouseY - shape.y) ** 2);return distance <= shape.radius;}// 如果是正方形if (shape.imgType === "square") {return (mouseX >= shape.x - shape.size / 2 &&mouseX <= shape.x + shape.size / 2 &&mouseY >= shape.y - shape.size / 2 &&mouseY <= shape.y + shape.size / 2);}// 如果是矩形if (shape.imgType === "rectangle") {return (mouseX >= shape.x &&mouseX <= shape.x + shape.width &&mouseY >= shape.y &&mouseY <= shape.y + shape.height);}// 你可以扩展更多形状的判断return false; // 默认返回false,如果形状类型不支持},// 创建小汽车图标createCarMark() {this.carMark = this.svgInstance.append("image").attr("id", "track-car").attr("width", 28).attr("height", 28).attr("opacity", 0).attr("xlink:href", setImgUrl("track"));},initTrack() {this.track = d3.line().x((d) => d.x).y((d) => d.y);// .curve(d3.curveCardinal);// 清除用line元素画的连接线d3.selectAll("line").remove();d3.selectAll("image").remove();// if (d3.selectAll("#track-car")) d3.selectAll("#track-car").remove();// 用path元素画轨迹this.gsKeyToValue.forEach((ele, index) => {const _pointData = this.allData.filter((item) => item.gsName === ele);this.svgInstance.append("path").data([_pointData]).attr("class", "line").attr("id", `track-path-${ele}`).attr("d", this.track).attr("fill", "none").attr("stroke", colorList[index]).attr("stroke-width", 5);});this.createCarMark();// 画“全部点位”this.drawPoint(this.svgInstance, this.allData);},setAnimateType() {this.animateType =this.animateType === "linear" ? "mark" : "linear";},setIsBaseGsName() {this.isBaseGsName = !this.isBaseGsName;},},created() {},mounted() {this.createCanvasChart();},
};
</script><style lang="less" scoped>
.canvas-render {height: 100%;width: 100%;position: relative;.map-test {height: 100%;width: 100%;overflow: hidden;cursor: pointer;position: relative;svg {width: 100%;height: 100%;cursor: pointer;}.tooltip {position: absolute;width: 200px;height: 40px;z-index: 9;transform: scale(0);font-size: 20px;display: flex;align-items: center;justify-content: center;opacity: 0;background: #fff;border-radius: 4px;box-shadow: 0 10px 15px 0 rgba(0, 0, 0, .1);word-break: break-all;border: 1px solid #ebeef5;transition: opacity 0.5s ease, transform 0.5s ease;.el-icon-close {position: absolute;top: 0;right: 0;font-size: 16px;}.arrow {position: absolute;width: 0;height: 0;bottom: -8px;border-left: 6px solid transparent;border-right: 6px solid transparent;border-top: 8px solid #fff; /* 这个颜色就是倒三角形的颜色 */}}::v-deep .visible {opacity: 1!important; /* 完全显示 */transform: scale(1)!important; /* 正常大小 */}::v-deep .opacity-10 {opacity: 1!important;}::v-deep .opacity-2 {opacity: 0.2;}::v-deep .opacity-1 {opacity: 0.1;}.empty {height: 100%;width: 100%;display: flex;position: absolute;z-index: 10;top: 0;span {margin: auto;}}}.action-panel {height: 40px;width: auto;box-shadow: 0 4px 15px 0 rgba(0, 0, 0, .1);position: absolute;top: 12px;left: 12px;border-radius: 4px;display: flex;align-items: center;background-color: #fff;padding: 12px;z-index: 99;.margin-right-6 {margin-right: 6px;}}
}</style>
SVG渲染
<template><div class="d3-container"><div class="action-panel"><inputtype="button"class="margin-right-6"@click="setPointMode":value="pointMode === 'contain' ? '铺满页面' : '保持比例'"/><inputtype="button"class="margin-right-6"@click="setIsShowGsName":value="isShowGsName ? '隐藏高速名称' : '显示高速名称'"/><inputtype="button"class="margin-right-6"@click="setIsTooltip":value="isTooltip ? '隐藏弹窗' : '展示弹窗'"/><inputtype="button"class="margin-right-6"@click="setLineStatus":value="lineStatus ? '点位直线连接' : '点位非直线连接'"/><inputtype="button"class="margin-right-6"@click="setIsAnimate":value="isAnimate ? '关闭动画' : '开启动画'"/><inputv-if="isAnimate"type="button"class="margin-right-6"@click="setAnimateType":value="animateType === 'linear' ? '图标动画' : '分段虚线流动'"/><inputv-if="isAnimate"type="button"class="margin-right-6"@click="setIsBaseGsName":value="!isBaseGsName ? '基于高速名称' : '基于路段'"/><inputv-if="isAnimate"type="button"class="margin-right-6"@click="setIsShowPointInfo":value="isShowPointInfo ? '隐藏点位信息' : '显示点位信息'"/><inputv-if="isAnimate"type="button"class="margin-right-6"@click="setIsShowFeeInfo":value="isShowFeeInfo ? '隐藏费用信息' : '显示费用信息'"/></div><div class="map-test" ref="d3SvgChart"><div class="tooltip" id="popup-element"><span>{{ text }}</span><i id="close-element" class="el-icon-close"></i><span class="arrow"></span></div><div class="empty" v-if="allData.length == 0"><span>暂无数据</span></div></div></div>
</template><script>
import * as d3 from "d3";
import { pointData, colorList } from "../data";
import {setImgUrl,pointFormat,pointCoordinateSwitch,addPositionFields,getAllLinkLineHaveArrowData,getAllLinkLineNoArrowData,addArrowPoint,calculateGSNamePosition,calculateMidPoints,convertImageCoordsToPageCoords,
} from "../utils";
import _ from "lodash";export default {name: "SvgTest",components: {},data() {return {text: null,svgInstance: null, // d3元素实例popupInstance: null, // 弹窗实例closeBtnInstance: null, // 关闭allData: [], // 全部点位数据allLineData: [], // 全部连线数据gsNamePointData: [], // 高速名称点位数据isTooltip: false, // 是否展示弹窗isShowGsName: false, // 显示箭头状态lineStatus: false, // 连接线状态:true:非直线连接,false:直线连接isBaseGsName: true, // 基于高速名称还是路段isShowFeeInfo: false, // 是否展示费用信息isShowPointInfo: false, // 是否展示点位信息isAnimate: false, // 是否开启动画,animateType: null, // 动画类型:linear(虚线),mark(图标)carMark: null,track: null,hoverPoint: null, // 鼠标悬浮的点位gsKeyToValue: [], // 高速名称集合offset: 0,activeTrack: [], // 只用于基于路段时的点位集合textGroups: null,simulation: null,lineGenerator: null, // 用于生成路径的线生成器pointMode: 'contain', // 图片填充模式:contain:保持比例,fill:铺满页面};},computed: {},methods: {// 设置点位模式setPointMode() {this.pointMode = this.pointMode === 'contain' ? 'fill' : 'contain';this.clearSvg();this.createSvgChart();},// 设置轨迹基于高速名称还是路段setIsBaseGsName() {this.isBaseGsName = !this.isBaseGsName;this.clearSvg();this.createSvgChart();},// 设置动画类型setAnimateType() {this.animateType =this.animateType === "linear" ? "mark" : "linear";this.clearSvg();this.createSvgChart();},// 切换是否展示费用信息setIsShowFeeInfo() {this.isShowFeeInfo = !this.isShowFeeInfo;this.clearSvg();this.createSvgChart();},// 创建svg实例createSvgChart() {let width = this.$refs.d3SvgChart.offsetWidth;let height = this.$refs.d3SvgChart.offsetHeight;if (!this.svgInstance) {this.svgInstance = d3.select(this.$refs.d3SvgChart).append("svg").attr("width", width).attr("height", height).attr("viewBox", `0 0 ${width} ${height}`).attr("preserveAspectRatio", "xMidYMid slice");}this.svgRender(width, height);},isOverlap(text1, text2) {const margin = 10; // 文本间的最小距离const dx = text1.x - text2.x;const dy = text1.y - text2.y;const distance = Math.sqrt(dx * dx + dy * dy);const minDistance = 12 + margin; // 文本之间的最小距离return distance < minDistance;},/*** 执行渲染操作* @param {Number} width 容器宽度* @param {Number} height 容器高度* @returns {Null} void*/svgRender(width, height) {// 添加position属性const _res = addPositionFields(_.cloneDeep(pointData));// 格式化并去重后的点位数据const _pointData = pointFormat(_res);// // 坐标转换后的点位数据// const pointConverted = pointCoordinateSwitch(// _pointData,// width,// height// );// 点位数据的xy坐标是相对图片(1988*1892)的坐标,需要转换为页面的坐标// 前端mock点位先将维度按照图片的宽高进行转换const _pointConverted = pointCoordinateSwitch(_pointData,1988,1892);// 然后再将转换后的坐标转换为页面的坐标const pointConverted = convertImageCoordsToPageCoords(_pointConverted,{ width: 1988, height: 1892 },{ width, height },this.pointMode,80,);this.lineGenerator = d3.line().curve(d3.curveCatmullRom.alpha(0.5)) // 使用贝塞尔曲线插值.x((d) => d.x).y((d) => d.y);// 获取高速名称集合this.gsKeyToValue = Object.keys(pointData);if (this.isShowGsName) {this.allData = addArrowPoint(pointConverted);this.allLineData = getAllLinkLineHaveArrowData(_.cloneDeep(pointData),this.allData);} else {this.allData = pointConverted;this.allLineData = getAllLinkLineNoArrowData(_.cloneDeep(pointData),this.allData);}this.track = d3.line().x((d) => d.x).y((d) => d.y);this.gsKeyToValue.forEach((ele, index) => {const line = this.allLineData.filter((item) => item.gsName === ele);if (line.length > 0) {const pathData = line.map((item) => item.source);pathData.push(line[line.length - 1].target);this.drawLine(this.svgInstance,pathData,ele,colorList[index],colorList[index]);}});if (this.isShowGsName) {this.calculateGsNameData();}if (this.animateType === "mark") {this.createCarMark();}// 画“全部点位”this.drawPoint(this.svgInstance, this.allData);},// 切换动画状态setIsAnimate() {this.isAnimate = !this.isAnimate;if (this.isAnimate) {this.animateType = "linear";} else {this.animateType = null;}},// 计算高速名称数据calculateGsNameData() {this.gsKeyToValue.forEach((ele, index) => {const gsPointList = this.allData.filter((item) => item.gsName === ele);const len = gsPointList.length;if (len >= 2) {this.gsNamePointData.push(calculateGSNamePosition(gsPointList[len - 1],gsPointList[len - 2],ele,40,"after","text"));} else if (len == 1) {this.gsNamePointData.push(calculateGSNamePosition(gsPointList[len - 1],gsPointList[len - 1],ele,40,"after","text"));}});this.drawPointText(this.svgInstance,this.gsNamePointData,"label",0,-5,15,6,"#000",16,"bold","gsName");},// 辅助函数:获取文本组的全局坐标边界框getGlobalBBox(node) {const matrix = node.getScreenCTM();const bbox = node.getBBox();return {x: bbox.x * matrix.a + matrix.e,y: bbox.y * matrix.d + matrix.f,width: bbox.width * matrix.a,height: bbox.height * matrix.d,};},// 辅助函数:获取文本的全局坐标位置getGlobalTextPosition(d) {// 获取文本组节点const group = this.textGroups.filter((g) => g.code === d.code).node();if (!group) {return [0, 0]; // 返回默认值或处理错误}const matrix = group.getScreenCTM(); // 获取当前变换矩阵// 获取文本组的边界框const bbox = group.getBBox();// 计算全局坐标const globalX = bbox.x * matrix.a + matrix.e;const globalY = bbox.y * matrix.d + matrix.f;return [globalX, globalY];},// 辅助函数:精确的圆形-矩形碰撞检测isOverlappingPoint(d, bbox) {// 点位的矩形边界框(假设点位是正方形,边长为 10)const pointSize = 10; // 点位的边长const pointBbox = {x: d.x - pointSize / 2, // 点位的左上角 X 坐标y: d.y - pointSize / 2, // 点位的左上角 Y 坐标width: pointSize, // 点位的宽度height: pointSize, // 点位的高度};// 文本框的边界框const textBbox = {x: bbox.x,y: bbox.y,width: bbox.width,height: bbox.height,};// 矩形碰撞检测逻辑const isColliding =pointBbox.x + pointBbox.width >= textBbox.x && // 点位的右边界 >= 文本框的左边界pointBbox.x <= textBbox.x + textBbox.width && // 点位的左边界 <= 文本框的右边界pointBbox.y + pointBbox.height >= textBbox.y && // 点位的下边界 >= 文本框的上边界pointBbox.y <= textBbox.y + textBbox.height; // 点位的上边界 <= 文本框的下边界return isColliding;},// 辅助函数:计算虚拟边界框calculateVirtualBbox(d, direction) {const { offsetX, offsetY, anchor } =this.getDirectionOffset(direction);const group = this.textGroups.filter((g) => g.code === d.code).node();if (!group) return { x: 0, y: 0, width: 0, height: 0 };// 获取精确文本尺寸const bbox = this.getGlobalBBox(group);// 根据锚点调整位置let adjustedX = d.x + offsetX;if (anchor === "end") adjustedX -= bbox.width;else if (anchor === "start") adjustedX += 0;return {x: adjustedX,y: d.y + offsetY - bbox.height / 2, // 垂直居中width: bbox.width,height: bbox.height,};},// 辅助函数:获取方向对应的偏移和锚点getDirectionOffset(direction) {switch (direction) {case "up":return { offsetX: 0, offsetY: -10, anchor: "middle" }; // 优先正上方(增加纵向距离)case "right":return { offsetX: 12, offsetY: 0, anchor: "start" }; // 右侧更靠近点位case "left":return { offsetX: -20, offsetY: 0, anchor: "end" }; // 左侧对称偏移case "down":return { offsetX: 10, offsetY: 25, anchor: "middle" }; // 最后考虑下方default:return { offsetX: 0, offsetY: 0, anchor: "middle" };}},// 辅助函数:检查文本是否与其他文本碰撞isOverlappingOthers(d, virtualBbox, data) {for (const node of data) {if (node.code === d.code) continue; // 跳过当前点位// 计算其他点位的边界框const nodeBbox = {x: node.x - 5, // 假设点位的边长为 10y: node.y - 5,width: 10,height: 10,};// 检测虚拟边界框与其他点位的边界框是否重叠if (virtualBbox.x + virtualBbox.width >= nodeBbox.x &&virtualBbox.x <= nodeBbox.x + nodeBbox.width &&virtualBbox.y + virtualBbox.height >= nodeBbox.y &&virtualBbox.y <= nodeBbox.y + nodeBbox.height) {return true; // 发生冲突}}return false; // 未发生冲突},// 绘制高速点位的名称drawPointTextInfo(gsName, data) {// 停止之前的模拟并清理旧元素if (this.simulation) {this.simulation.stop();this.simulation = null;}this.textGroups = this.svgInstance.selectAll(`text-group-${gsName}`).data(data, (d) => d.code).join("g").attr("class", "text-group").style("pointer-events", "none").attr("transform",(d) => `translate(${d.x + d.textX},${d.y + d.textY})`).attr("text-anchor", "middle");this.textGroups.append("text").attr("class", "text-name").attr("dy", "-0.5em").attr("font-size", 10).text((d) => (d.type ? d.label : ""));this.textGroups.append("text").attr("class", "text-code").attr("dy", "0.6em").attr("font-size", 10).text((d) => (d.type ? d.code : ""));this.simulation = d3// 创建一个力模拟系统.forceSimulation(data)// 为力模拟系统添加一个 碰撞力,用于防止节点之间的重叠.force("collide",d3.forceCollide().radius((d) => {const group = this.textGroups.filter((g) => g.code === d.code).node();return group? Math.max(group.getBBox().width,group.getBBox().height) * 0.8: 30;}).strength(0.8).iterations(3)).alphaDecay(0.05).on("tick", () => {this.textGroups.each((d) => {const targetElement = this.textGroups.filter((item) => item.code === d.code).node();const group = d3.select(targetElement);// 获取文本组的全局坐标边界框const bbox = this.getGlobalBBox(targetElement);// 使用精确的圆形碰撞检测if (this.isOverlappingPoint(d, bbox)) {const directions = ["down", "up", "left", "right"];for (const dir of directions) {const { offsetX, offsetY, anchor } =this.getDirectionOffset(dir);const virtualBbox = this.calculateVirtualBbox(d,dir);if (!this.isOverlappingOthers(d,virtualBbox,data)) {d.direction = dir;d.textX = offsetX;d.textY = offsetY;group.attr("text-anchor", anchor);break;}}}group.attr("transform",`translate(${d.x + d.textX},${d.y + d.textY})`);});});},/*** 画点* @param {Object} svg d3实例* @param {Array} pointData 点位数据* @param {String} type 点位类型* @returns {void} 无返回值*/drawPoint(svg, pointData) {const POINT_SIZE = 10;const POINT_OFFSET = POINT_SIZE / 2;const getPointClass = (d) => `point-${d.type}`;const getPointPosition = (d) => ({x: d.x - POINT_OFFSET,y: d.y - POINT_OFFSET,});const getImageUrl = (d) => (d.type ? setImgUrl(d.type) : "");// 根据类型设置图标地址svg.selectAll(".point").data(pointData, (d) => d.code).join((enter) =>enter.append("image").attr("class", getPointClass).attr("id", "point-image").attr("x", (d) => getPointPosition(d).x).attr("y", (d) => getPointPosition(d).y).attr("width", POINT_SIZE).attr("height", POINT_SIZE).style("will-change", "transform") // 提示浏览器将应用 GPU 加速.style("transform", "translate3d(0, 0, 0)") // 启用 GPU 加速.attr("href", getImageUrl),(update) =>update.attr("x", (d) => getPointPosition(d).x).attr("y", (d) => getPointPosition(d).y),(exit) => exit.remove()).on("mouseenter", (event, d) =>this.handlePointMouseEnter(event, d)).on("mouseleave", (event, d) =>this.handlePointMouseLeave(event, d)).on("click", (event, d) => this.handlePointClick(event, d));},/*** 处理鼠标进入事件* @param {Event} event 鼠标事件对象* @param {Object} d 数据对象* @returns {void} 无返回值*/handlePointClick(event, d) {event.stopPropagation();if (!this.isTooltip) return;if (this.popupInstance.style("opacity") == 1) {this.hidePopup();}this.text = d.label;this.openPopup(d.x, d.y, d);},/*** 处理鼠标进入事件* @param {Event} event 鼠标事件对象* @param {Object} d 数据对象* @returns {void} 无返回值*/handlePointMouseEnter(event, d) {if (this.hoverPoint && this.hoverPoint.code === d.code) return;this.hoverPoint = d;if (this.isBaseGsName) {this.handleBaseGsNameHover(d);} else {this.handleCrossRoadHover(d);}},/*** 基于高速名称的点位悬浮* @param {Object} d 数据对象* @returns {void} 无返回值*/handleBaseGsNameHover(d) {const { svgInstance } = this;const trackPointData = this.allData.filter((ele) => ele.gsName === d.gsName);if (this.animateType === "linear") {svgInstance.selectAll("image#point-image").filter((n) => n.gsName !== d.gsName).classed("opacity-1", true);svgInstance.selectAll("path").classed("opacity-1", true);this.handleLinearAnimation(d);} else if (this.animateType === "mark") {svgInstance.selectAll("image#point-image").classed("opacity-1", true);svgInstance.selectAll("path").filter(function () {return (this.parentNode.tagName !== "marker" &&this.id !== `line-${d.gsName}`);}).classed("opacity-1", true);this.handleMarkAnimation(d, trackPointData);}this.handleAdditionalInfo(d, trackPointData);},/*** 基于跨路段的点位悬浮* @param {Object} d 数据对象* @returns {void} 无返回值*/handleCrossRoadHover(d) {if (![1, 5].includes(d.road)) return;this.activeTrack = this.allData.filter((ele) =>(ele.road &&ele.road.length &&(ele.road.includes(1) || ele.road.includes(5))) ||(ele.road && (ele.road === 1 || ele.road === 5)));const codeList = new Set(this.activeTrack.map((item) => item.code));if (this.animateType === "linear") {this.handleCrossRoadLinearAnimation(codeList);} else if (this.animateType === "mark") {this.handleCrossRoadMarkAnimation(codeList);}this.handleAdditionalInfo(d, this.activeTrack);},/*** 处理跨路段-线性动画* @param {Set} codeList 代码集合* @returns {void} 无返回值*/handleCrossRoadLinearAnimation(codeList) {const { svgInstance } = this;svgInstance.selectAll("image#point-image").filter((n) => !codeList.has(n.code)).classed("opacity-1", true);svgInstance.selectAll("path").classed("opacity-1", true);svgInstance.selectAll(".dash-segment").remove();this.drawLine(this.svgInstance,this.activeTrack,"road-to-road","transparent","transparent");// 为每两个相邻点位创建独立虚线片段for (let i = 0; i < this.activeTrack.length - 1; i++) {const segment = [this.activeTrack[i], this.activeTrack[i + 1]];// 创建分段路径const segmentPath = svgInstance.append("path").datum(segment).attr("class", "dash-segment").attr("d", this.lineGenerator).attr("stroke", "#3388ff").attr("stroke-dasharray", "10,5") // 虚线样式.style("opacity", 0);// 计算分段长度const length = segmentPath.node().getTotalLength();this.startAnimation(segmentPath, length);svgInstance.selectAll("image#point-image").filter((n) => codeList.has(n.code)).raise(); // 将路径的点位置顶}},/*** 处理跨路段-图标动画* @param {Set} codeList 代码集合* @returns {void} 无返回值*/handleCrossRoadMarkAnimation(codeList) {const { svgInstance } = this;svgInstance.selectAll("image#point-image").filter((n) => !codeList.has(n.code)).classed("opacity-1", true);svgInstance.selectAll("path").classed("opacity-1", true);this.drawLine(this.svgInstance,this.activeTrack,"road-to-road","blue","blue");svgInstance.selectAll("image#point-image").filter((n) => codeList.has(n.code)).raise();this.moveCarAlongPath("road-to-road", this.activeTrack[0]);},/*** 处理鼠标离开事件* @param {Event} event 鼠标事件对象* @param {Object} d 数据对象* @returns {void} 无返回值*/handlePointMouseLeave(event, d) {if (this.isBaseGsName) {this.resetBaseGsNameState();} else {this.resetCrossRoadState();}this.cleanupTextInfo();this.hoverPoint = null;},/*** 处理线性动画* @param {Object} d 数据对象* @returns {void} 无返回值*/handleLinearAnimation(d) {const line = this.allLineData.filter((item) => item.gsName === d.gsName);const trajectory = line.map((item) => item.source);trajectory.push(line[line.length - 1].target);this.setupArrowMarker();if (this.lineStatus) {this.createCurveSegments(trajectory);} else {this.createDashSegments(trajectory);}this.svgInstance.selectAll("image#point-image").filter((n) => n && n.gsName == d.gsName).raise();},/*** 处理标记动画* @param {Object} d 数据对象* @param {Array} trackPointData 轨迹点数据* @returns {void} 无返回值*/handleMarkAnimation(d, trackPointData) {this.svgInstance.selectAll("image#point-image").filter((n) => n && n.gsName == d.gsName).remove();this.drawPoint(this.svgInstance, trackPointData);this.moveCarAlongPath(d.gsName, trackPointData[0]);},// 沿曲线路径移动图标moveCarAlongCurve(gsName, startPoint) {// 获取之前绘制的曲线路径const path = this.svgInstance.select(`path#line-${gsName}`).node();if (!path) return;// 重置图标到起点this.carMark.attr("x", startPoint.x - 14).attr("y", startPoint.y - 14).attr("opacity", 1);// 获取路径总长度const length = path.getTotalLength();// 创建沿路径移动的动画this.carMark.transition().duration(length / 0.2) // 速度控制 (0.2像素/毫秒).ease(d3.easeLinear).attrTween("transform", function () {// 保存初始位置const initialX = startPoint.x - 14;const initialY = startPoint.y - 14;return function (t) {// 获取当前路径点const p = path.getPointAtLength(t * length);// 计算相对于初始位置的偏移return `translate(${p.x - initialX}, ${p.y - initialY})`;};}).on("end", () => {console.log("图标沿曲线轨迹移动完成");});},/*** 重置基于高速名称状态* @param {Object} d 数据对象* @returns {void} 无返回值*/resetBaseGsNameState() {const { svgInstance } = this;svgInstance.selectAll("image#point-image").classed("opacity-1", false);svgInstance.selectAll("path").classed("opacity-1", false);if (this.animateType === "linear") {svgInstance.selectAll(".dash-segment, path#line-path-arrow").remove();svgInstance.select("#arrowhead-ele").remove();} else if (this.animateType === "mark") {this.carMark.interrupt().attr("x", 0).attr("y", 0).attr("opacity", 0);}},/*** 重置跨路段状态* @param {Object} d 数据对象* @returns {void} 无返回值*/resetCrossRoadState() {const { svgInstance } = this;svgInstance.selectAll("path").classed("opacity-1", false);if (this.animateType === "linear") {const codeList = new Set(this.activeTrack.map((item) => item.code));svgInstance.selectAll(".dash-segment, path#line-road-to-road").remove();svgInstance.selectAll("image#point-image").filter((n) => !codeList.has(n.code)).classed("opacity-1", false);} else if (this.animateType === "mark") {this.svgInstance.selectAll("image#point-image").classed("opacity-1", false);this.svgInstance.selectAll("path#line-road-to-road").remove();this.carMark.interrupt().attr("x", 0).attr("y", 0).attr("opacity", 0);}},/*** 设置箭头图标* @returns {void} 无返回值*/setupArrowMarker() {if (!this.svgInstance.select("#arrowhead-ele").node()) {this.svgInstance.append("defs").append("marker").attr("id", "arrowhead-ele").attr("viewBox", "0 0 10 10").attr("refX", 5).attr("refY", 5).attr("markerWidth", 4).attr("markerHeight", 4).attr("orient", "auto").append("path").attr("d", "M 0 0 L 10 5 L 0 10 Z").style("fill", "#3388ff");}},/*** 分段虚线流水-创建曲线* @param {Array} points 点位数据* @returns {void} 无返回值*/createCurveSegments(points) {const needsArrow = points.some((d) => d.type === null);// 移出已经存在的虚线片段this.svgInstance.selectAll(".dash-segment").remove();// 生成完整的曲线路径(用于计算分段长度)const fullPath = this.svgInstance.append("path").attr("visibility", "hidden").attr("d", this.lineGenerator(points));const totalLength = fullPath.node().getTotalLength();fullPath.remove(); // 移除临时路径// 计算每段曲线的长度比例const segmentLengths = [];for (let i = 0; i < points.length - 1; i++) {// 生成子路径(从起点到当前点)const subPoints = points.slice(0, i + 2);const subPath = this.svgInstance.append("path").attr("d", this.lineGenerator(subPoints)).attr("visibility", "hidden");const subLength = subPath.node().getTotalLength();subPath.remove();// 计算当前段长度const prevLength = i === 0 ? 0 : segmentLengths[i - 1];segmentLengths.push(subLength - prevLength);}// 3. 创建分段动画路径const animationPath = this.svgInstance.append("path").attr("class", "dash-segment").attr("d", this.lineGenerator(points)).attr("stroke", "#3388ff").attr("stroke-dasharray", "10,5").style("opacity", 0);if (needsArrow) {animationPath.attr("marker-end", "url(#arrowhead-ele)");}this.startAnimation(animationPath, totalLength);},/*** 分段虚线流水-创建虚线* @param {Array} points 点位数据* @returns {void} 无返回值*/createDashSegments(points) {const needsArrow = points.some((d) => d.type === null);// 移出已经存在的虚线片段this.svgInstance.selectAll(".dash-segment").remove();for (let i = 0; i < points.length - 1; i++) {const segment = [points[i], points[i + 1]];const segmentPath = this.svgInstance.append("path").datum(segment).attr("class", "dash-segment").attr("d", this.lineGenerator).attr("stroke", "#3388ff").attr("stroke-dasharray", "10,5").style("opacity", 0);if (needsArrow && i === points.length - 2) {segmentPath.attr("marker-end", "url(#arrowhead-ele)");}this.startAnimation(segmentPath,segmentPath.node().getTotalLength());}},/*** 清理文字信息* @returns {void} 无返回值*/cleanupTextInfo() {if (this.isShowPointInfo && this.textGroups) {this.textGroups.remove();}if (this.isShowFeeInfo) {this.svgInstance.selectAll("text#fee-info").remove();}},/*** 隐藏弹窗* @returns {void} 无返回值*/hidePopup() {this.popupInstance.style("opacity", 0).style("transform", "scale(0)");},/*** 处理额外信息* @param {Object} d 数据* @param {Array} points 点位数据* @returns {void} 无返回值*/handleAdditionalInfo(d, points) {if (this.isShowPointInfo) {const textData = points.map((ele) => ({...ele,direction: "up",textX: 0,textY: 0,}));this.drawPointTextInfo(d.gsName, textData);}if (this.isShowFeeInfo) {const midPointList = calculateMidPoints(points);this.drawPointText(this.svgInstance,midPointList,"label",0,-5,15,6,"#000",10,"400","fee-info");}},/*** 开始动画* @param {Object} path 路径* @param {Number} length 长度* @returns {void} 无返回值*/startAnimation(path, length) {const speed = 100; // 像素/秒(统一速度基准)const duration = (length / speed) * 1000; // 根据长度动态计算时间path.style("opacity", 1) // 显示路径.attr("stroke-dashoffset", length).transition().duration(duration).ease(d3.easeLinear).attr("stroke-dashoffset", 0).on("end",function () {if (path.node() && d3.active(path.node())) {this.startAnimation(path, length);}}.bind(this)); // 绑定正确的作用域},/*** 画“线文字”* @param {String} gsName 线路名称* @param {Array} point 点位数据* @returns {void} 无返回值*/moveCarAlongPath(gsName, point) {const path = this.svgInstance.selectAll(`path#line-${gsName}`).node();if (!path) return;// 重置图标到起点this.carMark.attr("x", point.x - 14).attr("y", point.y - 14).attr("opacity", 1);const length = path.getTotalLength();this.carMark.transition().ease(d3.easeLinear).duration((length / 200) * 1000).attrTween("transform", () => {return (t) => {const p = path.getPointAtLength(t * length);return `translate(${p.x - point.x}, ${p.y - point.y})`;};}).on("end", () => {console.log("图标沿轨迹移动完成");});},/*** 画“点文字”* @param {Object} svg d3实例* @param {Array} pointData 点位数据* @param {String} property 展示文字的属性* @param {Number} x 横向(x轴)偏移量* @param {Number} y 纵向(y轴)偏移量* @param {Number} dx 文本之间的间距* @param {Number} dy 文本之间的间距* @param {String} color 文本之间的间距* @param {Number} fontSize 文字大小* @param {Number} fontWeight 文字加粗* @param {String} id id属性值* @returns {void} 无返回值*/drawPointText(svg,pointData,property,x = 0,y = 0,dx = 0,dy = 0,color = "#000",fontSize = 12,fontWeight = 400,id) {svg.selectAll(`.text-${property}`).data(pointData).enter().append("text").attr("class", `text-${property}`).attr("x", (d) => d.x + x + dx).attr("y", (d) => d.y + y).attr("text-anchor", "middle").attr("id", id).attr("fill", color).attr("font-size", fontSize).attr("font-weight", fontWeight).style("will-change", "transform") // 提示浏览器将应用 GPU 加速.style("transform", "translate3d(0, 0, 0)") // 启用 GPU 加速.append("tspan").attr("x", (d) => d.x + dx).attr("dy", dy).text((d) => {if (property === "code") {return d.type ? d[property] : "";}return d[property];});},/**** @param {Object} svg d3实例* @param {Array} linkData 点位连接数据* @param {String} lineName 线名称* @param {String} lineColor 连接线颜色* @param {String} arrowColor 连接线颜色* @returns {void} 无返回值*/drawLine(svg, linkData, lineName, lineColor, arrowColor) {if (this.isShowGsName) {// 创建箭头标记svg.append("defs").append("marker").attr("id", `arrowhead-${lineName}`).attr("viewBox", "0 0 10 10").attr("refX", 5).attr("refY", 5).attr("markerWidth", 4).attr("markerHeight", 4).attr("orient", "auto").style("will-change", "transform") // 提示浏览器将应用 GPU 加速.style("transform", "translate3d(0, 0, 0)") // 启用 GPU 加速.append("path").attr("d", "M 0 0 L 10 5 L 0 10 Z").attr("class", "arrow").style("fill", arrowColor); // 设置箭头颜色}// 设置点位连线的状态(直线/曲线)const needsArrow = linkData.some((d) => d.type === null);if (!this.lineStatus) {svg.selectAll(`.path-${lineName}`).data([linkData]).enter().append("path").attr("class", "line").attr("id", `line-${lineName}`).attr("d", this.track).attr("fill", "none").attr("stroke", lineColor).attr("stroke-width", 2).attr("marker-end",needsArrow ? `url(#arrowhead-${lineName})` : null);} else {svg.selectAll(`.line-${lineName}`).data([linkData]).join("path").attr("class", `line-${lineName}`).attr("id", "linkGenerator").attr("id", `line-${lineName}`).attr("d", this.lineGenerator).style("stroke", lineColor).attr("stroke-width", 2).attr("fill", "none").attr("marker-end",needsArrow ? `url(#arrowhead-${lineName})` : null);}},/*** 初始化弹窗相关实例对象* @returns {void}*/initPopup() {if (!this.popupInstance)this.popupInstance = d3.select("#popup-element");if (!this.closeBtnInstance)this.closeBtnInstance = d3.select("#close-element");this.closeBtnInstance.on("click", (event) => {this.closePopup();});// 弹窗模式下,支持点击空白关闭弹窗d3.select("body").on("click", (event) => {// 判断点击的地方是否为弹窗外部if (this.isTooltip &&this.popupInstance &&!this.popupInstance.node().contains(event.target) &&!d3.select(event.target).classed("point")) {this.closePopup();}});},/*** 关闭弹窗* @returns {void}*/closePopup() {this.popupInstance.transition().duration(100).style("opacity", 0).style("transform", "scale(0)");this.text = null;},/*** 展示弹窗* @param {Number} x 横坐标* @param {Number} y 纵坐标* @returns {void}*/openPopup(x, y) {this.popupInstance.transition().duration(200).style("left", `${x - 100}px`).style("top", `${y - 60}px`).style("opacity", 1).style("transform", "scale(1)");},// 设置是否展示tooltipsetIsTooltip() {this.isTooltip = !this.isTooltip;this.clearSvg();this.createSvgChart();if (this.isTooltip) {this.initPopup();}},// 设置是否展示gsNamesetIsShowGsName() {this.isShowGsName = !this.isShowGsName;this.clearSvg();this.createSvgChart();},// 设置连线状态是直线还是曲线setLineStatus() {this.lineStatus = !this.lineStatus;this.clearSvg();this.createSvgChart();},// 设置是否展示点位信息setIsShowPointInfo() {this.isShowPointInfo = !this.isShowPointInfo;this.clearSvg();this.createSvgChart();},// 清空画布clearSvg() {this.allData = [];this.allLineData = [];this.gsNamePointData = [];if (this.svgInstance) {d3.select(this.$refs.d3SvgChart).selectAll("image, text, line, marker, path").remove();this.svgInstance.remove();this.svgInstance = null;}},// 创建小汽车图标createCarMark() {this.carMark = this.svgInstance.append("image").attr("id", "track-car").attr("width", 28).attr("height", 28).attr("opacity", 0).attr("xlink:href", setImgUrl("track"));},},created() {},mounted() {this.createSvgChart();// window.addEventListener("resize", () => {// setTimeout(() => {// this.clearSvg();// this.createSvgChart();// }, 200);// });},
};
</script><style lang="less" scoped>
.d3-container {height: 100%;width: 100%;position: relative;.map-test {height: 100%;width: 100%;overflow: hidden;cursor: pointer;position: relative;svg {width: 100%;height: 100%;cursor: pointer;}.tooltip {position: absolute;width: 200px;height: 40px;z-index: 9;transform: scale(0);font-size: 20px;display: flex;align-items: center;justify-content: center;opacity: 0;background: #fff;border-radius: 4px;box-shadow: 0 10px 15px 0 rgba(0, 0, 0, .1);word-break: break-all;border: 1px solid #ebeef5;transition: opacity 0.5s ease, transform 0.5s ease;.el-icon-close {position: absolute;top: 0;right: 0;font-size: 16px;}.arrow {position: absolute;width: 0;height: 0;bottom: -8px;border-left: 6px solid transparent;border-right: 6px solid transparent;border-top: 8px solid #fff; /* 这个颜色就是倒三角形的颜色 */}}::v-deep .visible {opacity: 1!important; /* 完全显示 */transform: scale(1)!important; /* 正常大小 */}::v-deep .opacity-10 {opacity: 1!important;}::v-deep .opacity-2 {opacity: 0.2;}::v-deep .opacity-1 {opacity: 0.1;}.empty {height: 100%;width: 100%;display: flex;position: absolute;z-index: 10;top: 0;span {margin: auto;}}}.action-panel {height: 40px;width: auto;box-shadow: 0 4px 15px 0 rgba(0, 0, 0, .1);position: absolute;top: 12px;left: 12px;border-radius: 4px;display: flex;align-items: center;background-color: #fff;padding: 12px;z-index: 99;.margin-right-6 {margin-right: 6px;}}
}
::v-deep .dash-segment {fill: none;stroke-width: 3;pointer-events: none;z-index: 1;
}
</style>
utils.js
/*** 通过判断type返回目标图片的地址* @param {String} type 图片类型* @returns {String} url 目标图片的地址*/
export function setImgUrl(type) {let url;switch (type) {case "track":url = require("./image/car.png");break;case "gantry":url = require("./image/equipmentIcon.png");break;case "station":url = require("./image/dataIcon.png");break;case "hub":url = require("./image/userIcon.png");break;case "hh-station":url = require("./image/homeIcon.png");break;case "icon-hub":url = require("./image/password.png");break;default:url = require("./image/user.png");break;}return url;
}/*** 为数据对象添加位置标记字段* @param {Object.<string, Array>} data 原始数据对象,键为分组标识,值为对象数组* @returns {Object.<string, Array>} 处理后的新对象,数组元素添加 position 字段(start/end/transition)*/
export function addPositionFields(data) {const result = {};// 遍历对象的每个属性for (const key in data) {if (data.hasOwnProperty(key)) {const array = data[key];result[key] = [];// 处理数组中的每个元素for (let i = 0; i < array.length; i++) {const item = { ...array[i] }; // 创建新对象避免修改原数据// 根据位置设置 position 字段if (i === 0) {item.position = 'start';} else if (i === array.length - 1) {item.position = 'end';} else {item.position = 'transition';}result[key].push(item);}}}return result;
}/*** 格式化并去重点位数据* @param {Object.<string, Array>} pointData 原始点位数据对象,键为分组标识,值为点位数组* @returns {Array} 去重后的点位数组(根据 code 字段去重)*/
export function pointFormat(pointData) {let _pointData = [];for (let key in pointData) {const gsPointList = pointData[key];// 获取点位数据(去重)gsPointList.forEach((ele) => {const info = _pointData.find((item) => item.code === ele.code);if (!info) {_pointData.push(ele);}});}return _pointData;
}/*** 将地理坐标转换为屏幕坐标* @param {Array<Object>} data 原始数据数组,需包含 lat(纬度)和 lon(经度)字段* @param {number} width 容器宽度(单位:像素)* @param {number} height 容器高度(单位:像素)* @returns {Array<Object>} 转换后的数据数组,新增屏幕坐标 x/y 和样式属性*/
export function pointCoordinateSwitch(data, width, height) {// 过滤无效点位const _data = data.filter((ele) => ele.lat && ele.lon);if (!_data.length) return [];// 初始化最大最小值let latMin = Infinity,latMax = -Infinity;let lonMin = Infinity,lonMax = -Infinity;// 单次遍历数组,计算最大最小值for (let i = 0; i < data.length; i++) {const { lat, lon } = data[i];if (lat < latMin) latMin = lat;if (lat > latMax) latMax = lat;if (lon < lonMin) lonMin = lon;if (lon > lonMax) lonMax = lon;}// 此处 减去 200 为了保证点位都显示在容器内,后续点位的横纵坐标 +100width -= 200;height -= 200;return data.map((ele) => ({...ele,imgType: "square",size: 10,x: ((ele.lon - lonMin) / (lonMax - lonMin)) * width + 100,y:height -((ele.lat - latMin) / (latMax - latMin)) * height +100,}));
}/*** 将图片中的相对坐标转换为页面中的绝对坐标* @param {Array} points - 点位数组,每个点位包含x,y坐标(相对图片的坐标)* @param {Object} imageInfo - 图片信息对象* @param {number} imageInfo.width - 图片原始宽度* @param {number} imageInfo.height - 图片原始高度* @param {Object} containerInfo - 容器信息对象* @param {number} containerInfo.width - 页面容器宽度* @param {number} containerInfo.height - 页面容器高度* @param {string} [mode='contain'] - 图片适配模式:'contain'(默认)|'fill'* @param {number} [margin=100] - 四周边距(像素)* @returns {Array} - 转换后的坐标数组*/
export function convertImageCoordsToPageCoords(points, imageInfo, containerInfo, mode = 'contain', margin = 100) {const { width: imgWidth, height: imgHeight } = imageInfo;let { width: containerWidth, height: containerHeight } = containerInfo;// 应用边距,调整有效容器尺寸const effectiveWidth = Math.max(containerWidth - 2 * margin, 1);const effectiveHeight = Math.max(containerHeight - 2 * margin, 1);// 计算图片在有效容器区域中的实际显示尺寸和位置let displayWidth, displayHeight, offsetX = margin, offsetY = margin;const imgRatio = imgWidth / imgHeight;const containerRatio = effectiveWidth / effectiveHeight;if (mode === 'fill') {// 填充模式,直接拉伸填满有效容器区域displayWidth = effectiveWidth;displayHeight = effectiveHeight;} else {// 默认contain模式,保持比例完整显示在有效容器区域内if (imgRatio > containerRatio) {displayWidth = effectiveWidth;displayHeight = displayWidth / imgRatio;offsetY += (effectiveHeight - displayHeight) / 2;} else {displayHeight = effectiveHeight;displayWidth = displayHeight * imgRatio;offsetX += (effectiveWidth - displayWidth) / 2;}}// 计算缩放比例const scaleX = displayWidth / imgWidth;const scaleY = displayHeight / imgHeight;// 转换每个点的坐标return points.map(point => {return {...point,x: offsetX + (point.x * scaleX),y: offsetY + (point.y * scaleY),// 保留原始数据originalX: point.x,originalY: point.y,};});
}/*** 生成站点连接线基础数据* @param {Array<Object>} linkData 原始链路数据,需包含 code(站点编码)和 target(目标站点编码)字段* @returns {Array<Object>} 连接线数组,每个元素包含:* - source: 源站点对象* - target: 目标站点对象* - gsName: 所属高速路名称*/
function getLineData(linkData) {const res = [];// 创建一个站点代码与站点对象的映射const stationMap = linkData.reduce((map, station) => {map[station.code] = station;return map;}, {});// 遍历原始的站点列表来构建最终的结果for (let i = 0; i < linkData.length; i++) {const currentStation = linkData[i];const targetCode = currentStation.target;// 如果目标站点存在if (targetCode && stationMap[targetCode]) {const targetStation = stationMap[targetCode];// 创建一个新的对象,将source和target配对res.push({source: currentStation,target: targetStation,gsName: currentStation.gsName,});// 标记该站点的目标站点为null,防止重复配对stationMap[targetCode] = null;}}return res;
}/*** 生成高速公路连接线数据集* @param {Object.<string, Array>} pointObj 分组点位对象,键为高速路名称,值为该路段点位数组* @param {Array} pointData 全量点位数据集,用于查找箭头标记点* @returns {Array} 包含完整坐标信息的连接线数组,每个元素包含:* - gsName: 高速路名称* - source: 起点坐标及元数据* - target: 终点坐标及元数据*/
export function getAllLinkLineHaveArrowData(pointObj, pointData) {// 获取连接线数据(遍历原数据集合,由于箭头点位在原数据集合中// 不存在,所以需要在遍历时为每一条高速添加上箭头点位)let _lineData = [];for (let key in pointObj) {let gsArrowPoint = pointData.find((ele) => ele.gsName === key && !ele.type);gsArrowPoint.source = gsArrowPoint.code;// 修改箭头点位前面的一个点位的target值pointObj[key][pointObj[key].length - 1].target =gsArrowPoint.code;pointObj[key].push(gsArrowPoint);_lineData.push(...getLineData(pointObj[key]));}// 根据已获取到的连线数据,结合点位数据,设置x,y坐标return _lineData.map((ele) => {const _target = pointData.find((item) => item.code === ele.target.code);const _source = pointData.find((item) => item.code === ele.source.code);return {gsName: ele.gsName,source: { ...ele.source, x: _source.x, y: _source.y },target: { ...ele.target, x: _target.x, y: _target.y },};});
}export function getAllLinkLineNoArrowData(pointObj, pointData) {// 获取连接线数据(遍历原数据集合,由于箭头点位在原数据集合中// 不存在,所以需要在遍历时为每一条高速添加上箭头点位)let _lineData = [];for (let key in pointObj) {let gsArrowPoint = pointData.find((ele) => ele.gsName === key);gsArrowPoint.source = gsArrowPoint.code;_lineData.push(...getLineData(pointObj[key]));}// 根据已获取到的连线数据,结合点位数据,设置x,y坐标return _lineData.map((ele) => {const _target = pointData.find((item) => item.code === ele.target.code);const _source = pointData.find((item) => item.code === ele.source.code);return {gsName: ele.gsName,source: { ...ele.source, x: _source.x, y: _source.y },target: { ...ele.target, x: _target.x, y: _target.y },};});
}export function calculateGSNamePosition(lastOne,lastTwo,label,distance,direction,type) {// 计算lastOne到lastTwo的向量const vx = lastOne.x - lastTwo.x;const vy = lastOne.y - lastTwo.y;// 计算lastOne到lastTwo的距离const dist = Math.sqrt(vx * vx + vy * vy);// 计算单位向量const unitX = vx / dist;const unitY = vy / dist;let newX, newY;if (direction === "front") {// 计算反向单位向量const reverseUnitX = -unitX;const reverseUnitY = -unitY;// 根据反向单位向量计算前一个点的位置,前一个点距离lastOne的横纵坐标为指定的距离newX = lastOne.x - reverseUnitX * distance;newY = lastOne.y - reverseUnitY * distance;} else if (direction === "after") {// 根据单位向量计算a3的位置,a3距离lastOne的横纵坐标都为200newX = lastOne.x + unitX * distance;newY = lastOne.y + unitY * distance;}if (type === "text") {return { x: newX, y: newY + 4, label, gsName: label };} else if (type === "arrow") {const num =new Date().getTime() + parseInt(Math.random() * 10000);return {x: newX,y: newY,// type: "station",type: null,gsName: label,code: num,source: lastOne.code,target: null,};}
}export function addArrowPoint(data) {// 创建一个新的数组来存储最终的结果const result = [];// 遍历原始数组for (let i = 0; i < data.length; i++) {// 当前项const current = data[i];// // 如果position为"start",先插入type为"arrow"的数据// if (current.position === "start") {// if (data[i + 1].gsName === current.gsName) {// // 计算首部箭头坐标// const frontArrow = this.calculateGSNamePosition(// current,// data[i + 1],// current.gsName,// 80,// "front",// "arrow"// );// result.push(frontArrow); // 插入箭头数据// }// }// 插入当前项result.push(current);// 如果position为"end",再插入type为"arrow"的数据if (current.position === "end") {if (data[i - 1].gsName === current.gsName) {// 计算尾部箭头坐标const afterArrow = calculateGSNamePosition(current,data[i - 1],current.gsName,50,"after","arrow");result.push(afterArrow); // 插入箭头数据current.target = afterArrow.code;}}}return result;
}export function calculateMidPoints(trackList) {const midPoints = [];for (let i = 0; i < trackList.length - 1; i++) {const currentPoint = trackList[i];const nextPoint = trackList[i + 1];// 检查当前点和下一个点的type和position是否符合条件if (currentPoint.type &&nextPoint.type &¤tPoint.position !== "end") {// 计算中间点const midLat = (currentPoint.x + nextPoint.x) / 2;const midLon = (currentPoint.y + nextPoint.y) / 2;// 将中间点信息存储到数组中midPoints.push({x: midLat,y: midLon,label: `${(currentPoint.code + nextPoint.code) / 2}`,code: (currentPoint.code + nextPoint.code) / 2,type: "gantry",gsName: (currentPoint.code + nextPoint.code) / 2,position: "mid",});}}return midPoints;
}const _target = pointData.find((item) => item.code === ele.target.code);const _source = pointData.find((item) => item.code === ele.source.code);return {gsName: ele.gsName,source: { ...ele.source, x: _source.x, y: _source.y },target: { ...ele.target, x: _target.x, y: _target.y },};});
}export function getAllLinkLineNoArrowData(pointObj, pointData) {// 获取连接线数据(遍历原数据集合,由于箭头点位在原数据集合中// 不存在,所以需要在遍历时为每一条高速添加上箭头点位)let _lineData = [];for (let key in pointObj) {let gsArrowPoint = pointData.find((ele) => ele.gsName === key);gsArrowPoint.source = gsArrowPoint.code;_lineData.push(...getLineData(pointObj[key]));}// 根据已获取到的连线数据,结合点位数据,设置x,y坐标return _lineData.map((ele) => {const _target = pointData.find((item) => item.code === ele.target.code);const _source = pointData.find((item) => item.code === ele.source.code);return {gsName: ele.gsName,source: { ...ele.source, x: _source.x, y: _source.y },target: { ...ele.target, x: _target.x, y: _target.y },};});
}export function calculateGSNamePosition(lastOne,lastTwo,label,distance,direction,type) {// 计算lastOne到lastTwo的向量const vx = lastOne.x - lastTwo.x;const vy = lastOne.y - lastTwo.y;// 计算lastOne到lastTwo的距离const dist = Math.sqrt(vx * vx + vy * vy);// 计算单位向量const unitX = vx / dist;const unitY = vy / dist;let newX, newY;if (direction === "front") {// 计算反向单位向量const reverseUnitX = -unitX;const reverseUnitY = -unitY;// 根据反向单位向量计算前一个点的位置,前一个点距离lastOne的横纵坐标为指定的距离newX = lastOne.x - reverseUnitX * distance;newY = lastOne.y - reverseUnitY * distance;} else if (direction === "after") {// 根据单位向量计算a3的位置,a3距离lastOne的横纵坐标都为200newX = lastOne.x + unitX * distance;newY = lastOne.y + unitY * distance;}if (type === "text") {return { x: newX, y: newY + 4, label, gsName: label };} else if (type === "arrow") {const num =new Date().getTime() + parseInt(Math.random() * 10000);return {x: newX,y: newY,// type: "station",type: null,gsName: label,code: num,source: lastOne.code,target: null,};}
}export function addArrowPoint(data) {// 创建一个新的数组来存储最终的结果const result = [];// 遍历原始数组for (let i = 0; i < data.length; i++) {// 当前项const current = data[i];// // 如果position为"start",先插入type为"arrow"的数据// if (current.position === "start") {// if (data[i + 1].gsName === current.gsName) {// // 计算首部箭头坐标// const frontArrow = this.calculateGSNamePosition(// current,// data[i + 1],// current.gsName,// 80,// "front",// "arrow"// );// result.push(frontArrow); // 插入箭头数据// }// }// 插入当前项result.push(current);// 如果position为"end",再插入type为"arrow"的数据if (current.position === "end") {if (data[i - 1].gsName === current.gsName) {// 计算尾部箭头坐标const afterArrow = calculateGSNamePosition(current,data[i - 1],current.gsName,50,"after","arrow");result.push(afterArrow); // 插入箭头数据current.target = afterArrow.code;}}}return result;
}export function calculateMidPoints(trackList) {const midPoints = [];for (let i = 0; i < trackList.length - 1; i++) {const currentPoint = trackList[i];const nextPoint = trackList[i + 1];// 检查当前点和下一个点的type和position是否符合条件if (currentPoint.type &&nextPoint.type &¤tPoint.position !== "end") {// 计算中间点const midLat = (currentPoint.x + nextPoint.x) / 2;const midLon = (currentPoint.y + nextPoint.y) / 2;// 将中间点信息存储到数组中midPoints.push({x: midLat,y: midLon,label: `${(currentPoint.code + nextPoint.code) / 2}`,code: (currentPoint.code + nextPoint.code) / 2,type: "gantry",gsName: (currentPoint.code + nextPoint.code) / 2,position: "mid",});}}return midPoints;
}