世界地图插件

开个新系列,主要写些不知道有什么用的功能 Demo,反正就是突然想做个试试。

像素点地图

一些简单的需求:

  • [ ] 渲染显示世界地图
  • [ ] 能够缩放 / 移动地图
  • [ ] 在地图上添加标点
  • [ ] 地图添加高亮某块区域的交互

实现

绘制地图

本来企图用 three 实现 3D 地图,最后还是使用 canvas 绘制 2D 地图。

  • 定义像素点结构
interface Node {
  x: number;
  y: number;
  isHighLight?: boolean;
}
  • canvas 绘制地图
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [data, setData] = useImmer(worldData);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.save();
    data.forEach(({ x, y, isHighLight }) => {
      ctx.fillStyle = isHighLight ? '#4D8359' : '#E3E3DF';
      // * 4 是为了适配高分辨率的屏幕,所以需要放大画布倍数
      ctx.fillRect(x * 4, y * 4, 5 * 4, 5 * 4); 
    });
    ctx.restore();
  }, [data]);

  return (
    <div className="content-center h-screen px-10">
      <div className="relative overflow-hidden">
        <canvas
          ref={canvasRef}
          width={995 * 4}
          height={535 * 4}
          className="w-full h-full"
        />
      </div>
    </div>
  );

看看效果已经初具成效:

map1.jpg

添加缩放和拖拽移动

缩放比较简单,就是记录一下滚轮事件,然后通过 ctx.scale(scale, scale); 放大缩小画布即可。需要注意的细节:

  • wheel 事件的默认行为是滚动页面,所以需要 event.preventDefault()
  • onWheel 事件默认是被动事件监听器,不允许调用 preventDefault 来阻止默认事件。可以通过显示调用 { passive: false } 来解决这个问题。
  • useEffect 在每次组件渲染时都会创建一个新的函数作用域,而 addEventListener 添加的事件监听器是在组件渲染完成后才会生效的。由于每次渲染都会创建新的函数作用域,之前添加的事件监听器会被销毁,导致无法获取到最新的 state,这里使用了 ref 来辅助解决,你也可以通过给 effect 添加 scale 依赖解决这个问题。
  useEffect(() => {
    function onWheel(this: HTMLCanvasElement, ev: WheelEvent) {
      ev.preventDefault();
      //
      scaleRef.current = Math.max(
        1,
        Math.min(
          (ev.deltaY > 0
            ? scaleRef.current * 1000 - 0.05 * 1000
            : scaleRef.current * 1000 + 0.05 * 1000) / 1000,
          5
        )
      );
      setScale(scaleRef.current);
    }
    const canvasDom = canvasRef.current;
    if (!canvasDom) return;
    canvasDom.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      canvasDom.removeEventListener("wheel", onWheel);
    };
  }, [canvasRef]);

实现拖拽需要监听三个事件,按下,移动,抬起。并且需要考虑和点击事件的冲突。

onPointerDown

  const onPointerDown = (ev: React.PointerEvent<HTMLCanvasElement>) => {
    setStartPos({ x: ev.clientX, y: ev.clientY });
    setStartTime(new Date().getTime());
    setIsDragging(false);
  };

onPointerMove

  const onPointerMove = (ev: React.PointerEvent<HTMLCanvasElement>) => {
    if (!startTime) return;
    setStartPos({ x: ev.clientX, y: ev.clientY });
    if (!canvasRef.current) return;
    const distance = Math.sqrt(
    Math.pow(ev.clientX - startPos.x, 2) +
        Math.pow(ev.clientY - startPos.y, 2)
    );

    if (distance > clickThreshold) {
        setIsDragging(true); // 如果移动距离大于阈值,标记为拖拽
    }

    //* 处理拖拽
    const dx = ev.clientX - startPos.x;
    const dy = ev.clientY - startPos.y;

      
    translateRef.current = limitTranslate({
        x: translateRef.current.x + dx,
        y: translateRef.current.y + dy,
    });
    setTranslate(translateRef.current);
    setStartPos({ x: ev.clientX, y: ev.clientY });
  };

onPointerUp

  const onPointerUp = (ev: React.PointerEvent<HTMLCanvasElement>) => {
    setIsDragging(false);
    setStartTime(0);

    const endTime = new Date().getTime();
    const timeElapsed = endTime - startTime;
    //* 如果移动距离小于阈值,并且时间小于阈值,认定为点击
    if (!isDragging && timeElapsed < timeThreshold) {
      //TODO: 处理点击事件
    }
  };

修改一下 canvas 绘制方法

...
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.save();

    const tran = limitTranslate(translate); // 避免在缩放时出现空白区域,将移动距离限制在屏幕内
    ctx.translate(tran.x, tran.y);

    data.forEach(({ x, y, isHighLight }) => {
      ctx.fillStyle = isHighLight ? '#4D8359' : '#E3E3DF';
      ctx.fillRect(
        x * INCREASE * scale,
        y * INCREASE * scale,
        SHAPE_SIZE * INCREASE * scale,
        SHAPE_SIZE * INCREASE * scale
      );
    });
    ctx.scale(scale, scale);
    ctx.restore();
...

map2.jpg

点击事件

最后我们需要处理点击事件,为了能区分标点点击和像素点高亮点击,用 alt + click 添加标点,普通点击事件处理为高亮。

处理点击事件的操作比较麻烦,因为我们要判断点击位置位于画布上的第几个像素点,所以需要先计算出点击位置相对于画布的坐标,再根据坐标计算出对应的像素点。另外还需要处理因为缩放移动导致的坐标偏移。上代码:

  const onCanvasClick = (x: number, y: number, altKey: boolean) => {
    if (isDragging) return;
    const canvas = canvasRef.current;
    if (!canvas) return;

    const canvasRect = canvas.getBoundingClientRect();
    const mouseX = x - canvasRect.left;
    const mouseY = y - canvasRect.top;

    const offsetX =
      ((mouseX / canvasRect.width - translate.x / canvas.width) * 1004) / scale;
    const offsetY =
      ((mouseY / canvasRect.height - translate.y / canvas.height) * 540) /
      scale;

    if (altKey) {
      setPinPosition((draft) => {
        draft.push({
          x: (offsetX / 1004) * 100,
          y: (offsetY / 540) * 100,
        });
      });
    } else {
      const newRectangles = data.map((rect) => {
        const positionX = [rect.x, rect.x + SHAPE_SIZE];
        const positionY = [rect.y, rect.y + SHAPE_SIZE];

        const targetNode =
          offsetX >= positionX[0] + CANVAS_OFFSET &&
          offsetX <= positionX[1] + CANVAS_OFFSET &&
          offsetY >= positionY[0] + CANVAS_OFFSET &&
          offsetY <= positionY[1] + CANVAS_OFFSET;
        if (targetNode) {
          return { ...rect, isHighLight: !rect.isHighLight };
        } else return rect;
      });
      setData(newRectangles);
    }
  };

高亮我们是通过修改数据重新绘制实现,所以不需要处理。
添加标点我们计算了一个坐标,并且将其添加到标点数组中,接下来我们需要处理这个坐标,通过 absolute + left + top 定位的方式将坐标定位到具体位置。

  return (
    <div className="content-center h-screen px-10">
      <div className="relative overflow-hidden">
        <div className="absolute bottom-0 left-0 text-white bg-gray-300 select-none min-w-10">
          {scale}
        </div>
        <div className="absolute bottom-0 right-0 text-white bg-gray-300 select-none min-w-10">
          {JSON.stringify(translate)}
        </div>
        <canvas
          ref={canvasRef}
          width={1004 * INCREASE}
          height={540 * INCREASE}
          className="w-full h-full"
          onPointerUp={onPointerUp}
          onMouseLeave={onPointerUp}
          onPointerMove={onPointerMove}
          onPointerDown={onPointerDown}
        />
        {activePin ? (
          <div
            className="absolute -translate-x-1/2 -translate-y-20 z-[100] text-white flex items-center font-semibold px-4 py-2 bg-[#1a1c1a] rounded-lg"
            style={{
              left: `calc(${activePin.x * scale}% + ${
                (translate.x / 4016) * 100
              }%)`,
              top: `calc(${activePin.y * scale}% + ${
                (translate.y / 2160) * 100
              }%)`,
            }}
          >
            {activePin.id}
          </div>
        ) : null}
        {pinPosition?.map((pin, i) => (
          <div
            id={pin.id}
            key={`${pin.x}-${pin.y}`}
            className="w-7 h-11 absolute -translate-x-1/2 -translate-y-full drop-shadow-[4px_16px_6px_rgba(0,0,10,0.2)]"
            style={{
              left: `calc(${pin.x * scale}% + ${(translate.x / 4016) * 100}%)`,
              top: `calc(${pin.y * scale}% + ${(translate.y / 2160) * 100}%)`,
              zIndex: activePin?.id === pin.id ? 999 : 99,
              color: activePin?.id === pin.id ? '#4D8359' : '#E3E3DF',
            }}
            onDoubleClick={() => {
              setPinPosition((draft) => {
                draft.splice(i, 1);
              });
              setActivePin(null);
            }}
            onClick={(ev) => {
              ev.stopPropagation();
              if (pin.id === activePin?.id) return;
              setActivePin(pin);
            }}
          >
            <svg
              version="1.1"
              viewBox="0 0 30 44"
              xmlns="http://www.w3.org/2000/svg"
            >
              <path
                fill="currentcolor"
                d="m29.29,14.89q0,7.87 -14.3,28.94q-14.29,-21.07 -14.29,-28.94c0,-7.88 6.4,-14.26 14.29,-14.26c7.9,0 14.3,6.38 14.3,14.26z"
              />
              <ellipse
                rx="7.06"
                ry="7.06"
                cx="14.82"
                cy="14.75"
                fill="#FFF8F4"
              />
            </svg>
          </div>
        ))}
      </div>
    </div>
  );

map3.jpg

到这里我们的地图勉强算是完成,已经拥有了一些最基础的功能。

下面放上 demo :

TODO List

或许会优化

  • [ ] 优化移动端,双指放大,拖拽
  • [ ] 优化标点位置允许通过经纬度添加标点
  • [ ] 使用不同颜色高亮不同区域
  • [ ] 高亮能够选区