LINNIL

像素点世界地图

Lucas

2024年9月23日
像素点世界地图

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

像素点地图

DEMO:

1
{"x":0,"y":0}

一些简单的需求:

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

实现

绘制地图

本来企图用 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>
  );

看看效果已经初具成效:

添加缩放和拖拽移动

缩放比较简单,就是记录一下滚轮事件,然后通过 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();
...

点击事件

最后我们需要处理点击事件,为了能区分标点点击和像素点高亮点击,用 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>
);

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

TODO List

或许会优化

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