227 lines
7.2 KiB
TypeScript
227 lines
7.2 KiB
TypeScript
import maplibregl from "maplibre-gl";
|
|
import { Geometry } from "@/lib/useEditorState";
|
|
|
|
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
|
|
|
const EARTH_RADIUS_METERS = 6371008.8;
|
|
const CIRCLE_SEGMENTS = 72;
|
|
const MIN_RADIUS_METERS = 1;
|
|
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
|
type: "FeatureCollection",
|
|
features: [],
|
|
};
|
|
|
|
export function initCircle(
|
|
map: maplibregl.Map,
|
|
getMode: ModeGetter,
|
|
onComplete: (geometry: Geometry) => void
|
|
) {
|
|
let center: [number, number] | null = null;
|
|
let radiusMeters = 0;
|
|
let isDragging = false;
|
|
let dragPanDisabledByCircle = false;
|
|
|
|
const clearPreview = () => {
|
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
|
EMPTY_PREVIEW
|
|
);
|
|
};
|
|
|
|
const releaseDragPan = () => {
|
|
if (!dragPanDisabledByCircle) return;
|
|
dragPanDisabledByCircle = false;
|
|
if (!map.dragPan.isEnabled()) {
|
|
map.dragPan.enable();
|
|
}
|
|
};
|
|
|
|
const resetDrawingState = () => {
|
|
center = null;
|
|
radiusMeters = 0;
|
|
isDragging = false;
|
|
clearPreview();
|
|
releaseDragPan();
|
|
};
|
|
|
|
const updatePreview = () => {
|
|
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
|
clearPreview();
|
|
return;
|
|
}
|
|
|
|
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
|
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
|
type: "FeatureCollection",
|
|
features: [
|
|
{
|
|
type: "Feature",
|
|
properties: {},
|
|
geometry: {
|
|
type: "Polygon",
|
|
coordinates: [ring],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
};
|
|
|
|
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
|
if (getMode() !== "add-circle") return;
|
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
|
|
|
center = [e.lngLat.lng, e.lngLat.lat];
|
|
radiusMeters = 0;
|
|
isDragging = true;
|
|
clearPreview();
|
|
|
|
if (map.dragPan.isEnabled()) {
|
|
map.dragPan.disable();
|
|
dragPanDisabledByCircle = true;
|
|
} else {
|
|
dragPanDisabledByCircle = false;
|
|
}
|
|
};
|
|
|
|
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
|
const canvas = map.getCanvas();
|
|
if (getMode() !== "add-circle") {
|
|
if (canvas.style.cursor === "crosshair") {
|
|
canvas.style.cursor = "";
|
|
}
|
|
if (isDragging) {
|
|
resetDrawingState();
|
|
}
|
|
return;
|
|
}
|
|
|
|
canvas.style.cursor = "crosshair";
|
|
if (!isDragging || !center) return;
|
|
|
|
radiusMeters = distanceMeters(center, [e.lngLat.lng, e.lngLat.lat]);
|
|
updatePreview();
|
|
};
|
|
|
|
const finishCircle = () => {
|
|
if (!isDragging || !center) {
|
|
resetDrawingState();
|
|
return;
|
|
}
|
|
|
|
if (radiusMeters < MIN_RADIUS_METERS) {
|
|
resetDrawingState();
|
|
return;
|
|
}
|
|
|
|
const ring = buildCircleRing(center, radiusMeters, CIRCLE_SEGMENTS);
|
|
onComplete({
|
|
type: "Polygon",
|
|
coordinates: [ring],
|
|
});
|
|
resetDrawingState();
|
|
};
|
|
|
|
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
|
if (getMode() !== "add-circle") return;
|
|
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
|
finishCircle();
|
|
};
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (getMode() !== "add-circle") return;
|
|
if (e.key !== "Escape") return;
|
|
e.preventDefault();
|
|
resetDrawingState();
|
|
};
|
|
|
|
map.on("mousedown", onMouseDown);
|
|
map.on("mousemove", onMouseMove);
|
|
map.on("mouseup", onMouseUp);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
return () => {
|
|
map.off("mousedown", onMouseDown);
|
|
map.off("mousemove", onMouseMove);
|
|
map.off("mouseup", onMouseUp);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
resetDrawingState();
|
|
if (map.getCanvas().style.cursor === "crosshair") {
|
|
map.getCanvas().style.cursor = "";
|
|
}
|
|
};
|
|
}
|
|
|
|
function buildCircleRing(
|
|
center: [number, number],
|
|
radiusMeters: number,
|
|
segments: number
|
|
): [number, number][] {
|
|
const ring: [number, number][] = [];
|
|
for (let i = 0; i <= segments; i += 1) {
|
|
const bearingDeg = (i / segments) * 360; // Chia đều 360 do quanh tâm để tạo các điểm trên vòng tròn.
|
|
ring.push(destinationPoint(center, radiusMeters, bearingDeg));
|
|
}
|
|
return ring;
|
|
}
|
|
|
|
function distanceMeters(a: [number, number], b: [number, number]): number {
|
|
const lat1 = toRad(a[1]);
|
|
const lat2 = toRad(b[1]);
|
|
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
|
|
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
|
|
|
|
const sinLat = Math.sin(dLat / 2); // Thành phần sin(dLat/2) của công thức Haversine.
|
|
const sinLng = Math.sin(dLng / 2); // Thành phần sin(dLng/2) của công thức Haversine.
|
|
const h =
|
|
sinLat * sinLat +
|
|
Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng; // h = haversine(d/R), độ lớn cung tròn chuẩn hóa.
|
|
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); // Góc tâm (radian) giữa hai điểm trên mặt cầu.
|
|
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
|
}
|
|
|
|
function destinationPoint(
|
|
center: [number, number],
|
|
distance: number,
|
|
bearingDeg: number
|
|
): [number, number] {
|
|
const lat1 = toRad(center[1]);
|
|
const lng1 = toRad(center[0]);
|
|
const bearing = toRad(bearingDeg);
|
|
const angularDistance = distance / EARTH_RADIUS_METERS; // d/R: khoảng cách góc trên mặt cầu.
|
|
|
|
const sinLat1 = Math.sin(lat1);
|
|
const cosLat1 = Math.cos(lat1);
|
|
const sinAngular = Math.sin(angularDistance);
|
|
const cosAngular = Math.cos(angularDistance);
|
|
|
|
const sinLat2 =
|
|
sinLat1 * cosAngular +
|
|
cosLat1 * sinAngular * Math.cos(bearing); // Công thức vĩ độ điểm đích theo great-circle.
|
|
const lat2 = Math.asin(clamp(sinLat2, -1, 1)); // Kẹp [-1,1] để tránh sai số số học trước khi asin.
|
|
|
|
const y = Math.sin(bearing) * sinAngular * cosLat1; // Tử số atan2 cho biến thiên kinh độ.
|
|
const x = cosAngular - sinLat1 * Math.sin(lat2); // Mẫu số atan2 cho biến thiên kinh độ.
|
|
const lng2 = lng1 + Math.atan2(y, x); // Kinh độ đích = kinh độ gốc + delta kinh độ.
|
|
|
|
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
|
}
|
|
|
|
function normalizeLng(lng: number): number {
|
|
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
|
if (normalized === -180) normalized = 180;
|
|
return normalized;
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
if (value < min) return min;
|
|
if (value > max) return max;
|
|
return value;
|
|
}
|
|
|
|
function toRad(value: number): number {
|
|
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
|
}
|
|
|
|
function toDeg(value: number): number {
|
|
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
|
}
|