在本文档中,我们将探讨如何使用 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] 范围内包括了 945 return { 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">
<input
type="button"
class="margin-right-6"
@click="setPointMode"
:value="pointMode === 'contain' ? '铺满页面' : '保持比例'"
/>
<input
type="button"
class="margin-right-6"
@click="setIsShowGsName"
:value="isShowGsName ? '隐藏高速名称' : '显示高速名称'"
/>
<input
type="button"
class="margin-right-6"
@click="setIsTooltip"
:value="isTooltip ? '展示弹窗' : '不展示弹窗'"
/>
<input
type="button"
class="margin-right-6"
@click="setLineStatus"
:value="lineStatus ? '点位直线连接' : '点位非直线连接'"
/>
<input
type="button"
class="margin-right-6"
@click="setIsAnimate"
:value="isAnimate ? '关闭动画' : '开启动画'"
/>
<input
v-if="animateType"
type="button"
class="margin-right-6"
@click="setAnimateType"
:value="
animateType === 'linear' ? '图标动画' : '分段虚线流动效果'
"
/>
<input
v-if="animateType"
type="button"
class="margin-right-6"
@click="setIsBaseGsName"
:value="!isBaseGsName ? '基于高速名称' : '基于路段'"
/>
<input
v-if="animateType"
type="button"
class="margin-right-6"
@click="setIsShowPointInfo"
:value="isShowPointInfo ? '隐藏点位信息' : '显示点位信息'"
/>
<input
v-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;
},
// 画canvas
canvasRender() {
// 添加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) {
// 基于gsName
this.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 &&
currentPoint.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; // 显式设置透明度为 1
this.svgInstance.drawImage(
icon.imgElement,
x - 15, // 图标中心对准轨迹点
y - 15,
30,
30
);
currentDistance += speed; // 更新当前距离
this.animationId = requestAnimationFrame(animate);
} else {
onComplete();
}
};
animate();
},
// 更新轨迹和点位透明度
updateTrackAndPointVisibility(gsName) {
// 基于gsName
this.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);
},
// 清空 Canvas
clearCanvasContent() {
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">
<input
type="button"
class="margin-right-6"
@click="setPointMode"
:value="pointMode === 'contain' ? '铺满页面' : '保持比例'"
/>
<input
type="button"
class="margin-right-6"
@click="setIsShowGsName"
:value="isShowGsName ? '隐藏高速名称' : '显示高速名称'"
/>
<input
type="button"
class="margin-right-6"
@click="setIsTooltip"
:value="isTooltip ? '隐藏弹窗' : '展示弹窗'"
/>
<input
type="button"
class="margin-right-6"
@click="setLineStatus"
:value="lineStatus ? '点位直线连接' : '点位非直线连接'"
/>
<input
type="button"
class="margin-right-6"
@click="setIsAnimate"
:value="isAnimate ? '关闭动画' : '开启动画'"
/>
<input
v-if="isAnimate"
type="button"
class="margin-right-6"
@click="setAnimateType"
:value="animateType === 'linear' ? '图标动画' : '分段虚线流动'"
/>
<input
v-if="isAnimate"
type="button"
class="margin-right-6"
@click="setIsBaseGsName"
:value="!isBaseGsName ? '基于高速名称' : '基于路段'"
/>
<input
v-if="isAnimate"
type="button"
class="margin-right-6"
@click="setIsShowPointInfo"
:value="isShowPointInfo ? '隐藏点位信息' : '显示点位信息'"
/>
<input
v-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, // 假设点位的边长为 10
y: 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)");
},
// 设置是否展示tooltip
setIsTooltip() {
this.isTooltip = !this.isTooltip;
this.clearSvg();
this.createSvgChart();
if (this.isTooltip) {
this.initPopup();
}
},
// 设置是否展示gsName
setIsShowGsName() {
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 为了保证点位都显示在容器内,后续点位的横纵坐标 +100
width -= 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的横纵坐标都为200
newX = 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 &&
currentPoint.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的横纵坐标都为200
newX = 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 &&
currentPoint.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;
}