在本文档中,我们将探讨如何使用 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,
l














最低0.47元/天 解锁文章
338

被折叠的 条评论
为什么被折叠?



