使用canvas实现大剧院选座功能

2,436 阅读3分钟

关键字

VUE, canvas, seat-select, 选座,大剧院

jj2.png

实现大剧院选座功能,实现步骤如下

  1. 基础图形渲染
  2. 绑定鼠标点击,滑过,滚轮放大缩小,画布移动事件
  3. 鼠标指针位置与元素关系,在座位上?在房间(区域)上?及路径判断
  4. 点击选择座位,改变座位状态,选中/未选中
  5. 单选,多选,排选,框选(关键)
  6. 节流防抖处理,性能优化

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

** 新增在路径判断 **

此处为画线法,判断交叉点单双,确定是否在元素内

hxf.jpg

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元素的渲染方式,还在研究。 希望此文能为此功能开发的前端朋友提供一定的帮助。 具体问题也可以留言 **