tons of feature

This commit is contained in:
taDuc
2026-04-04 22:24:36 +07:00
commit 2a1b4f2f2a
25 changed files with 8539 additions and 0 deletions

97
lib/drawingEngine.ts Normal file
View File

@@ -0,0 +1,97 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
export function initDrawing(
map: maplibregl.Map,
getMode: ModeGetter,
onComplete: (geometry: Geometry) => void
) {
let coords: [number, number][] = [];
/**
* Close polygon ring if not closed.
*/
function closePolygon(c: [number, number][]) {
if (c.length < 3) return c;
const first = c[0];
const last = c[c.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
return [...c, first];
}
return c;
}
/**
* Update preview layer while drawing.
*/
function update(c: [number, number][]) {
const closed = closePolygon(c);
(map.getSource("draw-preview") as maplibregl.GeoJSONSource)?.setData({
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [closed],
},
},
],
});
}
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw") return;
coords.push([e.lngLat.lng, e.lngLat.lat]);
update(coords);
}
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "draw" || coords.length === 0) return;
const preview = [...coords, [e.lngLat.lng, e.lngLat.lat]];
update(preview);
}
/**
* Finalize polygon, emit geometry to caller, reset preview.
*/
function finishDrawing() {
if (getMode() !== "draw" || coords.length < 3) return;
const geometry = {
type: "Polygon",
coordinates: [closePolygon(coords)],
};
onComplete(geometry);
coords = [];
map.getSource("draw-preview").setData({
type: "FeatureCollection",
features: [],
});
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Enter") {
finishDrawing();
}
}
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);
};
}

211
lib/editingEngine.ts Normal file
View File

@@ -0,0 +1,211 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
export type EditingHandle = {
id: string | number;
ring: [number, number][];
original: Geometry;
};
export type EditingAPI = {
beginEditing: (feature: maplibregl.MapGeoJSONFeature) => void;
clearEditing: () => void;
bindEditEvents: (map: maplibregl.Map) => void;
};
export function createEditingEngine(options: {
mapRef: React.MutableRefObject<maplibregl.Map | null>;
onUpdate: (id: string | number, geometry: Geometry) => void;
}) {
const { mapRef, onUpdate } = options;
const editingRef = { current: null as EditingHandle | null };
const dragStateRef = { current: null as { idx: number } | null };
const modifierRef = { current: { ctrl: false, meta: false } };
const clearEditing = () => {
editingRef.current = null;
dragStateRef.current = null;
const map = mapRef.current;
if (!map) return;
const empty: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: [] };
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(empty);
};
const updateEditSources = () => {
const editing = editingRef.current;
const map = mapRef.current;
if (!editing || !map) return;
const closedRing = [...editing.ring, editing.ring[0]];
const shape: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Polygon", coordinates: [closedRing] },
properties: {},
},
],
};
const handles: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: "FeatureCollection",
features: editing.ring.map((c, idx) => ({
type: "Feature",
geometry: { type: "Point", coordinates: c },
properties: { idx },
})),
};
(map.getSource("edit-shape") as maplibregl.GeoJSONSource | undefined)?.setData(shape);
(map.getSource("edit-handles") as maplibregl.GeoJSONSource | undefined)?.setData(handles);
};
const finishEditing = () => {
const editing = editingRef.current;
if (!editing) return;
const geometry: Geometry = {
type: "Polygon",
coordinates: [[...editing.ring, editing.ring[0]]],
};
onUpdate(editing.id, geometry);
clearEditing();
};
const cancelEditing = () => {
clearEditing();
};
const beginEditing = (feature: maplibregl.MapGeoJSONFeature) => {
if (feature.geometry.type !== "Polygon") return;
const coords = (feature.geometry.coordinates?.[0] ?? []) as [number, number][];
if (coords.length < 4) return;
// remove duplicated closing point
const ring = coords.slice(0, -1).map((c) => [c[0], c[1]] as [number, number]);
editingRef.current = {
id: feature.id ?? feature.properties?.id,
ring,
original: feature.geometry as Geometry,
};
updateEditSources();
};
const isModifierPressed = (e?: maplibregl.MapLayerMouseEvent | maplibregl.MapMouseEvent) => {
const oe = e?.originalEvent as MouseEvent | undefined;
return (
modifierRef.current.ctrl ||
modifierRef.current.meta ||
!!oe?.ctrlKey ||
!!oe?.metaKey
);
};
const bindEditEvents = (map: maplibregl.Map) => {
const onHandleDown = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return;
const feature = e.features?.[0];
const idx = feature?.properties?.idx;
if (idx === undefined) return;
e.preventDefault();
dragStateRef.current = { idx };
map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable();
};
const onHandleMove = (e: maplibregl.MapMouseEvent) => {
const drag = dragStateRef.current;
const editing = editingRef.current;
if (!drag || !editing) return;
editing.ring[drag.idx] = [e.lngLat.lng, e.lngLat.lat];
updateEditSources();
};
const stopDragging = () => {
dragStateRef.current = null;
map.getCanvas().style.cursor = "";
map.dragPan.enable();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Control") {
modifierRef.current.ctrl = true;
} else if (e.key === "Meta") {
modifierRef.current.meta = true;
}
if (!editingRef.current) return;
if (e.key === "Enter") {
finishEditing();
} else if (e.key === "Escape") {
cancelEditing();
}
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === "Control") {
modifierRef.current.ctrl = false;
} else if (e.key === "Meta") {
modifierRef.current.meta = false;
}
};
const onInsertHandle = (e: maplibregl.MapLayerMouseEvent) => {
if (!editingRef.current) return;
if (!isModifierPressed(e)) return;
e.preventDefault();
const editing = editingRef.current;
const ring = editing.ring;
const click = [e.lngLat.lng, e.lngLat.lat] as [number, number];
let nearestIdx = 0;
let bestDist = Number.POSITIVE_INFINITY;
ring.forEach((pt, idx) => {
const dx = pt[0] - click[0];
const dy = pt[1] - click[1];
const d = dx * dx + dy * dy;
if (d < bestDist) {
bestDist = d;
nearestIdx = idx;
}
});
const insertIdx = nearestIdx + 1;
ring.splice(insertIdx, 0, click);
dragStateRef.current = { idx: insertIdx };
map.getCanvas().style.cursor = "grabbing";
map.dragPan.disable();
updateEditSources();
};
const onCanvasLeave = () => {
stopDragging();
};
map.on("mousedown", "edit-handles-circle", onHandleDown);
map.on("mousedown", "edit-shape-line", onInsertHandle);
map.on("mousemove", onHandleMove);
map.on("mouseup", stopDragging);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
map.getCanvas().addEventListener("mouseleave", onCanvasLeave);
map.on("remove", () => {
map.off("mousedown", "edit-handles-circle", onHandleDown);
map.off("mousedown", "edit-shape-line", onInsertHandle);
map.off("mousemove", onHandleMove);
map.off("mouseup", stopDragging);
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
map.getCanvas().removeEventListener("mouseleave", onCanvasLeave);
});
};
return {
beginEditing,
clearEditing,
bindEditEvents,
updateEditSources,
editingRef,
dragStateRef,
};
}

37
lib/pointEngine.ts Normal file
View File

@@ -0,0 +1,37 @@
import maplibregl from "maplibre-gl";
import { Geometry } from "@/lib/useEditorState";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
export function initPoint(
map: maplibregl.Map,
getMode: ModeGetter,
onComplete: (geometry: Geometry) => void
) {
/**
* Add a new point when in add-point mode.
*/
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "add-point") return;
const geometry = {
type: "Point",
coordinates: [e.lngLat.lng, e.lngLat.lat],
};
onComplete?.(geometry);
}
function onMove() {
if (getMode() !== "add-point") return;
map.getCanvas().style.cursor = "crosshair";
}
map.on("click", onClick);
map.on("mousemove", onMove);
return () => {
map.off("click", onClick);
map.off("mousemove", onMove);
};
}

198
lib/selectingEngine.ts Normal file
View File

@@ -0,0 +1,198 @@
import maplibregl from "maplibre-gl";
type ModeGetter = () => "idle" | "draw" | "select" | "add-point";
export function initSelect(
map: maplibregl.Map,
getMode: ModeGetter,
onDelete: (id: string | number) => void,
onEdit: (feature: maplibregl.MapGeoJSONFeature) => void
) {
const selectedIds = new Set<number | string>();
let contextMenu: HTMLDivElement | null = null;
let docClickHandler: ((ev: MouseEvent) => void) | null = null;
/**
* Clear feature-state highlight for all selected features.
*/
function clearSelection() {
if (!selectedIds.size) return;
selectedIds.forEach((id) => {
map.setFeatureState({ source: "countries", id }, { selected: false });
});
selectedIds.clear();
}
/**
* Select (or toggle) a feature. Holding Alt enables additive/toggle selection.
*/
function selectFeature(feature: maplibregl.MapGeoJSONFeature, additive: boolean) {
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
if (!additive) {
clearSelection();
}
if (additive && selectedIds.has(id)) {
// Alt + click on an already selected feature removes it from the selection
map.setFeatureState({ source: "countries", id }, { selected: false });
selectedIds.delete(id);
return;
}
map.setFeatureState({ source: "countries", id }, { selected: true });
selectedIds.add(id);
}
function onClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
const features = map.queryRenderedFeatures(e.point, {
layers: ["countries-fill"],
}) as maplibregl.MapGeoJSONFeature[];
if (!features.length) {
clearSelection();
return;
}
const additive = !!e.originalEvent?.altKey;
selectFeature(features[0], additive);
}
/**
* Show context menu (edit/delete) on right click.
*/
function onRightClick(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
e.preventDefault(); // block browser menu
const features = map.queryRenderedFeatures(e.point, {
layers: ["countries-fill"],
}) as maplibregl.MapGeoJSONFeature[];
if (!features.length) return;
const feature = features[0];
const id = feature.id ?? feature.properties?.id;
if (id === undefined || id === null) return;
// if right-clicked item not selected, make it the sole selection
if (!selectedIds.has(id)) {
clearSelection();
selectFeature(feature, false);
}
showContextMenu(
e.originalEvent?.clientX ?? e.point.x,
e.originalEvent?.clientY ?? e.point.y,
feature
);
}
function onMove(e: maplibregl.MapLayerMouseEvent) {
if (getMode() !== "select") return;
const features = map.queryRenderedFeatures(e.point, {
layers: ["countries-fill"],
});
map.getCanvas().style.cursor = features.length ? "pointer" : "";
}
map.on("click", onClick);
map.on("mousemove", onMove);
map.on("contextmenu", onRightClick);
return () => {
map.off("click", onClick);
map.off("mousemove", onMove);
map.off("contextmenu", onRightClick);
hideContextMenu();
};
function hideContextMenu() {
if (contextMenu) {
contextMenu.remove();
contextMenu = null;
}
if (docClickHandler) {
document.removeEventListener("click", docClickHandler);
docClickHandler = null;
}
}
/**
* Render a minimal context menu near cursor.
*/
function showContextMenu(
x: number,
y: number,
clickedFeature: maplibregl.MapGeoJSONFeature
) {
hideContextMenu();
const menu = document.createElement("div");
menu.style.position = "fixed";
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.background = "#0f172a";
menu.style.color = "white";
menu.style.border = "1px solid #1f2937";
menu.style.borderRadius = "6px";
menu.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.minWidth = "120px";
menu.style.fontSize = "14px";
menu.style.padding = "4px 0";
const createItem = (label: string, onClick: () => void) => {
const item = document.createElement("div");
item.textContent = label;
item.style.padding = "8px 12px";
item.style.cursor = "pointer";
item.onmouseenter = () => (item.style.background = "#1f2937");
item.onmouseleave = () => (item.style.background = "transparent");
item.onclick = () => {
onClick();
hideContextMenu();
};
return item;
};
const selectedCount = selectedIds.size || 1;
if (selectedCount === 1) {
const single = clickedFeature;
menu.appendChild(createItem("Chỉnh sửa", () => onEdit(single)));
}
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();
}
)
);
document.body.appendChild(menu);
contextMenu = menu;
const onDocClick = (ev: MouseEvent) => {
if (!menu.contains(ev.target as Node)) {
hideContextMenu();
}
};
docClickHandler = onDocClick;
setTimeout(() => document.addEventListener("click", onDocClick), 0);
}
}

296
lib/useEditorState.ts Normal file
View File

@@ -0,0 +1,296 @@
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Basic GeoJSON geometry union (no GeometryCollection).
*/
export type Geometry =
| { type: "Point"; coordinates: [number, number] }
| { type: "MultiPoint"; coordinates: [number, number][] }
| { type: "LineString"; coordinates: [number, number][] }
| { type: "MultiLineString"; coordinates: [number, number][][] }
| { type: "Polygon"; coordinates: [number, number][][] }
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
export type FeatureProperties = {
id: string | number;
time_start?: number | null;
time_end?: number | null;
kind?: string | null;
};
export type Feature = {
type: "Feature";
properties: FeatureProperties;
geometry: Geometry;
};
export type FeatureCollection = {
type: "FeatureCollection";
features: Feature[];
};
/**
* Change map entry for saving.
*/
export type Change =
| { type: "create"; feature: Feature }
| { type: "update"; id: FeatureProperties["id"]; geometry: Geometry }
| { type: "delete"; id: FeatureProperties["id"] };
/**
* Minimal undo record.
*/
export type UndoAction =
| { type: "update"; id: FeatureProperties["id"]; prevGeometry: Geometry }
| { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] };
const deepClone = <T,>(obj: T): T => JSON.parse(JSON.stringify(obj));
function geometryEquals(a: Geometry | undefined, b: Geometry | undefined): boolean {
if (!a || !b) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
function buildInitialMap(fc: FeatureCollection) {
const map = new Map<FeatureProperties["id"], Geometry>();
for (const f of fc.features) {
map.set(f.properties.id, deepClone(f.geometry));
}
return map;
}
function diffDraftToInitial(
draft: FeatureCollection,
initialMap: Map<FeatureProperties["id"], Geometry>
) {
const next = new Map<FeatureProperties["id"], Change>();
// track which initial ids are still present
const seen = new Set<FeatureProperties["id"]>();
// additions & updates
for (const f of draft.features) {
const id = f.properties.id;
seen.add(id);
const initialGeom = initialMap.get(id);
if (!initialGeom) {
next.set(id, { type: "create", feature: deepClone(f) });
} else if (!geometryEquals(initialGeom, f.geometry)) {
next.set(id, { type: "update", id, geometry: deepClone(f.geometry) });
}
}
// deletions
for (const [id] of initialMap.entries()) {
if (!seen.has(id)) {
next.set(id, { type: "delete", id });
}
}
return next;
}
function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
if (!a) return false;
if (a.type !== b.type) return false;
switch (a.type) {
case "create":
return a.id === (b as UndoAction & { id: typeof a.id }).id;
case "delete": {
const bb = b as UndoAction & { feature: Feature };
return (
a.feature?.properties?.id === bb.feature?.properties?.id &&
geometryEquals(a.feature?.geometry, bb.feature?.geometry)
);
}
case "update": {
const bb = b as UndoAction & { prevGeometry: Geometry };
return (
a.id === bb.id &&
geometryEquals(a.prevGeometry, bb.prevGeometry)
);
}
default:
return false;
}
}
/**
* 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
*/
export function useEditorState(initialData: FeatureCollection) {
const [draft, setDraft] = useState<FeatureCollection>(() => deepClone(initialData));
const [undoStack, setUndoStack] = useState<UndoAction[]>([]);
// baseline to know what is "saved" state
const initialMapRef = useRef<Map<FeatureProperties["id"], Geometry>>(
buildInitialMap(initialData)
);
const draftRef = useRef<FeatureCollection>(deepClone(initialData));
// central entrypoint: keep draftRef + React state in sync
const commitDraft = (nextDraft: FeatureCollection) => {
const cloned = deepClone(nextDraft);
draftRef.current = cloned;
setDraft(cloned);
};
// reset when initialData changes (e.g., after first load or after refresh)
useEffect(() => {
commitDraft(deepClone(initialData));
setUndoStack([]);
initialMapRef.current = buildInitialMap(initialData);
}, [initialData]);
// derive pending changes on every render: source of truth for save + changeCount.
// read baseline from state captured in closure to avoid ref access during render.
const [baselineVersion, setBaselineVersion] = useState(0);
const changes = useMemo(() => {
const baseline = initialMapRef.current;
return diffDraftToInitial(draft, baseline);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, baselineVersion]);
const changeCount = useMemo(() => changes.size, [changes]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
function pushUndo(action: UndoAction) {
setUndoStack((prev) => {
const last = prev[prev.length - 1];
if (isSameUndo(last, action)) return prev; // tránh trùng lặp liên tiếp
return [...prev, action];
});
}
/**
* Add new feature to draft and record "create".
*/
function createFeature(feature: Feature) {
const featureClone = deepClone(feature);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, featureClone],
});
pushUndo({ type: "create", id: featureClone.properties.id });
}
/**
* Update geometry of an existing feature and record change.
*/
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
const prevFeature = draftRef.current.features[idx];
const prevGeometry = prevFeature.geometry;
const updatedFeature = {
...prevFeature,
geometry: deepClone(newGeometry),
};
pushUndo({ type: "update", id, prevGeometry: deepClone(prevGeometry) });
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = updatedFeature;
commitDraft({ ...draftRef.current, features: nextFeatures });
}
/**
* Remove a feature from draft and record delete.
*/
function deleteFeature(id: FeatureProperties["id"]) {
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
if (idx === -1) return;
const feature = draftRef.current.features[idx];
// store undo
pushUndo({ type: "delete", feature: deepClone(feature) });
const nextFeatures = [...draftRef.current.features];
nextFeatures.splice(idx, 1);
commitDraft({ ...draftRef.current, features: nextFeatures });
}
/**
* Undo last action, reverting both draft and change map.
*/
function undo() {
let applied = false; // guards against React StrictMode double invoke of setState updater
setUndoStack((prev) => {
if (applied) return prev;
if (!prev.length) return prev;
applied = true;
const last = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
switch (last.type) {
case "create": {
commitDraft({
...draftRef.current,
features: draftRef.current.features.filter((f) => f.properties.id !== last.id),
});
break;
}
case "delete": {
const feature = deepClone(last.feature);
commitDraft({
...draftRef.current,
features: [...draftRef.current.features, feature],
});
break;
}
case "update": {
const { id, prevGeometry } = last;
const idx = draftRef.current.features.findIndex((f) => f.properties.id === id);
if (idx === -1) return remaining;
const updated = {
...draftRef.current.features[idx],
geometry: deepClone(prevGeometry),
};
const nextFeatures = [...draftRef.current.features];
nextFeatures[idx] = updated;
commitDraft({ ...draftRef.current, features: nextFeatures });
break;
}
}
return remaining;
});
}
/**
* Build payload array for API save.
*/
function buildPayload(): Change[] {
return Array.from(changes.values()).map((c) => deepClone(c));
}
/**
* Clear pending changes after successful save.
*/
function clearChanges() {
setUndoStack([]);
initialMapRef.current = buildInitialMap(draftRef.current);
setBaselineVersion((v) => v + 1);
}
return {
draft,
changes,
undoStack,
changeCount,
createFeature,
updateFeature,
deleteFeature,
undo,
buildPayload,
clearChanges,
};
}