源码
// 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>} data 原始数据对象,键为分组标识,值为对象数组
* @returns {Object.<string, Array>} 处理后的新对象,数组元素添加 source(当前节点ID)和 target(下一节点ID)字段,
* 格式为 { 分组标识: [{ source: "key-i", target: "key-(i+1)" }, ...] }
* @example
* // 输入
* { group1: [{}, {}, {}] }
* // 输出
* {
* group1: [
* { source: 'group1-0', target: 'group1-1' },
* { source: 'group1-1', target: 'group1-2' },
* { source: 'group1-2', target: null }
* ]
* }
*/
export function setSourceAndTarget(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] }; // 创建新对象避免修改原数据
// 设置节点连接关系:
// - 非末尾节点:target指向下一节点(key-(i+1))
// - 末尾节点:target设为null表示终点
if (i !== array.length - 1) {
item.source = `${key}-${i}`;
item.target = `${key}-${i + 1}`;
} else {
item.source = `${key}-${i}`;
item.target = null;
}
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;
const uniquePoints = new Set();
const result = [];
for (const gsPointList of Object.values(pointData)) {
for (const point of gsPointList) {
if (!uniquePoints.has(point.code)) {
uniquePoints.add(point.code);
result.push(point);
}
}
}
return result;
}
/**
* 将地理坐标转换为屏幕坐标
* @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;
}
// data.js
export const colorList = [
"#409EFF",
"#67C23A",
// "#E6A23C",
// "#F56C6C",
// "#909399",
]
export const pointData = Object.freeze({
'申嘉湖': [
{
lat: 30.577337,
lon: 120.293738,
label: "新市枢纽",
code: 3206,
source: 3206,
target: 4053,
road: 3,
type: "icon-hub",
gsName: "申嘉湖",
},
{
lat: 30.552239,
lon: 120.363261,
label: "洲泉",
code: 4053,
source: 4053,
target: 4055,
road: 3,
type: "gantry",
gsName: "申嘉湖",
},
{
lat: 30.561971,
lon: 120.415528,
label: "崇福北",
code: 4055,
source: 4055,
target: 4057,
road: 3,
type: "gantry",
gsName: "申嘉湖",
},
{
lat: 30.560235,
lon: 120.456098,
label: "凤鸣",
code: 4057,
source: 4057,
target: 4009,
road: 3,
type: "gantry",
gsName: "申嘉湖",
},
{
lat: 30.559178,
lon: 120.487902,
label: "凤鸣枢纽",
code: 4009,
source: 4009,
target: null,
road: [1, 2, 3],
type: "icon-hub",
gsName: "申嘉湖",
},
],
'申苏浙皖': [
{
lat: 30.719011,
lon: 120.455571,
label: "练市枢纽",
code: 3203,
source: 3203,
target: 4447,
road: 1,
type: "icon-hub",
gsName: "申苏浙皖",
},
{
lat: 30.656565,
lon: 120.475788,
label: "乌镇南",
code: 4447,
source: 4447,
target: 4449,
road: 1,
type: "gantry",
gsName: "申苏浙皖",
},
{
lat: 30.600793,
lon: 120.482239,
label: "梧桐",
code: 4449,
source: 4449,
target: 4006,
road: 1,
type: "gantry",
gsName: "申苏浙皖",
},
{
lat: 30.559178,
lon: 120.487902,
label: "凤鸣枢纽",
code: 4006,
source: 4006,
target: 4451,
road: [1, 2, 3],
type: "icon-hub",
gsName: "申苏浙皖",
},
{
lat: 30.542289,
lon: 120.488243,
label: "崇福",
code: 4451,
source: 4451,
target: 4005,
road: 2,
type: "gantry",
gsName: "申苏浙皖",
},
{
lat: 30.509447,
lon: 120.510563,
label: "骑塘枢纽",
code: 4005,
source: 4005,
target: null,
road: 2,
type: "icon-hub",
gsName: "申苏浙皖",
},
],
});
export const doubleTrackPointData = Object.freeze({
'申嘉湖': [
{
lat: 30.577337,
lon: 120.293738,
label: "新市枢纽",
code: 3206,
source: 3206,
target: 4053,
road: 3,
type: "icon-hub",
gsName: "申嘉湖",
},
{
lat: 30.552239,
lon: 120.363261,
label: "洲泉",
code: 4053,
source: 4053,
target: 4055,
road: 3,
type: "gantry",
gsName: "申嘉湖",
},
{
lat: 30.561971,
lon: 120.415528,
label: "崇福北",
code: 4055,
source: 4055,
target: 4057,
road: 3,
type: "gantry",
gsName: "申嘉湖",
},
{
lat: 30.560235,
lon: 120.456098,
label: "凤鸣",
code: 4057,
source: 4057,
target: 4009,
road: 3,
type: "gantry",
gsName: "申嘉湖",
},
{
lat: 30.559178,
lon: 120.487902,
label: "凤鸣枢纽",
code: 4009,
source: 4009,
target: null,
road: [1, 2, 3],
type: "icon-hub",
gsName: "申嘉湖",
},
],
'申苏浙皖': [
{
lat: 30.719011,
lon: 120.455571,
label: "练市枢纽",
code: 3203,
source: 3203,
target: 4447,
road: 1,
type: "icon-hub",
gsName: "申苏浙皖",
},
{
lat: 30.656565,
lon: 120.475788,
label: "乌镇南",
code: 4447,
source: 4447,
target: 4449,
road: 1,
type: "gantry",
gsName: "申苏浙皖",
},
{
lat: 30.600793,
lon: 120.482239,
label: "梧桐",
code: 4449,
source: 4449,
target: 4006,
road: 1,
type: "gantry",
gsName: "申苏浙皖",
},
{
lat: 30.559178,
lon: 120.487902,
label: "凤鸣枢纽",
code: 4006,
source: 4006,
target: 4451,
road: [1, 2, 3],
type: "icon-hub",
gsName: "申苏浙皖",
},
{
lat: 30.542289,
lon: 120.488243,
label: "崇福",
code: 4451,
source: 4451,
target: 4005,
road: 2,
type: "gantry",
gsName: "申苏浙皖",
},
{
lat: 30.509447,
lon: 120.510563,
label: "骑塘枢纽",
code: 4005,
source: 4005,
target: null,
road: 2,
type: "icon-hub",
gsName: "申苏浙皖",
},
],
});
// index.vue
<template>
<div class="d3-test">
<div class="type-change">
<div class="label">类型:</div>
<div class="radio-button">
<el-radio-group
v-model="type"
size="small"
>
<el-radio-button
v-for="item in typeList"
:label="item.value"
:key="item.value"
>
{{ item.name }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<canvas-render v-if="type == 0" />
<canvas-second v-if="type == 1" />
</div>
</template>
<script>
import CanvasRender from "./canvasRender/index.vue";
import CanvasSecond from './canvasSecond/index.vue';
export default {
name: "D3Test",
components: {
CanvasRender,
CanvasSecond,
},
data () {
return {
type: 0,
typeList: [
{ name: '路段联通', value: 0 },
{ name: '路网效果', value: 1 },
]
}
}
};
</script>
<style lang="less" scoped>
.d3-test {
width: 100%;
height: 100%;
position: relative;
.action-container {
// height: 60px;
width: auto;
box-shadow: 0 4px 15px 0 rgba(0, 0, 0, .1);
position: absolute;
top: 12px;
right: 12px;
border-radius: 4px;
display: flex;
align-items: center;
background-color: #fff;
border: 1px solid #eee;
padding: 12px;
z-index: 1;
input {
cursor: pointer;
}
}
.type-change {
position: absolute;
top: 24px;
left: 24px;
width: 400px;
height: 40px;
z-index: 999;
display: flex;
.label {
width: 60px;
height: 40px;
line-height: 40px;
font-size: 14px;
text-align: right;
}
.radio-button {
height: 40px;
display: flex;
align-items: center;
}
}
}
</style>
// canvasSecond.vue
<template>
<div class="canvas-second">
<div class="container"></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, doubleTrackPointData } from "../data";
import {
setImgUrl,
pointFormat,
pointCoordinateSwitch,
addPositionFields,
getAllLinkLineHaveArrowData,
getAllLinkLineNoArrowData,
addArrowPoint,
calculateGSNamePosition,
calculateMidPoints,
convertImageCoordsToPageCoords,
setSourceAndTarget,
} from "../utils";
import _ from "lodash";
export default {
name: "canvasSecond",
components: {},
data() {
return {
canvasHeight: null, // 容器高度
canvasWidth: null, // 容器宽度
canvansInstance: null, // 画布元素
preloadedImages: [],
pointMode: "fill",
gsNameToColorList: [], // 高速名称颜色集合
};
},
computed: {},
methods: {
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");
}
this.canvasRender();
},
canvasRender() {
const _res = addPositionFields(_.cloneDeep(pointData));
let _pointData = [];
// 设置target和source字段
const _data = setSourceAndTarget(_res);
// 格式化并去重后的点位数据
_pointData = pointFormat(_data);
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);
this.allData = pointConverted;
this.allLineData = getAllLinkLineNoArrowData(
_.cloneDeep(pointData),
this.allData
);
this.preloadImages(this.pointTypeList).then((imageList) => {
this.preloadedImages = imageList;
this.drawLink();
let uniqueArr = this.allData.filter(
(value, index, self) =>
index === self.findIndex((t) => t.label === value.label)
);
this.drawCanvasPoint(uniqueArr, 1);
});
},
// 预加载图片
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);
},
// 绘制点位
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
);
this.svgInstance.fillText(
`${point.label}(${point.code})`,
point.x,
point.y - 10
);
}
});
},
// 画连接线
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);
}
});
},
draweCanvasLine(data, color, opacity = 1) {
this.svgInstance.setLineDash([]);
this.svgInstance.strokeStyle = color;
this.svgInstance.globalAlpha = opacity;
this.svgInstance.lineWidth = 2;
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();
},
},
created() {},
mounted() {
this.createCanvasChart();
},
};
</script>
<style lang="less" scoped>
.canvas-second {
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;
}
}
}
.container {
height: 40px;
width: 400px;
box-shadow: 0 4px 15px 0 rgba(0, 0, 0, .1);
position: absolute;
top: 12px;
left: 12px;
border-radius: 4px;
display: flex;
flex-direction: column;
background-color: #fff;
padding: 12px;
z-index: 99;
}
}
</style>
// canvasRender.vue
<template>
<div class="canvas-render">
<div class="action-panel">
<div class="container">
<div class="label">轨迹:</div>
<div class="radio-button">
<el-radio-group
v-model="trackType"
@input="trackTypeChange"
size="small"
>
<el-radio-button
v-for="item in trackPointList"
:label="item.value"
:key="item.value"
>
{{ item.name }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<div class="track-info">
<el-radio
v-for="item in trackPointList[trackType].children"
:label="item.value"
:key="item.value"
v-model="trackDrawType"
@change="trackDrawTypeChange"
>{{ item.name }}</el-radio
>
</div>
</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, doubleTrackPointData } from "../data";
import {
setImgUrl,
pointFormat,
pointCoordinateSwitch,
addPositionFields,
convertImageCoordsToPageCoords,
setSourceAndTarget,
} 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,
form: {
trackTotalType: "multiple",
pointMode: "fill", // 图片填充模式:contain:保持比例,fill:铺满页面
isShowGsName: false, // 是否展示高速名称
isTooltip: true, // 是否展示弹窗
lineStatus: false, // 点位连线状态
isAnimate: false, // 是否开启动画
animateType: "linear", // 动画类型:linear(虚线),mark(图标)
isBaseGsName: true,
isShowPointInfo: false, // 是否展示点位信息
isShowFeeInfo: false, // 是否展示费用信息
},
canvasHeight: null, // 容器高度
canvasWidth: null, // 容器宽度
canvansInstance: null, // 画布元素
singleTrackData: null, // 单条轨迹数据
trackType: 0, // 轨迹类型:0上行;1下行
trackDrawType: "0-backward",
trackPointData: [], // 轨迹点数据
trackPointKeyData: [], // 非轨迹点数据
animationOffset: 0,
animationSpeed: 1,
gapLength: 3, // 调整速度(值越小越慢)
dashLength: 10, // 虚线单段长度
activeDirection: "forward", // forward/backward分为下行/上行
trackPointList: [
{
name: "上行",
value: 0,
children: [
{
name: "骑塘枢纽 -> 崇福 -> 凤鸣枢纽 -> 凤鸣 -> 崇福北 -> 洲泉 -> 新市枢纽",
value: "0-backward",
},
{
name: "骑塘枢纽 -> 崇福 -> 凤鸣枢纽 -> 梧桐 -> 乌镇南 -> 练市枢纽",
value: "1-backward",
},
],
},
{
name: "下行",
value: 1,
children: [
{
name: "新市枢纽 -> 洲泉 -> 崇福北 -> 凤鸣 -> 凤鸣枢纽 -> 崇福 -> 骑塘枢纽",
value: "0-forward",
},
{
name: "练市枢纽 -> 乌镇南 -> 梧桐 -> 凤鸣枢纽 -> 崇福 -> 骑塘枢纽",
value: "1-forward",
},
],
},
],
};
},
methods: {
setTrackData(val) {
const strList = val.split("-");
if (strList[0] == 0) {
// 跨路段
// 枢纽集合
const crossPoint = this.allData.filter((ele) =>
Array.isArray(ele.road)
);
// 第一条轨迹
let firstTrackData = this.allData
.filter((ele) => ele.road === 3 && ele.gsName === "申嘉湖")
.map((item) => ({
...item,
isEnd: false,
}));
let secondTrackData = this.allData
.filter(
(ele) => ele.road === 2 && ele.gsName === "申苏浙皖"
)
.map((item) => ({ ...item, isEnd: false }));
const firstSecondCrossPoint = this.getCrossPoint(
firstTrackData[firstTrackData.length - 1],
secondTrackData[0],
crossPoint
).map((ele) => ({ ...ele, isEnd: false }));
const firstTrack = [
...firstTrackData,
...firstSecondCrossPoint,
...secondTrackData,
];
this.trackPointData = this.completeTrack(
firstTrack,
crossPoint
);
this.trackPointKeyData = this.trackPointData.map(
(ele) => ele.code
);
} else if (strList[0] == 1) {
// 整条轨迹
const _trackRes = pointData["申苏浙皖"];
this.trackPointData = _trackRes;
this.trackPointKeyData = _trackRes.map((ele) => ele.code);
}
},
trackTypeChange(val) {
this.trackType = val;
if (val == 0) {
this.trackDrawType = "0-backward";
} else if (val == 1) {
this.trackDrawType = "0-forward";
}
const strList = this.trackDrawType.split("-");
this.activeDirection = strList[1];
this.canvansInstance.removeEventListener(
"click",
this.clickLineEvent
);
this.clearAllElement();
this.clearAnimation();
this.createDoubleCanvasChart();
},
trackDrawTypeChange(val) {
this.trackDrawType = val;
const strList = this.trackDrawType.split("-");
this.activeDirection = strList[1];
this.canvansInstance.removeEventListener(
"click",
this.clickLineEvent
);
this.clearAllElement();
this.clearAnimation();
this.createDoubleCanvasChart();
},
// 添加点击事件监听
addClickListeners() {
this.canvansInstance.addEventListener("click", this.clickLineEvent);
},
clickLineEvent(event) {
const rect = this.canvansInstance.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const clickY = event.clientY - rect.top;
// 检查是否点击了线条
this.checkLineClick(clickX, clickY);
},
checkLineClick(clickX, clickY) {
// 遍历所有路段
this.gsKeyToValue.forEach((item) => {
const roadData = this.allData.filter((i) => i.gsName === item);
// 检查每个路段的所有线段
roadData.forEach((point, index) => {
if (index + 1 === roadData.length) return;
const nextPoint = roadData[index + 1];
const parallelPoints = this.calculateParallelPoints(
point,
nextPoint,
3
);
// 检查是否点击了第一条平行线(箭头指向终点)
if (
this.isPointNearLine(
clickX,
clickY,
parallelPoints.line1Start,
parallelPoints.line1End
)
) {
if (
this.trackPointKeyData.includes(point.code) &&
this.trackPointKeyData.includes(nextPoint.code)
) {
this.$message({
message: "切换下行",
type: "success",
});
this.switchTrack("forward");
this.trackType = 1;
this.trackDrawType = `${this.trackType}-forward`;
} else {
this.$message.error("不是轨迹中的连线!");
}
return;
}
// 检查是否点击了第二条平行线(箭头指向起点)
if (
this.isPointNearLine(
clickX,
clickY,
parallelPoints.line2Start,
parallelPoints.line2End
)
) {
if (
this.trackPointKeyData.includes(point.code) &&
this.trackPointKeyData.includes(nextPoint.code)
) {
this.$message({
message: "切换上行",
type: "success",
});
this.trackType = 0;
this.switchTrack("backward");
this.trackDrawType = `${this.trackType}-backward`;
} else {
this.$message.error("不是轨迹中的连线!");
}
return;
}
});
});
},
createDoubleCanvasChart() {
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");
}
// 添加点击事件监听
this.addClickListeners();
this.canvasDoubleRender();
},
canvasDoubleRender() {
const _res = addPositionFields(_.cloneDeep(doubleTrackPointData));
let _pointData = [];
// 设置target和source字段
const _data = setSourceAndTarget(_res);
// 格式化并去重后的点位数据
_pointData = pointFormat(_data);
const _pointConverted = pointCoordinateSwitch(
_pointData,
1988,
1892
);
// 然后再将转换后的坐标转换为页面的坐标
const pointConverted = convertImageCoordsToPageCoords(
_pointConverted,
{ width: 1988, height: 1892 },
{ width: this.canvasWidth, height: this.canvasHeight },
"fill",
80
);
// 获取点位类型集合
this.pointTypeList = [
...new Set(pointConverted.map((ele) => ele.type)),
"track",
];
this.gsKeyToValue = Object.keys(pointData);
this.allData = pointConverted;
// 预加载图片
this.preloadImages(this.pointTypeList).then((imageList) => {
this.preloadedImages = imageList;
this.gsKeyToValue.forEach((ele) => {
let data = this.allData.filter(
(item) => item.gsName == ele
);
this.drawDoubleCanvasPoint(data, 1);
});
});
this.setTrackData(this.trackDrawType);
this.animateDouble();
},
// 完善轨迹路径
completeTrack(data, crossPointList) {
const _data = [];
// 遍历数据
for (let i = 0; i < data.length - 1; i++) {
const currentItem = data[i];
const nextItem = data[i + 1];
// 如果当前元素是结束点,直接添加
if (currentItem.isEnd) {
_data.push(currentItem);
continue;
}
// 如果当前和下一个元素的 gsName 不相同,则需要处理交叉点
if (currentItem.gsName !== nextItem.gsName) {
_data.push(currentItem);
// 查找交叉点
const crossPoint = crossPointList.find(
(ele) =>
ele.label === currentItem.label &&
ele.gsName === nextItem.gsName &&
ele.code !== currentItem.code
);
if (crossPoint) {
_data.push({ ...crossPoint, isEnd: false });
}
} else {
// 如果 gsName 相同,直接添加当前元素
_data.push(currentItem);
}
}
// 最后添加最后一个元素
_data.push(data[data.length - 1]);
return _data;
},
// 新增方法:切换轨迹和方向
switchTrack(direction) {
// 停止当前动画
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
this.activeDirection = direction;
// 重新绘制并启动动画
this.clearCanvasContent();
this.drawAnimatedLines();
this.animateDouble();
},
// 绘制线条(分动态和静态)-跨路段路径
drawAnimatedLines() {
this.gsKeyToValue.forEach((item) => {
const roadData = this.allData.filter((i) => i.gsName === item);
roadData.forEach((point, index) => {
// 确保不是最后一个点
if (index + 1 === roadData.length) {
this.svgInstance.fillText(
`${point.label}(${point.code})`,
point.x,
point.y - 10
);
return;
}
const nextPoint = roadData[index + 1];
const parallelPoints = this.calculateParallelPoints(
point,
nextPoint,
3
);
const trackPoint = this.trackPointData.find(
(i) => i.code === point.code
);
// 检查条件是否满足
if (
this.shouldDrawDynamicLine(trackPoint, point, nextPoint)
) {
this.drawDynamicLines(parallelPoints);
} else {
this.drawStaticLines(parallelPoints, 0.2);
}
this.drawImageAtPoint(point);
});
});
},
// 在点位置绘制图像
drawImageAtPoint(point) {
const imgInfo = this.preloadedImages.find(
(item) => item.name === point.type
);
if (imgInfo && point.type) {
this.svgInstance.globalAlpha = this.trackPointKeyData.includes(
point.code
)
? 1
: 0.2;
this.svgInstance.fillText(
`${point.label}(${point.code})`,
point.x,
point.y - 10
);
this.svgInstance.drawImage(
imgInfo.imgElement,
point.x - 3,
point.y - 3,
6,
6
);
}
},
// 绘制静态线条
drawStaticLines(parallelPoints, opacity) {
const { line1Start, line1End, line2Start, line2End } =
parallelPoints;
this.drawStaticLine(line2Start, line2End, true, false, opacity);
this.drawStaticLine(line1Start, line1End, false, true, opacity);
},
drawStaticLine(start, end, arrowAtStart, arrowAtEnd, opacity) {
this.svgInstance.globalAlpha = opacity;
this.svgInstance.setLineDash([]); // 实线
this.svgInstance.lineDashOffset = 0; // 重置偏移
this.svgInstance.beginPath();
this.svgInstance.moveTo(start.x, start.y);
this.svgInstance.lineTo(end.x, end.y);
this.svgInstance.strokeStyle = "#000";
this.svgInstance.lineWidth = 2;
this.svgInstance.stroke();
// 绘制箭头
if (arrowAtStart)
this.drawArrowHead(start.x, start.y, end.x, end.y, opacity);
if (arrowAtEnd)
this.drawArrowHead(end.x, end.y, start.x, start.y, opacity);
},
// 绘制动态线条
drawDynamicLines(parallelPoints) {
const { line1Start, line1End, line2Start, line2End } =
parallelPoints;
if (this.activeDirection === "forward") {
// 正向流动:第一条线动态,第二条线静态
this.drawDynamicLine(line1Start, line1End, false, true);
this.drawStaticLine(line2Start, line2End, true, false, 0.2);
} else {
// 反向流动:第二条线动态,第一条线静态
this.drawDynamicLine(line2Start, line2End, true, false);
this.drawStaticLine(line1Start, line1End, false, true, 0.2);
}
},
// 绘制动态虚线流水线(修改为新的虚线流动效果)
drawDynamicLine(start, end, arrowAtStart, arrowAtEnd) {
const totalLen = Math.sqrt(
Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)
);
// 设置虚线样式
this.svgInstance.globalAlpha = 1;
this.svgInstance.setLineDash([this.dashLength, this.gapLength]);
// 关键点:反转偏移方向以实现向左上流动
const directionFactor = this.activeDirection === "forward" ? -1 : 1;
this.svgInstance.lineDashOffset =
-this.animationOffset * directionFactor;
// 绘制虚线
this.svgInstance.beginPath();
this.svgInstance.moveTo(start.x, start.y);
this.svgInstance.lineTo(end.x, end.y);
this.svgInstance.strokeStyle = "#000";
this.svgInstance.lineWidth = 2;
this.svgInstance.stroke();
// 绘制箭头(根据方向决定箭头位置)
if (arrowAtStart)
this.drawArrowHead(start.x, start.y, end.x, end.y);
if (arrowAtEnd) this.drawArrowHead(end.x, end.y, start.x, start.y);
},
drawArrowHead(x, y, fromX, fromY, opacity) {
const angle = Math.atan2(y - fromY, x - fromX);
const arrowLength = 8;
const arrowAngle = Math.PI / 6;
this.svgInstance.globalAlpha = opacity; // 箭头完全不透明
this.svgInstance.fillStyle = "#000";
this.svgInstance.beginPath();
this.svgInstance.moveTo(x, y);
this.svgInstance.lineTo(
x - arrowLength * Math.cos(angle - arrowAngle),
y - arrowLength * Math.sin(angle - arrowAngle)
);
this.svgInstance.lineTo(
x - arrowLength * Math.cos(angle + arrowAngle),
y - arrowLength * Math.sin(angle + arrowAngle)
);
this.svgInstance.closePath();
this.svgInstance.fill();
},
// 判断是否需要绘制动态线条
shouldDrawDynamicLine(trackPoint, point, nextPoint) {
return (
trackPoint &&
!trackPoint.isEnd &&
this.trackPointKeyData.includes(point.code) &&
this.trackPointKeyData.includes(nextPoint.code)
);
},
// 判断点是否在线附近
isPointNearLine(clickX, clickY, start, end, threshold = 5) {
// 线段向量
const lineVecX = end.x - start.x;
const lineVecY = end.y - start.y;
// 点到线段起点的向量
const pointVecX = clickX - start.x;
const pointVecY = clickY - start.y;
// 计算投影长度
const lineLength = Math.sqrt(
lineVecX * lineVecX + lineVecY * lineVecY
);
const unitVecX = lineVecX / lineLength;
const unitVecY = lineVecY / lineLength;
const projection = pointVecX * unitVecX + pointVecY * unitVecY;
// 如果投影不在线段范围内
if (projection < 0 || projection > lineLength) {
return false;
}
// 计算点到线段的垂直距离
const perpendicularVecX = pointVecX - projection * unitVecX;
const perpendicularVecY = pointVecY - projection * unitVecY;
const distance = Math.sqrt(
perpendicularVecX * perpendicularVecX +
perpendicularVecY * perpendicularVecY
);
return distance <= threshold;
},
// 动画循环
animateDouble() {
// 根据方向更新偏移量
this.animationOffset += this.animationSpeed * -1;
// 边界检查
if (this.animationOffset > this.dashLength + this.gapLength) {
this.animationOffset = 0;
} else if (this.animationOffset < 0) {
this.animationOffset = this.dashLength + this.gapLength;
}
this.clearCanvasContent();
this.drawAnimatedLines();
this.animationId = requestAnimationFrame(this.animateDouble);
},
// 获取交叉点
getCrossPoint(preRoad, nextRoad, data) {
return data.filter(
(ele) =>
ele.road.includes(preRoad.road) &&
ele.road.includes(nextRoad.road) &&
ele.gsName == preRoad.gsName
);
},
drawDoubleArrowLine(
fromX,
fromY,
toX,
toY,
arrowAtStart,
arrowAtEnd,
style = {}
) {
// 合并默认样式和传入样式
const {
// lineColor = "#3498db",
lineColor = "#000",
lineWidth = 2,
lineType = "solid",
dashPattern = [5, 3],
arrowColor = lineColor,
arrowLength = 8,
arrowAngle = Math.PI / 6,
} = style;
// 计算线角度
const angle = Math.atan2(toY - fromY, toX - fromX);
// 保存当前绘图状态
this.svgInstance.save();
// 设置线条样式
this.svgInstance.strokeStyle = lineColor;
this.svgInstance.lineWidth = lineWidth;
// 根据线条类型设置虚线模式
switch (lineType) {
case "dashed":
this.svgInstance.setLineDash(dashPattern);
break;
case "dotted":
this.svgInstance.setLineDash([2, 2]);
break;
default: // solid
this.svgInstance.setLineDash([]);
}
// 绘制线
this.svgInstance.beginPath();
this.svgInstance.moveTo(fromX, fromY);
this.svgInstance.lineTo(toX, toY);
this.svgInstance.stroke();
// 恢复实线状态(避免影响箭头绘制)
this.svgInstance.setLineDash([]);
// 绘制终点箭头
if (arrowAtEnd) {
this.svgInstance.fillStyle = arrowColor;
this.svgInstance.beginPath();
this.svgInstance.moveTo(toX, toY);
this.svgInstance.lineTo(
toX - arrowLength * Math.cos(angle - arrowAngle),
toY - arrowLength * Math.sin(angle - arrowAngle)
);
this.svgInstance.lineTo(
toX - arrowLength * Math.cos(angle + arrowAngle),
toY - arrowLength * Math.sin(angle + arrowAngle)
);
this.svgInstance.closePath();
this.svgInstance.fill();
}
// 绘制起点箭头
if (arrowAtStart) {
this.svgInstance.fillStyle = arrowColor;
this.svgInstance.beginPath();
this.svgInstance.moveTo(fromX, fromY);
this.svgInstance.lineTo(
fromX + arrowLength * Math.cos(angle - arrowAngle),
fromY + arrowLength * Math.sin(angle - arrowAngle)
);
this.svgInstance.lineTo(
fromX + arrowLength * Math.cos(angle + arrowAngle),
fromY + arrowLength * Math.sin(angle + arrowAngle)
);
this.svgInstance.closePath();
this.svgInstance.fill();
}
// 恢复绘图状态
this.svgInstance.restore();
},
drawDashedLine(p1, p2, dashPattern = [10, 5]) {
// 计算主线方向向量
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const length = Math.sqrt(dx * dx + dy * dy);
// 计算单位向量
const unitX = dx / length;
const unitY = dy / length;
// 计算偏移4像素后的起点和终点
const start = {
x: p1.x + unitX * 4,
y: p1.y + unitY * 4,
};
const end = {
x: p2.x - unitX * 4,
y: p2.y - unitY * 4,
};
this.svgInstance.beginPath();
this.svgInstance.moveTo(start.x, start.y);
// 设置虚线样式
this.svgInstance.setLineDash(dashPattern);
this.svgInstance.lineTo(end.x, end.y);
this.svgInstance.strokeStyle = "#000";
this.svgInstance.lineWidth = 2;
this.svgInstance.stroke();
// 恢复实线状态
this.svgInstance.setLineDash([]);
},
// 绘制点位
drawDoubleCanvasPoint(data, opacity, lineType = "solid") {
data.forEach((point, index) => {
if (index + 1 !== data.length) {
// 计算两条平行线的坐标
const parallelPoints = this.calculateParallelPoints(
data[index],
data[index + 1],
3
);
// 第一条线:箭头全部指向B点(终点)
this.drawDoubleArrowLine(
parallelPoints.line1Start.x,
parallelPoints.line1Start.y,
parallelPoints.line1End.x,
parallelPoints.line1End.y,
false, // 起点无箭头
true, // 终点有箭头
{
lineType,
}
);
// 第二条线:箭头全部指向A点(起点)
this.drawDoubleArrowLine(
parallelPoints.line2Start.x,
parallelPoints.line2Start.y,
parallelPoints.line2End.x,
parallelPoints.line2End.y,
true, // 起点有箭头
false, // 终点无箭头
{
lineType,
}
);
}
const imgInfo = this.preloadedImages.find(
(item) => item.name === point.type
);
if (imgInfo && point.type) {
this.svgInstance.globalAlpha = opacity;
this.svgInstance.fillText(
`${point.label}(${point.code})`,
point.x,
point.y - 10
);
this.svgInstance.drawImage(
imgInfo.imgElement,
point.x - 3,
point.y - 3,
6,
6
);
}
});
},
calculateParallelPoints(start, end, distance) {
// 计算主线方向向量
const dx = end.x - start.x;
const dy = end.y - start.y;
const length = Math.sqrt(dx * dx + dy * dy);
// 计算单位向量
const unitX = dx / length;
const unitY = dy / length;
// 计算垂直单位向量
const perpX = -dy / length;
const perpY = dx / length;
// 计算起点和终点偏移4像素后的位置(沿主线方向)
const startOffset = {
x: start.x + unitX * 4,
y: start.y + unitY * 4,
};
const endOffset = {
x: end.x - unitX * 4,
y: end.y - unitY * 4,
};
// 计算平行线偏移点
return {
line1Start: {
x: startOffset.x + perpX * distance,
y: startOffset.y + perpY * distance,
},
line1End: {
x: endOffset.x + perpX * distance,
y: endOffset.y + perpY * distance,
},
line2Start: {
x: startOffset.x - perpX * distance,
y: startOffset.y - perpY * distance,
},
line2End: {
x: endOffset.x - perpX * distance,
y: endOffset.y - perpY * distance,
},
};
},
// 关闭弹窗的点击事件
closeElementClick() {
const popupEle = document.getElementById("popup-element");
popupEle.classList.remove("visible");
this.activeClickPoint = null;
},
// 清除canvas所有元素
clearAllElement() {
this.gsNamePointData = [];
this.svgInstance.clearRect(
0,
0,
this.canvasWidth,
this.canvasHeight
);
},
// 绘制点位文本信息
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
);
}
}
});
},
// 绘制点位
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
);
if (!this.form.isShowPointInfo) {
this.svgInstance.fillText(
point.label,
point.x,
point.y - 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.functionType != 8
) {
this.drawArrow(
points[points.length - 2], // 倒数第二个点
points[points.length - 1], // 最后一个点
color,
opacity
);
}
},
// 辅助方法:获取路段相关数据
getRoadData(key) {
return {
points: this.allData.filter((ele) => ele.gsName === key),
lines: this.allLineData.filter((ele) => ele.gsName === key),
labels: this.gsNamePointData.filter(
(ele) => ele.gsName === key
),
};
},
// 辅助方法:绘制路段点
drawRoadPoints(points, isActive) {
this.drawCanvasPoint(points, isActive ? 1 : 0.3);
},
// 辅助方法:绘制路段线
drawRoadLines(lines, key, isActive) {
const color = this.getRoadColor(key);
if (!isActive) this.draweCanvasLine(lines, color, 20, 0.3);
if (
isActive &&
this.form.isShowGsName &&
this.form.trackTotalType === "multiple"
) {
this.drawLastLineArrow(lines, color, 15);
}
},
// 辅助方法:获取路段颜色
getRoadColor(key) {
return this.gsNameToColorList.find((ele) => ele.name === key).color;
},
// 辅助方法:绘制最后一条线的箭头
drawLastLineArrow(lines, color, size) {
const lastLine = lines[lines.length - 1];
const arrowColor = this.form.isBaseGsName
? this.trackColor
: "blue";
this.drawArrow(
lastLine.source,
lastLine.target,
arrowColor,
1,
size
);
},
// 绘制点位连线
draweCanvasLine(data, color, curveAmount = 20, opacity = 1) {
this.svgInstance.setLineDash([]);
this.svgInstance.strokeStyle = color;
this.svgInstance.globalAlpha = opacity;
this.svgInstance.lineWidth = 2;
if (this.form.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();
},
// 辅助方法:清除动画
clearAnimation() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
},
// 计算轨迹中两点间的中间坐标集合
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;
},
// 计算多行文本的边界框,用于后续的碰撞检测。
// 根据文本内容、位置和画布上下文(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, // 文本区域的高度(含安全边距)
};
},
// 清空 Canvas
clearCanvasContent() {
this.svgInstance.clearRect(
0,
0,
this.canvasWidth,
this.canvasHeight
);
},
},
created() {},
mounted() {
this.createDoubleCanvasChart();
},
};
</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: auto;
width: auto;
box-shadow: 0 4px 15px 0 rgba(0, 0, 0, .1);
position: absolute;
top: 12px;
left: 12px;
border-radius: 4px;
display: flex;
flex-direction: column;
// align-items: center;
background-color: #fff;
padding: 12px;
z-index: 99;
.margin-right-6 {
margin-right: 6px;
}
.container {
height: 40px;
display: flex;
align-items: center;
margin-top: 40px;
.label {
width: 60px;
height: 40px;
line-height: 40px;
font-size: 14px;
text-align: right;
}
.radio-button {
height: 40px;
display: flex;
align-items: center;
}
}
.track-info {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 60px;
.el-radio {
margin-top: 12px;
margin-right: 0!important;
}
}
}
}
</style>
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: auto;
width: auto;
box-shadow: 0 4px 15px 0 rgba(0, 0, 0, .1);
position: absolute;
top: 12px;
left: 12px;
border-radius: 4px;
display: flex;
flex-direction: column;
// align-items: center;
background-color: #fff;
padding: 12px;
z-index: 99;
.margin-right-6 {
margin-right: 6px;
}
.container {
height: 40px;
display: flex;
align-items: center;
margin-top: 40px;
.label {
width: 60px;
height: 40px;
line-height: 40px;
font-size: 14px;
text-align: right;
}
.radio-button {
height: 40px;
display: flex;
align-items: center;
}
}
.track-info {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 60px;
.el-radio {
margin-top: 12px;
margin-right: 0!important;
}
}
}
}
```图片可以自行设置,按照对应的名称命名即可,这里就不上传图片