简介
网传看到一个电影院选票的面试题,试着在脑海里回答,先这样再这样,最后在这样,然后我扩展了一下,变成了演唱会选票
实现:canvas 需要处理的问题:
- 绘制
- 放大缩小
- 移动
- 选中座位
- 性能问题
在实现之前,想过s使用3种方案
- 高分辨率图片,使用canvas展示图片,好处是绘制,移动,放大缩小比较好处理,弊端是,图片体积大(可以提前加载解决),选中也是好处理,emmm...
- svg,让UI设计好,然后导出svg,前端将svg放到页面上,使用transform 移动,缩放,选中使用事件委托方式。弊端是:dom节点较多
- 使用canvas手绘,移动,缩放,选中都需要处理,对于节点较多和频繁重新绘制,使用2种方案解决,canvas离线缓存和对于视口以外(canvas范围外)的座位不绘制。
我选择的是第三种方式,以及视口以外(canvas范围外)的座位不绘制。
功能
- 放大缩小
- 移动
- 选中座位
前置知识
- canvas 特别是canvas的translate,移动坐标轴
- 以鼠标中心,缩放图片/canvas
实现思路
有一个比较重要的点就是,先以1倍正常绘制,绘制完后,以0.5倍初始展示,这样才能展示全貌,这样放大查看座位时不会模糊。以及这里坐标比例是1:1,1px对应1米,
绘制
确定好各项数据,先确定好舞台,确定好一行多少个区域,一个区域座位多少个,边距,过道等等
//绘制入口
function draw(current=0.5) {
ctx.clearRect(-20000, -20000, 200000, 200000);
ctx.scale(current, current);
drawStage()// 绘制舞台
drawArea(areas); // areas是每个区域的信息,比如颜色,id,座位,
drawChoosePoint() // 选中的回显
}
function drawStage(){
ctx.save()
// 舞台
ctx.beginPath();
ctx.rect(stageStartX,0,stageW,stageh);
ctx.stroke();
ctx.textAlign='center'
ctx.font="bold 48px serif"
ctx.fillText('舞台',wAll/2, stageh)
ctx.restore()
}
function drawArea(areas) {
const {a,d,e,f}=ctx.getTransform()
areas.forEach((item,index) => {
drawAreaReact(item,index,{a,d,e,f})
});
}
function getDrawPosition({row,col},instvelxw,rectw){
let instvelx=col==1?0:instvelxw
let instvely=row==1?0:instvelxw
const x= (rectw+instvelx)*(col-1)
const y= (rectw+instvely)*(row-1)
return {x,y}
}
function drawAreaReact(area,index,{a,d,e,f}) {
const rowcol =getDrawRowCol(areanumW,index) //根据index 和每行有多少个,确定row,col
//根据row,col,间距,确定canvas 要绘制的x,y坐标
const {x,y}= getDrawPosition(rowcol,aisleWidth,areaWidth)
const { color } = area;
ctx.strokeStyle = color||"#49b0f2";
ctx.setLineDash([5, 5]) // 设置虚线
//if((e+x*a)>lxs || (f+(y+areaStartX)*d)>lys ){
//return //超出位置不绘制
//}
// if(a*(x+areaWidth)<Math.abs(e) || (y+areaStartX+areaWidth)*d<Math.abs(f)){
// console.log(index,'index');
// return
// }
// 保存areaReact位置,可以在初始化的时候确定
areaReact.push(
{
// x,y+areaStartX, areaWidth, areaWidth
x:x,
y:y+areaStartX,
x1:x+areaWidth,
y1:y+areaStartX+areaWidth
// w:areaWidth,
// h:areaWidth
}
)
//这里绘制的area区域是按照index,一个一个的绘制,实际情况下,是写死的不规则的,
//所以记录每个areaRect的x,y,后面选中时需要用到,
drawRect(x,y+areaStartX, areaWidth, areaWidth)
// areaPeople是area里面的位置
new Array(areaPeople).fill(null).forEach((item,index1)=>{
drawAreaPoint(x,y+areaStartX,index1,{a,d,e,f}) //绘制area里面的位置
})
}
function drawAreaPoint(startx,starty,index1,{a,d,e,f}) {
// startx,starty是绘制起点
// const {} = area;
const rowcol = getDrawRowCol(areaPeoplew,index1) //根据index 和每行有多少个,确定row,col
//根据row,col,间距,确定canvas 要绘制的x,y坐标
const {x,y}= getDrawPosition(rowcol,aAreaInstvel,aAreawh)
ctx.setLineDash([0, 0]) //实线
// 超出范围不绘制
//if((e+(startx+aAreaInstvel+x)*a)>lxs || (f+(starty+aAreaInstvel+y)*d)>lys)return
//if(e<0){
// if(a*(startx+aAreaInstvel+x)<Math.abs(e)){
// return
// }
// }
//if(f<0){
// if( (starty+aAreaInstvel+y)*d<Math.abs(f)){
// return
// }
// }
//绘制座位
drawRect(startx+aAreaInstvel+x,starty+aAreaInstvel+y,aAreawh,aAreawh)
}
function drawRect(x,y,w,h){
ctx.beginPath();
ctx.rect(x,y,w,h);
ctx.stroke();
}
function getDrawRowCol(linenum,index){
const row= Math.floor(index/linenum)+1
const col= index%linenum +1
return {row,col}
}
缩放
监听wheel事件,使用捕获方式,阻止默认行为,阻止document滚动, 使用ctx.scale缩放,重新绘制画布
可以查看这篇文章# Canvas以鼠标为中心缩放原理
canvas.addEventListener('wheel', function(event) {
console.log(event,'e');
const {offsetY,offsetX,wheelDeltaY}=event
event.preventDefault();
let current = 1;
const {a,d,e,f}=ctx.getTransform()
if(wheelDeltaY>0){
// 放大
if(a>=2 )return //倍数限制
current+=step
}else{
// 缩小
if(a<=0.3 )return // 倍数限制
current-=step
}
console.log(current,'current');
//以鼠标中心点和缩放倍数,确定需要移动x,y
const xp=((offsetX-e)/a - ((offsetX-e)/(current*a)))*current
const yp=((offsetY-f)/a - ((offsetY-f)/(current*a)))*current
// 先放大在 移动
ctx.translate(-xp,-yp)
draw(current)
}, { passive: false });
确定超出范围
对于座位点 同样的判断方式, 对于rect,左上脚判断的是,rect的右下角
//rect
let lxs=canvas.width
let lys=canvas.height
....
if((e+x*a)>lxs || (f+(y+areaStartX)*d)>lys ){
return
}
drawRect(x,y+areaStartX, areaWidth, areaWidth)
// point 座位
....
if((e+(startx+aAreaInstvel+x)*a)>lxs || (f+(starty+aAreaInstvel+y)*d)>lys)return
if(e<0){
if(a*(startx+aAreaInstvel+x)<Math.abs(e)){
return
}
}
if(f<0){
if( (starty+aAreaInstvel+y)*d<Math.abs(f)){
return
}
}
drawRect(startx+aAreaInstvel+x,starty+aAreaInstvel+y,aAreawh,aAreawh)
移动
监听onmousedown,mousemove(开始移动画布),mouseup(鼠标松开),实时获取移动的距离,使用ctx.translate移动坐标轴,重新绘制画布
鼠标按下
const state={
x0:-1,//上一个鼠标位置x
y0:-1,//上一个鼠标位置y
x1:-1,//当前鼠标位置x
y1:-1,//当前鼠标位置y
ismoving:false,//是否正在移动
isDown:false,//鼠标是否按下
}
let time1=0 // 按下时间戳
let time2=0 // 松开鼠标时间戳 用于计算是点击 还是移动
canvas.onmousedown=(event)=>{
time1=+new Date()
const {offsetY,offsetX,wheelDeltaY}=event
state.isDown=true
state.x0=offsetX //初始鼠标位置
state.y0=offsetY
}
鼠标移动
canvas.onmousemove=(event)=>{
const {offsetY,offsetX,wheelDeltaY}=event
if(!state.isDown)return
state.x1=offsetX //当前鼠标位置
state.y1=offsetY
state.ismoving=true
let dx=offsetX-state.x0 //移动的距离
let dy=offsetY-state.y0
// 用于超出范围禁止移动,比如上面已经没内容,没东西,就不能继续下移
//const {a,d,e,f}=ctx.getTransform()
// let xm=e+dx
// let ym=f+dy
// if(xm>paddingpreview|| ym>paddingpreview)return
// if((canvas.width-xm)>(lx+paddingpreview)|| (canvas.height-ym)>(ly+paddingpreview) ){
// return }
const {a,d,e,f}=ctx.getTransform()
ctx.translate(dx/a,dy/d) //移动坐标轴,重新绘制
requestAnimationFrame(()=>{
draw(1) //重新绘制
})
state.x0=offsetX
state.y0=offsetY
}
鼠标松开
canvas.onmouseup=(event)=>{
time2=+new Date()
const {offsetY,offsetX,wheelDeltaY}=event
state.x1=offsetX
state.y1=offsetY
state.ismoving=false //重置状态
state.isDown=false
}
选中座位
首先需要区别移动还是点击,使用时间戳的方式
let time1=0
let time2=0
canvas.onmousedown=(event)=>{
time1=+new Date()
....
}
canvas.onmouseup=(event)=>{
time2=+new Date()
....
}
canvas.onclick=(event)=>{
if(time2-time1>1000){ //超过1秒就是移动画布
return}
....
}
先确定鼠标点击的位置在整个绘制区域的百分比,确定鼠标是在哪个area中,然后在根据这个点在区域中位置,确定座位行和列。 但是实际情况下,可能这个区域是不规则的,那么可以记录每个点与area区域的边界距离,以确定座位。 在确定area时,还可以使用 isPointInPath api
canvas.onclick=(event)=>{
if(time2-time1>1000){
return
}
console.log(event,'ee');
const {offsetX,offsetY}= event
// a: 1, // 水平缩放因子
// d: 1, // 垂直缩放因子
// e: 0, // 水平平移
// f: 0, // 垂直平移
const {a,d,e,f}=ctx.getTransform()
const xs=offsetX-e //在画布上实际x,y坐标
const ys=offsetY-f
const xp=xs/a //还原倍数为1,因为当初记录area坐标位置时放大倍数是1
const yp=ys/d
const {index,item}= getcheckInRect(xp,yp) // 查找rect
if(item){
getPointRect(item,xp,yp,{a,d,e,f})
}
//console.log(index,'index');
}
function getcheckInRect(xc,yc){
let index=-1
let item= areaReact.find((rect,i)=>{
const {x,y,x1,y1}=rect
if(checkinRange(rect,xc,yc)) { // 根据点击的位置,以及rect绘制的起点和宽高
// 判断点在矩形中
index=i
return rect
}
})
return { item,index}
}
function checkinRange({x,y,x1,y1},xc,yc){
if(xc>x&&xc<x1&&yc>y&&yc<y1){
return true
}
}
判断点在矩形中,实际是分为x方向,y方向上的判断,既,xp处于中间,yp处于中间
在area区域中找到座位
function getPointRect(rect,xp,yp,{a,d,e,f}){
const {x,y,x1,y1}=rect
const xinner=xp-x -aAreawh // xinner是area内部的距离,根据距离确定row col
const yinner=yp-y -aAreawh //
let col = Math.ceil(xinner/(aAreawh+aAreaInstvel))
let row= Math.ceil(yinner/(aAreawh+aAreaInstvel))
// 找到后
ctx.save()
ctx.strokeStyle='red'
let index0=(row-1)*areaPeoplew + col-1 //确定index
chooseTask.push({
taskParams:[x,y,index0,{a,d,e,f}] //缓存起来
})
drawAreaPoint(x,y,index0,{a,d,e,f}) //绘制 标红
ctx.restore()
}
结尾
还有一个可以优化点,放大倍数过小时,座位不绘制,只绘制rect区域。 我发给我朋友,朋友说演唱会的票不能选,emmm...。 接下来有时间想做一些动画相关的。