关键字
VUE, canvas, seat-select, 选座,大剧院
实现大剧院选座功能,实现步骤如下
- 基础图形渲染
- 绑定鼠标点击,滑过,滚轮放大缩小,画布移动事件
- 鼠标指针位置与元素关系,在座位上?在房间(区域)上?及路径判断
- 点击选择座位,改变座位状态,选中/未选中
- 单选,多选,排选,框选(关键)
- 节流防抖处理,性能优化
HTML
<div id="chart_wrap" class="chart_wrap"></div>
对css设置计算属性,其中100vh-215px,215为页面中的非cancas区域所有元素高度总和,例如导航栏,底部空白高度
CSS
.chart_wrap {
float: left;
height: calc(100vh - 215px);
background: #eee;
width: 70%;
}
渲染canvas画布
js
this.wrapDom = document.getElementById("chart_wrap");
// getcomputedstyle 是window对象,https://developer.mozilla.org/zh-CN/docs/Web/API/Window/getComputedStyle
var wrapDomStyle = getComputedStyle(this.wrapDom);
this.width = parseInt(wrapDomStyle.width, 10);
this.height = parseInt(wrapDomStyle.height, 10);
console.log(this.width, this.height, "宽度---高度");
// 创建canvas画布
this.El = document.createElement("canvas");
this.El.height = this.height;
this.El.width = this.width;
this.ctx = this.El.getContext("2d");
this.wrapDom.appendChild(this.El);
大剧院涉及的图形包含屏幕,座位,房间(区域)
js 屏幕渲染
let data = {
type: "line",
lineWidth: 1,
fillStyle: "#ddd",
id: "0000",
data: [20, 30, 1540, 30, 1550, 50, 785, 70, 10, 50, 20, 30],
}
// 绘制线条方法
drawLine(data) {
var arr = data.data.concat();
var ctx = ctx || this.ctx;
ctx.beginPath();
ctx.moveTo(arr.shift(), arr.shift());
ctx.lineWidth = data.lineWidth || 1;
do {
ctx.lineTo(arr.shift(), arr.shift());
} while (arr.length);
ctx.fillStyle = data.fillStyle;
ctx.fill();
ctx.stroke();
},
js 区域渲染
let data = {
type: "rect",
lineWidth: 2,
fillStyle: "#fff",
data: [0, 100, 1570, 1080],
blockId: "0",
title: "剧院一楼",
areaType: "1",
areaTypeValue: "舞台",
entryDoor: "",
desc: "",
seats: [],
}
// 绘制线条方法
// 绘制矩形方法
drawRect(data) {
this.ctx.beginPath();
this.ctx.fillStyle = data.fillStyle;
this.ctx.strokeStyle = "#2F9BE9";
this.ctx.lineWidth = data.lineWidth;
this.ctx.strokeRect(...data.data);
this.ctx.fillRect(...data.data);
},
js 座位渲染
let data = {
blockId: "0"
blockName: "大剧院一楼"
color: "#00a5ff"
positionLeft: "1180"
positionTop: "200"
rowName: "C"
rowNo: "3"
seatColor: "#fff"
// 移动端定位属性
x: "0"
xcoord: "44"
y: "200"
ycoord: "5"
}
drawSeatCircle(data) {
this.ctx.beginPath();
this.ctx.fillStyle = element.seatColor;
this.ctx.arc(
element.positionLeft,
element.positionTop,
12,
0,
2 * Math.PI
);
this.ctx.fill();
// 填充背景颜色
this.ctx.lineWidth = element.lineWidth | 1;
this.ctx.lineCap = "round";
this.ctx.strokeStyle = "#000000";
this.ctx.stroke();
this.ctx.closePath();
},
** init **
this.wrapDom = document.getElementById("chart_wrap");
var wrapDomStyle = getComputedStyle(this.wrapDom);
this.width = parseInt(wrapDomStyle.width, 10);
this.height = parseInt(wrapDomStyle.height, 10);
// 创建canvas画布
this.El = document.createElement("canvas");
this.El.height = this.height;
this.El.width = this.width;
this.ctx = this.El.getContext("2d");
this.wrapDom.appendChild(this.El);
this.scale = 1; // 默认缩放值是 1
// 缩放具体实现会用到,下面会讲,现在可以不看
this.maxScale = 3; // 最大缩放值
this.minScale = 1; // 最小缩放值
this.step = 0.1; // 缩放率
this.offsetX = 0; // 画布X轴偏移值
this.offsetY = 0; // 画布Y轴偏移值
// 添加滚轮判断事件
this.addScaleFunc();
// 添加拖拽事件
this.addDragFunc();
** 事件注册 **
// 添加鼠标移动 功能,获取保存当前点击坐标
addMouseMove = (e) => {
this.targetX = e.offsetX;
this.targetY = e.offsetY;
this.mousedownOriginX = this.offsetX;
this.mousedownOriginY = this.offsetY;
var x = (this.targetX - this.offsetX) / this.scale;
var y = (this.targetY - this.offsetY) / this.scale;
this.activeShape = null;
this.data.forEach((item) => {
switch (item.type) {
case "rect":
this.isInnerRect(...item.data, x, y) && (this.activeShape = item);
break;
case "circle":
this.isInnerCircle(item.x, item.y, item.r, x, y) &&
(this.activeShape = item);
break;
case "line":
var lineNumber = item.data.length / 2 - 1;
var flag = false;
for (let i = 0; i < lineNumber; i++) {
let index = i * 2;
flag = this.isInnerPath(
item.data[index],
item.data[index + 1],
item.data[index + 2],
item.data[index + 3],
x,
y,
item.lineWidth || 1
);
if (flag) {
this.activeShape = item;
break;
}
}
}
});
console.log(this.activeShape, "-----21421-4-21");
if (!this.activeShape) {
this.wrapDom.style.cursor = "grabbing";
this.El.addEventListener("mousemove", this.moveCanvasFunc, false);
} else {
this.wrapDom.style.cursor = "all-scroll";
this.shapedOldX = null;
this.shapedOldY = null;
this.El.addEventListener("mousemove", this.moveShapeFunc, false);
}
};
// 移除鼠标移动事件
removeMouseMove = () => {
this.wrapDom.style.cursor = "";
this.El.removeEventListener("mousemove", this.moveCanvasFunc, false);
this.El.removeEventListener("mousemove", this.moveShapeFunc, false);
};
// 移动画布
moveCanvasFunc = (e) => {
// 获取 最大可移动宽
var maxMoveX = this.El.width / 2;
var maxMoveY = this.El.height / 2;
var offsetX = this.mousedownOriginX + (e.offsetX - this.targetX);
var offsetY = this.mousedownOriginY + (e.offsetY - this.targetY);
this.offsetX = Math.abs(offsetX) > maxMoveX ? this.offsetX : offsetX;
this.offsetY = Math.abs(offsetY) > maxMoveY ? this.offsetY : offsetY;
this.render();
};
// 移动形状
moveShapeFunc = (e) => {
var moveX = e.offsetX - (this.shapedOldX || this.targetX);
var moveY = e.offsetY - (this.shapedOldY || this.targetY);
moveX /= this.scale;
moveY /= this.scale;
switch (this.activeShape.type) {
case "rect":
let x = this.activeShape.data[0];
let y = this.activeShape.data[1];
let width = this.activeShape.data[2];
let height = this.activeShape.data[3];
this.activeShape.data = [x + moveX, y + moveY, width, height];
break;
case "circle":
this.activeShape.x += moveX;
this.activeShape.y += moveY;
break;
case "line":
var item = this.activeShape;
var lineNumber = item.data.length / 2;
for (let i = 0; i < lineNumber; i++) {
let index = i * 2;
item.data[index] += moveX;
item.data[index + 1] += moveY;
}
}
this.shapedOldX = e.offsetX;
this.shapedOldY = e.offsetY;
this.render();
};
** 元素判断 **
// 判断是否在矩形框内
isInnerRect(x0, y0, width, height, x, y) {
return x0 <= x && y0 <= y && x0 + width >= x && y0 + height >= y;
}
// 判断是否在圆形内
isInnerCircle(x0, y0, r, x, y) {
return Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2) <= Math.pow(r, 2);
}
// 判断是否在路径上
isInnerPath(x0, y0, x1, y1, x, y, lineWidth) {
var a1pow = Math.pow(x0 - x, 2) + Math.pow(y0 - y, 2);
var a1 = Math.sqrt(a1pow, 2);
var a2pow = Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2);
var a2 = Math.sqrt(a2pow, 2);
var a3pow = Math.pow(x1 - x0, 2) + Math.pow(y1 - y0, 2);
var a3 = Math.sqrt(a3pow, 2);
var r = lineWidth / 2;
var ab = (a1pow - a2pow + a3pow) / (2 * a3);
var h = Math.sqrt(a1pow - Math.pow(ab, 2), 2);
var ad = Math.sqrt(Math.pow(a3, 2) + Math.pow(r, 2));
return h <= r && a1 <= ad && a2 <= ad;
}
** 全部渲染 **
// 渲染整个 图形画布
render() {
this.El.width = this.width;
this.data.forEach((item) => {
this.draw(item);
});
}
** 新增在路径判断 **
此处为画线法,判断交叉点单双,确定是否在元素内
isInnerOtherLine(x0, y0, arr) {
// x y x坐标集合,y坐标集合.
let x = [];
let y = [];
function arrSort(arr, x, y) {
arr.map((item, i, arr) => {
if (i % 2 === 0) {
x.push(item);
} else {
y.push(item);
}
});
}
arrSort(arr, x, y);
let crossings = 0;
for (let i = 0; i < 6; i++) {
let slope = (y[i + 1] - y[i]) / (x[i + 1] - x[i]);
let cound1 = x[i] <= x0 && x0 < x[i + 1];
let cound2 = x[i + 1] <= x0 && x0 < x[i];
let above = y0 < slope * (x0 - x[i]) + y[i];
if ((cound1 || cound2) && above) {
crossings++;
}
}
return crossings % 2 != 0;
},
总结
**此间经历了很多问题,参考了卖座(逻辑和业务),国外的seatmap-canvas, fabricjs.com/, hammerjs.github.io/ 实现了PC端在线选座的功能。包括业务中的票价计算,总价计算 后续功能开发,标尺,鹰眼等功能还在开发。 还有移动端的选座功能
个人认为面对1000+数量的座位元素渲染,卖座的是优秀的处理方案,本人实现的是纯canvas逻辑渲染,没有做过完整的性能测试和压力测试
个人最希望的是svg>>rect元素的渲染方式,还在研究。 希望此文能为此功能开发的前端朋友提供一定的帮助。 具体问题也可以留言 **