preview map editor 60%
This commit is contained in:
@@ -13,6 +13,7 @@ export const BACKGROUND_LAYER_OPTIONS = [
|
||||
export type BackgroundLayerId = (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"];
|
||||
export type BackgroundLayerVisibility = Record<BackgroundLayerId, boolean>;
|
||||
|
||||
// Tạo map visibility mặc định cho toàn bộ background layers.
|
||||
function buildBackgroundLayerVisibility(value: boolean): BackgroundLayerVisibility {
|
||||
return BACKGROUND_LAYER_OPTIONS.reduce((acc, option) => {
|
||||
acc[option.id] = value;
|
||||
|
||||
@@ -11,6 +11,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ circle bằng thao tác kéo chuột từ tâm ra biên.
|
||||
export function initCircle(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -21,12 +22,14 @@ export function initCircle(
|
||||
let isDragging = false;
|
||||
let dragPanDisabledByCircle = false;
|
||||
|
||||
// Xóa dữ liệu preview circle trên map.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-circle-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Bật lại drag pan nếu trước đó bị tắt khi đang kéo vẽ circle.
|
||||
const releaseDragPan = () => {
|
||||
if (!dragPanDisabledByCircle) return;
|
||||
dragPanDisabledByCircle = false;
|
||||
@@ -35,6 +38,7 @@ export function initCircle(
|
||||
}
|
||||
};
|
||||
|
||||
// Reset toàn bộ trạng thái vẽ circle tạm thời.
|
||||
const resetDrawingState = () => {
|
||||
center = null;
|
||||
radiusMeters = 0;
|
||||
@@ -43,6 +47,7 @@ export function initCircle(
|
||||
releaseDragPan();
|
||||
};
|
||||
|
||||
// Cập nhật polygon preview theo tâm và bán kính hiện tại.
|
||||
const updatePreview = () => {
|
||||
if (!center || radiusMeters < MIN_RADIUS_METERS) {
|
||||
clearPreview();
|
||||
@@ -65,6 +70,7 @@ export function initCircle(
|
||||
});
|
||||
};
|
||||
|
||||
// Bắt đầu phiên vẽ circle khi nhấn chuột trái.
|
||||
const onMouseDown = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
@@ -82,6 +88,7 @@ export function initCircle(
|
||||
}
|
||||
};
|
||||
|
||||
// Cập nhật bán kính theo vị trí chuột trong lúc kéo.
|
||||
const onMouseMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() !== "add-circle") {
|
||||
@@ -101,6 +108,7 @@ export function initCircle(
|
||||
updatePreview();
|
||||
};
|
||||
|
||||
// Hoàn tất circle và trả geometry cho callback.
|
||||
const finishCircle = () => {
|
||||
if (!isDragging || !center) {
|
||||
resetDrawingState();
|
||||
@@ -120,12 +128,14 @@ export function initCircle(
|
||||
resetDrawingState();
|
||||
};
|
||||
|
||||
// Kết thúc thao tác kéo bằng mouseup chuột trái.
|
||||
const onMouseUp = (e: maplibregl.MapMouseEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if ((e.originalEvent as MouseEvent | undefined)?.button !== 0) return;
|
||||
finishCircle();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ circle khi nhấn Escape.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-circle") return;
|
||||
if (e.key !== "Escape") return;
|
||||
@@ -150,6 +160,7 @@ export function initCircle(
|
||||
};
|
||||
}
|
||||
|
||||
// Tạo vòng polygon xấp xỉ hình tròn từ tâm, bán kính và số phân đoạn.
|
||||
function buildCircleRing(
|
||||
center: [number, number],
|
||||
radiusMeters: number,
|
||||
@@ -163,6 +174,7 @@ function buildCircleRing(
|
||||
return ring;
|
||||
}
|
||||
|
||||
// Tính khoảng cách hai điểm theo công thức Haversine (đơn vị mét).
|
||||
function distanceMeters(a: [number, number], b: [number, number]): number {
|
||||
const lat1 = toRad(a[1]);
|
||||
const lat2 = toRad(b[1]);
|
||||
@@ -178,6 +190,7 @@ function distanceMeters(a: [number, number], b: [number, number]): number {
|
||||
return EARTH_RADIUS_METERS * c; // Khoảng cách cung tròn: d = R * c.
|
||||
}
|
||||
|
||||
// Tính tọa độ điểm đích từ tâm, khoảng cách và góc phương vị.
|
||||
function destinationPoint(
|
||||
center: [number, number],
|
||||
distance: number,
|
||||
@@ -205,22 +218,26 @@ function destinationPoint(
|
||||
return [normalizeLng(toDeg(lng2)), toDeg(lat2)];
|
||||
}
|
||||
|
||||
// Chuẩn hóa kinh độ về miền [-180, 180].
|
||||
function normalizeLng(lng: number): number {
|
||||
let normalized = ((lng + 540) % 360) - 180; // Wrap về khoảng [-180, 180).
|
||||
if (normalized === -180) normalized = 180;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Kẹp giá trị trong đoạn [min, max].
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (value < min) return min;
|
||||
if (value > max) return max;
|
||||
return value;
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ độ sang radian.
|
||||
function toRad(value: number): number {
|
||||
return (value * Math.PI) / 180; // Đổi độ sang radian.
|
||||
}
|
||||
|
||||
// Đổi đơn vị góc từ radian sang độ.
|
||||
function toDeg(value: number): number {
|
||||
return (value * 180) / Math.PI; // Đổi radian sang độ.
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
// Khởi tạo engine vẽ polygon tự do theo chuỗi click.
|
||||
export function initDrawing(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -10,9 +11,7 @@ export function initDrawing(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
/**
|
||||
* Close polygon ring if not closed.
|
||||
*/
|
||||
// Đóng vòng polygon nếu điểm cuối chưa trùng điểm đầu.
|
||||
function closePolygon(c: [number, number][]) {
|
||||
if (c.length < 3) return c;
|
||||
const first = c[0];
|
||||
@@ -24,9 +23,7 @@ export function initDrawing(
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update preview layer while drawing.
|
||||
*/
|
||||
// Cập nhật layer preview trong lúc đang vẽ.
|
||||
function update(c: [number, number][]) {
|
||||
const closed = closePolygon(c);
|
||||
|
||||
@@ -45,6 +42,7 @@ export function initDrawing(
|
||||
});
|
||||
}
|
||||
|
||||
// Ghi nhận đỉnh polygon mới khi click map.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "draw") return;
|
||||
|
||||
@@ -52,6 +50,7 @@ export function initDrawing(
|
||||
update(coords);
|
||||
}
|
||||
|
||||
// Render preview polygon với điểm chuột hiện tại.
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "draw" || coords.length === 0) return;
|
||||
|
||||
@@ -62,9 +61,7 @@ export function initDrawing(
|
||||
update(preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize polygon, emit geometry to caller, reset preview.
|
||||
*/
|
||||
// Hoàn tất polygon, trả geometry ra ngoài và reset preview.
|
||||
function finishDrawing() {
|
||||
if (getMode() !== "draw" || coords.length < 3) return;
|
||||
|
||||
@@ -83,6 +80,7 @@ export function initDrawing(
|
||||
});
|
||||
}
|
||||
|
||||
// Lắng nghe Enter để chốt polygon.
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
finishDrawing();
|
||||
|
||||
@@ -13,6 +13,7 @@ export type EditingAPI = {
|
||||
bindEditEvents: (map: maplibregl.Map) => void;
|
||||
};
|
||||
|
||||
// Tạo engine chỉnh sửa polygon đã có (kéo đỉnh, thêm đỉnh, commit/cancel).
|
||||
export function createEditingEngine(options: {
|
||||
mapRef: React.MutableRefObject<maplibregl.Map | null>;
|
||||
onUpdate: (id: string | number, geometry: Geometry) => void;
|
||||
@@ -22,6 +23,7 @@ export function createEditingEngine(options: {
|
||||
const dragStateRef = { current: null as { idx: number } | null };
|
||||
const modifierRef = { current: { ctrl: false, meta: false } };
|
||||
|
||||
// Hủy trạng thái chỉnh sửa hiện tại và dọn hai source edit.
|
||||
const clearEditing = () => {
|
||||
editingRef.current = null;
|
||||
dragStateRef.current = null;
|
||||
@@ -32,6 +34,7 @@ export function createEditingEngine(options: {
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
|
||||
};
|
||||
|
||||
// Đồng bộ polygon tạm và các handle point lên map source.
|
||||
const updateEditSources = () => {
|
||||
const editing = editingRef.current;
|
||||
const map = mapRef.current;
|
||||
@@ -62,6 +65,7 @@ export function createEditingEngine(options: {
|
||||
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
|
||||
};
|
||||
|
||||
// Chốt chỉnh sửa và emit geometry mới cho caller.
|
||||
const finishEditing = () => {
|
||||
const editing = editingRef.current;
|
||||
if (!editing) return;
|
||||
@@ -73,10 +77,12 @@ export function createEditingEngine(options: {
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Thoát chế độ chỉnh sửa mà không lưu thay đổi.
|
||||
const cancelEditing = () => {
|
||||
clearEditing();
|
||||
};
|
||||
|
||||
// Bắt đầu chỉnh sửa từ feature polygon được chọn.
|
||||
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
|
||||
if (feature.geometry.type !== "Polygon") return;
|
||||
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
|
||||
@@ -92,6 +98,7 @@ export function createEditingEngine(options: {
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kiểm tra trạng thái nhấn phím modifier để bật thao tác chèn đỉnh.
|
||||
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
|
||||
const oe = e?.originalEvent as MouseEvent | undefined;
|
||||
return (
|
||||
@@ -102,7 +109,9 @@ export function createEditingEngine(options: {
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (!editingRef.current) return;
|
||||
const feature = e.features?.[0];
|
||||
@@ -114,6 +123,7 @@ export function createEditingEngine(options: {
|
||||
map.dragPan.disable();
|
||||
};
|
||||
|
||||
// Cập nhật vị trí đỉnh trong lúc kéo chuột.
|
||||
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
|
||||
const drag = dragStateRef.current;
|
||||
const editing = editingRef.current;
|
||||
@@ -123,12 +133,14 @@ export function createEditingEngine(options: {
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Kết thúc kéo đỉnh và khôi phục trạng thái tương tác map.
|
||||
const stopDragging = () => {
|
||||
dragStateRef.current = null;
|
||||
map.getCanvas().style.cursor = "";
|
||||
map.dragPan.enable();
|
||||
};
|
||||
|
||||
// Bắt phím điều khiển phiên chỉnh sửa (Enter/Escape + modifier flags).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = true;
|
||||
@@ -143,6 +155,7 @@ export function createEditingEngine(options: {
|
||||
}
|
||||
};
|
||||
|
||||
// Hạ cờ modifier khi nhả phím.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === "Control") {
|
||||
modifierRef.current.ctrl = false;
|
||||
@@ -151,6 +164,7 @@ export function createEditingEngine(options: {
|
||||
}
|
||||
};
|
||||
|
||||
// Chèn thêm một đỉnh mới vào ring tại vị trí gần điểm click nhất.
|
||||
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (!editingRef.current) return;
|
||||
if (!isModifierPressed(e)) return;
|
||||
@@ -177,6 +191,7 @@ export function createEditingEngine(options: {
|
||||
updateEditSources();
|
||||
};
|
||||
|
||||
// Ngắt kéo nếu con trỏ rời canvas.
|
||||
const onCanvasLeave = () => {
|
||||
stopDragging();
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export type EntityTypeGroupId =
|
||||
| "split"
|
||||
| "route"
|
||||
| "area_polygon"
|
||||
| "area_circle"
|
||||
| "line"
|
||||
| "polygon"
|
||||
| "circle"
|
||||
| "point";
|
||||
|
||||
export type EntityGeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||
@@ -24,43 +23,36 @@ export type EntityTypeOption = {
|
||||
|
||||
export const ENTITY_TYPE_GROUPS: EntityTypeGroup[] = [
|
||||
{
|
||||
id: "split",
|
||||
label: "Split",
|
||||
id: "line",
|
||||
label: "line - Tuyến",
|
||||
geometryLabel: "Line",
|
||||
description: "Tuyến chia cắt/phòng thủ.",
|
||||
description: "Các tuyến line/path (gấp khúc).",
|
||||
},
|
||||
{
|
||||
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",
|
||||
id: "polygon",
|
||||
label: "polygon - Đ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",
|
||||
id: "circle",
|
||||
label: "circle - Tròn",
|
||||
geometryLabel: "Circle",
|
||||
description: "Vùng sự kiện theo bán kính ảnh hưởng.",
|
||||
},
|
||||
{
|
||||
id: "point",
|
||||
label: "Point",
|
||||
label: "point - Điểm",
|
||||
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],
|
||||
line: ENTITY_TYPE_GROUPS[0],
|
||||
polygon: ENTITY_TYPE_GROUPS[1],
|
||||
circle: ENTITY_TYPE_GROUPS[2],
|
||||
point: ENTITY_TYPE_GROUPS[3],
|
||||
};
|
||||
|
||||
const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||
@@ -69,25 +61,25 @@ const RAW_ENTITY_TYPE_OPTIONS: Array<{
|
||||
groupId: EntityTypeGroupId;
|
||||
geometryPreset: EntityGeometryPreset;
|
||||
}> = [
|
||||
{ value: "defense_line", label: "Defense Line", groupId: "split", geometryPreset: "line" },
|
||||
{ value: "defense_line", label: "Defense Line", groupId: "line", 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: "attack_route", label: "Attack Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "retreat_route", label: "Retreat Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "invasion_route", label: "Invasion Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "migration_route", label: "Migration Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "refugee_route", label: "Refugee Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "trade_route", label: "Trade Route", groupId: "line", geometryPreset: "line" },
|
||||
{ value: "shipping_route", label: "Shipping Route", groupId: "line", 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: "country", label: "Country", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "state", label: "State", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "empire", label: "Empire", groupId: "polygon", geometryPreset: "polygon" },
|
||||
{ value: "kingdom", label: "Kingdom", groupId: "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: "war", label: "War", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "battle", label: "Battle", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "civilization", label: "Civilization", groupId: "circle", geometryPreset: "circle-area" },
|
||||
{ value: "rebellion_zone", label: "Rebellion Zone", groupId: "circle", geometryPreset: "circle-area" },
|
||||
|
||||
{ value: "person_deathplace", label: "Person Deathplace", groupId: "point", geometryPreset: "point" },
|
||||
{ value: "person_birthplace", label: "Person Birthplace", groupId: "point", geometryPreset: "point" },
|
||||
@@ -109,6 +101,7 @@ export const ENTITY_TYPE_OPTIONS: EntityTypeOption[] = RAW_ENTITY_TYPE_OPTIONS.m
|
||||
|
||||
export const DEFAULT_ENTITY_TYPE_ID = "country";
|
||||
|
||||
// Gom option theo group để render select phân nhóm.
|
||||
export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE_OPTIONS): Array<{
|
||||
id: EntityTypeGroupId;
|
||||
label: string;
|
||||
@@ -122,8 +115,8 @@ export function groupEntityTypeOptions(options: EntityTypeOption[] = ENTITY_TYPE
|
||||
})).filter((group) => group.options.length > 0);
|
||||
}
|
||||
|
||||
// Tìm option theo type id, trả null nếu không tồn tại.
|
||||
export function findEntityTypeOption(typeId: string | null | undefined): EntityTypeOption | null {
|
||||
if (!typeId) return null;
|
||||
return ENTITY_TYPE_OPTIONS.find((option) => option.value === typeId) || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ line (gấp khúc, không mũi tên).
|
||||
export function initLine(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -15,17 +16,20 @@ export function initLine(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview line.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ line hiện tại.
|
||||
const cancelLine = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Cập nhật line preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
@@ -47,6 +51,7 @@ export function initLine(
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt line khi đủ số đỉnh tối thiểu.
|
||||
const finishLine = () => {
|
||||
if (getMode() !== "add-line" || coords.length < 2) return;
|
||||
|
||||
@@ -59,12 +64,14 @@ export function initLine(
|
||||
cancelLine();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng trong line đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (!coords.length) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh line khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
@@ -72,6 +79,7 @@ export function initLine(
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
@@ -90,6 +98,7 @@ export function initLine(
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ line.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-line") return;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const EMPTY_PREVIEW: GeoJSON.FeatureCollection = {
|
||||
features: [],
|
||||
};
|
||||
|
||||
// Khởi tạo engine vẽ path (gấp khúc, sẽ render có mũi tên ở layer path).
|
||||
export function initPath(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
@@ -15,12 +16,14 @@ export function initPath(
|
||||
) {
|
||||
let coords: [number, number][] = [];
|
||||
|
||||
// Xóa dữ liệu preview path.
|
||||
const clearPreview = () => {
|
||||
(map.getSource("draw-path-preview") as maplibregl.GeoJSONSource | undefined)?.setData(
|
||||
EMPTY_PREVIEW
|
||||
);
|
||||
};
|
||||
|
||||
// Cập nhật path preview theo danh sách tọa độ tạm.
|
||||
const updatePreview = (lineCoords: [number, number][]) => {
|
||||
if (lineCoords.length < 2) {
|
||||
clearPreview();
|
||||
@@ -42,6 +45,7 @@ export function initPath(
|
||||
});
|
||||
};
|
||||
|
||||
// Chốt path khi đủ số đỉnh tối thiểu.
|
||||
const finishPath = () => {
|
||||
if (getMode() !== "add-path" || coords.length < 2) return;
|
||||
|
||||
@@ -55,17 +59,20 @@ export function initPath(
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Hủy phiên vẽ path hiện tại.
|
||||
const cancelPath = () => {
|
||||
coords = [];
|
||||
clearPreview();
|
||||
};
|
||||
|
||||
// Xóa đỉnh cuối cùng của path đang vẽ.
|
||||
const removeLastVertex = () => {
|
||||
if (coords.length === 0) return;
|
||||
coords = coords.slice(0, -1);
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Thêm một đỉnh path khi click map.
|
||||
const onClick = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
@@ -73,6 +80,7 @@ export function initPath(
|
||||
updatePreview(coords);
|
||||
};
|
||||
|
||||
// Cập nhật preview path động theo vị trí chuột.
|
||||
const onMove = (e: maplibregl.MapLayerMouseEvent) => {
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
@@ -92,6 +100,7 @@ export function initPath(
|
||||
updatePreview([...coords, [e.lngLat.lng, e.lngLat.lat]]);
|
||||
};
|
||||
|
||||
// Xử lý phím nóng Enter/Escape/Backspace cho chế độ vẽ path.
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (getMode() !== "add-path") return;
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ import { Geometry } from "@/lib/useEditorState";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
// Khởi tạo engine thêm point bằng click đơn.
|
||||
export function initPoint(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onComplete: (geometry: Geometry) => void
|
||||
) {
|
||||
/**
|
||||
* Add a new point when in add-point mode.
|
||||
*/
|
||||
// Thêm point mới khi đang ở chế độ add-point.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "add-point") return;
|
||||
|
||||
@@ -22,6 +21,7 @@ export function initPoint(
|
||||
onComplete?.(geometry);
|
||||
}
|
||||
|
||||
// Cập nhật trạng thái con trỏ theo mode add-point.
|
||||
function onMove() {
|
||||
const canvas = map.getCanvas();
|
||||
if (getMode() === "add-point") {
|
||||
|
||||
@@ -2,11 +2,12 @@ import maplibregl from "maplibre-gl";
|
||||
|
||||
type ModeGetter = () => "idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle";
|
||||
|
||||
// Khởi tạo engine chọn feature và context menu edit/delete.
|
||||
export function initSelect(
|
||||
map: maplibregl.Map,
|
||||
getMode: ModeGetter,
|
||||
onDelete: (id: string | number) => void,
|
||||
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
onDelete?: (id: string | number) => void,
|
||||
onEdit?: (feature: maplibregl.MapGeoJSONFeature) => void,
|
||||
onSelectId?: (id: string | number | null) => void
|
||||
) {
|
||||
const SELECTABLE_LAYERS = [
|
||||
@@ -17,12 +18,11 @@ export function initSelect(
|
||||
"places-symbol",
|
||||
] as const;
|
||||
const selectedIds = new Set<number | string>();
|
||||
const hasContextActions = Boolean(onDelete || onEdit);
|
||||
let contextMenu: HTMLDivElement | null = null;
|
||||
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Clear feature-state highlight for all selected features.
|
||||
*/
|
||||
// Bỏ highlight feature-state của toàn bộ đối tượng đang chọn.
|
||||
function clearSelection() {
|
||||
if (!selectedIds.size) return;
|
||||
selectedIds.forEach((id) => {
|
||||
@@ -32,9 +32,7 @@ export function initSelect(
|
||||
onSelectId?.(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select (or toggle) a feature. Holding Alt enables additive/toggle selection.
|
||||
*/
|
||||
// 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;
|
||||
if (id === undefined || id === null) return;
|
||||
@@ -56,6 +54,7 @@ export function initSelect(
|
||||
onSelectId?.(selectedIds.size === 1 ? id : null);
|
||||
}
|
||||
|
||||
// Chọn feature theo click trái, hỗ trợ additive bằng Alt.
|
||||
function onClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
|
||||
@@ -72,9 +71,8 @@ export function initSelect(
|
||||
selectFeature(features[0], additive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show context menu (edit/delete) on right click.
|
||||
*/
|
||||
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
||||
// Mở menu thao tác khi click phải lên feature.
|
||||
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
|
||||
@@ -103,6 +101,7 @@ export function initSelect(
|
||||
);
|
||||
}
|
||||
|
||||
// Đổi cursor pointer khi hover lên đối tượng có thể chọn.
|
||||
function onMove(e: maplibregl.MapLayerMouseEvent) {
|
||||
if (getMode() !== "select") return;
|
||||
|
||||
@@ -115,15 +114,20 @@ export function initSelect(
|
||||
|
||||
map.on("click", onClick);
|
||||
map.on("mousemove", onMove);
|
||||
map.on("contextmenu", onRightClick);
|
||||
if (hasContextActions) {
|
||||
map.on("contextmenu", onRightClick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
map.off("click", onClick);
|
||||
map.off("mousemove", onMove);
|
||||
map.off("contextmenu", onRightClick);
|
||||
if (hasContextActions) {
|
||||
map.off("contextmenu", onRightClick);
|
||||
}
|
||||
hideContextMenu();
|
||||
};
|
||||
|
||||
// Ẩn và dọn dẹp context menu hiện tại.
|
||||
function hideContextMenu() {
|
||||
if (contextMenu) {
|
||||
contextMenu.remove();
|
||||
@@ -135,9 +139,7 @@ export function initSelect(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a minimal context menu near cursor.
|
||||
*/
|
||||
// Render menu ngữ cảnh tối giản gần vị trí con trỏ.
|
||||
function showContextMenu(
|
||||
x: number,
|
||||
y: number,
|
||||
@@ -159,6 +161,7 @@ export function initSelect(
|
||||
menu.style.fontSize = "14px";
|
||||
menu.style.padding = "4px 0";
|
||||
|
||||
// Tạo một item thao tác trong context menu.
|
||||
const createItem = (label: string, onClick: () => void) => {
|
||||
const item = document.createElement("div");
|
||||
item.textContent = label;
|
||||
@@ -174,30 +177,38 @@ export function initSelect(
|
||||
};
|
||||
|
||||
const selectedCount = selectedIds.size || 1;
|
||||
let hasMenuItems = false;
|
||||
|
||||
if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon") {
|
||||
if (selectedCount === 1 && clickedFeature.geometry?.type === "Polygon" && onEdit) {
|
||||
const single = clickedFeature;
|
||||
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
menu.appendChild(
|
||||
createItem(
|
||||
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||
() => {
|
||||
const ids = selectedIds.size
|
||||
? Array.from(selectedIds)
|
||||
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||
ids.forEach((eachId) => {
|
||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||
});
|
||||
clearSelection();
|
||||
}
|
||||
)
|
||||
);
|
||||
if (onDelete) {
|
||||
menu.appendChild(
|
||||
createItem(
|
||||
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
|
||||
() => {
|
||||
const ids = selectedIds.size
|
||||
? Array.from(selectedIds)
|
||||
: [clickedFeature.id ?? clickedFeature.properties?.id];
|
||||
ids.forEach((eachId) => {
|
||||
if (eachId !== undefined && eachId !== null) onDelete(eachId);
|
||||
});
|
||||
clearSelection();
|
||||
}
|
||||
)
|
||||
);
|
||||
hasMenuItems = true;
|
||||
}
|
||||
|
||||
if (!hasMenuItems) return;
|
||||
|
||||
document.body.appendChild(menu);
|
||||
contextMenu = menu;
|
||||
|
||||
// Đóng menu khi click ra ngoài vùng menu.
|
||||
const onDocClick = (ev: MouseEvent) => {
|
||||
if (!menu.contains(ev.target as Node)) {
|
||||
hideContextMenu();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Basic GeoJSON geometry union (no GeometryCollection).
|
||||
*/
|
||||
// Kiểu union các GeoJSON geometry cơ bản (không gồm GeometryCollection).
|
||||
export type Geometry =
|
||||
| { type: "Point"; coordinates: [number, number] }
|
||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||
@@ -13,10 +11,11 @@ export type Geometry =
|
||||
|
||||
export type FeatureProperties = {
|
||||
id: string | number;
|
||||
type?: string | null;
|
||||
geometry_preset?: "point" | "line" | "polygon" | "circle-area" | null;
|
||||
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;
|
||||
@@ -35,29 +34,28 @@ export type FeatureCollection = {
|
||||
features: Feature[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Change map entry for saving.
|
||||
*/
|
||||
// Kiểu thay đổi dùng để gửi payload lưu dữ liệu.
|
||||
export type Change =
|
||||
| { type: "create"; feature: Feature }
|
||||
| { type: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
||||
| { type: "delete"; id: FeatureProperties["id"] };
|
||||
| { action: "create"; feature: Feature }
|
||||
| { action: "update"; id: FeatureProperties["id"]; geometry: Geometry }
|
||||
| { action: "delete"; id: FeatureProperties["id"] };
|
||||
|
||||
/**
|
||||
* Minimal undo record.
|
||||
*/
|
||||
// Kiểu bản ghi undo tối thiểu.
|
||||
export type UndoAction =
|
||||
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
|
||||
| { type: "delete"; feature: Feature }
|
||||
| { type: "create"; id: FeatureProperties["id"] };
|
||||
|
||||
// Deep clone dữ liệu JSON-serializable để tránh mutate tham chiếu cũ.
|
||||
const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
|
||||
|
||||
// So sánh hai geometry theo nội dung tuần tự JSON.
|
||||
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
// Tạo baseline map id -> geometry từ dữ liệu đã lưu.
|
||||
function buildInitialMap(fc: FeatureCollection) {
|
||||
const map = new Map<FeatureProperties["id"], Geometry>();
|
||||
for (const f of fc.features) {
|
||||
@@ -66,6 +64,7 @@ function buildInitialMap(fc: FeatureCollection) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// Tính diff giữa draft hiện tại và baseline để sinh payload thay đổi.
|
||||
function diffDraftToInitial(
|
||||
draft: FeatureCollection,
|
||||
initialMap: Map<FeatureProperties["id"], Geometry>
|
||||
@@ -81,22 +80,23 @@ function diffDraftToInitial(
|
||||
seen.add(id);
|
||||
const initialGeom = initialMap.get(id);
|
||||
if (!initialGeom) {
|
||||
next.set(id, { type: "create", feature: deepClone(f) });
|
||||
next.set(id, { action: "create", feature: deepClone(f) });
|
||||
} else if (!geometryEquals(initialGeom, f.geometry)) {
|
||||
next.set(id, { type: "update", id, geometry: deepClone(f.geometry) });
|
||||
next.set(id, { action: "update", id, geometry: deepClone(f.geometry) });
|
||||
}
|
||||
}
|
||||
|
||||
// deletions
|
||||
for (const [id] of initialMap.entries()) {
|
||||
if (!seen.has(id)) {
|
||||
next.set(id, { type: "delete", id });
|
||||
next.set(id, { action: "delete", id });
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
// Kiểm tra 2 undo action có cùng nội dung hay không (để tránh push trùng).
|
||||
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||
if (!a) return false;
|
||||
if (a.type !== b.type) return false;
|
||||
@@ -124,12 +124,10 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Central state for the editor.
|
||||
* - draft: source of truth for UI rendering
|
||||
* - changes: map of pending changes for save
|
||||
* - undoStack: minimal actions to revert last step
|
||||
*/
|
||||
// State trung tâm của editor:
|
||||
// - draft: dữ liệu nguồn để render UI
|
||||
// - changes: map các thay đổi chờ lưu
|
||||
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
|
||||
export function useEditorState(initialData: FeatureCollection) {
|
||||
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
|
||||
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
|
||||
@@ -168,6 +166,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
// Đẩy undo action mới vào stack nếu khác action gần nhất.
|
||||
function pushUndo(action: UndoAction) {
|
||||
setUndoStack((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
@@ -176,9 +175,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new feature to draft and record "create".
|
||||
*/
|
||||
// Thêm feature mới vào draft và ghi nhận thao tác "create".
|
||||
function createFeature(feature: Feature) {
|
||||
const featureClone = deepClone(feature);
|
||||
commitDraft({
|
||||
@@ -188,9 +185,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
pushUndo({ type: "create", id: featureClone.properties.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch non-geometry properties on a feature (used for entity/time metadata).
|
||||
*/
|
||||
// Cập nhật các thuộc tính không phải geometry của feature (entity/time metadata).
|
||||
function patchFeatureProperties(
|
||||
id: FeatureProperties["id"],
|
||||
patch: Partial<FeatureProperties>
|
||||
@@ -209,9 +204,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update geometry of an existing feature and record change.
|
||||
*/
|
||||
// Cập nhật geometry của feature hiện có và ghi nhận thay đổi.
|
||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
if (idx === -1) return; // nothing to update
|
||||
@@ -231,9 +224,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a feature from draft and record delete.
|
||||
*/
|
||||
// Xóa feature khỏi draft và ghi nhận thao tác delete.
|
||||
function deleteFeature(id: FeatureProperties["id"]) {
|
||||
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
|
||||
if (idx === -1) return;
|
||||
@@ -247,9 +238,7 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
commitDraft({ ...draftRef.current, features: nextFeatures });
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo last action, reverting both draft and change map.
|
||||
*/
|
||||
// Hoàn tác thao tác gần nhất, đồng bộ lại cả draft và danh sách thay đổi.
|
||||
function undo() {
|
||||
let applied = false; // guards against React StrictMode double invoke of setState updater
|
||||
setUndoStack((prev) => {
|
||||
@@ -294,22 +283,19 @@ export function useEditorState(initialData: FeatureCollection) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payload array for API save.
|
||||
*/
|
||||
// Dựng mảng payload gửi API save từ map changes hiện tại.
|
||||
function buildPayload(): Change[] {
|
||||
return Array.from(changes.values()).map((c) => deepClone(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending changes after successful save.
|
||||
*/
|
||||
// Xóa thay đổi đang chờ sau khi lưu thành công.
|
||||
function clearChanges() {
|
||||
setUndoStack([]);
|
||||
initialMapRef.current = buildInitialMap(draftRef.current);
|
||||
setBaselineVersion((v) => v + 1);
|
||||
}
|
||||
|
||||
// Kiểm tra feature id đã tồn tại ở baseline đã lưu hay chưa.
|
||||
function hasPersistedFeature(id: FeatureProperties["id"]) {
|
||||
return initialMapRef.current.has(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user