演唱会选票

1,034 阅读7分钟

简介

网传看到一个电影院选票的面试题,试着在脑海里回答,先这样再这样,最后在这样,然后我扩展了一下,变成了演唱会选票

20250128000752_rec_-convert.gif

实现:canvas 需要处理的问题:

  1. 绘制
  2. 放大缩小
  3. 移动
  4. 选中座位
  5. 性能问题

在实现之前,想过s使用3种方案

  1. 高分辨率图片,使用canvas展示图片,好处是绘制,移动,放大缩小比较好处理,弊端是,图片体积大(可以提前加载解决),选中也是好处理,emmm...
  2. svg,让UI设计好,然后导出svg,前端将svg放到页面上,使用transform 移动,缩放,选中使用事件委托方式。弊端是:dom节点较多
  3. 使用canvas手绘,移动,缩放,选中都需要处理,对于节点较多和频繁重新绘制,使用2种方案解决,canvas离线缓存和对于视口以外(canvas范围外)的座位不绘制。

我选择的是第三种方式,以及视口以外(canvas范围外)的座位不绘制。

功能

  1. 放大缩小
  2. 移动
  3. 选中座位

前置知识

  1. canvas 特别是canvas的translate,移动坐标轴
  2. 以鼠标中心,缩放图片/canvas

实现思路

有一个比较重要的点就是,先以1倍正常绘制,绘制完后,以0.5倍初始展示,这样才能展示全貌,这样放大查看座位时不会模糊。以及这里坐标比例是1:1,1px对应1米,

绘制

image.png 确定好各项数据,先确定好舞台,确定好一行多少个区域,一个区域座位多少个,边距,过道等等

    //绘制入口
     function draw(current=0.5) {   
        ctx.clearRect(-20000, -20000, 200000, 200000);
        ctx.scale(current, current);
        drawStage()// 绘制舞台
        drawArea(areas); // areas是每个区域的信息,比如颜色,id,座位,
        drawChoosePoint() // 选中的回显
      }
      

image.png

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

确定超出范围

image.png

对于座位点 同样的判断方式, 对于rect,左上脚判断的是,rect的右下角

image.png

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

image.png

       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处于中间 image.png

在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...。 接下来有时间想做一些动画相关的。