preview map editor 60%

This commit is contained in:
taDuc
2026-04-13 21:47:28 +07:00
parent 3023fa947c
commit 458de8dadc
16 changed files with 1664 additions and 1149 deletions

View File

@@ -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;

View File

@@ -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 độ.
}

View File

@@ -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();

View File

@@ -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();
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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") {

View File

@@ -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();

View File

@@ -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);
}