canvas实现路段连通和路段效果

在这里插入图片描述

源码

// 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;
        }
    }
}

}

```

图片可以自行设置,按照对应的名称命名即可,这里就不上传图片

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值