refactor: improve snap precision and UI by adding high-precision lng-lat snap calculations and visual vertex status indicators

This commit is contained in:
taDuc
2026-06-04 21:57:00 +07:00
parent 38b6e9fca6
commit e9657a4003
9 changed files with 228 additions and 31 deletions
+24 -3
View File
@@ -216,7 +216,14 @@ export function setupMapLayers(
type: "circle",
source: "edit-handles",
paint: {
"circle-color": "#f97316",
"circle-color": [
"match",
["get", "status"],
"delete", "#ef4444",
"vertex", "#22c55e",
"edge", "#eab308",
"#3b82f6" // default none
],
"circle-radius": 22,
"circle-opacity": 0.35,
"circle-blur": 0.85,
@@ -228,9 +235,23 @@ export function setupMapLayers(
type: "circle",
source: "edit-handles",
paint: {
"circle-color": "#f97316",
"circle-color": [
"match",
["get", "status"],
"delete", "#ef4444",
"vertex", "#22c55e",
"edge", "#eab308",
"#3b82f6" // default none
],
"circle-radius": 12,
"circle-stroke-color": "#0f172a",
"circle-stroke-color": [
"match",
["get", "status"],
"delete", "#7f1d1d",
"vertex", "#14532d",
"edge", "#713f12",
"#0f172a" // default none
],
"circle-stroke-width": 3,
},
});
+88
View File
@@ -0,0 +1,88 @@
# Cơ Chế Hút Điểm Khi Vẽ (Snapping Precision)
Tài liệu này giải thích chi tiết về cơ chế hoạt động của tính năng hút điểm (**Snapping**) khi người dùng vẽ hoặc chỉnh sửa các đối tượng địa lý (Point, LineString, Polygon) trên bản đồ lịch sử. Đồng thời so sánh giải thuật cũ (Pixel Space) và giải thuật mới (LngLat Space) để đảm bảo độ chính xác tuyệt đối ở mọi mức Zoom.
---
## 1. Giới thiệu Tính năng Snapping
Khi vẽ biên giới, tuyến đường hành quân hoặc định vị địa điểm, người dùng có thể nhấn giữ phím **`Shift`** hoặc **`Alt`** để tự động hút điểm vẽ hiện tại vào các đối tượng địa lý sẵn có trên bản đồ (như bờ biển, biên giới quốc gia lân cận, hoặc các điểm di tích).
Tính năng này giúp:
- Tránh các khe hở (gaps) hoặc chồng chéo (overlaps) giữa các vùng lãnh thổ giáp ranh.
- Giảm thiểu thời gian và công sức vẽ tay thủ công khi đi theo các đường biên phức tạp.
---
## 2. Vấn đề của giải thuật cũ (Pixel Space)
### Cách thức hoạt động cũ:
1. Chiếu các đỉnh của bản đồ gốc từ Kinh/Vĩ độ (`LngLat`) lên hệ tọa độ màn hình (`Pixel` - $x, y$).
2. Tìm điểm pixel gần nhất trên đoạn thẳng pixel tương ứng với con trỏ chuột.
3. Giải chiếu ngược (`map.unproject`) điểm pixel đó thành tọa độ địa lý `LngLat` để lưu trữ.
### Nhược điểm:
* **Sai số tỉ lệ theo mức Zoom:** Ở mức zoom nhỏ (nhìn từ xa), một đoạn biên giới ngoài đời thực dài hàng trăm kilômét chỉ hiển thị ngắn ngủi vài pixel trên màn hình.
* **Lệch tọa độ khi phóng to:** Sai số làm tròn pixel lúc vẽ ở zoom nhỏ sẽ phóng đại lên thành sai số hàng nghìn mét ngoài thực địa khi người dùng phóng to bản đồ (zoom lớn). Hai quốc gia giáp ranh vẽ ở zoom nhỏ sẽ bị hở hoặc đè lên nhau khi zoom cận cảnh.
---
## 3. Giải thuật mới: Tính toán trên không gian Kinh/Vĩ độ gốc (LngLat Space)
Để khắc phục hoàn toàn hiện tượng lệch tọa độ, giải thuật mới kết hợp cả **Pixel Space** (để lọc tương tác) và **LngLat Space** (để tính toán tọa độ chốt).
```mermaid
graph TD
A[Người dùng di chuột + giữ Shift] --> B(Tìm các đối tượng gần nhất trong bán kính Pixel màn hình)
B --> C(Xác định đoạn thẳng AB mục tiêu trên màn hình)
C --> D{Tính toán tọa độ điểm hút}
D -->|Giải thuật cũ| E(Tính điểm gần nhất trên Pixel màn hình -> Giải chiếu ngược LngLat)
D -->|Giải thuật mới| F(Nội suy tuyến tính LngLat trực tiếp từ tọa độ gốc của A & B)
E --> G(Bị lệch tọa độ khi phóng to bản đồ)
F --> H(Chính xác tuyệt đối 100% ở mọi mức Zoom)
```
### Các bước thực hiện chi tiết:
1. **Bước 1: Lọc đối tượng gần màn hình**
Hệ thống sử dụng khoảng cách pixel để xác định đối tượng mà người dùng đang trỏ tới (ví dụ: nằm trong phạm vi `24px` đến `34px` trên màn hình). Điều này đảm bảo tính năng hoạt động đúng theo cảm quan của mắt người dùng.
2. **Bước 2: Xác định đoạn thẳng mục tiêu**
Hệ thống xác định đoạn thẳng nối hai đỉnh gốc $A(lng_A, lat_A)$ và $B(lng_B, lat_B)$ của đối tượng đích.
3. **Bước 3: Chiếu điểm trực tiếp trên không gian LngLat**
Để tính toán chính xác điểm gần nhất $C(lng_C, lat_C)$ nằm trên đoạn thẳng $AB$, hệ thống thực hiện phép chiếu vector trong không gian tọa độ địa lý địa phương, có bù trừ độ cong kinh tuyến dựa vào vĩ độ trung bình ($\cos(lat)$):
$$\text{lat}_{\text{rad}} = \frac{lat_A + lat_B + lat_{\text{cursor}}}{3} \times \frac{\pi}{180}$$
$$\text{cos}_{\text{lat}} = \cos(\text{lat}_{\text{rad}})$$
Chuyển đổi tạm thời sang hệ tọa độ phẳng cục bộ:
$$x_A = lng_A \times \text{cos}_{\text{lat}}, \quad y_A = lat_A$$
$$x_B = lng_B \times \text{cos}_{\text{lat}}, \quad y_B = lat_B$$
$$x_P = lng_{\text{cursor}} \times \text{cos}_{\text{lat}}, \quad y_P = lat_{\text{cursor}}$$
Tính tham số nội suy $t$ ($0 \le t \le 1$) của điểm hình chiếu trên đoạn thẳng $AB$:
$$dx = x_B - x_A, \quad dy = y_B - y_A$$
$$t = \max\left(0, \min\left(1, \frac{(x_P - x_A)dx + (y_P - y_A)dy}{dx^2 + dy^2}\right)\right)$$
Nội suy tọa độ LngLat chính xác của điểm chốt:
$$lng_C = a[0] + (b[0] - a[0]) \times t$$
$$lat_C = a[1] + (b[1] - a[1]) \times t$$
---
## 4. Ưu điểm vượt trội của Giải thuật mới
* **Độ chính xác tuyệt đối:** Điểm chốt luôn nằm **collinear (thẳng hàng/nội suy tuyến tính)** hoàn hảo giữa hai đỉnh $A$ and $B$ gốc của bản đồ với độ chính xác số thực dấu phẩy động 64-bit.
* **Độc lập với Zoom:** Dù bạn vẽ ở Zoom nhỏ nhất (mức 2 - toàn cầu) hay Zoom lớn nhất (mức 18 - cận cảnh), đường vẽ mới vẫn sẽ khít khịt với đường biên cũ mà không xuất hiện bất kỳ sai số hay khe hở nào.
* **Tối ưu trải nghiệm:** Người dùng có thể bao quát toàn bộ bản đồ quốc gia lớn để vẽ nhanh mà vẫn đạt được độ chuẩn xác tuyệt đối như khi phóng to cận cảnh để chỉnh sửa.
---
## 5. Chỉ báo Màu sắc Hút điểm (Snapping Color Indicators)
Để tăng tính tương tác và giúp người dùng kiểm soát chính xác điểm vẽ đang hút vào đâu, hệ thống tự động đổi màu sắc của **đỉnh đang được kéo** (dragged handle) trong chế độ chỉnh sửa:
| Trạng thái Snap | Màu sắc hiển thị | Mã màu HEX | Ý nghĩa |
| :--- | :---: | :---: | :--- |
| **Hút vào Đỉnh (Vertex)** | Xanh lá | `#22c55e` | Điểm đang kéo trùng khít với một đỉnh mốc cũ của đối tượng địa lý khác. |
| **Hút vào Cạnh (Edge)** | Vàng | `#eab308` | Điểm đang kéo nằm hoàn hảo trên đường nối giữa hai đỉnh của đối tượng địa lý khác. |
| **Không hút (None)** | Xanh dương | `#3b82f6` | Điểm đang kéo tự do, không dính vào bất kỳ đối tượng nào (hoặc không nhấn Shift). |
| **Chế độ xóa hàng loạt** | Đỏ | `#ef4444` | Toàn bộ các đỉnh chuyển sang màu đỏ khi bạn bật chế độ xóa đỉnh bằng phím `Delete`. |
+3 -3
View File
@@ -61,8 +61,8 @@ export function initDrawing(
if (getMode() !== "draw") return;
let lngLat = e.lngLat;
// Dùng Shift (hoặc Alt nếu Shift bị maplibre chiếm dụng) để snap
if (e.originalEvent.shiftKey || e.originalEvent.altKey) {
// Dùng Shift để snap
if (e.originalEvent.shiftKey) {
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
}
@@ -75,7 +75,7 @@ export function initDrawing(
if (getMode() !== "draw" || coords.length === 0) return;
let lngLat = e.lngLat;
if (e.originalEvent.shiftKey || e.originalEvent.altKey) {
if (e.originalEvent.shiftKey) {
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
}
+49 -14
View File
@@ -1,7 +1,7 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { buildCircleRing, destinationPoint, distanceMeters } from "@/uhm/lib/map/geo/geoMath";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
import { snapToNearestGeometry, snapToNearestGeometryDetailed } from "@/uhm/lib/map/engines/snapUtils";
export type EditingHandle = {
id: string | number;
@@ -28,6 +28,7 @@ export function createEditingEngine(options: {
const editingRef = { current: null as EditingHandle | null };
const dragStateRef = { current: null as { idx: number } | null };
const deleteVertexModeRef = { current: false };
let vertexSnapStatuses: ("vertex" | "edge" | "none")[] = [];
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
@@ -35,6 +36,7 @@ export function createEditingEngine(options: {
const clearEditing = () => {
editingRef.current = null;
dragStateRef.current = null;
vertexSnapStatuses = [];
setDeleteVertexMode(false);
hideContextMenu();
const map = mapRef.current;
@@ -56,6 +58,42 @@ export function createEditingEngine(options: {
const geomType = editing.geometryType || "Polygon";
const getHandleProperties = (idx: number, coordinate: [number, number], extraProps = {}) => {
let status: "none" | "vertex" | "edge" | "delete" = "none";
if (deleteVertexModeRef.current) {
status = "delete";
} else {
const isDragging = dragStateRef.current !== null;
const isDraggedVertex = dragStateRef.current?.idx === idx;
if (isDragging && !isDraggedVertex && vertexSnapStatuses[idx]) {
status = vertexSnapStatuses[idx];
} else {
const lngLat = new maplibregl.LngLat(coordinate[0], coordinate[1]);
const pointPx = map.project(lngLat);
const snapResult = snapToNearestGeometryDetailed(map, lngLat, pointPx, editing.id);
if (snapResult.type !== "none") {
const dist = distanceMeters(coordinate, [snapResult.lngLat.lng, snapResult.lngLat.lat]);
if (dist <= 1.0) {
status = snapResult.type;
} else {
status = "none";
}
} else {
status = "none";
}
vertexSnapStatuses[idx] = status;
}
}
return {
idx,
status,
...extraProps
};
};
if (geomType === "Polygon") {
if (editing.isCircle && editing.circleCenter && editing.circleRadius !== undefined) {
const ring = buildCircleRing(editing.circleCenter, editing.circleRadius);
@@ -79,12 +117,12 @@ export function createEditingEngine(options: {
{
type: "Feature",
geometry: { type: "Point", coordinates: editing.circleCenter },
properties: { idx: 0, type: "center" },
properties: getHandleProperties(0, editing.circleCenter, { type: "center" }),
},
{
type: "Feature",
geometry: { type: "Point", coordinates: radiusHandlePoint },
properties: { idx: 1, type: "radius" },
properties: getHandleProperties(1, radiusHandlePoint, { type: "radius" }),
},
],
};
@@ -106,7 +144,7 @@ export function createEditingEngine(options: {
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: { idx },
properties: getHandleProperties(idx, c),
})),
};
}
@@ -127,7 +165,7 @@ export function createEditingEngine(options: {
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: { idx },
properties: getHandleProperties(idx, c),
})),
};
} else {
@@ -143,7 +181,7 @@ export function createEditingEngine(options: {
{
type: "Feature",
geometry: { type: "Point", coordinates: editing.ring[0] },
properties: { idx: 0 },
properties: getHandleProperties(0, editing.ring[0]),
},
],
};
@@ -200,13 +238,7 @@ export function createEditingEngine(options: {
const setDeleteVertexMode = (enabled: boolean) => {
deleteVertexModeRef.current = enabled;
const map = mapRef.current;
if (!map || !map.isStyleLoaded() || !map.getLayer("edit-handles-circle")) return;
map.setPaintProperty("edit-handles-circle", "circle-color", enabled ? "#ef4444" : "#f97316");
map.setPaintProperty("edit-handles-circle", "circle-stroke-color", enabled ? "#7f1d1d" : "#0f172a");
if (map.getLayer("edit-handles-glow")) {
map.setPaintProperty("edit-handles-glow", "circle-color", enabled ? "#ef4444" : "#f97316");
}
updateEditSources();
};
// Bắt đầu chỉnh sửa từ feature polygon/line/point được chọn.
@@ -306,7 +338,7 @@ export function createEditingEngine(options: {
if (!drag || !editing) return;
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
? snapToNearestGeometry(map, e.lngLat, e.point, editing.id)
: e.lngLat;
const nextCoordinate: [number, number] = [lngLat.lng, lngLat.lat];
@@ -329,6 +361,7 @@ export function createEditingEngine(options: {
dragStateRef.current = null;
map.getCanvas().style.cursor = "";
map.dragPan.enable();
updateEditSources();
};
// Bắt phím điều khiển phiên chỉnh sửa.
@@ -465,6 +498,7 @@ export function createEditingEngine(options: {
if (editing.ring.length <= minLength) return;
if (idx < 0 || idx >= editing.ring.length) return;
editing.ring.splice(idx, 1);
vertexSnapStatuses.splice(idx, 1);
updateEditSources();
};
@@ -482,6 +516,7 @@ export function createEditingEngine(options: {
(current[1] + next[1]) / 2,
];
editing.ring.splice(idx + 1, 0, midpoint);
vertexSnapStatuses.splice(idx + 1, 0, "none");
updateEditSources();
};
+2 -2
View File
@@ -77,7 +77,7 @@ export function initLine(
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-line") return;
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
@@ -104,7 +104,7 @@ export function initLine(
}
if (coords.length === 0) return;
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
+2 -2
View File
@@ -78,7 +78,7 @@ export function initPath(
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
if (getMode() !== "add-path") return;
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
@@ -105,7 +105,7 @@ export function initPath(
}
if (coords.length === 0) return;
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
updatePreview([...coords, [lngLat.lng, lngLat.lat]]);
+1 -1
View File
@@ -13,7 +13,7 @@ export function initPoint(
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "add-point") return;
const lngLat = e.originalEvent.shiftKey || e.originalEvent.altKey
const lngLat = e.originalEvent.shiftKey
? snapToNearestGeometry(map, e.lngLat, e.point)
: e.lngLat;
+1 -1
View File
@@ -108,7 +108,7 @@ export function initSelect(
return;
}
const additive = !!e.originalEvent?.altKey;
const additive = !!e.originalEvent?.shiftKey;
const didSelect = selectFeature(feature, additive);
if (!didSelect) {
onFeatureClick?.(null);
+58 -5
View File
@@ -12,18 +12,33 @@ type GeometryWithCoordinates = Exclude<GeoJSON.Geometry, GeoJSON.GeometryCollect
coordinates: unknown;
};
export type SnapResult = {
lngLat: maplibregl.LngLat;
type: "vertex" | "edge" | "none";
};
export function snapToNearestGeometry(
map: maplibregl.Map,
lngLat: maplibregl.LngLat,
pointPx: maplibregl.Point
pointPx: maplibregl.Point,
excludeFeatureId?: string | number | null
): maplibregl.LngLat {
return snapToNearestGeometryDetailed(map, lngLat, pointPx, excludeFeatureId).lngLat;
}
export function snapToNearestGeometryDetailed(
map: maplibregl.Map,
lngLat: maplibregl.LngLat,
pointPx: maplibregl.Point,
excludeFeatureId?: string | number | null
): SnapResult {
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[pointPx.x - QUERY_THRESHOLD_PX, pointPx.y - QUERY_THRESHOLD_PX],
[pointPx.x + QUERY_THRESHOLD_PX, pointPx.y + QUERY_THRESHOLD_PX],
];
const snapLayerIds = getSnapLayerIds(map);
if (!snapLayerIds.length) return lngLat;
if (!snapLayerIds.length) return { lngLat, type: "none" };
const features = map.queryRenderedFeatures(bbox, {
layers: snapLayerIds,
@@ -38,7 +53,7 @@ export function snapToNearestGeometry(
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
};
// Tìm điểm gần nhất trên đoạn thẳng [a, b] so với điểm p
// Tìm điểm gần nhất trên đoạn thẳng [a, b] so với điểm p (tính trên pixel màn hình)
const getClosestPointOnSegment = (p: maplibregl.Point, a: maplibregl.Point, b: maplibregl.Point): maplibregl.Point => {
const atob = { x: b.x - a.x, y: b.y - a.y };
const atop = { x: p.x - a.x, y: p.y - a.y };
@@ -51,6 +66,30 @@ export function snapToNearestGeometry(
return new maplibregl.Point(a.x + atob.x * t, a.y + atob.y * t);
};
// Tìm điểm gần nhất trên đoạn thẳng kinh vĩ độ [a, b] so với tọa độ con trỏ p (bảo toàn độ chính xác 64-bit)
const getClosestPointOnLngLatSegment = (p: maplibregl.LngLat, a: Coordinate, b: Coordinate): maplibregl.LngLat => {
const latRad = ((a[1] + b[1] + p.lat) / 3) * Math.PI / 180;
const cosLat = Math.cos(latRad);
const ax = a[0] * cosLat, ay = a[1];
const bx = b[0] * cosLat, by = b[1];
const px = p.lng * cosLat, py = p.lat;
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return new maplibregl.LngLat(a[0], a[1]);
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const resultLng = (ax + dx * t) / cosLat;
const resultLat = ay + dy * t;
return new maplibregl.LngLat(resultLng, resultLat);
};
const processVertex = (coordinate: Coordinate) => {
const vertexLngLat = new maplibregl.LngLat(coordinate[0], coordinate[1]);
const vertexPx = map.project(vertexLngLat);
@@ -84,7 +123,7 @@ export function snapToNearestGeometry(
if (distSq < nearestEdgeDist && distSq <= EDGE_SNAP_THRESHOLD_PX ** 2) {
nearestEdgeDist = distSq;
nearestEdgeLngLat = map.unproject(closestPx);
nearestEdgeLngLat = getClosestPointOnLngLatSegment(lngLat, start, end);
}
}
};
@@ -102,6 +141,14 @@ export function snapToNearestGeometry(
continue;
}
// Bỏ qua chính đối tượng đang được chỉnh sửa để không tự snap vào chính nó
const fId = feature.id ?? feature.properties?.id;
if (excludeFeatureId !== undefined && excludeFeatureId !== null && fId !== undefined && fId !== null) {
if (String(fId) === String(excludeFeatureId)) {
continue;
}
}
const type = feature.geometry.type;
if (type === "GeometryCollection") continue;
const coords = (feature.geometry as GeometryWithCoordinates).coordinates;
@@ -124,7 +171,13 @@ export function snapToNearestGeometry(
}
}
return nearestVertexLngLat || nearestEdgeLngLat || lngLat;
if (nearestVertexLngLat) {
return { lngLat: nearestVertexLngLat, type: "vertex" };
}
if (nearestEdgeLngLat) {
return { lngLat: nearestEdgeLngLat, type: "edge" };
}
return { lngLat, type: "none" };
}
function getSnapLayerIds(map: maplibregl.Map): string[] {