feat: implement boundary tracing feature for polygon drawing with Shift+T shortcut

This commit is contained in:
taDuc
2026-06-05 02:41:05 +07:00
parent e9657a4003
commit 61949e7149
10 changed files with 1527 additions and 98 deletions
+3 -2
View File
@@ -132,6 +132,7 @@ export function useMapInteraction({
}, [mode, mapRef]);
const setupMapInteractions = (map: maplibregl.Map) => {
(map as any)._renderDraftRef = renderDraftRef;
const drawingEngine = initDrawing(
map,
() => modeRef.current,
@@ -163,7 +164,7 @@ export function useMapInteraction({
onDeleteRef.current?.(id);
},
(feature) => {
const rawId = feature.id ?? feature.properties?.id;
const rawId = feature.properties?.id ?? feature.id;
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId)
);
@@ -206,7 +207,7 @@ export function useMapInteraction({
},
(feature) => {
if (!onAddFeatureToProjectRef?.current) return;
const rawId = feature.id ?? feature.properties?.id;
const rawId = feature.properties?.id ?? feature.id;
if (rawId === undefined || rawId === null) return;
const originalFeature = renderDraftRef.current.features.find(
+2
View File
@@ -48,6 +48,7 @@ export function setupMapLayers(
id: "draw-preview-fill",
type: "fill",
source: "draw-preview",
filter: ["==", ["get", "type"], "fill"],
paint: {
"fill-color": "#22c55e",
"fill-opacity": 0.4,
@@ -58,6 +59,7 @@ export function setupMapLayers(
id: "draw-preview-line",
type: "line",
source: "draw-preview",
filter: ["!=", ["get", "type"], "fill"],
paint: {
"line-color": "#16a34a",
"line-width": 2,
+7
View File
@@ -99,6 +99,13 @@ export function useMapSync({
const lastPolygonLabelStrRef = useRef("");
const lastPathArrowStrRef = useRef("");
useEffect(() => {
const map = mapRef.current;
if (map) {
(map as any)._renderDraftRef = renderDraftRef;
}
}, [mapRef]);
useEffect(() => {
fitBoundsAppliedRef.current = false;
}, [fitBoundsKey]);
+22 -1
View File
@@ -84,5 +84,26 @@ graph TD
| :--- | :---: | :---: | :--- |
| **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). |
| **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. |
| **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`. |
---
## 6. Tính Năng Tự Động Bám Biên (Auto-Tracing)
Khi vẽ bản đồ lịch sử, việc copy hoặc chạy dọc theo biên giới có sẵn của quốc gia láng giềng là cực kỳ thường gặp. Thay vì phải click thủ công từng đỉnh, hệ thống hỗ trợ **Auto-Tracing** (Bắt và chạy theo biên giới) với quy trình tối giản và chính xác.
### Cách thức hoạt động:
1. **Bật Snapping:** Nhấn giữ **`Shift`** khi click để đặt điểm bắt đầu trên biên giới (Point 1).
2. **Kích hoạt Trace:** Nhấn tổ hợp **`Shift + T`** và click chọn điểm bắt đầu để hệ thống hiểu bạn muốn bắt đầu một chuỗi trace.
3. **Xem trước (Preview):** Di chuột đến điểm kết thúc mong muốn (Point 2) trên cùng quốc gia đó. Một đường vẽ nháp **màu vàng hổ phách (`#eab308`)** sẽ tự động chạy dọc theo biên giới để bạn xem trước.
4. **Chốt Trace:** Click chuột để chốt. Toàn bộ các đỉnh trung gian sẽ lập tức được chèn vào hình vẽ của bạn. Đường biên giới đã trace xong cũng sẽ giữ nguyên **màu vàng** để phân biệt với phần vẽ tự do (màu xanh lá).
### Thuật toán Dò hướng tối ưu (Area-based Splitting):
Khi chọn 2 điểm trên một đa giác khép kín (Polygon), biên giới sẽ chia đa giác làm 2 con đường (xuôi và ngược chiều kim đồng hồ). Để xác định chính xác người dùng muốn đi đường nào:
1. Hệ thống tạo ra 2 đa giác phụ khép kín tương ứng với 2 con đường bằng cách nối thẳng điểm bắt đầu và điểm kết thúc.
2. Tính diện tích của cả 2 đa giác phụ này.
3. Chọn con đường thuộc đa giác phụ **có diện tích nhỏ hơn** (vì đường biên cần copy luôn là một lát cắt nhỏ của quốc gia, đa giác phụ chứa nó sẽ nhỏ hơn rất nhiều so với phần còn lại của quốc gia láng giềng).
### Quay lại bước trước (Undo/Backspace):
* Khi nhấn **`Backspace`** sau khi thực hiện trace, hệ thống sẽ **xóa hàng loạt** tất cả các đỉnh trung gian được copy của lượt trace đó, đưa hình vẽ quay trở lại ngay điểm bắt đầu trace (Point 1). Điều này giúp người dùng không phải bấm Backspace hàng chục lần để hoàn tác một đường biên phức tạp.
+28 -11
View File
@@ -118,22 +118,39 @@ Mặc định `type: "war"` và `geometry_preset: "circle-area"`.
- `SelectedGeometryPanel`, `ProjectEntityRefsPanel``GeometryBindingPanel` đều đọc từ selection này.
- Multi-select có tồn tại ở level state, nhưng một số thao tác chỉ hợp lệ khi các geometry cùng shape.
### Vertex editing
### Vertex editing (Chỉnh sửa đỉnh)
Khi đang ở `select`, editor có thể sửa polygon/circle qua `editingEngine`.
Khi đang ở chế độ `select`, nhấp đúp vào geometry để mở chế độ chỉnh sửa chi tiết qua `editingEngine`:
- Kéo handle để đổi vị trí đỉnh.
- Với circle:
- handle `0`: di tâm
- handle `1`: đổi bán kính
- `Ctrl` hoặc `Cmd` + click lên đường edit để chèn thêm đỉnh mới cho polygon.
- `Enter` để áp dụng chỉnh sửa.
- `Escape` để hủy chỉnh sửa.
* **Kéo thả đỉnh (Move Vertex):** Kéo các handle (điểm tròn) để dịch chuyển vị trí đỉnh.
* **Chỉnh sửa hình tròn (Circle Editing):**
* Handle `0`: di chuyển tâm hình tròn.
* Handle `1`: thay đổi bán kính.
* **Thêm đỉnh mới:** `Ctrl` (hoặc `Cmd`) + click vào một cạnh bất kỳ của Polygon/LineString để chèn thêm đỉnh mới.
* **Vẽ tiếp / Bám dọc biên (Continue Draw / Tracing):**
* Nhấp chuột phải vào một đỉnh và chọn `"Vẽ tiếp về bên trái"` hoặc `"Vẽ tiếp về bên phải"`.
* Trong quá trình vẽ tiếp, nhấn giữ phím `T` để tự động bám dọc (trace) theo biên của hình học khác gần nhất.
* Hệ thống tự khóa snap vào đối tượng đang bám để tránh đứt gãy hình học, tự động khâu nối (`stitchRing`) và làm sạch đỉnh trùng bằng sai số sai biệt $10^{-9}$ để giữ nguyên nút kết nối.
* Nhấn `Backspace` để hoàn tác (undo) các đỉnh hoặc toàn bộ đoạn vừa bám (trace).
* Nhấn `Enter` để lưu đoạn vẽ tiếp, hoặc `Escape` để hủy.
* **Xóa hàng loạt đỉnh (Range Delete):**
* Nhấn phím `Delete` (hoặc click nút Xóa đỉnh trên panel) để vào chế độ Xóa đỉnh (các handle đổi sang màu **Đỏ**).
* *Xóa đơn:* Nhấp chuột trái vào bất kỳ đỉnh nào để xóa đỉnh đó.
* *Xóa khoảng (Range Delete):*
* Giữ phím `Shift` và click vào đỉnh đầu tiên (đổi sang màu **Xanh lá** làm điểm neo, các đỉnh khác đổi sang màu **Xanh dương** an toàn).
* Di chuyển chuột tới đỉnh thứ hai: Toàn bộ cung đường đi giữa hai điểm neo dự kiến xóa sẽ hiển thị màu **Đỏ**, các đỉnh không bị ảnh hưởng sẽ giữ màu **Xanh dương**.
* Đối với Polygon, mặc định cung đường ngắn nhất (trung điểm gần chuột nhất) sẽ được chọn. Người dùng có thể **nhấn giữ phím Alt** để cưỡng bức chọn cung ngược lại.
* Click vào đỉnh thứ hai (hoặc nhấn giữ Shift + click) để xác nhận xóa toàn bộ các đỉnh màu đỏ ở giữa.
* Nhấn `Escape` hoặc click chuột phải, hoặc click ra vùng trống ngoài bản đồ để hủy chọn khoảng xóa.
* Nhấn `Delete` lần nữa hoặc nhấn `Escape` (khi không chọn khoảng) để thoát chế độ Xóa đỉnh.
* **Áp dụng & Hủy chỉnh sửa:**
* Nhấn `Enter` để lưu toàn bộ thay đổi hình học.
* Nhấn `Escape` (khi không trong chế độ xóa/vẽ tiếp) để hủy bỏ mọi thay đổi và quay lại trạng thái cũ.
### Xóa geometry
- Hành động xóa được đi qua `onDeleteFeature`.
- Undo có thể khôi phục lại geometry vừa xóa.
- Hành động xóa toàn bộ một hình học được đi qua `onDeleteFeature`.
- Undo có thể khôi phục lại geometry vừa xóa cùng các liên kết tương ứng.
## 6. Metadata geometry
+136
View File
@@ -0,0 +1,136 @@
# UHM Editor - Tài liệu tham chiếu thuật toán Toán học & Hình học
Tài liệu này hệ thống hóa toàn bộ các công thức toán học, thuật toán hình học không gian (Geospatial) và thuật toán đồ thị được áp dụng trong công cụ chỉnh sửa bản đồ của **Ultimate History Map (UHM)**.
---
## 1. Công thức khoảng cách Haversine (`distanceMeters`)
Để tính toán khoảng cách thực tế giữa hai tọa độ Địa lý $(lng_1, lat_1)$ và $(lng_2, lat_2)$ trên bề mặt cong của Trái Đất (mô hình cầu), hệ thống sử dụng công thức Haversine.
### Công thức toán học
Cho bán kính trung bình của Trái Đất $R = 6,378,137\text{ m}$. Chuyển đổi tọa độ từ độ (degrees) sang radian (radians):
$$\Delta lat = (lat_2 - lat_1) \times \frac{\pi}{180}$$
$$\Delta lng = (lng_2 - lng_1) \times \frac{\pi}{180}$$
Đại lượng trung gian $a$:
$$a = \sin^2\left(\frac{\Delta lat}{2}\right) + \cos(lat_1 \times \frac{\pi}{180}) \times \cos(lat_2 \times \frac{\pi}{180}) \times \sin^2\left(\frac{\Delta lng}{2}\right)$$
Khoảng cách góc $c$:
$$c = 2 \times \operatorname{atan2}\left(\sqrt{a}, \sqrt{1 - a}\right)$$
Khoảng cách thực tế $d$ (mét):
$$d = R \times c$$
---
## 2. Chiếu điểm lên đoạn thẳng & Snap hình học (`snapToNearestGeometry`)
Khi di chuyển hoặc kéo đỉnh, hệ thống chiếu tọa độ chuột hiện tại lên các cạnh của đa giác hoặc đường thẳng để tìm điểm bám (snap) gần nhất.
### Chiếu Vector tuyến tính
Xét một đoạn thẳng nối từ điểm $A(x_A, y_A)$ đến điểm $B(x_B, y_B)$ và điểm chuột hiện tại là $P(x_P, y_P)$.
Ta định nghĩa các vector:
$$\vec{AB} = B - A = (x_B - x_A, y_B - y_A)$$
$$\vec{AP} = P - A = (x_P - x_A, y_P - y_A)$$
Hình chiếu vuông góc của $P$ lên đường thẳng chứa $AB$ được xác định bởi tham số tỉ lệ $t$:
$$t = \frac{\vec{AP} \cdot \vec{AB}}{\|\vec{AB}\|^2} = \frac{(x_P - x_A)(x_B - x_A) + (y_P - y_A)(y_B - y_A)}{(x_B - x_A)^2 + (y_B - y_A)^2}$$
Để giới hạn điểm chiếu nằm trực tiếp **trong lòng đoạn thẳng** $AB$, ta ràng buộc tham số $t$ về đoạn $[0, 1]$:
$$t_{\text{clamped}} = \max(0, \min(1, t))$$
Tọa độ điểm chiếu gần nhất $P_{\text{projected}}$:
$$P_{\text{projected}} = A + t_{\text{clamped}} \times \vec{AB}$$
### Ngưỡng Snap (Tolerance)
Hệ thống chuyển đổi khoảng cách từ điểm chiếu đến con trỏ chuột sang đơn vị pixel màn hình. Nếu khoảng cách hình chiếu nhỏ hơn ngưỡng sai số cho phép (ví dụ: $8\text{px}$ hoặc $1\text{m}$ thực tế), con trỏ sẽ tự động bị hút vào điểm $P_{\text{projected}}$ đó.
---
## 3. Tạo hình tròn đa giác (`buildCircleRing`)
Vì các chuẩn dữ liệu GeoJSON không hỗ trợ kiểu dữ liệu `Circle` nguyên bản, hệ thống chuyển đổi hình tròn có tâm $C(lng_C, lat_C)$ và bán kính $r$ (mét) thành một đa giác khép kín (`Polygon`) gồm 64 đỉnh.
### Công thức lượng giác trên mặt cầu
Với mỗi góc $\theta$ chạy từ $0^{\circ}$ đến $360^{\circ}$ (chia thành 64 phân đoạn, mỗi bước $\Delta\theta = \frac{2\pi}{64}$ radians):
1. Tính bán kính góc $d = \frac{r}{R}$ (với $R$ là bán kính Trái Đất).
2. Tọa độ vĩ độ mới ($lat_{\theta}$):
$$lat_{\theta} = \arcsin\left(\sin(lat_C) \cos(d) + \cos(lat_C) \sin(d) \cos(\theta)\right)$$
3. Tọa độ kinh độ mới ($lng_{\theta}$):
$$lng_{\theta} = lng_C + \operatorname{atan2}\left(\sin(\theta) \sin(d) \cos(lat_C), \cos(d) - \sin(lat_C) \sin(lat_{\theta})\right)$$
Tập hợp 64 tọa độ $(lng_{\theta}, lat_{\theta})$ tạo thành vòng khép kín mô tả chính xác biên hình tròn trên bản đồ.
---
## 4. Kiểm tra vòng khép kín sai số cao (`isClosed`)
Trong tính toán đồ thị địa lý, do sai số dấu phẩy động (floating-point precision) tích lũy trong quá trình tính toán của trình duyệt, tọa độ điểm đầu và điểm cuối của Polygon có thể lệch nhau một lượng cực nhỏ.
Hệ thống áp dụng sai số tuyệt đối $\epsilon = 10^{-9}$ để kiểm tra tính khép kín:
$$\Delta lng = |lng_{\text{start}} - lng_{\text{end}}|$$
$$\Delta lat = |lat_{\text{start}} - lat_{\text{end}}|$$
$$\text{isClosed} = (\Delta lng < 10^{-9}) \land (\Delta lat < 10^{-9})$$
Điều này ngăn chặn việc hệ thống phân loại nhầm Polygon khép kín thành LineString hở.
---
## 5. Khâu nối và làm sạch đường biên (`stitchRing` & `cleanRing`)
Khi bám dọc biên (Trace) từ một đỉnh vẽ tiếp, hệ thống tiến hành cắt và ghép 3 mảng tọa độ:
1. `prefix`: Các điểm trước điểm bắt đầu trace.
2. `activeDrawn`: Các điểm thu được từ đường đi trace.
3. `suffix`: Các điểm sau điểm kết thúc trace.
Do quá trình ghép nối trực tiếp tại các ranh giới khâu (join points) dễ sinh ra các điểm trùng lặp gần nhau (sai số nhỏ), hàm `cleanRing` sẽ duyệt qua mảng kết quả và loại bỏ các điểm trùng kế tiếp nếu khoảng cách giữa chúng bé hơn $\epsilon = 10^{-9}$:
$$\text{duplicate} = (|lng_i - lng_{i-1}| < 10^{-9}) \land (|lat_i - lat_{i-1}| < 10^{-9})$$
---
## 6. Định hướng Đông - Tây / Trái - Phải (`isToTheRight`)
Để xác định một đỉnh nằm bên trái hay bên phải đỉnh khác khi vẽ tiếp mà không phụ thuộc vào thứ tự chỉ mục ban đầu (vốn không trực quan cho người dùng):
$$\text{isToTheRight}(A, B) = \begin{cases}
lng_A > lng_B, & \text{nếu } lng_A \neq lng_B \\
lat_A < lat_B, & \text{nếu } lng_A = lng_B
\end{cases}$$
Quy ước này giúp người dùng dễ dàng định hình hướng đi (bên phải tương đương với đi về phía Đông hoặc đi xuống phía Nam nếu trùng kinh độ).
---
## 7. Giải thuật chọn cung xóa của Polygon trong Range Delete
Khi xóa một khoảng đỉnh trên đa giác khép kín giữa 2 đỉnh chỉ mục $i_{\text{start}}$ và $i_{\text{hover}}$, đa giác luôn bị chia làm hai cung đường đi thay thế:
* **Đường đi A (Thuận chiều kim đồng hồ):**
$$P_A = \{ (i_{\text{start}} + 1) \bmod N, \dots, i_{\text{hover}} - 1 \bmod N \}$$
* **Đường đi B (Ngược chiều kim đồng hồ):**
$$P_B = \{ (i_{\text{start}} - 1 + N) \bmod N, \dots, i_{\text{hover}} + 1 \bmod N \}$$
### Khoảng cách hình chiếu Pixel (Smart Decision)
Để tự động chọn cung đường người dùng muốn xóa:
1. Xác định tọa độ trung điểm hình học của từng cung đường đi.
* Nếu cung đường trống (xóa trực tiếp giữa 2 đỉnh kề nhau), trung điểm là trung điểm của đoạn thẳng nối 2 đỉnh neo:
$$M = \left(\frac{lng_{\text{start}} + lng_{\text{hover}}}{2}, \frac{lat_{\text{start}} + lat_{\text{hover}}}{2}\right)$$
* Nếu cung có chứa các đỉnh trung gian, lấy tọa độ của đỉnh nằm chính giữa mảng chỉ mục đó.
2. Chiếu tọa độ trung điểm của $P_A$ và $P_B$ lên hệ tọa độ pixel của màn hình thiết bị thông qua phép chiếu MapLibre (`map.project`):
$$M_{\text{pixel}, A} = \text{project}(M_A)$$
$$M_{\text{pixel}, B} = \text{project}(M_B)$$
3. Đo khoảng cách Euclid từ vị trí con trỏ chuột hiện tại $Cursor(x, y)$ đến hai hình chiếu trung điểm:
$$d_A = \sqrt{(x - x_{M, A})^2 + (y - y_{M, A})^2}$$
$$d_B = \sqrt{(x - x_{M, B})^2 + (y - y_{M, B})^2}$$
Cung đường nào có khoảng cách ngắn hơn ($d \le$ đối thủ) sẽ tự động được bôi đỏ để chuẩn bị xóa.
### Ghi đè bằng phím Alt (Alt Key Override)
Nếu người dùng nhấn giữ phím **Alt**, hệ thống lập tức phủ quyết kết quả so sánh khoảng cách và chọn cung đường ngược lại:
$$\text{DeleteRange} = \begin{cases}
P_B, & \text{nếu } (d_A \le d_B \land \text{AltPressed}) \lor (d_A > d_B \land \neg\text{AltPressed}) \\
P_A, & \text{nếu } (d_A \le d_B \land \neg\text{AltPressed}) \lor (d_A > d_B \land \text{AltPressed})
\end{cases}$$
+213 -24
View File
@@ -1,7 +1,7 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/uhm/lib/editor/state/useEditorState";
import type { ModeGetter } from "@/uhm/lib/map/engines/engineTypes";
import { snapToNearestGeometry } from "@/uhm/lib/map/engines/snapUtils";
import { snapToNearestGeometryDetailed, tracePathBetweenPoints, getRingWithSnaps } from "@/uhm/lib/map/engines/snapUtils";
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
export function initDrawing(
@@ -10,6 +10,24 @@ export function initDrawing(
onComplete: (geometry: Geometry) => void
) {
let coords: [number, number][] = [];
let coordMeta: { isTrace: boolean; traceGroupId?: number }[] = [];
let currentTraceGroupId = 0;
let isTKeyDown = false;
// Trạng thái trace tích cực từ điểm bắt đầu
let traceStartState: {
startCoord: [number, number];
startIdx: number;
targetFeatureId: string | number;
targetFeatureRing: [number, number][];
snap1: {
type: "vertex" | "edge";
vertexIdx?: number;
edgeIdx?: number;
lngLat: { lng: number; lat: number };
};
} | null = null;
const clearPreview = () => {
if (!map.isStyleLoaded()) return;
@@ -21,6 +39,8 @@ export function initDrawing(
const cancelDrawing = () => {
coords = [];
coordMeta = [];
traceStartState = null;
clearPreview();
};
@@ -39,20 +59,31 @@ export function initDrawing(
// Cập nhật layer preview trong lúc đang vẽ.
function update(c: [number, number][]) {
const closed = closePolygon(c);
if (closed.length === 0) return;
const features: GeoJSON.Feature[] = [
{
type: "Feature",
properties: { type: "fill" },
geometry: {
type: "Polygon",
coordinates: [closed],
},
},
{
type: "Feature",
properties: { type: "line" },
geometry: {
type: "LineString",
coordinates: closed,
},
}
];
if (!map.isStyleLoaded()) return;
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {},
geometry: {
type: "Polygon",
coordinates: [closed],
},
},
],
features: features
});
}
@@ -61,12 +92,88 @@ export function initDrawing(
if (getMode() !== "draw") return;
let lngLat = e.lngLat;
// Dùng Shift để snap
if (e.originalEvent.shiftKey) {
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
// Snap nếu có phím Shift
const snapRes = e.originalEvent.shiftKey
? snapToNearestGeometryDetailed(map, e.lngLat, e.point)
: null;
if (snapRes && snapRes.type !== "none") {
lngLat = snapRes.lngLat;
}
const currentPoint: [number, number] = [lngLat.lng, lngLat.lat];
// 1. Nếu đang có điểm bắt đầu trace, thử chốt trace
if (traceStartState) {
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
if (
targetSnap.type !== "none" &&
targetSnap.featureId !== undefined &&
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
targetSnap.ringCoords
) {
// Hợp lệ, tiến hành trace dọc biên giới
const snap1 = traceStartState.snap1;
const snap2 = {
type: targetSnap.type as "vertex" | "edge",
vertexIdx: targetSnap.vertexIdx,
edgeIdx: targetSnap.edgeIdx,
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
};
const { ring, idx1, idx2 } = getRingWithSnaps(
traceStartState.targetFeatureRing,
snap1,
snap2
);
const path = tracePathBetweenPoints(
ring as [number, number][],
idx1,
idx2
);
if (path.length > 0) {
const newGroupId = currentTraceGroupId++;
for (let i = 1; i < path.length; i++) {
coords.push(path[i]);
coordMeta.push({ isTrace: true, traceGroupId: newGroupId });
}
}
} else {
// Không tìm thấy điểm kết thúc hợp lệ trên cùng Geo, đặt điểm vẽ tự do bình thường
coords.push(currentPoint);
coordMeta.push({ isTrace: false });
}
traceStartState = null;
update(coords);
return;
}
// 2. Nếu chưa có trace, kiểm tra xem click này có kích hoạt tạo điểm bắt đầu trace không (Shift + T)
const isShiftT = e.originalEvent.shiftKey && isTKeyDown;
if (isShiftT && snapRes && snapRes.type !== "none" && snapRes.featureId !== undefined && snapRes.ringCoords) {
coords.push(currentPoint);
coordMeta.push({ isTrace: false }); // start point của trace vẫn tính là điểm bình thường
traceStartState = {
startCoord: currentPoint,
startIdx: coords.length - 1,
targetFeatureId: snapRes.featureId,
targetFeatureRing: snapRes.ringCoords as [number, number][],
snap1: {
type: snapRes.type as "vertex" | "edge",
vertexIdx: snapRes.vertexIdx,
edgeIdx: snapRes.edgeIdx,
lngLat: { lng: snapRes.lngLat.lng, lat: snapRes.lngLat.lat }
}
};
} else {
// Click bình thường
coords.push(currentPoint);
coordMeta.push({ isTrace: false });
}
coords.push([lngLat.lng, lngLat.lat] as [number, number]);
update(coords);
}
@@ -75,15 +182,62 @@ export function initDrawing(
if (getMode() !== "draw" || coords.length === 0) return;
let lngLat = e.lngLat;
if (e.originalEvent.shiftKey) {
lngLat = snapToNearestGeometry(map, e.lngLat, e.point);
const snapRes = e.originalEvent.shiftKey
? snapToNearestGeometryDetailed(map, e.lngLat, e.point)
: null;
if (snapRes && snapRes.type !== "none") {
lngLat = snapRes.lngLat;
}
const preview: [number, number][] = [
...coords,
[lngLat.lng, lngLat.lat] as [number, number],
];
update(preview);
const currentPoint: [number, number] = [lngLat.lng, lngLat.lat];
// Nếu đang trong quá trình trace, tìm đường đi để vẽ nháp màu vàng
if (traceStartState) {
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
if (
targetSnap.type !== "none" &&
targetSnap.featureId !== undefined &&
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
targetSnap.ringCoords
) {
const snap1 = traceStartState.snap1;
const snap2 = {
type: targetSnap.type as "vertex" | "edge",
vertexIdx: targetSnap.vertexIdx,
edgeIdx: targetSnap.edgeIdx,
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
};
const { ring, idx1, idx2 } = getRingWithSnaps(
traceStartState.targetFeatureRing,
snap1,
snap2
);
const path = tracePathBetweenPoints(
ring as [number, number][],
idx1,
idx2
);
if (path.length > 0) {
const previewCoords = [...coords];
const traceStartOffset = coords.length;
for (let i = 1; i < path.length; i++) {
previewCoords.push(path[i]);
}
update(previewCoords);
return;
}
}
}
// Preview bình thường
const previewCoords = [...coords, currentPoint];
update(previewCoords);
}
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
@@ -99,9 +253,15 @@ export function initDrawing(
cancelDrawing();
}
// Lắng nghe Enter để chốt polygon.
// Lắng nghe Enter/Escape/Backspace.
function onKeyDown(e: KeyboardEvent) {
if (getMode() !== "draw") return;
if (e.key.toLowerCase() === "t") {
isTKeyDown = true;
return;
}
if (e.key === "Enter") {
e.preventDefault();
finishDrawing();
@@ -114,7 +274,22 @@ export function initDrawing(
}
if (e.key === "Backspace") {
e.preventDefault();
coords = coords.slice(0, -1);
if (coords.length === 0) return;
const lastMeta = coordMeta[coordMeta.length - 1];
if (lastMeta && lastMeta.isTrace && lastMeta.traceGroupId !== undefined) {
const targetGroupId = lastMeta.traceGroupId;
while (coordMeta.length > 0 && coordMeta[coordMeta.length - 1].traceGroupId === targetGroupId) {
coords.pop();
coordMeta.pop();
}
} else {
coords.pop();
coordMeta.pop();
}
traceStartState = null;
if (coords.length) {
update(coords);
} else {
@@ -123,6 +298,16 @@ export function initDrawing(
}
}
function onKeyUp(e: KeyboardEvent) {
if (e.key.toLowerCase() === "t") {
isTKeyDown = false;
}
}
function onBlur() {
isTKeyDown = false;
}
// Tắt tính năng box zoom và double click zoom để Shift không bị lỗi
map.boxZoom.disable();
map.doubleClickZoom.disable();
@@ -130,6 +315,8 @@ export function initDrawing(
map.on("click", onClick);
map.on("mousemove", onMove);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onBlur);
const cleanup = () => {
try {
@@ -140,6 +327,8 @@ export function initDrawing(
map.off("click", onClick);
map.off("mousemove", onMove);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onBlur);
cancelDrawing();
} catch {
// ignore
+833 -21
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, snapToNearestGeometryDetailed } from "@/uhm/lib/map/engines/snapUtils";
import { snapToNearestGeometry, snapToNearestGeometryDetailed, getRingWithSnaps, tracePathBetweenPoints } from "@/uhm/lib/map/engines/snapUtils";
export type EditingHandle = {
id: string | number;
@@ -17,6 +17,7 @@ export type EditingAPI = {
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
clearEditing: () => void;
bindEditEvents: (map: maplibregl.Map) => (() => void);
editingRef: React.MutableRefObject<{ id: string | number } | null>;
};
// Tạo engine chỉnh sửa polygon, line, point đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
@@ -29,11 +30,47 @@ export function createEditingEngine(options: {
const dragStateRef = { current: null as { idx: number } | null };
const deleteVertexModeRef = { current: false };
let vertexSnapStatuses: ("vertex" | "edge" | "none")[] = [];
let deleteRangeStartIdx: number | null = null;
let deleteRangeHoverIdx: number | null = null;
let deleteRangeIndices: number[] = [];
let isAltKeyDown = false;
let lastMousePointPx: maplibregl.Point | null = null;
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
// Trạng thái vẽ tiếp (Continue Draw) để vẽ nối tiếp/sửa từ một đỉnh
let isDrawingContinued = false;
let isTKeyDown = false;
let originalRingBackup: [number, number][] | null = null;
let coordMeta: { isTrace: boolean; traceGroupId?: number }[] = [];
let traceStartState: {
startCoord: [number, number];
startIdx: number;
targetFeatureId: string | number;
targetFeatureRing: [number, number][];
snap1: {
type: "vertex" | "edge";
vertexIdx?: number;
edgeIdx?: number;
lngLat: { lng: number; lat: number };
};
} | null = null;
let currentTraceGroupId = 1;
let drawnPoints: [number, number][] = [];
let continueDrawConfig: {
i: number;
j: number;
reverseDrawn: boolean;
prefix: [number, number][];
suffix: [number, number][];
} | null = null;
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
const clearEditing = () => {
if (isDrawingContinued) {
stopContinueDraw(false);
}
editingRef.current = null;
dragStateRef.current = null;
vertexSnapStatuses = [];
@@ -61,7 +98,17 @@ export function createEditingEngine(options: {
const getHandleProperties = (idx: number, coordinate: [number, number], extraProps = {}) => {
let status: "none" | "vertex" | "edge" | "delete" = "none";
if (deleteVertexModeRef.current) {
status = "delete";
if (deleteRangeStartIdx !== null) {
if (idx === deleteRangeStartIdx || idx === deleteRangeHoverIdx) {
status = "vertex";
} else if (deleteRangeIndices.includes(idx)) {
status = "delete";
} else {
status = "none";
}
} else {
status = "delete";
}
} else {
const isDragging = dragStateRef.current !== null;
const isDraggedVertex = dragStateRef.current?.idx === idx;
@@ -141,11 +188,13 @@ export function createEditingEngine(options: {
handles = {
type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: getHandleProperties(idx, c),
})),
features: isDrawingContinued
? []
: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: getHandleProperties(idx, c),
})),
};
}
} else if (geomType === "LineString") {
@@ -162,11 +211,13 @@ export function createEditingEngine(options: {
handles = {
type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: getHandleProperties(idx, c),
})),
features: isDrawingContinued
? []
: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: getHandleProperties(idx, c),
})),
};
} else {
// Point
@@ -238,6 +289,9 @@ export function createEditingEngine(options: {
const setDeleteVertexMode = (enabled: boolean) => {
deleteVertexModeRef.current = enabled;
deleteRangeStartIdx = null;
deleteRangeHoverIdx = null;
deleteRangeIndices = [];
updateEditSources();
};
@@ -287,7 +341,7 @@ export function createEditingEngine(options: {
}
editingRef.current = {
id: feature.id ?? feature.properties?.id,
id: feature.properties?.id ?? feature.id,
ring,
original: geom,
isCircle,
@@ -311,10 +365,107 @@ export function createEditingEngine(options: {
}
};
const updateDeleteRange = (pointPx: maplibregl.Point) => {
const editing = editingRef.current;
const map = mapRef.current;
if (!editing || !map || deleteRangeStartIdx === null || deleteRangeHoverIdx === null) {
deleteRangeIndices = [];
return;
}
const n = editing.ring.length;
if (deleteRangeStartIdx === deleteRangeHoverIdx) {
deleteRangeIndices = [];
updateEditSources();
return;
}
const isLine = editing.geometryType === "LineString";
if (isLine) {
const start = Math.min(deleteRangeStartIdx, deleteRangeHoverIdx);
const end = Math.max(deleteRangeStartIdx, deleteRangeHoverIdx);
deleteRangeIndices = [];
for (let i = start + 1; i < end; i++) {
deleteRangeIndices.push(i);
}
} else {
// Path A: clockwise/forward (exclusive)
const pathA: number[] = [];
let idx = (deleteRangeStartIdx + 1) % n;
while (idx !== deleteRangeHoverIdx) {
pathA.push(idx);
idx = (idx + 1) % n;
}
// Path B: counter-clockwise/backward (exclusive)
const pathB: number[] = [];
idx = (deleteRangeStartIdx - 1 + n) % n;
while (idx !== deleteRangeHoverIdx) {
pathB.push(idx);
idx = (idx - 1 + n) % n;
}
// Determine which path's midpoint is closer to the mouse cursor
const getPathMidpointPx = (indices: number[]) => {
if (indices.length === 0) {
const p1 = editing.ring[deleteRangeStartIdx!];
const p2 = editing.ring[deleteRangeHoverIdx!];
const midLngLat = new maplibregl.LngLat((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2);
return map.project(midLngLat);
}
const midIdx = indices[Math.floor(indices.length / 2)];
const coord = editing.ring[midIdx];
return map.project(new maplibregl.LngLat(coord[0], coord[1]));
};
const midPxA = getPathMidpointPx(pathA);
const midPxB = getPathMidpointPx(pathB);
const distA = Math.hypot(pointPx.x - midPxA.x, pointPx.y - midPxA.y);
const distB = Math.hypot(pointPx.x - midPxB.x, pointPx.y - midPxB.y);
const smartChoice = distA <= distB ? pathA : pathB;
const alternativeChoice = distA <= distB ? pathB : pathA;
deleteRangeIndices = isAltKeyDown ? alternativeChoice : smartChoice;
}
updateEditSources();
};
const performRangeDelete = (endIdx: number, pointPx: maplibregl.Point) => {
const editing = editingRef.current;
if (!editing || deleteRangeStartIdx === null) return;
const isLine = editing.geometryType === "LineString";
const minLength = isLine ? 2 : 3;
// Recalculate range indices one last time to make sure they are up-to-date
deleteRangeHoverIdx = endIdx;
updateDeleteRange(pointPx);
const toDeleteCount = deleteRangeIndices.length;
if (toDeleteCount > 0 && editing.ring.length - toDeleteCount >= minLength) {
const sortedIndices = [...deleteRangeIndices].sort((a, b) => b - a);
for (const idx of sortedIndices) {
editing.ring.splice(idx, 1);
vertexSnapStatuses.splice(idx, 1);
}
}
// Reset state
deleteRangeStartIdx = null;
deleteRangeHoverIdx = null;
deleteRangeIndices = [];
updateEditSources();
};
// Gắn toàn bộ sự kiện phục vụ chỉnh sửa hình.
const bindEditEvents = (map: maplibregl.Map) => {
// Bắt đầu kéo một handle point.
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
if (isDrawingContinued) return;
if (!editingRef.current) return;
if (e.originalEvent.button === 2) return;
const feature = e.features?.[0];
@@ -323,7 +474,14 @@ export function createEditingEngine(options: {
e.preventDefault();
e.originalEvent.stopPropagation(); // Chặn sự kiện lan ra bản đồ tránh gây kéo/pan bản đồ
if (deleteVertexModeRef.current) {
deleteVertex(idx);
if (deleteRangeStartIdx !== null) {
performRangeDelete(idx, e.point);
} else if (e.originalEvent.shiftKey) {
deleteRangeStartIdx = idx;
updateEditSources();
} else {
deleteVertex(idx);
}
return;
}
dragStateRef.current = { idx };
@@ -331,8 +489,58 @@ export function createEditingEngine(options: {
map.dragPan.disable();
};
const onHandleMouseEnter = (e: maplibregl.MapLayerMouseEvent) => {
if (isDrawingContinued) return;
const feature = e.features?.[0];
const idx = Number(feature?.properties?.idx);
if (!Number.isInteger(idx)) return;
lastMousePointPx = e.point;
if (deleteVertexModeRef.current && deleteRangeStartIdx !== null) {
deleteRangeHoverIdx = idx;
updateDeleteRange(e.point);
}
};
const onHandleMouseLeave = () => {
if (isDrawingContinued) return;
lastMousePointPx = null;
if (deleteRangeStartIdx !== null && deleteRangeHoverIdx !== null) {
deleteRangeHoverIdx = null;
deleteRangeIndices = [];
updateEditSources();
}
};
const onHandleMouseMove = (e: maplibregl.MapLayerMouseEvent) => {
if (isDrawingContinued) return;
const feature = e.features?.[0];
const idx = Number(feature?.properties?.idx);
if (!Number.isInteger(idx)) return;
lastMousePointPx = e.point;
if (deleteVertexModeRef.current && deleteRangeStartIdx !== null) {
deleteRangeHoverIdx = idx;
updateDeleteRange(e.point);
}
};
const onGeneralMapClick = (e: maplibregl.MapMouseEvent) => {
if (isDrawingContinued) return;
const features = map.queryRenderedFeatures(e.point, { layers: ["edit-handles-circle"] });
if (features.length > 0) return;
if (deleteRangeStartIdx !== null) {
deleteRangeStartIdx = null;
deleteRangeHoverIdx = null;
deleteRangeIndices = [];
updateEditSources();
}
};
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
if (isDrawingContinued) return;
const drag = dragStateRef.current;
const editing = editingRef.current;
if (!drag || !editing) return;
@@ -358,6 +566,7 @@ export function createEditingEngine(options: {
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
const stopDragging = () => {
if (isDrawingContinued) return;
dragStateRef.current = null;
map.getCanvas().style.cursor = "";
map.dragPan.enable();
@@ -366,8 +575,59 @@ export function createEditingEngine(options: {
// Bắt phím điều khiển phiên chỉnh sửa.
const onKeyDown = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === "t") {
isTKeyDown = true;
}
if (e.key === "Alt") {
isAltKeyDown = true;
if (deleteRangeStartIdx !== null && deleteRangeHoverIdx !== null && lastMousePointPx) {
updateDeleteRange(lastMousePointPx);
}
}
const editing = editingRef.current;
if (!editing) return;
if (isDrawingContinued) {
if (e.key.toLowerCase() === "t") {
return;
}
if (e.key === "Enter") {
e.preventDefault();
stopContinueDraw(true);
return;
}
if (e.key === "Escape") {
e.preventDefault();
stopContinueDraw(false);
return;
}
if (e.key === "Backspace") {
e.preventDefault();
if (editing.ring.length <= 1) return;
const lastMeta = coordMeta[coordMeta.length - 1];
if (lastMeta && lastMeta.isTrace && lastMeta.traceGroupId !== undefined) {
const targetGroupId = lastMeta.traceGroupId;
while (coordMeta.length > 0 && coordMeta[coordMeta.length - 1].traceGroupId === targetGroupId) {
editing.ring.pop();
coordMeta.pop();
}
} else {
editing.ring.pop();
coordMeta.pop();
}
traceStartState = null;
updateEditSources();
return;
}
return;
}
if (e.key === "Enter") {
finishEditing();
} else if (e.key === "Delete" && editing.geometryType !== "Point" && !editing.isCircle) {
@@ -376,7 +636,14 @@ export function createEditingEngine(options: {
} else if (e.key === "Escape") {
if (deleteVertexModeRef.current) {
e.preventDefault();
setDeleteVertexMode(false);
if (deleteRangeStartIdx !== null) {
deleteRangeStartIdx = null;
deleteRangeHoverIdx = null;
deleteRangeIndices = [];
updateEditSources();
} else {
setDeleteVertexMode(false);
}
return;
}
cancelEditing();
@@ -389,6 +656,15 @@ export function createEditingEngine(options: {
if (!editing || editing.geometryType === "Point" || editing.isCircle) return;
e.preventDefault();
e.originalEvent.stopPropagation();
if (deleteVertexModeRef.current) {
if (deleteRangeStartIdx !== null) {
deleteRangeStartIdx = null;
deleteRangeHoverIdx = null;
deleteRangeIndices = [];
updateEditSources();
}
return;
}
const feature = e.features?.[0];
const idx = Number(feature?.properties?.idx);
if (!Number.isInteger(idx)) return;
@@ -404,21 +680,55 @@ export function createEditingEngine(options: {
stopDragging();
};
const onWindowBlur = () => {
isAltKeyDown = false;
isTKeyDown = false;
if (deleteRangeStartIdx !== null && deleteRangeHoverIdx !== null && lastMousePointPx) {
updateDeleteRange(lastMousePointPx);
}
};
map.on("mousedown", "edit-handles-circle", onHandleDown);
map.on("contextmenu", "edit-handles-circle", onHandleContextMenu);
map.on("mouseenter", "edit-handles-circle", onHandleMouseEnter);
map.on("mouseleave", "edit-handles-circle", onHandleMouseLeave);
map.on("mousemove", "edit-handles-circle", onHandleMouseMove);
map.on("click", onGeneralMapClick);
map.on("mousemove", onHandleMove);
map.on("mouseup", stopDragging);
document.addEventListener("keydown", onKeyDown);
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
document.addEventListener("keyup", onKeyUp);
window.addEventListener("blur", onWindowBlur);
const canvas = map.getCanvas();
if (canvas) {
canvas.addEventListener("keydown", onKeyDown);
canvas.addEventListener("keyup", onKeyUp);
canvas.addEventListener("mouseleave", onCanvasLeave);
}
const cleanup = () => {
if (isDrawingContinued) {
stopContinueDraw(false);
}
map.off("mousedown", "edit-handles-circle", onHandleDown);
map.off("contextmenu", "edit-handles-circle", onHandleContextMenu);
map.off("mouseenter", "edit-handles-circle", onHandleMouseEnter);
map.off("mouseleave", "edit-handles-circle", onHandleMouseLeave);
map.off("mousemove", "edit-handles-circle", onHandleMouseMove);
map.off("click", onGeneralMapClick);
map.off("mousemove", onHandleMove);
map.off("mouseup", stopDragging);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
window.removeEventListener("blur", onWindowBlur);
try {
map.getCanvas()?.removeEventListener("mouseleave", onCanvasLeave);
const canvas = map.getCanvas();
if (canvas) {
canvas.removeEventListener("keydown", onKeyDown);
canvas.removeEventListener("keyup", onKeyUp);
canvas.removeEventListener("mouseleave", onCanvasLeave);
}
} catch {
// ignore
}
@@ -431,6 +741,419 @@ export function createEditingEngine(options: {
return cleanup;
};
const updateEditSourcesWithPreview = (previewRing: [number, number][]) => {
const editing = editingRef.current;
const map = mapRef.current;
if (!editing || !map || !map.isStyleLoaded()) return;
let shape: GeoJSON.FeatureCollection;
const geomType = editing.geometryType || "Polygon";
if (geomType === "Polygon") {
const closedRing = [...previewRing, previewRing[0]];
shape = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Polygon", coordinates: [closedRing] },
properties: {},
},
],
};
} else {
shape = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "LineString", coordinates: previewRing },
properties: {},
},
],
};
}
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
features: [],
});
};
const startContinueDraw = (idx: number, side: "left" | "right") => {
const map = mapRef.current;
const editing = editingRef.current;
if (!map || !editing) return;
const isLine = editing.geometryType === "LineString";
const len = editing.ring.length;
// 1. Sao lưu ring gốc để hoàn tác nếu nhấn ESC
originalRingBackup = [...editing.ring.map((c) => [c[0], c[1]] as [number, number])];
// 2. Xác định nốt bắt đầu (startVertex) và nốt lân cận mục tiêu (endVertex)
const current = editing.ring[idx];
let targetNeighborIdx = -1;
if (isLine) {
if (idx === 0) {
targetNeighborIdx = 1;
} else if (idx === len - 1) {
targetNeighborIdx = len - 2;
} else {
const prev = editing.ring[idx - 1];
const next = editing.ring[idx + 1];
const nextIsRight = isToTheRight(next, prev);
if (side === "right") {
targetNeighborIdx = nextIsRight ? idx + 1 : idx - 1;
} else {
targetNeighborIdx = nextIsRight ? idx - 1 : idx + 1;
}
}
} else {
// Polygon
const prevIdx = (idx - 1 + len) % len;
const nextIdx = (idx + 1) % len;
const prev = editing.ring[prevIdx];
const next = editing.ring[nextIdx];
const nextIsRight = isToTheRight(next, prev);
if (side === "right") {
targetNeighborIdx = nextIsRight ? nextIdx : prevIdx;
} else {
targetNeighborIdx = nextIsRight ? prevIdx : nextIdx;
}
}
const startVertex = idx;
const endVertex = targetNeighborIdx;
let i: number;
let j: number;
let reverseDrawn = false;
if (startVertex === len - 1 && endVertex === 0 && !isLine) {
i = startVertex;
j = endVertex;
reverseDrawn = false;
} else if (startVertex === 0 && endVertex === len - 1 && !isLine) {
i = endVertex;
j = startVertex;
reverseDrawn = true;
} else if (startVertex < endVertex) {
i = startVertex;
j = endVertex;
reverseDrawn = false;
} else {
i = endVertex;
j = startVertex;
reverseDrawn = true;
}
const prefix = editing.ring.slice(0, i + 1);
const suffix = (j === 0 && !isLine) ? [] : editing.ring.slice(j);
continueDrawConfig = {
i,
j,
reverseDrawn,
prefix,
suffix
};
// 4. Khởi tạo mảng drawnPoints với điểm bắt đầu
drawnPoints = [[current[0], current[1]]];
// 5. Khởi tạo coordMeta tương ứng với các điểm vẽ tiếp
coordMeta = [{ isTrace: false }];
isDrawingContinued = true;
isTKeyDown = false;
traceStartState = null;
// Vô hiệu hóa chế độ xóa đỉnh
setDeleteVertexMode(false);
// 6. Gắn các sự kiện vẽ bản đồ
map.on("click", onMapClick);
map.on("mousemove", onMapMove);
map.on("contextmenu", onMapContextMenu);
if (map.getCanvas()) {
map.getCanvas().style.cursor = "crosshair";
}
// 7. Cập nhật hiển thị
updateEditSources();
};
const stopContinueDraw = (save: boolean) => {
const map = mapRef.current;
if (!map) return;
isDrawingContinued = false;
isTKeyDown = false;
traceStartState = null;
// Gỡ các sự kiện vẽ bản đồ
map.off("click", onMapClick);
map.off("mousemove", onMapMove);
map.off("contextmenu", onMapContextMenu);
if (map.getCanvas()) {
map.getCanvas().style.cursor = "";
}
if (!save && originalRingBackup && editingRef.current) {
// Khôi phục lại mảng cũ
editingRef.current.ring = [...originalRingBackup];
}
originalRingBackup = null;
drawnPoints = [];
continueDrawConfig = null;
coordMeta = [];
// Cập nhật lại nguồn dữ liệu (hiển thị lại các handle points chỉnh sửa bình thường)
updateEditSources();
};
const onMapClick = (e: maplibregl.MapMouseEvent) => {
const map = mapRef.current;
const editing = editingRef.current;
if (!isDrawingContinued || !map || !editing || !continueDrawConfig) return;
let lngLat = e.lngLat;
const snapRes = e.originalEvent.shiftKey
? snapToNearestGeometryDetailed(
map,
e.lngLat,
e.point,
null,
traceStartState ? traceStartState.targetFeatureId : null
)
: null;
if (snapRes && snapRes.type !== "none") {
lngLat = snapRes.lngLat;
}
const currentPoint: [number, number] = [lngLat.lng, lngLat.lat];
// 1. Thử chốt trace dọc biên giới
if (traceStartState) {
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
if (
targetSnap.type !== "none" &&
targetSnap.featureId !== undefined &&
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
targetSnap.ringCoords
) {
const snap1 = traceStartState.snap1;
const snap2 = {
type: targetSnap.type as "vertex" | "edge",
vertexIdx: targetSnap.vertexIdx,
edgeIdx: targetSnap.edgeIdx,
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
};
const { ring, idx1, idx2 } = getRingWithSnaps(
traceStartState.targetFeatureRing,
snap1,
snap2
);
const path = tracePathBetweenPoints(
ring as [number, number][],
idx1,
idx2
);
if (path.length > 0) {
const newGroupId = currentTraceGroupId++;
for (let i = 1; i < path.length; i++) {
drawnPoints.push(path[i]);
coordMeta.push({ isTrace: true, traceGroupId: newGroupId });
}
traceStartState = null;
const { prefix, suffix, reverseDrawn } = continueDrawConfig;
const activeDrawn = reverseDrawn ? [...drawnPoints].reverse() : drawnPoints;
const isLine = editing.geometryType === "LineString";
editing.ring = stitchRing(prefix, suffix, activeDrawn, reverseDrawn, isLine);
updateEditSources();
return;
}
}
}
// 2. Shift + T để kích hoạt start trace
const isShiftT = e.originalEvent.shiftKey && isTKeyDown;
if (isShiftT && snapRes && snapRes.type !== "none" && snapRes.featureId !== undefined && snapRes.ringCoords) {
drawnPoints.push(currentPoint);
coordMeta.push({ isTrace: false });
traceStartState = {
startCoord: currentPoint,
startIdx: drawnPoints.length - 1,
targetFeatureId: snapRes.featureId,
targetFeatureRing: snapRes.ringCoords as [number, number][],
snap1: {
type: snapRes.type as "vertex" | "edge",
vertexIdx: snapRes.vertexIdx,
edgeIdx: snapRes.edgeIdx,
lngLat: { lng: snapRes.lngLat.lng, lat: snapRes.lngLat.lat }
}
};
} else {
drawnPoints.push(currentPoint);
coordMeta.push({ isTrace: false });
traceStartState = null;
}
const { prefix, suffix, reverseDrawn } = continueDrawConfig;
const activeDrawn = reverseDrawn ? [...drawnPoints].reverse() : drawnPoints;
const isLine = editing.geometryType === "LineString";
editing.ring = stitchRing(prefix, suffix, activeDrawn, reverseDrawn, isLine);
updateEditSources();
};
const onMapMove = (e: maplibregl.MapMouseEvent) => {
const map = mapRef.current;
const editing = editingRef.current;
if (!isDrawingContinued || !map || !editing || !continueDrawConfig) return;
let lngLat = e.lngLat;
const snapRes = e.originalEvent.shiftKey
? snapToNearestGeometryDetailed(
map,
e.lngLat,
e.point,
null,
traceStartState ? traceStartState.targetFeatureId : null
)
: null;
if (snapRes && snapRes.type !== "none") {
lngLat = snapRes.lngLat;
}
const currentPoint: [number, number] = [lngLat.lng, lngLat.lat];
const isLine = editing.geometryType === "LineString";
// Nếu đang trong quá trình trace, tìm đường đi nháp
if (traceStartState) {
const targetSnap = snapToNearestGeometryDetailed(map, e.lngLat, e.point, null, traceStartState.targetFeatureId);
if (
targetSnap.type !== "none" &&
targetSnap.featureId !== undefined &&
String(targetSnap.featureId) === String(traceStartState.targetFeatureId) &&
targetSnap.ringCoords
) {
const snap1 = traceStartState.snap1;
const snap2 = {
type: targetSnap.type as "vertex" | "edge",
vertexIdx: targetSnap.vertexIdx,
edgeIdx: targetSnap.edgeIdx,
lngLat: { lng: targetSnap.lngLat.lng, lat: targetSnap.lngLat.lat }
};
const { ring, idx1, idx2 } = getRingWithSnaps(
traceStartState.targetFeatureRing,
snap1,
snap2
);
const path = tracePathBetweenPoints(
ring as [number, number][],
idx1,
idx2
);
if (path.length > 0) {
const previewDrawn = [...drawnPoints];
for (let i = 1; i < path.length; i++) {
previewDrawn.push(path[i]);
}
const { prefix, suffix, reverseDrawn } = continueDrawConfig;
const activeDrawn = reverseDrawn ? [...previewDrawn].reverse() : previewDrawn;
const combinedPreview = stitchRing(prefix, suffix, activeDrawn, reverseDrawn, isLine);
updateEditSourcesWithPreview(combinedPreview);
return;
}
}
}
const previewDrawn = [...drawnPoints, currentPoint];
const { prefix, suffix, reverseDrawn } = continueDrawConfig;
const activeDrawn = reverseDrawn ? [...previewDrawn].reverse() : previewDrawn;
const combinedPreview = stitchRing(prefix, suffix, activeDrawn, reverseDrawn, isLine);
updateEditSourcesWithPreview(combinedPreview);
};
const onMapContextMenu = (e: maplibregl.MapMouseEvent) => {
if (isDrawingContinued) {
e.preventDefault();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === "t") {
isTKeyDown = false;
}
if (e.key === "Alt") {
isAltKeyDown = false;
if (deleteRangeStartIdx !== null && deleteRangeHoverIdx !== null && lastMousePointPx) {
updateDeleteRange(lastMousePointPx);
}
}
};
const isToTheRight = (pointA: [number, number], pointB: [number, number]) => {
if (pointA[0] !== pointB[0]) {
return pointA[0] > pointB[0];
}
return pointA[1] < pointB[1];
};
const cleanRing = (ring: [number, number][]): [number, number][] => {
const cleaned: [number, number][] = [];
for (const pt of ring) {
if (cleaned.length === 0) {
cleaned.push(pt);
} else {
const last = cleaned[cleaned.length - 1];
if (Math.abs(pt[0] - last[0]) > 1e-9 || Math.abs(pt[1] - last[1]) > 1e-9) {
cleaned.push(pt);
}
}
}
return cleaned;
};
const stitchRing = (
prefix: [number, number][],
suffix: [number, number][],
activeDrawn: [number, number][],
reverseDrawn: boolean,
isLine: boolean
): [number, number][] => {
if (!continueDrawConfig) return activeDrawn;
let combined = prefix.concat(activeDrawn).concat(suffix);
// Đối với polygon khép kín nếu bị quấn vòng qua điểm bắt đầu/kết thúc
if (!isLine && combined.length > 1) {
const first = combined[0];
const last = combined[combined.length - 1];
if (Math.abs(first[0] - last[0]) < 1e-9 && Math.abs(first[1] - last[1]) < 1e-9) {
combined.pop();
}
}
return cleanRing(combined);
};
const showHandleContextMenu = (x: number, y: number, idx: number) => {
hideContextMenu();
@@ -471,11 +1194,82 @@ export function createEditingEngine(options: {
const isLine = editing.geometryType === "LineString";
const canDelete = isLine ? editing.ring.length > 2 : editing.ring.length > 3;
const canInsert = isLine ? idx < editing.ring.length - 1 : true;
menu.appendChild(createItem("Xóa đỉnh", () => deleteVertex(idx), !canDelete));
if (canInsert) {
menu.appendChild(createItem("Thêm đỉnh", () => insertVertexAfter(idx)));
const current = editing.ring[idx];
const len = editing.ring.length;
if (isLine) {
if (idx === 0) {
// Chỉ có next (idx = 1)
const next = editing.ring[1];
if (isToTheRight(next, current)) {
menu.appendChild(createItem("Thêm đỉnh vào bên phải", () => insertVertexRight(0)));
} else {
menu.appendChild(createItem("Thêm đỉnh vào bên trái", () => insertVertexRight(0)));
}
} else if (idx === len - 1) {
// Chỉ có prev (idx = len - 2)
const prev = editing.ring[len - 2];
if (isToTheRight(prev, current)) {
menu.appendChild(createItem("Thêm đỉnh vào bên phải", () => insertVertexLeft(len - 1)));
} else {
menu.appendChild(createItem("Thêm đỉnh vào bên trái", () => insertVertexLeft(len - 1)));
}
} else {
// Có cả prev và next
const prev = editing.ring[idx - 1];
const next = editing.ring[idx + 1];
const nextIsRight = isToTheRight(next, prev);
if (nextIsRight) {
menu.appendChild(createItem("Thêm đỉnh vào bên trái", () => insertVertexLeft(idx)));
menu.appendChild(createItem("Thêm đỉnh vào bên phải", () => insertVertexRight(idx)));
} else {
menu.appendChild(createItem("Thêm đỉnh vào bên phải", () => insertVertexLeft(idx)));
menu.appendChild(createItem("Thêm đỉnh vào bên trái", () => insertVertexRight(idx)));
}
}
} else {
// Polygon
const prev = editing.ring[(idx - 1 + len) % len];
const next = editing.ring[(idx + 1) % len];
const nextIsRight = isToTheRight(next, prev);
if (nextIsRight) {
menu.appendChild(createItem("Thêm đỉnh vào bên trái", () => insertVertexLeft(idx)));
menu.appendChild(createItem("Thêm đỉnh vào bên phải", () => insertVertexRight(idx)));
} else {
menu.appendChild(createItem("Thêm đỉnh vào bên phải", () => insertVertexLeft(idx)));
menu.appendChild(createItem("Thêm đỉnh vào bên trái", () => insertVertexRight(idx)));
}
}
// Vẽ tiếp từ nốt này
if (!editing.isCircle && (editing.geometryType === "Polygon" || editing.geometryType === "LineString")) {
if (isLine) {
if (idx === 0) {
const next = editing.ring[1];
if (isToTheRight(next, current)) {
menu.appendChild(createItem("Vẽ tiếp về bên phải", () => startContinueDraw(0, "right")));
} else {
menu.appendChild(createItem("Vẽ tiếp về bên trái", () => startContinueDraw(0, "left")));
}
} else if (idx === len - 1) {
const prev = editing.ring[len - 2];
if (isToTheRight(prev, current)) {
menu.appendChild(createItem("Vẽ tiếp về bên phải", () => startContinueDraw(len - 1, "right")));
} else {
menu.appendChild(createItem("Vẽ tiếp về bên trái", () => startContinueDraw(len - 1, "left")));
}
} else {
menu.appendChild(createItem("Vẽ tiếp về bên trái", () => startContinueDraw(idx, "left")));
menu.appendChild(createItem("Vẽ tiếp về bên phải", () => startContinueDraw(idx, "right")));
}
} else {
// Polygon
menu.appendChild(createItem("Vẽ tiếp về bên trái", () => startContinueDraw(idx, "left")));
menu.appendChild(createItem("Vẽ tiếp về bên phải", () => startContinueDraw(idx, "right")));
}
}
document.body.appendChild(menu);
@@ -502,7 +1296,25 @@ export function createEditingEngine(options: {
updateEditSources();
};
const insertVertexAfter = (idx: number) => {
const insertVertexLeft = (idx: number) => {
const editing = editingRef.current;
if (!editing || editing.geometryType === "Point" || editing.isCircle || editing.ring.length < 2) return;
if (idx < 0 || idx >= editing.ring.length) return;
const isLine = editing.geometryType === "LineString";
if (isLine && idx === 0) return;
const current = editing.ring[idx];
const prev = editing.ring[(idx - 1 + editing.ring.length) % editing.ring.length];
const midpoint: [number, number] = [
(current[0] + prev[0]) / 2,
(current[1] + prev[1]) / 2,
];
editing.ring.splice(idx, 0, midpoint);
vertexSnapStatuses.splice(idx, 0, "none");
updateEditSources();
};
const insertVertexRight = (idx: number) => {
const editing = editingRef.current;
if (!editing || editing.geometryType === "Point" || editing.isCircle || editing.ring.length < 2) return;
if (idx < 0 || idx >= editing.ring.length) return;
+3 -3
View File
@@ -50,7 +50,7 @@ export function initSelect(
// Chọn hoặc toggle đối tượng; giữ Alt để chọn cộng dồn/tắt chọn.
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
const id = feature.id ?? feature.properties?.id;
const id = feature.properties?.id ?? feature.id;
if (id === undefined || id === null) return false;
if (!additive) {
@@ -97,7 +97,7 @@ export function initSelect(
}
const feature = pickPreferredFeature(features);
const id = feature.id ?? feature.properties?.id;
const id = feature.properties?.id ?? feature.id;
if (id === undefined || id === null) return;
if (allowFeatureSelection && !allowFeatureSelection()) {
onFeatureClick?.({
@@ -141,7 +141,7 @@ export function initSelect(
if (!features.length) return;
const feature = pickPreferredFeature(features);
const id = feature.id ?? feature.properties?.id;
const id = feature.properties?.id ?? feature.id;
if (id === undefined || id === null) return;
const isRightClickedItemAlreadySelected = Array.from(selectedIds).some(sid => String(sid) === String(id));
+274 -30
View File
@@ -15,6 +15,10 @@ type GeometryWithCoordinates = Exclude<GeoJSON.Geometry, GeoJSON.GeometryCollect
export type SnapResult = {
lngLat: maplibregl.LngLat;
type: "vertex" | "edge" | "none";
featureId?: string | number;
ringCoords?: Coordinate[];
vertexIdx?: number;
edgeIdx?: number;
};
export function snapToNearestGeometry(
@@ -30,7 +34,8 @@ export function snapToNearestGeometryDetailed(
map: maplibregl.Map,
lngLat: maplibregl.LngLat,
pointPx: maplibregl.Point,
excludeFeatureId?: string | number | null
excludeFeatureId?: string | number | null,
includeFeatureId?: string | number | null
): SnapResult {
const bbox: [maplibregl.PointLike, maplibregl.PointLike] = [
[pointPx.x - QUERY_THRESHOLD_PX, pointPx.y - QUERY_THRESHOLD_PX],
@@ -46,8 +51,15 @@ export function snapToNearestGeometryDetailed(
let nearestVertexDist = Infinity;
let nearestVertexLngLat: maplibregl.LngLat | null = null;
let nearestVertexFeatureId: string | number | undefined = undefined;
let nearestVertexRing: Coordinate[] | null = null;
let nearestVertexIdx: number = -1;
let nearestEdgeDist = Infinity;
let nearestEdgeLngLat: maplibregl.LngLat | null = null;
let nearestEdgeFeatureId: string | number | undefined = undefined;
let nearestEdgeRing: Coordinate[] | null = null;
let nearestEdgeIdx: number = -1;
const getDistSq = (p1: maplibregl.Point, p2: maplibregl.Point) => {
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2;
@@ -68,12 +80,19 @@ export function snapToNearestGeometryDetailed(
// 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 toMercatorY = (lat: number) => {
if (lat > 85.0511) lat = 85.0511;
if (lat < -85.0511) lat = -85.0511;
return Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
};
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 fromMercatorY = (y: number) => {
return (360 / Math.PI) * Math.atan(Math.exp(y)) - 90;
};
const ax = a[0], ay = toMercatorY(a[1]);
const bx = b[0], by = toMercatorY(b[1]);
const px = p.lng, py = toMercatorY(p.lat);
const dx = bx - ax;
const dy = by - ay;
@@ -84,13 +103,13 @@ export function snapToNearestGeometryDetailed(
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;
const resultLng = ax + dx * t;
const resultLat = fromMercatorY(ay + dy * t);
return new maplibregl.LngLat(resultLng, resultLat);
};
const processVertex = (coordinate: Coordinate) => {
const processVertex = (coordinate: Coordinate, featureId: string | number | undefined, ring: Coordinate[], idx: number) => {
const vertexLngLat = new maplibregl.LngLat(coordinate[0], coordinate[1]);
const vertexPx = map.project(vertexLngLat);
const distSq = getDistSq(pointPx, vertexPx);
@@ -100,18 +119,23 @@ export function snapToNearestGeometryDetailed(
) {
nearestVertexDist = distSq;
nearestVertexLngLat = vertexLngLat;
nearestVertexFeatureId = featureId;
nearestVertexRing = ring;
nearestVertexIdx = idx;
}
};
const processLineString = (line: number[][]) => {
const processLineString = (line: number[][], featureId: string | number | undefined) => {
if (!line || line.length < 2) return;
for (let i = 0; i < line.length - 1; i++) {
const start = toCoordinate(line[i]);
const end = toCoordinate(line[i + 1]);
if (!start || !end) continue;
const lineCoords = line.map(c => toCoordinate(c)).filter((c): c is Coordinate => c !== null);
for (let i = 0; i < lineCoords.length - 1; i++) {
const start = lineCoords[i];
const end = lineCoords[i + 1];
processVertex(start);
if (i === line.length - 2) processVertex(end);
processVertex(start, featureId, lineCoords, i);
if (i === lineCoords.length - 2) {
processVertex(end, featureId, lineCoords, i + 1);
}
const p1LngLat = new maplibregl.LngLat(start[0], start[1]);
const p2LngLat = new maplibregl.LngLat(end[0], end[1]);
@@ -124,13 +148,16 @@ export function snapToNearestGeometryDetailed(
if (distSq < nearestEdgeDist && distSq <= EDGE_SNAP_THRESHOLD_PX ** 2) {
nearestEdgeDist = distSq;
nearestEdgeLngLat = getClosestPointOnLngLatSegment(lngLat, start, end);
nearestEdgeFeatureId = featureId;
nearestEdgeRing = lineCoords;
nearestEdgeIdx = i;
}
}
};
const processPoint = (coordinate: unknown) => {
const processPoint = (coordinate: unknown, featureId: string | number | undefined) => {
const point = toCoordinate(coordinate);
if (point) processVertex(point);
if (point) processVertex(point, featureId, [point], 0);
};
for (const feature of features) {
@@ -142,40 +169,66 @@ export function snapToNearestGeometryDetailed(
}
// 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;
const fId = feature.properties?.id ?? feature.id;
if (excludeFeatureId !== undefined && excludeFeatureId !== null && fId !== undefined && fId !== null) {
if (String(fId) === String(excludeFeatureId)) {
continue;
}
}
if (includeFeatureId !== undefined && includeFeatureId !== null && fId !== undefined && fId !== null) {
if (String(fId) !== String(includeFeatureId)) {
continue;
}
}
const type = feature.geometry.type;
let geometry = feature.geometry;
const sourceId = feature.layer.source;
const origFeature = getOriginalFeature(map, sourceId, fId);
if (origFeature && origFeature.geometry) {
geometry = origFeature.geometry;
}
const type = geometry.type;
if (type === "GeometryCollection") continue;
const coords = (feature.geometry as GeometryWithCoordinates).coordinates;
const coords = (geometry as GeometryWithCoordinates).coordinates;
// Xử lý cả Polygon và LineString vì viền bản đồ (border) đôi khi được render dưới dạng LineString
if (type === "Polygon") {
for (const ring of asCoordinateMatrix(coords)) processLineString(ring);
for (const ring of asCoordinateMatrix(coords)) processLineString(ring, fId);
} else if (type === "MultiPolygon") {
for (const poly of asCoordinateTensor(coords)) {
for (const ring of poly) processLineString(ring);
for (const ring of poly) processLineString(ring, fId);
}
} else if (type === "LineString") {
processLineString(asCoordinateArray(coords));
processLineString(asCoordinateArray(coords), fId);
} else if (type === "MultiLineString") {
for (const line of asCoordinateMatrix(coords)) processLineString(line);
for (const line of asCoordinateMatrix(coords)) processLineString(line, fId);
} else if (type === "Point") {
processPoint(coords);
processPoint(coords, fId);
} else if (type === "MultiPoint") {
for (const point of asCoordinateArray(coords)) processPoint(point);
for (const point of asCoordinateArray(coords)) processPoint(point, fId);
}
}
if (nearestVertexLngLat) {
return { lngLat: nearestVertexLngLat, type: "vertex" };
return {
lngLat: nearestVertexLngLat,
type: "vertex",
featureId: nearestVertexFeatureId,
ringCoords: nearestVertexRing || undefined,
vertexIdx: nearestVertexIdx
};
}
if (nearestEdgeLngLat) {
return { lngLat: nearestEdgeLngLat, type: "edge" };
if (nearestEdgeLngLat && nearestEdgeRing) {
const edgeLngLat = nearestEdgeLngLat as maplibregl.LngLat;
const edgeRing = nearestEdgeRing as Coordinate[];
return {
lngLat: edgeLngLat,
type: "edge",
featureId: nearestEdgeFeatureId,
ringCoords: edgeRing,
edgeIdx: nearestEdgeIdx
};
}
return { lngLat, type: "none" };
}
@@ -215,3 +268,194 @@ function asCoordinateMatrix(value: unknown): number[][][] {
function asCoordinateTensor(value: unknown): number[][][][] {
return Array.isArray(value) ? value as number[][][][] : [];
}
export function getArea(points: [number, number][]): number {
let area = 0;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
area += (p1[0] + p2[0]) * (p1[1] - p2[1]);
}
return Math.abs(area / 2);
}
export function tracePathBetweenPoints(
ring: [number, number][],
startIdx: number,
endIdx: number
): [number, number][] {
const n = ring.length;
if (startIdx < 0 || startIdx >= n || endIdx < 0 || endIdx >= n) {
return [];
}
const isClosed = n > 2 &&
Math.abs(ring[0][0] - ring[n - 1][0]) < 1e-9 &&
Math.abs(ring[0][1] - ring[n - 1][1]) < 1e-9;
if (!isClosed) {
// Case LineString
if (startIdx <= endIdx) {
return ring.slice(startIdx, endIdx + 1);
} else {
return ring.slice(endIdx, startIdx + 1).reverse();
}
}
// Case Closed Polygon
// Path 1: Forward
const path1: [number, number][] = [];
let idx = startIdx;
while (idx !== endIdx) {
path1.push(ring[idx]);
idx = (idx + 1) % n;
}
path1.push(ring[endIdx]);
// Path 2: Backward
const path2: [number, number][] = [];
idx = startIdx;
while (idx !== endIdx) {
path2.push(ring[idx]);
idx = (idx - 1 + n) % n;
}
path2.push(ring[endIdx]);
const poly1 = [...path1, ring[startIdx]];
const poly2 = [...path2, ring[startIdx]];
const area1 = getArea(poly1);
const area2 = getArea(poly2);
return area1 <= area2 ? path1 : path2;
}
export function getRingWithSnaps(
ring: Coordinate[],
snap1: { type: "vertex" | "edge"; vertexIdx?: number; edgeIdx?: number; lngLat: { lng: number; lat: number } },
snap2: { type: "vertex" | "edge"; vertexIdx?: number; edgeIdx?: number; lngLat: { lng: number; lat: number } }
): { ring: Coordinate[]; idx1: number; idx2: number } {
let tempRing = [...ring];
const coord1: Coordinate = [snap1.lngLat.lng, snap1.lngLat.lat];
const coord2: Coordinate = [snap2.lngLat.lng, snap2.lngLat.lat];
let idx1 = -1;
let idx2 = -1;
if (snap1.type === "vertex" && snap2.type === "vertex") {
idx1 = snap1.vertexIdx!;
idx2 = snap2.vertexIdx!;
} else if (snap1.type === "vertex" && snap2.type === "edge") {
idx1 = snap1.vertexIdx!;
const eIdx2 = snap2.edgeIdx!;
tempRing.splice(eIdx2 + 1, 0, coord2);
idx2 = eIdx2 + 1;
if (idx1 > eIdx2) {
idx1 += 1;
}
} else if (snap1.type === "edge" && snap2.type === "vertex") {
idx2 = snap2.vertexIdx!;
const eIdx1 = snap1.edgeIdx!;
tempRing.splice(eIdx1 + 1, 0, coord1);
idx1 = eIdx1 + 1;
if (idx2 > eIdx1) {
idx2 += 1;
}
} else {
const eIdx1 = snap1.edgeIdx!;
const eIdx2 = snap2.edgeIdx!;
if (eIdx1 < eIdx2) {
tempRing.splice(eIdx2 + 1, 0, coord2);
tempRing.splice(eIdx1 + 1, 0, coord1);
idx1 = eIdx1 + 1;
idx2 = eIdx2 + 2;
} else if (eIdx1 > eIdx2) {
tempRing.splice(eIdx1 + 1, 0, coord1);
tempRing.splice(eIdx2 + 1, 0, coord2);
idx1 = eIdx1 + 2;
idx2 = eIdx2 + 1;
} else {
const segStart = ring[eIdx1];
const dist1 = Math.hypot(coord1[0] - segStart[0], coord1[1] - segStart[1]);
const dist2 = Math.hypot(coord2[0] - segStart[0], coord2[1] - segStart[1]);
if (dist1 <= dist2) {
tempRing.splice(eIdx1 + 1, 0, coord1, coord2);
idx1 = eIdx1 + 1;
idx2 = eIdx1 + 2;
} else {
tempRing.splice(eIdx1 + 1, 0, coord2, coord1);
idx1 = eIdx1 + 2;
idx2 = eIdx1 + 1;
}
}
}
return { ring: tempRing, idx1, idx2 };
}
export function getOriginalFeature(
map: maplibregl.Map,
sourceId: string,
featureId: string | number | undefined
): GeoJSON.Feature | null {
if (featureId === undefined || featureId === null) return null;
// 1. Prioritize direct lookup inside the React/Zustand draft ref attached to the map instance.
// This contains the exact, unsimplified 64-bit coordinates for all local, baseline, and global features.
const renderDraft = (map as any)._renderDraftRef?.current;
if (renderDraft && Array.isArray(renderDraft.features)) {
const found = renderDraft.features.find((f: any) => {
const id = f.properties?.id ?? f.id;
return id !== undefined && String(id) === String(featureId);
});
if (found) {
console.log(`[DEBUG] getOriginalFeature: found featureId=${featureId} in map._renderDraftRef`);
return found;
}
}
// 2. Fallback to MapLibre's GeoJSONSource internal cache.
const source = map.getSource(sourceId) as any;
if (!source || !source._data) {
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId} source/data not found`);
return null;
}
const data = source._data;
// MapLibre v5 updateable Map lookup
if (data.updateable instanceof Map) {
const found = data.updateable.get(featureId) || data.updateable.get(String(featureId)) || data.updateable.get(Number(featureId));
if (found) {
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, featureId=${featureId}, found in updateable Map`);
return found;
}
}
// Resolve GeoJSON object (MapLibre v5 stores geojson under data.geojson)
const geojson = data.geojson || data;
if (typeof geojson === "object" && geojson !== null) {
if (geojson.type === "FeatureCollection" && Array.isArray(geojson.features)) {
const found = geojson.features.find((f: any) => {
const id = f.properties?.id ?? f.id;
return id !== undefined && String(id) === String(featureId);
});
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, featureId=${featureId}, found in geojson collection=${!!found}`);
return found || null;
} else if (geojson.type === "Feature") {
const id = geojson.properties?.id ?? geojson.id;
const matches = id !== undefined && String(id) === String(featureId);
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, featureId=${featureId}, matched_single=${matches}`);
if (matches) {
return geojson;
}
}
}
console.log(`[DEBUG] getOriginalFeature: sourceId=${sourceId}, data format not recognized`, data);
return null;
}