世界地图插件
开个新系列,主要写些不知道有什么用的功能 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>
);
看看效果已经初具成效:
添加缩放和拖拽移动
缩放比较简单,就是记录一下滚轮事件,然后通过 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>
);
到这里我们的地图勉强算是完成,已经拥有了一些最基础的功能。
下面放上 demo :
TODO List
或许会优化
- [ ] 优化移动端,双指放大,拖拽
- [ ] 优化标点位置允许通过经纬度添加标点
- [ ] 使用不同颜色高亮不同区域
- [ ] 高亮能够选区