利用 konva 实现会议室选座位

3,073 阅读8分钟

先贴一张实现的效果图

11e181621f65fb140415a31a4ae6867a.png

老规矩先进行功能分析和准备工作

  • 这块功能我实现的不多,主要就是拖动新增座位和已占用座位拖动到未占用座位,放大缩小图层。如右键功能,定义座位中心点,所有已占用座位向中心点靠齐,忽视空闲座位,框选同行同列多个座位整体移动等等都相对简单,有时间可以实现一下。

  • 思考 1: 实现这个模块 最主要的是思考如何定义座位的数据结构。我一开始想的是通过 x y 坐标来定义座位的标记。经过实践我发现有点行不通,这个坐标受到 放大 | 缩小 | 图层移动等等影响。后来我想到一招,以会议桌为中心点来定义。于是就有了下面的数据结构 key包含了座位的方向和下标,我不用记录座位的 x y 数字坐标,到时候通过渲染的时候再动态计算。

      { key: 'top-5', occupied: true },
      { key: 'bottom-3', occupied: true },
      { key: 'left-1', occupied: true },
      { key: 'left-5', occupied: true },
      { key: 'right-1', occupied: true },
      { key: 'top-10', occupied: true },
      { key: 'top-12', occupied: true },
      { key: 'top-13', occupied: true },
      { key: 'top-14', occupied: true },
    
  • 思考 2:画布结构如何定义?我发现只要涉及到存在交互性较多的功能,尽量采用双画布的实现思维,一个画布用于渲染一些较少变更的Shape, 另一个画布属于用户交互频繁更新的,虽然更新频繁,但是Shape较少,还是比较合理的。

  <Stage
   width={innerWidth - 200}
    height={innerHeight - 50}
    ref={stageRef}
    onWheel={(event) => void handleWheel(event)}
    onMouseMove={() => void store.onmouseMove()}
    onMouseDown={() => void store.onMouseDown()}
     onMouseUp={() => void store.onMouseUp()}
     onDragEnd={() => {
          childrensRef.current = stageRef.current?.find('.occupiedSeats,.freeSeats');
    }}
      >
        {/* <GridLine /> */}
        <Layer ref={layerRef}>
          <FeatureLayer />
          <Container />
          <Highlight />
        </Layer>
       <Layer name="controls-layer" visible={true} />
     </Stage>
  • 接下来我们需要思考 定义变量 我附带上变量说明
  //  会议桌的相对位置
  tableX = 100;
  tableY = 100;
  //  座位间距
  spacing = 20;
  //  根据桌子的宽高 生成可容纳的所有座位 
  allSeats = [] as { key: string; occupied: boolean; }[];
  //  图层是否可拖动
  draggable = false;
  //  座位大小
  imageSize = 60;
  //  会议桌宽度
  tableWidth = 800;
  //  会议桌高度
  tableHeight = 300;
  //  是否正在拖拽 新增座位
  isDropingAddSeat = false;
  //  座位是否移动中
  isSeatMoving = false;
  moveingSourceGroup = null as { x: number, y: number, name: string } | any;
  moveingTargetGroup = null as { x: number, y: number, name: string } | any;
  moveingSeat: null | { x: number, y: number } = null;
  //  点击座位时 鼠标坐标相对座位的坐标  用于拖动中减去这段距离
  oldRect: any = {};
  //  未占用座位
  unoccupiedSeats = observable.array([]) as ISeat[];
  //  已占用座位
  occupiedSeats = [
    { key: 'top-2', occupied: true },
    { key: 'top-5', occupied: true },
    { key: 'bottom-3', occupied: true },
    { key: 'left-1', occupied: true },
    { key: 'left-5', occupied: true },
    { key: 'right-1', occupied: true },
    { key: 'top-10', occupied: true },
    { key: 'top-12', occupied: true },
    { key: 'top-13', occupied: true },
    { key: 'top-14', occupied: true },
  ] as ISeat[];

接下来是逻辑实现,重点我先讲解桌子和座位。


tableWidth和 tableHeight 的值一定是 (imageSize + spacing)的倍数。初始化的时候也需要先确定下来会议桌的宽高才能计算出总的座位应该有多少。 举个例子: { key: 'top-14', occupied: true }作为横轴下标最大座位,那么桌子的宽度就能确定下来 this.tableWidth = Math.max(xMax * this.imageSize + xMax * this.spacing, this.tableWidth); 纵轴也是一样的,就拿 left 和 right 比较。

  //  假如已占用座位
  occupiedSeats = [
    { key: 'top-2', occupied: true },
    { key: 'top-5', occupied: true },
    { key: 'bottom-3', occupied: true },
    { key: 'left-1', occupied: true },
    { key: 'left-5', occupied: true },
    { key: 'right-1', occupied: true },
    { key: 'top-10', occupied: true },
    { key: 'top-12', occupied: true },
    { key: 'top-13', occupied: true },
    { key: 'top-14', occupied: true },
  ] as ISeat[];
  
   ready() {
    // 存储座位的最大索引
    let maxTop = 0;
    let maxBottom = 0;
    let maxLeft = 0;
    let maxRight = 0;

    // 解析 occupiedSeats 来找到最大索引
    this.occupiedSeats.forEach(seat => {
      const [position, index] = seat.key.split('-');
      const idx = parseInt(index, 10);

      if (seat.occupied) {
        switch (position) {
          case 'top':
            maxTop = Math.max(maxTop, idx);
            break;
          case 'bottom':
            maxBottom = Math.max(maxBottom, idx);
            break;
          case 'left':
            maxLeft = Math.max(maxLeft, idx);
            break;
          case 'right':
            maxRight = Math.max(maxRight, idx);
            break;
        }
      }
    });

    const xMax = Math.max(maxTop, maxBottom) + 1;
    const yMax = Math.max(maxLeft, maxRight) + 1;

    this.tableWidth = Math.max(xMax * this.imageSize + xMax * this.spacing, this.tableWidth);
    this.tableHeight = Math.max(yMax * this.imageSize + yMax * this.spacing, this.tableHeight);

    this.getSeats();
    this.unoccupiedSeats = this.getUnoccupiedSeats();
   }

确定了桌子宽高 , 就需要计算出总共容纳多少座位 和 未占用座位有哪些

 // 计算出所有的座位
  getSeats() {
    const { imagesVerSide, imagesHorSize } = this;
    const allSeats: ISeat[] = [];
    // 得到横排所有座位
    for (let i = 0; i < imagesVerSide; i++) {
      allSeats.push(
        { key: 'top-' + i, occupied: false },
        { key: 'bottom-' + i, occupied: false }
      )
    }
    // 得到竖排所有座位
    for (let i = 0; i < imagesHorSize; i++) {
      allSeats.push(
        { key: 'left-' + i, occupied: false },
        { key: 'right-' + i, occupied: false }
      );
    }
    this.allSeats = allSeats;
  }
  
    // 找出未占用的座位
  getUnoccupiedSeats() {
    return this.allSeats.filter(pos =>
      !this.occupiedSeats.some(occupied =>
        occupied.key === pos.key
      )
    );
  }

明确了 桌子的宽高和座位的数量 此时我们就去渲染到界面。

export const Container = observer(() => {
  const store = useConference();

  return (
    <Group
      name="main"
      x={store.tableX}
      y={store.tableY}
      ref={(refs) => {
        (window as any).refs = refs
      }}
      draggable={store.draggable}
      onDragEnd={(events) => {
        store.tableX = events.target.attrs.x;
        store.tableY = events.target.attrs.y;
        store.getSeats();
      }}
    >
      <Group id="桌子">
        <Image
          height={store.tableHeight}
          width={store.tableWidth}
          x={0}
          y={0}
          image={useImage(png)[0]}
        />
        <Rect
          x={50}
          y={35}
          height={store.tableHeight - 70}
          width={store.tableWidth - 100}
          fill="transparent"
          stroke="#FFF"
          // 圆角
          cornerRadius={[3, 3, 3, 3]}
          strokeWidth={1}
        />
        <Image
          x={store.tableWidth / 2 - 40}
          y={store.tableHeight / 2 - 40}
          height={80}
          width={80}
          image={useImage(png2)[0]}
          // 圆角
          cornerRadius={40}
          strokeWidth={1}
        />
        <Group
          x={store.tableWidth / 2 + 60}
          y={store.tableHeight / 2 - 60}
        >
          <Line
            // points={[0, 0, 25, 0, 20, 25]}
            points={[-12, 30, 0, 0, 18, 27]} // 更新顶部点以形成 90 度的三角形
            tension={0.2}
            closed
            stroke="red"
            fill={'red'}
          />

          <Circle
            x={7}   // 圆形的中心位置
            y={15}  // 圆形的中心位置
            radius={17}  // 圆形的半径
            stroke="red"
            fill="red"
          />
          <Image
            x={-6}   // 圆形的中心位置
            y={1}  // 圆形的中心位置
            width={26}  // 圆形的半径
            height={26}  // 圆形的半径
            cornerRadius={13}
            image={useImage(png2)[0]}
            strokeEnabled={false}
            fill="#F8F8F8"
          />
        </Group>

      </Group>
      {store.occupiedSeats?.map((pos, index) => {
        const rect = store.getSeatCoordinates(pos.key);
        store.cache.set(pos.key, rect)
        return <Group id={pos.key} name="occupiedSeats" key={index}>
          <Image
            key={index}
            {...rect}
            height={store.imageSize}
            width={store.imageSize}
            image={img3}
            strokeEnabled={false}
          // fill="#F8F8F8"

          />
        </Group>
      })}
      {store.unoccupiedSeats?.map((pos, index) => {
        const rect = store.getSeatCoordinates(pos.key);
        store.cache.set(pos.key, rect)
        return (
          <Group name="freeSeats" key={index} visible={store.unoccupiedSeatGroupVisible}>
            <Image
              key={index}
              name={pos.key}
              // x={pos.x}
              // y={pos.y}
              {...rect}
              //  旋转 180
              // rotationDeg={180}
              // offsetX={rect.rotationDeg ? store.imageSize  : 0} // 设置锚点为图像中心
              // offsetY={rect.rotationDeg ? store.imageSize : 0} // 设置锚点为图像中心
              height={store.imageSize}
              width={store.imageSize}
              image={img2}
              strokeEnabled={false}
              fill="#F8F8F8"
            />
          </Group>
        )
      })}
    </Group>
  )
});

渲染界面时我们才去确定座位的位置 x |y | 容器 Group 的偏移量 | 座位的旋转角度 ,可能保证不受任何拖动 | 放大什么的影响。

const rect = store.getSeatCoordinates(pos.key);

     // 获取某个座位的坐标
  getSeatCoordinates(key: string) {
    const { tableHeight, tableWidth, imageSize, spacing } = this;
    const parts = key.split('-');
    // top, bottom, left 或 right
    const position = parts[0];     
    // 获取座位索引
    const index = parseInt(parts[1]); 
    // 计算座位的坐标
    let x, y;
    //  座位旋转角度
    let rotation = 0;
    let offsetY = 0;
    let offsetX = 0; 

    if (position === 'top') {
      x = (imageSize + spacing) * index;
      y = -imageSize;
    } else if (position === 'bottom') {
      x = (imageSize + spacing) * index; // 计算X坐标
      y = tableHeight;
      rotation = 180;
      offsetY = this.imageSize;
      offsetX = this.imageSize;
    } else if (position === 'left') {
      // 计算左侧座位的坐标
      x = -imageSize;
      y = (imageSize * index) + (spacing * (index + 1));
      rotation = -90
      offsetY = 0
      offsetX = this.imageSize
    } else if (position === 'right') {
      // 计算右侧座位的坐标
      x = tableWidth;
      y = (imageSize * index) + (spacing * (index + 1));
      rotation = 90;
      offsetY = this.imageSize
      offsetX = 0
    } else {
      throw new Error('Invalid seat position');
    }
    return { x, y, rotation, offsetY, offsetX };
  }
}

最复杂的解决了 接下来就是交互模块 交互相对都是最简单的。


重点说一下这个拖动新增座位结束 命中了哪一个未占用座位

// 
   给stageRef设置 PositionsEvent
   stageRef.current!.setPointersPositions(event); 
   const pointer = stageRef.current?.getPointerPosition();

我在实现的过程中 遇到了 drop 事件 getPointerPosition错误的问题 这块儿我目前还不甚了解,只知道座位错误,我就给stage设置了拖动结束后的PointersPositions event 在获取就是正确的,小坑...

拖动的过程中 命中问题 haveIntersection比较鼠标坐标跟所有未占用座位坐标是否相交

if (haveIntersection(rect, pointer!) && group.attrs.name === 'freeSeats')

座位坐标的获取方式 group.getClientRect()能拿到最新的座位group坐标

      <div
        onDrop={(event) => {
          event.preventDefault();
          //  给stageRef设置 position
          stageRef.current!.setPointersPositions(event);
          const pointer = stageRef.current?.getPointerPosition();
          let targetName = '';

          for (let i = 0; i < childrensRef.current.length; i++) {
            const group = childrensRef.current[i] as Konva.Group;
            const rect = group.getClientRect();
            if (haveIntersection(rect, pointer!) && group.attrs.name === 'freeSeats') {
              targetName = group.children[0].attrs.name;
              break;
            }
          }
          if (targetName) {
            store.add({ key: targetName, occupied: true });
          }
          store.changeHighlight({ x: 0, y: 0, width: 0, height: 0 })
        }}
        // onDragOver={(e) => e.preventDefault()}
        onDragEnter={(event) => event.preventDefault()}
        onDragOver={(event) => {
          stageRef.current!.setPointersPositions(event);
          event.preventDefault()
          const pointer = stageRef.current?.getPointerPosition();
          let templateConfig;
          for (let i = 0; i < childrensRef.current.length; i++) {
            const group = childrensRef.current[i] as Konva.Group;
            const rect = group.getClientRect();
            if (haveIntersection(rect, pointer!) && group.attrs.name === 'freeSeats') {
              const config = store.getCacheValue(group.children[0].attrs.name);
              templateConfig = {
                ...rect,
                ...config
              }
              break;
            }
          }
          store.changeHighlight(templateConfig || {
            x: 0,
            y: 0,
            width: 0,
            height: 0
          });
        }}
      >
    <Stage

接下来是添加一个座位的实现逻辑,添加一个座位 计算出新的未占用座位列表。添加的座位的下标达到了会议桌的最大下标 就需要添加一个座位

this.tableWidth = this.tableWidth + this.imageSize + this.spacing;

this.tableHeight = this.tableHeight + + this.imageSize + this.spacing;

  add(seat: { key: string; occupied: boolean; }) {
    this.occupiedSeats.push(seat);
    const [position, index] = seat.key.split('-');
    const length = this.getPositionLength(position as any);

    if (length - 1 === Number(index)) {
      //  加一个座位
      if (position === 'top' || position === 'bottom') {
        this.tableWidth = this.tableWidth + this.imageSize + this.spacing;
      } else {
        this.tableHeight = this.tableHeight + + this.imageSize + this.spacing;
      }

    }
    // this.getSeats();
    this.unoccupiedSeats = this.getUnoccupiedSeats();
  }

剩下的就是拖动已占用座位到其他座位 , 按下鼠标 判断是否命中已占用座位 , 移动过程中需要准备三个座位对象

1.拖动占位moveingSourceGroup = null as { x: number, y: number, name: string };

2.拖动中座位对象坐标moveingSeat = { x : number , y : number }

3.拖动到未占用座位时的高亮座位对象moveingTargetGroup = null as { x: number, y: number, name: string }

  onMouseDown() {
    if (this.draggable) {
      return;
    }
    this.isSeatMoving = true;
    const shape = this.getPointer('.occupiedSeats')!;
    if (!shape) {
      return;
    }
    const { group, pointer } = shape;
    const rect = group.getClientRect();

    const scale = this.stage?.scaleX()!
    this.oldRect = {
      x: (pointer?.x! - rect?.x) / scale,
      y: (pointer?.y! - rect?.y) / scale
    };
  }
  
   onmouseMove() {
    if (!this.isSeatMoving) {
      return;
    }

    if (!this.moveingSourceGroup) {
      //  拖动抓手中
      this.stage!.container()!.style.cursor = 'grabbing';
      if (!this.templatSeatMap.get('occupiedSeats')) {
        this.templatSeatMap.set('occupiedSeats', this.getPointer('.occupiedSeats'))
      }
      const rectOccupiedSeats = this.templatSeatMap.get('occupiedSeats');
      if (!rectOccupiedSeats) {
        this.isSeatMoving = false;
        return;
      }

      if (rectOccupiedSeats) {
        this.isSeatMoving = true;
        this.moveingSourceGroup = {
          name: rectOccupiedSeats.group.attrs.id,
          ...this.cache.get(rectOccupiedSeats.group.attrs.id)! as any
        }
      }
    }

    const rect = this.getPointer('.freeSeats')
    const pointer = this.stage?.getPointerPosition()!;

    this.moveingSeat = {
      ...this.moveingSourceGroup,
      x: (pointer.x - this.oldRect.x) / this.stage?.scaleX()!,
      y: (pointer.y - this.oldRect.y) / this.stage?.scaleY()!,
    };

    if (!rect) {
      this.moveingTargetGroup = null;
      return;
    }
    //  查询到目标
    const name = rect.group.children[0].attrs.name;
    this.moveingTargetGroup = {
      name,
      ...this.cache.get(name)! as any
    }
  }

渲染拖动的三种状态 将交互性质的 Group 包裹在 <Portal selector='.controls-layer'>组件下,可以保证在Group 在visible的状态下层级最高

export const FeatureLayer = observer(() => {
  const store = useConference();
  return (
    <Portal selector='.controls-layer'>
      <Group
        x={store.tableX}
        y={store.tableY}
        visible={store.moveingSourceGroup !== null}
      >
        <Image
          {...store.moveingSourceGroup}
          height={store.imageSize}
          width={store.imageSize}
          image={img2}
          strokeEnabled={false}
          fill="#F8F8F8"
        />
        <Circle
          {...{
            ...store.moveingSourceGroup,
            x: (store.moveingSourceGroup?.x || 0) + 10,
            y: (store.moveingSourceGroup?.y || 0) + 5
          }}
          radius={5}
          fill="red"
        />
      </Group>

      <Group
        x={store.tableX}
        y={store.tableY}
        visible={store.moveingTargetGroup !== null}
      >
        <Image
          {...store.moveingTargetGroup}
          height={store.imageSize}
          width={store.imageSize}
          image={img1}
          strokeEnabled={false}
          fill="#F8F8F8"
        />
        <Circle
          {...{
            ...store.moveingTargetGroup,
            x: (store.moveingTargetGroup?.x || 0) + 10,
            y: (store.moveingTargetGroup?.y || 0) + 5
          }}
          radius={5}
          //  填充绿色
          fill="green"
        />
      </Group>

      <Group visible={store.moveingSeat !== null}>
        <Image
          {...store.moveingSeat}
          height={store.imageSize}
          width={store.imageSize}
          image={img3}
          strokeEnabled={false}
        // fill="#F8F8F8"
        />
      </Group>
    </Portal>
  )
})

最后就是 已占用座位拖动结束后的处理逻辑,反正移动源座位到新的目标 然后重新计算下未占用座位集合。

  onMouseUp() {
    this.isDropingAddSeat = false;
    this.stage!.container()!.style.cursor = 'default';
    //  处理 移动座位结束的逻辑
    if (this.moveingTargetGroup) {
      if (this.moveingTargetGroup?.name === this.moveingSourceGroup!.name) {
        this.clearStatus();
        return;
      }
      //  找到移走的座位
      const index = this.occupiedSeats.findIndex((seat) => seat.key === this.moveingSourceGroup!.name);
      this.occupiedSeats.splice(
        index,
        1,
        { key: (this.moveingTargetGroup as any)!.name, occupied: true }
      );
      //  重新计算空闲座位
      this.unoccupiedSeats = this.getUnoccupiedSeats();
    }
    this.clearStatus();
  }

结束

还有一部分细节的逻辑没有贴出来 有兴趣可以去看源代码

github : github.com/ayuechuan/T…