1. 简介
大家好,我是 simple ,我的理想是利用科技手段来解决生活中遇到的各种问题。
绘制道路交叉点的初衷是为了在cesium的地图上清晰地展示道路交汇处的位置和结构,提升地图的可读性和美观性,但由于里面涉及较多的业务,在此用canvas抽出其中技术要点。
这是未绘制之前的效果:
这是绘制后的效果:
2. 技术实现
js
// 这个是道路的初始数据
const path1 = [
{ x: 500, y: 0 },
{ x: 500, y: 1000 }
];
const path2 = [
{ x: 0, y: 800, },
{ x: 1000, y: 800 }
];
const path3 = [
{ x: 0, y: 100, },
{ x: 1000, y: 300 }
];
思路:通过计算出path1,path2,path3的所有路段的交接点,再根据路宽,通过线段平移和点平移,贝塞尔曲线计算生成交界面。
2.1 准备工作
- 创建Canvas画布,并准备好绘制道路的基本环境。
html
<canvas id="myCanvas" width="1000" height="1000" style="border:1px solid #000;"></canvas>
<script>
class Road {
/**
* Road 类的构造函数
* @param {Canvas} canvas - 画布对象
*/
constructor(canvas, options) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.paths = []; // 存储所有道路,新增道路时需要遍历老道路,看是否存在交点
this.lineWidth = 100;
}
}
</script>
- 初始化Road类,包括构造函数和属性设置。
js
const canvas = document.getElementById("myCanvas");
const road = new Road(canvas);
2.2 绘制路径
- 绘制路径的方法
drawPath
,展示如何绘制道路线段。
js
/**
* 绘制路径
* @param {Array} positions - 路径顶点数组
* @param {Number} lineWidth - 线条宽度
* @param {String} color - 线条颜色
*/
drawPath(positions, lineWidth = this.lineWidth, color = "#666467") {
// 绘制路径
this.ctx.beginPath();
this.ctx.moveTo(positions[0].x, positions[0].y); // 移动到第一个顶点
for (let point of positions) {
this.ctx.lineTo(point.x, point.y);
}
// this.ctx.closePath(); // 封闭路径
this.ctx.strokeStyle = color; // 设置填充颜色
this.ctx.lineWidth = lineWidth; // 设置线条宽度
this.ctx.stroke(); // 描边第二条线
}
这里就是简单的通过canvas绘制了一条宽为100像素的直线,颜色为#666467,canvas的具体使用方法可以参考MDN文档。
2.3 获取交叉点
-
计算两条线段的交点坐标的方法
getCross
,用于确定交叉点坐标。在获取交叉点坐标之前,我们需要掌握一定的数学知识。
- 两向量是否有交点,需判断两向量是否平行,若平行则无交点。(这里不讨论两线段重合的问题)
- 当两向量不平行时,计算出对应的相交点。
- 计算出的交点通过判断是否在线段上,即可判断两线段是否相交。
如果不清楚怎么获取交叉点,可以细看这篇文章。
js
/**
* 计算两条线段的交点坐标
* @param {Array} path1 - 第一条线段顶点数组
* @param {Array} path2 - 第二条线段顶点数组
* @returns {Object} - 交点坐标对象
*/
getCross(path1, path2) {
// 计算交点的坐标
const x1 = path1[0].x;
const y1 = path1[0].y;
const x2 = path1[1].x;
const y2 = path1[1].y;
const x3 = path2[0].x;
const y3 = path2[0].y;
const x4 = path2[1].x;
const y4 = path2[1].y;
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
if (denom === 0) {
// 两条线段平行,无交点
return null;
}
const t = (x3 * (y4 - y3) + y1 * (x4 - x3) - y3 * (x4 - x3) - x1 * (y4 - y3)) / denom;
const intersectionX = x1 + t * (x2 - x1);
const intersectionY = y1 + t * (y2 - y1);
// 判断交点是否在两条线段之间
if (t < 1 && t > 0) {
return {
x: intersectionX,
y: intersectionY
}
} else {
return null;
}
}
2.4 绘制交叉点形状
绘制交叉多边形,我的想法是这样:
- 通过前面的计算我们已经知道交叉点坐标。
- 通过交叉点以及线段的起始或终点位置,获取一个向量
- 获取该向量的法向量,将向量沿着法向量的方向平移
道路宽度lineWidth
的长度
- 取平移后向量的
lineWidth/2
的长度即可获得路段平移后的点
- 按上述方式即可获取马路的8个点,依次取2个点和交点取一个合适的值,计算出一个中点。
js
getCurve(p1, p2, cross) {
let midPoint = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
const x = midPoint.x - cross.x;
const y = midPoint.y - cross.y;
const len = - 5 * Math.sqrt(2) * (Math.sqrt(x * x + y * y)) / 8;
const p = this.extendLine([midPoint, cross], len);
const points = this.createBezire(p1, p, p2);
return points;
}
- 根据3个点即可获取贝塞尔曲线,然后绘制曲线路径,根据路径绘制多边形即可。
3. 完整代码展示
js
/**
* Road 类用于操作道路相关的绘图功能
*/
class Road {
/**
* Road 类的构造函数
* @param {Canvas} canvas - 画布对象
*/
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d");
this.paths = [];
this.lineWidth = 100;
}
/**
* 创建新路径
* @param {Array} positions - 路径顶点数组
* @param {String} color - 路径颜色
*/
createPath(positions, color) {
this.drawPath(positions, color);
this.drawCrossRoad(positions);
this.paths.push(positions);
}
/**
* 绘制路径
* @param {Array} positions - 路径顶点数组
* @param {Number} lineWidth - 线条宽度
* @param {String} color - 线条颜色
*/
drawPath(positions, lineWidth = this.lineWidth, color = "#666467") {
// 绘制路径
this.ctx.beginPath();
this.ctx.moveTo(positions[0].x, positions[0].y); // 移动到第一个顶点
for (let point of positions) {
this.ctx.lineTo(point.x, point.y);
}
// this.ctx.closePath(); // 封闭路径
this.ctx.strokeStyle = color; // 设置填充颜色
this.ctx.lineWidth = lineWidth; // 设置线条宽度
this.ctx.stroke(); // 描边第二条线
}
/**
* 绘制多边形
* @param {Array} positions - 多边形顶点数组
*/
drawPolygon(positions) {
// 绘制多边形
this.ctx.beginPath();
this.ctx.moveTo(positions[0].x, positions[0].y);
for (let point of positions) {
this.ctx.lineTo(point.x, point.y);
}
this.ctx.closePath();
this.ctx.fillStyle = "#383a46";
this.ctx.fill();
}
/**
* 计算两条线段的交点坐标
* @param {Array} path1 - 第一条线段顶点数组
* @param {Array} path2 - 第二条线段顶点数组
* @returns {Object} - 交点坐标对象
*/
getCross(path1, path2) {
// 计算交点的坐标
const x1 = path1[0].x;
const y1 = path1[0].y;
const x2 = path1[1].x;
const y2 = path1[1].y;
const x3 = path2[0].x;
const y3 = path2[0].y;
const x4 = path2[1].x;
const y4 = path2[1].y;
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
if (denom === 0) {
// 两条线段平行,无交点
return null;
}
const t = (x3 * (y4 - y3) + y1 * (x4 - x3) - y3 * (x4 - x3) - x1 * (y4 - y3)) / denom;
const intersectionX = x1 + t * (x2 - x1);
const intersectionY = y1 + t * (y2 - y1);
// 判断交点是否在两条线段之间
if (t < 1 && t > 0) {
return {
x: intersectionX,
y: intersectionY
}
} else {
return null;
}
// const k1 = (y1 - y2) / (x1 - x2);
// const b1 = y1 - k1 * x1;
// const k2 = (y3 - y4) / (x3 - x4);
// const b2 = y3 - k2 * x3;
// if(k1 === k2) {
// return null;
// }
// const x = (b2 - b1) / (k1 - k2);
// let y;
// if (k1) {
// y = k1 * x + b1;
// } else {
// y = k2 * x + b2;
// }
// if (
// y >= Math.min(y1, y2) &&
// y <= Math.max(y1, y2) &&
// y >= Math.min(y3, y4) &&
// y <= Math.max(y3, y4) &&
// x >= Math.min(x1, x2) &&
// x <= Math.max(x1, x2) &&
// x >= Math.min(x3, x4) &&
// x <= Math.max(x3, x4)
// ) {
// return {
// x: x,
// y: y
// }
// }
// return null;
}
/**
* 计算二次贝塞尔曲线上的点
* @param {Object} p0 - 起点
* @param {Object} p1 - 控制点
* @param {Object} p2 - 终点
* @param {Number} t - 参数值
* @returns {Object} - 曲线上的点
*/
static quadraticBezier(p0, p1, p2, t) {
let x = Math.pow(1 - t, 2) * p0.x + 2 * (1 - t) * t * p1.x + Math.pow(t, 2) * p2.x;
let y = Math.pow(1 - t, 2) * p0.y + 2 * (1 - t) * t * p1.y + Math.pow(t, 2) * p2.y;
return { x: x, y: y };
}
/**
* 获取曲线路径
* @param {Object} p1 - 起点
* @param {Object} p2 - 终点
* @param {Object} cross - 交点
* @returns {Array} - 曲线路径数组
*/
getCurve(p1, p2, cross) {
let midPoint = {
x: (p1.x + p2.x) / 2,
y: (p1.y + p2.y) / 2
};
const x = midPoint.x - cross.x;
const y = midPoint.y - cross.y;
const len = - 5 * Math.sqrt(2) * (Math.sqrt(x * x + y * y)) / 8;
const p = this.extendLine([midPoint, cross], len);
const points = this.createBezire(p1, p, p2);
return points;
}
/**
* 生成贝塞尔曲线
* @param {Object} p0 - 起点
* @param {Object} p1 - 控制点
* @param {Object} p2 - 终点
* @returns {Array} - 贝塞尔曲线路径数组
*/
createBezire(p0, p1, p2) {
const path = [];
for (let t = 0; t <= 1; t += 0.1) {
let point = Road.quadraticBezier(p0, p1, p2, t);
path.push(point);
}
return path;
}
/**
* 按法线平移线段
* @param {Array} path - 线段顶点数组
* @param {Number} movementLength - 移动距离
* @returns {Array} - 新的起点和终点坐标数组
*/
moveLineAlongNormal(path, movementLength) {
const x1 = path[0].x;
const y1 = path[0].y;
const x2 = path[1].x;
const y2 = path[1].y;
// 计算线段的方向向量
let dx = x2 - x1;
let dy = y2 - y1;
// 计算线段的法线向量
let nx = -dy;
let ny = dx;
// 计算法线向量的长度
let length_n = Math.sqrt(nx * nx + ny * ny);
// 计算单位法线向量
let unit_nx = nx / length_n;
let unit_ny = ny / length_n;
// 返回新的起点和终点坐标
return [
{
x: x1 + movementLength * unit_nx,
y: y1 + movementLength * unit_ny
},
{
x: x2 + movementLength * unit_nx,
y: y2 + movementLength * unit_ny
}
];
}
/**
* 延长线段计算坐标
* @param {Array} path - 线段顶点数组
* @param {Number} distance - 射线长度
* @returns {Object} - 计算得到的坐标对象
*/
extendLine(path, distance) {
let x1 = path[0].x;
let y1 = path[0].y;
let x2 = path[1].x;
let y2 = path[1].y;
// 计算线段的方向向量
let dx = x2 - x1;
let dy = y2 - y1;
// 计算方向向量的长度
let length = Math.sqrt(dx * dx + dy * dy);
// 计算单位方向向量
let unit_dx = dx / length;
let unit_dy = dy / length;
// 计算新的终点坐标
let new_x2 = x2 + distance * unit_dx;
let new_y2 = y2 + distance * unit_dy;
// 输出新的终点坐标
return {
x: new_x2,
y: new_y2
}
}
/**
* 绘制道路
* @param {Array} positions - 路径顶点数组
*/
drawCrossRoad(positions) {
const res = [];
for (let path of this.paths) {
for (let i = 0, len = path.length; i < len - 1; i++) {
const oldPath = [path[i], path[i + 1]];
for (let j = 0, len = positions.length; j < len - 1; j++) {
const newPath = [positions[j], positions[j + 1]];
const cross = this.getCross(oldPath, newPath);
if (cross) {
const path1 = this.moveLineAlongNormal([newPath[0], cross], this.lineWidth / 2);
const point1Start = this.extendLine(path1, -this.lineWidth);
const point1End = this.extendLine(path1, this.lineWidth);
const path2 = this.moveLineAlongNormal([newPath[0], cross], -this.lineWidth / 2);
const point2Start = this.extendLine(path2, -this.lineWidth);
const point2End = this.extendLine(path2, this.lineWidth);
const path3 = this.moveLineAlongNormal([oldPath[0], cross], this.lineWidth / 2);
const point3Start = this.extendLine(path3, -this.lineWidth);
const point3End = this.extendLine(path3, this.lineWidth);
const path4 = this.moveLineAlongNormal([oldPath[0], cross], -this.lineWidth / 2);
const point4Start = this.extendLine(path4, -this.lineWidth);
const point4End = this.extendLine(path4, this.lineWidth);
const bezire1 = this.getCurve(point1Start, point3End, cross);
const bezire2 = this.getCurve(point4End, point1End, cross);
const bezire3 = this.getCurve(point2End, point4Start, cross);
const bezire4 = this.getCurve(point3Start, point2Start, cross);
const points = [...bezire1, ...bezire2, ...bezire3, ...bezire4, bezire1[0]];
this.drawPolygon(points, 1, '#0099ff');
}
}
}
}
}
}
html
<canvas id="myCanvas" width="1000" height="1000" style="border:1px solid #000;"></canvas>
<script src="./road.js"></script>
<script>
var canvas = document.getElementById("myCanvas");
const road = new Road(canvas);
const path1 = [
{ x: 500, y: 0 },
{ x: 500, y: 1000 }
]
const path2 = [
{ x: 0, y: 600, },
{ x: 1000, y: 600 }
]
const path3 = [
{ x: 0, y: 100, },
{ x: 1000, y: 300 }
];
road.createPath(path1);
road.createPath(path2);
road.createPath(path3);
</script>