分享一下通过D3实现的站点路线分布图,这是一个demo。效果图如下:
源码如下:
<template>
<div class="map-test" ref="d3Chart">
<div class="tooltip" id="popup-element">
<span>{{ text }}</span>
<i id="close-element" class="el-icon-close"></i>
</div>
<div class="mark" v-show="visible"></div>
</div>
</template>
<script>
import * as d3 from "d3";
export default {
name: "MapTest",
components: {},
data() {
return {
text: null,
svgInstance: null, // d3元素实例
popupInstance: null, // 弹窗实例
visible: false,
allData: [], // 全部点位数据
allLineData: [], // 全部连线数据
gsNamePointData: [], // 高速名称点位数据
};
},
computed: {},
methods: {
createChart() {
const width = window.innerWidth;
const height = window.innerHeight;
this.svgInstance = d3
.select(this.$refs.d3Chart)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("preserveAspectRatio", "xMidYMid slice");
this.popupInstance = d3.select("#popup-element");
const closeBtn = d3.select("#close-element");
closeBtn.on("click", (event) => {
this.popupInstance
.transition()
.duration(300)
.style("opacity", 0) // 使弹窗逐渐透明
.style("transform", "scale(0)"); // 缩小弹窗
});
// 全部点位信息
const data = [
{
x: 500,
y: 150,
label: "湖州",
code: 2117,
source: 2117,
target: 2115,
type: "station",
gsName: "申苏浙皖",
},
{
x: 700,
y: 150,
label: "织里",
code: 2115,
source: 2115,
target: 2113,
type: "station",
gsName: "申苏浙皖",
},
{
x: 1200,
y: 150,
label: "南浔",
code: 2113,
source: 2113,
target: null,
type: "station",
gsName: "申苏浙皖",
},
{
x: 700,
y: 350,
label: "湖州东",
code: 3233,
source: 3233,
target: 3231,
type: "station",
gsName: "申嘉湖",
},
{
x: 1200,
y: 350,
label: "双林",
code: 3231,
source: 3231,
target: 3229,
type: "station",
gsName: "申嘉湖",
},
{
x: 1400,
y: 350,
label: "南浔南",
code: 3229,
source: 3229,
target: null,
type: "station",
gsName: "申嘉湖",
},
{
x: 700,
y: 550,
label: "钟管",
code: 4049,
source: 4049,
target: 4051,
type: "station",
gsName: "杭绕西复线",
},
{
x: 1200,
y: 550,
label: "新市西",
code: 4051,
source: 4051,
target: 3206,
type: "station",
gsName: "杭绕西复线",
},
{
x: 1350,
y: 550,
label: "新市枢纽",
code: 3206,
source: 3206,
target: null,
type: "station",
gsName: "杭绕西复线",
},
{
x: 800,
y: 700,
label: "雷甸",
code: 3243,
source: 3243,
target: 3241,
type: "station",
gsName: "练杭",
},
{
x: 1300,
y: 700,
label: "新安",
code: 3241,
source: 3241,
target: 3239,
type: "station",
gsName: "练杭",
},
{
x: 1500,
y: 700,
label: "新市",
code: 3239,
source: 3239,
target: null,
type: "station",
gsName: "练杭",
},
{
x: 900,
y: 150,
label: "织里枢纽",
code: 5101,
source: 5101,
target: 5111,
type: "hub",
gsName: "沪杭高速",
},
{
x: 900,
y: 210,
label: "织里东",
code: 5111,
source: 5111,
target: 5113,
type: "hh-station",
gsName: "沪杭高速",
},
{
x: 900,
y: 280,
label: "南浔西",
code: 5113,
source: 5113,
target: 5102,
type: "hh-station",
gsName: "沪杭高速",
},
{
x: 900,
y: 350,
label: "双林枢纽",
code: 5102,
source: 5102,
target: 5115,
type: "hh-station",
gsName: "沪杭高速",
},
{
x: 900,
y: 410,
label: "菱湖(分中心)",
code: 5115,
source: 5115,
target: 5117,
type: "hh-station",
gsName: "沪杭高速",
},
{
x: 900,
y: 480,
label: "千金",
code: 5117,
source: 5117,
target: 5103,
type: "hh-station",
gsName: "沪杭高速",
},
{
x: 900,
y: 550,
label: "士林枢纽",
code: 5103,
source: 5103,
target: 5119,
type: "hub",
gsName: "沪杭高速",
},
{
x: 960,
y: 620,
label: "下舍",
code: 5119,
source: 5119,
target: 5104,
type: "hh-station",
gsName: "沪杭高速",
},
{
x: 1050,
y: 700,
label: "新安枢纽",
code: 5104,
source: 5104,
target: null,
type: "hub",
gsName: "沪杭高速",
},
];
this.allData = data;
const colorList = [
"#409EFF",
"#67C23A",
"#E6A23C",
"#F56C6C",
"#909399",
];
// 获取“全部点位”连线数据
this.allLineData = this.getLineData(data);
const gsKeyToValue = [...new Set(data.map((ele) => ele.gsName))];
// 高速名称数据
gsKeyToValue.forEach((ele, index) => {
const line = this.allLineData.filter(
(item) => item.gsName === ele
);
if (line.length > 0) {
this.drawLine(
this.svgInstance,
line,
index,
colorList[index]
);
}
const gsPointList = this.allData.filter(
(item) => item.gsName === ele
);
const len = gsPointList.length;
if (len >= 2) {
this.gsNamePointData.push(
this.calculateGSNamePosition(
gsPointList[len - 1],
gsPointList[len - 2],
ele
)
);
} else if (len == 1) {
this.gsNamePointData.push(
this.calculateGSNamePosition(
gsPointList[len - 1],
gsPointList[len - 1],
ele
)
);
}
});
// 画“全部点位”
this.drawPoint(this.svgInstance, data);
// 画“收费站名称”
this.drawPointText(
this.svgInstance,
data,
"label",
0,
-25,
40,
-10
);
// 画“收费站编码”
this.drawPointText(this.svgInstance, data, "code", 0, -25, 40, 6);
this.drawPointText(
this.svgInstance,
this.gsNamePointData,
"label",
0,
-25,
0,
30,
'#000',
20,
'bold'
);
},
/**
* 通过判断type返回目标图片的地址
* @param {String} type 图片类型
* @returns {String} url 目标图片的地址
*/
setImgUrl(type) {
let url;
switch (type) {
case "gantry":
url = require("../../../assets/equipmentIcon.png");
break;
case "station":
url = require("../../../assets/dataIcon.png");
break;
case "hub":
url = require("../../../assets/userIcon.png");
break;
case "hh-station":
url = require("../../../assets/homeIcon.png");
break;
}
return url;
},
/**
* 画点
* @param {Object} svg d3实例
* @param {Array} pointData 点位数据
* @param {String} type 点位类型
* @returns {void} 无返回值
*/
drawPoint(svg, pointData) {
// 根据类型设置图标地址
svg.selectAll(".point")
.data(pointData)
.enter()
.append("image")
.attr("class", (d) => {
return `.point-${d.type}`;
})
.attr("id", (d) => {
return `id-${d.code}`;
})
.attr("x", (d) => d.x - 20)
.attr("y", (d) => d.y - 20)
.attr("width", 40)
.attr("height", 40)
.attr("href", (d) => {
return this.setImgUrl(d.type);
})
.attr("r", 8)
.on("mouseover", (event, d) => {
this.visible = true;
// 置灰“当前节点非相关节点”的文本
svg.selectAll("text").classed("opacity-1", true);
// 高亮“当前节点相关节点”的文本
svg.selectAll("text")
.filter((n) => n.gsName == d.gsName)
.classed("opacity-10", true);
// 置灰“当前节点非相关节点”的图标
svg.selectAll("image").classed("opacity-1", true);
// 高亮“当前节点相关节点”的图标
svg.selectAll("image")
.filter((n) => n.gsName == d.gsName)
.classed("opacity-10", true);
// 置灰“当前节点非相关节点”的连线
svg.selectAll("line").classed("opacity-1", true);
// 高亮“当前节点相关节点”的连线
const ele = svg
.selectAll("line")
.filter((l) => l.gsName === d.gsName);
// 设置初始状态
ele.classed("opacity-10", true)
.style("stroke-dasharray", "25, 25")
.style("stroke-dashoffset", 0);
// 启动流水效果
function startAnimation() {
const length = 1000;
ele.style("stroke-dasharray", "25,25") // 设置虚线的总长度
.style("stroke-dashoffset", length) // 设置初始的偏移量
.transition() // 创建过渡动画
.duration(10000) // 设置动画时长
.ease(d3.easeLinear) // 使用线性过渡
.style("stroke-dashoffset", 0) // 让偏移量为 0,从而产生流水效果
.on("end", startAnimation); // 动画结束时递归调用
}
// 启动动画
startAnimation();
})
.on("mouseout", (event, d) => {
this.visible = false;
// 置灰节点的文本
svg.selectAll("text").classed("opacity-1", false);
svg.selectAll("text")
.filter((n) => n.gsName == d.gsName)
.classed("opacity-10", false);
// 置灰节点的图标
svg.selectAll("image")
.filter((n) => n.gsName == d.gsName)
.classed("opacity-10", false);
svg.selectAll("image").classed("opacity-1", false);
// 置灰节点间的连线,停止动画
svg.selectAll("line").classed("opacity-1", false);
svg.selectAll("line")
.filter((l) => l.gsName === d.gsName)
.classed("opacity-10", false)
.style("stroke-dasharray", "0,0")
.interrupt();
});
},
/**
* 画“点文字”
* @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 文字加粗
* @returns {void} 无返回值
*/
drawPointText(
svg,
pointData,
property,
x = 0,
y = 0,
dx = 0,
dy = 0,
color = "#000",
fontSize = 14,
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)
.append("tspan")
.attr("x", (d) => d.x + dx)
.attr("dy", dy)
.text((d) => d[property]);
},
/**
*
* @param {Object} svg d3实例
* @param {Array} linkData 点位连接数据
* @param {String} lineName 线名称
* @param {String} lineColor 线名称
* @returns {void} 无返回值
*/
drawLine(svg, linkData, lineName, lineColor) {
svg.selectAll(`.line-${lineName}`)
.data(linkData)
.enter()
.append("line")
.attr("class", `.line-${lineName}`)
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y)
.style("stroke", lineColor)
.style("stroke-width", 10) // 设置线条宽度
.style("stroke-linecap", "square") // 设置端点样式
.style("stroke-linejoin", "round"); // 设置连接点样式
},
/**
* 获取连线数据
* @param {Array} linkData 点位连接数据
* @returns {void} 无返回值
*/
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} lastOne 倒数第一个点位
* @param {Object} lastTwo 倒数第二个点位
* @param {String} label 高速名称
* @param {Number} distance 距离
* @returns {Object} 高速点位
*/
calculateGSNamePosition(lastOne, lastTwo, label, distance = 100) {
// 计算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;
// 根据单位向量计算a3的位置,a3距离lastOne的横纵坐标都为200
const a3X = lastOne.x + unitX * distance;
const a3Y = lastOne.y + unitY * distance;
// 返回a3的位置
return { x: a3X, y: a3Y, label, gsName: label };
},
},
created() {},
mounted() {
this.createChart();
},
};
</script>
<style lang="less">
.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;
background-color: pink;
z-index: 9;
transform: scale(0);
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.5s ease, transform 0.5s ease;
.el-icon-close {
position: absolute;
top: 0;
right: 0;
font-size: 16px;
}
}
.opacity-10 {
opacity: 1!important;
}
.opacity-2 {
opacity: 0.2;
}
.opacity-1 {
opacity: 0.1;
}
.mark {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: rgba(0,0,0,0.05);
z-index: -1;
}
}
</style>
D3 官网:https://d3js.org/
D3 API地址:https://d3js.org/api
功能描述:
- 画点、画线、画文本内容
- 鼠标悬浮点位,高亮关联点位、文本及其高速公路名称
- 鼠标悬浮展示动态流水线效果
- 弹窗效果已包含,未在demo中展示
数据分析:
当前数据是前端mock数据,后续应用在实际项目中需要后端给到的数据格式是:
- 每个点位中需要有相对位置坐标,前端根据视口范围计算,展示点位
- 点位需要包含类型,来判断展示对应的图表(门架、站点、枢纽等)
- 点位需要包含指向关系,作用是用来画连接线
- 点位需要包含高速名称,用于展示高速名称标识
- 点位必须按照顺序返回,否则连线会比较乱,视觉体验差
注意:
- 高速公路名称是根据
calculateGSNamePosition函数
计算的出来的
后端返回的数据样例:
// x,y代表坐标
// label: 名称
// source:源
// target:指向目标
// type:类型
const data = {
'沪杭高速': [
{
x: 900,
y: 150,
label: "织里枢纽",
code: 5101,
source: 5101,
target: 5111,
type: "hub",
},
{
x: 900,
y: 210,
label: "织里东",
code: 5111,
source: 5111,
target: 5113,
type: "station",
},
],
};
// x,y代表坐标
// label: 名称
// source:源
// target:指向目标
// type:类型
// gsName:高速名称
const data1 = [
{
x: 900,
y: 150,
label: "织里枢纽",
code: 5101,
source: 5101,
target: 5111,
type: "hub",
gsName: "沪杭高速",
},
{
x: 900,
y: 210,
label: "织里东",
code: 5111,
source: 5111,
target: 5113,
type: "station",
gsName: "沪杭高速",
},
];