add line | add circle | zoom | selectable geometry | geometry define entities type
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
const CIRCLE_SEGMENTS = 72;
|
||||
@@ -157,7 +157,7 @@ function buildCircleRing(
|
||||
): [number, number][] {
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= segments; i += 1) {
|
||||
const bearingDeg = (i / segments) * 360;
|
||||
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;
|
||||
@@ -166,16 +166,16 @@ function buildCircleRing(
|
||||
function distanceMeters(a: [number, number], b: [number, number]): number {
|
||||
const lat1 = toRad(a[1]);
|
||||
const lat2 = toRad(b[1]);
|
||||
const dLat = lat2 - lat1;
|
||||
const dLng = toRad(b[0] - a[0]);
|
||||
const dLat = lat2 - lat1; // Delta vĩ độ (radian).
|
||||
const dLng = toRad(b[0] - a[0]); // Delta kinh độ (radian).
|
||||
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLng = Math.sin(dLng / 2);
|
||||
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;
|
||||
const c = 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||||
return EARTH_RADIUS_METERS * c;
|
||||
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(
|
||||
@@ -186,7 +186,7 @@ function destinationPoint(
|
||||
const lat1 = toRad(center[1]);
|
||||
const lng1 = toRad(center[0]);
|
||||
const bearing = toRad(bearingDeg);
|
||||
const angularDistance = distance / EARTH_RADIUS_METERS;
|
||||
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);
|
||||
@@ -195,18 +195,18 @@ function destinationPoint(
|
||||
|
||||
const sinLat2 =
|
||||
sinLat1 * cosAngular +
|
||||
cosLat1 * sinAngular * Math.cos(bearing);
|
||||
const lat2 = Math.asin(clamp(sinLat2, -1, 1));
|
||||
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;
|
||||
const x = cosAngular - sinLat1 * Math.sin(lat2);
|
||||
const lng2 = lng1 + Math.atan2(y, x);
|
||||
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;
|
||||
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
||||
if (normalized === -180) normalized = 180;
|
||||
return normalized;
|
||||
}
|
||||
@@ -218,9 +218,9 @@ function clamp(value: number, min: number, max: number): number {
|
||||
}
|
||||
|
||||
function toRad(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||
}
|
||||
|
||||
function toDeg(value: number): number {
|
||||
return (value * 180) / Math.PI;
|
||||
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
export function initDrawing(
|
||||
map: maplibregl.Map,
|
||||
|
||||
@@ -163,7 +163,7 @@ export function createEditingEngine(options: {
|
||||
ring.forEach((pt, idx) => {
|
||||
const dx = pt[0] - click[0];
|
||||
const dy = pt[1] - click[1];
|
||||
const d = dx * dx + dy * dy;
|
||||
const d = dx * dx + dy * dy; // Dùng khoảng cách Euclid bình phương để so sánh nhanh, không cần sqrt.
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
nearestIdx = idx;
|
||||
|
||||
129
lib/entityTypeOptions.ts
Normal file
129
lib/entityTypeOptions.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
export type EntityTypeGroupId =
|
||||
| "split"
|
||||
| "route"
|
||||
| "area_polygon"
|
||||
| "area_circle"
|
||||
| "point";
|
||||
|
||||
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||
|
||||
export type EntityTypeGroup = {
|
||||
id: EntityTypeGroupId;
|
||||
label: string;
|
||||
geometryLabel: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type EntityTypeOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
groupId: EntityTypeGroupId;
|
||||
groupLabel: string;
|
||||
geometryPreset: EntityGeometryPreset;
|
||||
};
|
||||
|
||||
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||
{
|
||||
id: "split",
|
||||
label: "Split",
|
||||
geometryLabel: "Line",
|
||||
description: "Tuyến chia cắt/phòng thủ.",
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
label: "Route",
|
||||
geometryLabel: "Line",
|
||||
description: "Các tuyến di chuyển theo hướng.",
|
||||
},
|
||||
{
|
||||
id: "area_polygon",
|
||||
label: "Area - Đa giác",
|
||||
geometryLabel: "Polygon",
|
||||
description: "Vùng lãnh thổ dạng đa giác.",
|
||||
},
|
||||
{
|
||||
id: "area_circle",
|
||||
label: "Area - Tròn",
|
||||
geometryLabel: "Polygon tròn",
|
||||
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||
},
|
||||
{
|
||||
id: "point",
|
||||
label: "Point",
|
||||
geometryLabel: "Point",
|
||||
description: "Địa điểm đơn lẻ.",
|
||||
},
|
||||
];
|
||||
|
||||
const GROUP_BY_ID: Record<EntityTypeGroupId, EntityTypeGroup> = {
|
||||
split: ENTITY_TYPE_GROUPS[0],
|
||||
route: ENTITY_TYPE_GROUPS[1],
|
||||
area_polygon: ENTITY_TYPE_GROUPS[2],
|
||||
area_circle: ENTITY_TYPE_GROUPS[3],
|
||||
point: ENTITY_TYPE_GROUPS[4],
|
||||
};
|
||||
|
||||
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
groupId: EntityTypeGroupId;
|
||||
geometryPreset: EntityGeometryPreset;
|
||||
}> = [
|
||||
{ value: "defense_line", label: "Defense Line", groupId: "split", geometryPreset: "line" },
|
||||
|
||||
{ value: "attack_route", label: "Attack Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "retreat_route", label: "Retreat Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "invasion_route", label: "Invasion Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "migration_route", label: "Migration Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "refugee_route", label: "Refugee Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "trade_route", label: "Trade Route", groupId: "route", geometryPreset: "line" },
|
||||
{ value: "shipping_route", label: "Shipping Route", groupId: "route", geometryPreset: "line" },
|
||||
|
||||
{ value: "country", label: "Country", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
{ value: "state", label: "State", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
{ value: "empire", label: "Empire", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
{ value: "kingdom", label: "Kingdom", groupId: "area_polygon", geometryPreset: "polygon" },
|
||||
|
||||
{ value: "war", label: "War", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
{ value: "battle", label: "Battle", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
{ value: "civilization", label: "Civilization", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "area_circle", geometryPreset: "circle-area" },
|
||||
|
||||
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "person_activity", label: "Person Activity", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "temple", label: "Temple", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "capital", label: "Capital", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "city", label: "City", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "fortress", label: "Fortress", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "castle", label: "Castle", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "ruin", label: "Ruin", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "port", label: "Port", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "bridge", label: "Bridge", groupId: "point", geometryPreset: "point" },
|
||||
];
|
||||
|
||||
export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
groupLabel: GROUP_BY_ID[item.groupId].label,
|
||||
}));
|
||||
|
||||
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
||||
|
||||
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||
id: EntityTypeGroupId;
|
||||
label: string;
|
||||
geometryLabel: string;
|
||||
description: string;
|
||||
options: EntityTypeOption[];
|
||||
}> {
|
||||
return ENTITY_TYPE_GROUPS.map((group) => ({
|
||||
...group,
|
||||
options: options.filter((option) => option.groupId === group.id),
|
||||
})).filter((group) => group.options.length > 0);
|
||||
}
|
||||
|
||||
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
||||
if (!typeId) return null;
|
||||
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||
}
|
||||
|
||||
127
lib/lineEngine.ts
Normal file
127
lib/lineEngine.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
|
||||
export function initLine(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
const cancelLine = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: lineCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const finishLine = () => {
|
||||
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||
|
||||
const geometry: Geometry = {
|
||||
type: "LineString",
|
||||
coordinates: [...coords],
|
||||
};
|
||||
|
||||
onComplete(geometry);
|
||||
cancelLine();
|
||||
};
|
||||
|
||||
const removeLastVertex = () => {
|
||||
if (!coords.length) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
coords.push([e.lngLat.lng, e.lngLat.lat]);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
if (getMode() !== "add-line") {
|
||||
if (coords.length) {
|
||||
cancelLine();
|
||||
}
|
||||
if (canvas.style.cursor === "crosshair") {
|
||||
canvas.style.cursor = "";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.style.cursor = "crosshair";
|
||||
if (coords.length === 0) return;
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
finishLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
removeLastVertex();
|
||||
}
|
||||
};
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
return () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
cancelLine();
|
||||
if (map.getCanvas().style.cursor === "crosshair") {
|
||||
map.getCanvas().style.cursor = "";
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
export function initPoint(
|
||||
map: maplibregl.Map,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import maplibregl from "maplibre-gl";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle";
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
export function initSelect(
|
||||
map: maplibregl.Map,
|
||||
|
||||
@@ -15,6 +15,8 @@ export type FeatureProperties = {
|
||||
id: string | number;
|
||||
time_start?: number | null;
|
||||
time_end?: number | null;
|
||||
binding?: string[];
|
||||
line_mode?: string | null;
|
||||
entity_id?: string | null;
|
||||
entity_ids?: string[];
|
||||
entity_name?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user