记录一下用Canvas实现的排座例子

633 阅读3分钟

image.png

体验地址: http://1.14.12.55:8882/(已过期)

仓库: github.com/ssieie/seat…

目录结构解释

image.png

assetsLoader

用来加载需要用到的静态资源,目前主要用于加载图片(输出ImageBitmap)。

ImageBitmap 接口表示能够被绘制到 <canvas> 上的位图图像,具有低延迟的特性。运用 Window.createImageBitmap() 或 WorkerGlobalScope.createImageBitmap() 工厂方法模式,它可以从多种源中生成。ImageBitmap 提供了一种异步且高资源利用率的方式来为 WebGL 的渲染准备基础结构。

behaviorTasks

维护一个Map结构的behaviorTasks实现指定时间间隔触发某个行为,也能勉强用来实现关键帧。

  // 指定时间间隔触发器的例子
  behaviorTasksInstance.addBehavior<number | undefined>(
    "demo",
    () => {
      return 1
    },
    (frequency) => {
      if (frequency) {
        // console.log(frequency);
        // todo 改变某个值
      }
    },
    1000 / 0.1,
    false,
  ); // 0.1 次/秒

canvasTool

画布上的一些工具存放在此。目前实现了zoomTool用作画布的缩放操作控件。

class ZoomTool {
  private static instance: ZoomTool;
  private toolElement: HTMLDivElement | null = null;
  private readonly handleToolClickBind: ((e: MouseEvent) => void);
  private readonly onTransformStateChangeHandlerBind: (() => void);

  static getInstance(): ZoomTool {
    if (!ZoomTool.instance) {
      ZoomTool.instance = new ZoomTool();
    }
    return ZoomTool.instance;
  }

  constructor() {
    this.handleToolClickBind = this.handleToolClick.bind(this);
    this.onTransformStateChangeHandlerBind = this.onTransformStateChangeHandler.bind(this);
  }
  
  ...........
}

container

用于主容器的相关操作,重置画布大小。维护containerTransformState画布映射状态,如缩放,移动。

export interface ContainerTransformState {
  lastX: number;
  lastY: number;
  offsetX: number;
  offsetY: number;
  scale: number;
}

contextMenu

画布内右键菜单的封装

core

init函数初始化画布、数据中心、右键菜单、画布组件、静态资源、注册全局事件、主渲染方法、注册图形并返回外界与画布交互的一些方法。

export async function init(
  target: HTMLElement,
  fps = 30,
): Promise<OperateFunc> {

  MY_CANVAS.cvs = document.createElement('canvas')

  MY_CANVAS.cvs.setAttribute('id', 'zx-drag-canvas');
  MY_CANVAS.cvs.style.display = 'block'
  MY_CANVAS.cvs.style.backgroundColor = MY_CANVAS_BG

  MY_CANVAS.pen = MY_CANVAS.cvs.getContext('2d')

  graphicUtilsInit()

  ContainerInstance = new Container(target.clientWidth, target.clientHeight, MY_CANVAS)

  target.appendChild(MY_CANVAS.cvs)

  await AssetsLoader.load().catch(console.error)

  const func = initGraphicInstances(MY_CANVAS, instances)

  initRender(fps);

  registerAllEvents(MY_CANVAS.cvs)

  menu.init(instances);
  menu.generateContextMenuItem(func);

  zoomTool.init()

  return func
}

eventCenter

处理和分发画布中的一些交互事件

graphic

画布上不同图形的管理合集

circle

圆形布局的绘制方法和更新方法

matrix

行列布局的绘制方法和更新方法

strip

矩形布局的绘制方法和更新方法

render

用于管理 Canvas 渲染循环的核心模块

class Render {
  cvs: HTMLCanvasElement;
  $: CanvasRenderingContext2D;
  fps: number;
  fpsInterval: number;
  lastRenderTime: number;
  frame: number | undefined;

  constructor(fps: number, context: Canvaser) {
    if (!context.cvs || !context.pen) {
      throw new Error("Canvaser context is incomplete.");
    }
    this.cvs = context.cvs!;
    this.$ = context.pen!;

    this.fps = fps;
    this.fpsInterval = 1000 / this.fps;
    this.lastRenderTime = performance.now();
  }

  run(instances: RenderTargetInstances) {
    this.frame = window.requestAnimationFrame(this.run.bind(this, instances));

    let currentTime = performance.now();
    let elapsed = currentTime - this.lastRenderTime;

    if (elapsed > this.fpsInterval) {

      this.lastRenderTime = currentTime - (elapsed % this.fpsInterval);

      this.clearScreen();
      this.renderGrid();
      this.renderInstances(instances);
    }

    this.processBehavior(currentTime);
  }

  clear() {
    this.frame && window.cancelAnimationFrame(this.frame);

    delBehaviorAll()
  }

  private clearScreen() {
    this.$.clearRect(0, 0, this.cvs.width, this.cvs.height);
  }

  private renderGrid() {
    drawGrid(this.$, this.cvs.width, this.cvs.height);
  }

  private renderInstances(instances: RenderTargetInstances) {
    for (const key in instances) {
      instances[key]?.draw?.();
    }
  }

  private processBehavior(time: number) {
    if (behaviorTasksInstance.getBehaviorTasksSize) {
      behaviorTasksInstance.behaviorProcess(time);
    }
  }
}

this.processBehavior(currentTime);调动behaviorTasks中注册的事件。

runtimeStore

整合画布所有状态的容器

type RuntimeState = {
  highlightElements: boolean
  currentDragEl: Element | null
  cvs: HTMLCanvasElement | null;
  containerTransformState: ContainerTransformState
  graphicMatrix: Graphic
  groupTree: RBush<RBushGroupItem>
  canvasState: CanvasState
};

export interface Graphic {
  groups: Record<GroupType, Record<string, Group>>; // 组ID与组的MAP
  elements: Record<string, Element>; // 元素ID与元素的MAP
  groupElements: Record<string, string[]>; // 组id与元素id[]的MAP
  // groupElementsMatrix: Record<string, Element[][]>; // 方便矩阵操作更直观
}

transform

画布缩放移动后,屏幕坐标 <--> 逻辑坐标的转换。

一些关键帧动画的实现。

utils

一些工具函数。

使用方式

以VUE为例

<div
  class="wrapper"
  id="zx-drag-canvas-wrapper"
  @contextmenu.prevent
  ref="wrapperRef"
  :style="{ width: seatingPersonnelOpen ? `calc(100% - 450px)` : '100%' }"
></div>

const cFuncClickMenuHandler = (type: string, ags: string) => {
  const data = JSON.parse(ags);
  currentOperatingPosition.value = ags;
  switch (type) {
    case 'insert': // 插入座位
      break;
    case 'newPersonnelAdded': // 新增人员
      break;
    case 'selectFromPersonnelPool': // 从人员库选择
      break;
    case 'areaEditing': // 区域编辑
      break;
    case 'delPersonnel': // 删除人员
      cFunc?.contextMenuOperateFunc.updateElementBusinessState(
                data.element.id,
                '',
                '',
                'idle'
              );
      break;
    case 'deleteSeat': // 删除座位
     cFunc?.contextMenuOperateFunc.updateElementBusinessState(
                data.element.id,
                '',
                '',
                'idle'
              );
      break;
    case 'elementChanged': // 交换座位
      break;
    case 'deleteGroup': // 删除区域
      break;
  }
};

const resizeHandler = (entries: ResizeObserverEntry[]) => {
  const entry = entries[0];
  if (entry && entry.contentRect) {
    const { width, height } = entry.contentRect;
    resize(width, height);
  }
};
const resizeThrottleHandler = throttle(resizeHandler, 300);

const resizeObserver = new ResizeObserver(resizeThrottleHandler);

onMounted(async () => {
  if (wrapperRef.value) {
    cFunc = await init(wrapperRef.value, 60);

    resizeObserver.observe(wrapperRef.value);

    cFunc!.clickMenu(cFuncClickMenuHandler);
  }
});

onBeforeUnmount(() => {
  if (wrapperRef.value) {
    resizeObserver.unobserve(wrapperRef.value);
    exit();
    cFunc = null;
  }
});

总结

此示例中,所有事件采用发布订阅管理。菜单、画布组件等采用单例模式实现。画布状态的维护一些需要被其它地方知道的状态,会采用发布订阅。图形的状态则使用JS Object 引用类型的特征实现。

图形在渲染过程中将原坐标转为屏幕坐标const [x, y] = canvasToScreen(pos.x, pos.y)

以上项目结构,后续新增画布组件,新增图形,新增右键菜单的功能实现成本较低。

由于不爱写注释的习惯,代码中缺少注释并且目前代码结构存在部分不合理。