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
+280 -36
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,29 +80,36 @@ 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 ax = a[0] * cosLat, ay = a[1];
const bx = b[0] * cosLat, by = b[1];
const px = p.lng * cosLat, py = p.lat;
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 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;
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;
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;
}