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

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/ultimate-history-map.iml" filepath="$PROJECT_DIR$/.idea/ultimate-history-map.iml" />
</modules>
</component>
</project>

8
.idea/ultimate-history-map.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

34
app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

86
app/page.tsx Normal file
View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import Map from "@/components/Map";
import Editor from "@/components/Editor";
import {
FeatureCollection,
useEditorState,
} from "@/lib/useEditorState";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point">("idle");
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false);
const editor = useEditorState(initialData);
useEffect(() => {
async function loadInitial() {
try {
const params = new URLSearchParams({
minLng: "-180",
minLat: "-90",
maxLng: "180",
maxLat: "90",
});
const res = await fetch(`http://localhost:3000/geometries?${params.toString()}`);
if (!res.ok) return;
const data = await res.json();
setInitialData(data);
} catch (err) {
console.error("Load initial data failed", err);
}
}
loadInitial();
}, []);
const handleSave = async () => {
const payload = editor.buildPayload();
if (!payload.length) return;
setIsSaving(true);
try {
const res = await fetch("http://localhost:3000/geometries/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes: payload }),
});
if (!res.ok) {
const text = await res.text();
console.error("Save failed", text);
return;
}
editor.clearChanges();
} catch (err) {
console.error("Save error", err);
} finally {
setIsSaving(false);
}
};
return (
<div style={{ display: "flex" }}>
<Editor
mode={mode}
setMode={setMode}
onUndo={editor.undo}
onSave={handleSave}
isSaving={isSaving}
changesCount={editor.changeCount}
undoStack={editor.undoStack}
/>
<Map
mode={mode}
draft={editor.draft}
onCreateFeature={editor.createFeature}
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
/>
</div>
);
}

174
components/Editor.tsx Normal file
View File

@@ -0,0 +1,174 @@
"use client";
import { UndoAction } from "@/lib/useEditorState";
type Mode = "draw" | "select" | "idle" | "add-point";
type Props = {
mode: Mode;
setMode: (mode: Mode) => void;
onUndo: () => void;
onSave: () => void;
isSaving: boolean;
changesCount: number;
undoStack: UndoAction[];
};
export default function Editor({
mode,
setMode,
onUndo,
onSave,
isSaving,
changesCount,
undoStack,
}: Props) {
const toggleMode = (newMode: Mode) => {
if (mode === newMode) {
setMode("idle"); // bấm lại → tắt
} else {
setMode(newMode); // chuyển mode
}
};
// Lấy tối đa 8 tác vụ mới nhất, bỏ trùng nhãn (cùng loại/cùng id)
const recentUndoLabels = (() => {
const seen = new Set<string>();
const labels: string[] = [];
for (let i = undoStack.length - 1; i >= 0 && labels.length < 8; i -= 1) {
const label = formatUndoLabel(undoStack[i]);
if (seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels.reverse();
})();
const getButtonStyle = (btnMode: Mode) => ({
width: "100%",
padding: "8px",
marginBottom: "6px",
border: "none",
cursor: "pointer",
background: mode === btnMode ? "#4caf50" : "#222",
color: "white",
borderRadius: "4px",
});
return (
<div
style={{
width: "220px",
background: "#111",
color: "white",
padding: "12px",
borderRight: "1px solid #333",
}}
>
<h3 style={{ marginBottom: "10px" }}>Editor</h3>
<button
style={getButtonStyle("draw")}
onClick={() => toggleMode("draw")}
>
Draw
</button>
<button
style={getButtonStyle("select")}
onClick={() => toggleMode("select")}
>
Select
</button>
<button
style={getButtonStyle("idle")}
onClick={() => setMode("idle")}
>
Idle
</button>
<button
style={getButtonStyle("add-point")}
onClick={() => setMode("add-point")}
>
Add point
</button>
<div style={{ marginTop: "12px", fontSize: "14px" }}>
Mode: <b>{mode}</b>
</div>
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
<button
style={{
flex: 1,
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: "pointer",
background: "#334155",
color: "white",
}}
onClick={onUndo}
>
Undo
</button>
<button
style={{
flex: 1,
padding: "8px",
borderRadius: "4px",
border: "none",
cursor: isSaving ? "not-allowed" : "pointer",
background: isSaving ? "#555" : "#3b82f6",
color: "white",
opacity: changesCount === 0 ? 0.6 : 1,
}}
onClick={onSave}
disabled={isSaving || changesCount === 0}
>
Save ({changesCount})
</button>
</div>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "14px" }}>
Tác vụ thể undo ({recentUndoLabels.length})
</div>
{recentUndoLabels.length === 0 ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa thao tác</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "13px", color: "#e2e8f0" }}>
{recentUndoLabels.map((label, idx) => (
<li key={`${label}-${idx}`} style={{ padding: "4px 0", borderBottom: "1px solid #1f2937" }}>
{label}
</li>
))}
</ul>
)}
</div>
</div>
);
}
function formatUndoLabel(action: UndoAction) {
switch (action.type) {
case "create":
return `Thêm mới #${action.id}`;
case "delete":
return `Xóa #${action.feature.properties.id}`;
case "update":
return `Chỉnh sửa #${action.id}`;
default:
return "Tác vụ";
}
}

328
components/Map.tsx Normal file
View File

@@ -0,0 +1,328 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { initDrawing } from "@/lib/drawingEngine";
import { initSelect } from "@/lib/selectingEngine";
import { initPoint } from "@/lib/pointEngine";
import { createEditingEngine } from "@/lib/editingEngine";
import { FeatureCollection, Geometry } from "@/lib/useEditorState";
type MapProps = {
mode: "idle" | "draw" | "select" | "add-point";
draft: FeatureCollection;
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature: (id: string | number) => void;
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
};
export default function Map({ mode, draft, onCreateFeature, onDeleteFeature, onUpdateFeature }: MapProps) {
const mapRef = useRef<maplibregl.Map | null>(null);
const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft);
const onCreateRef = useRef(onCreateFeature);
const onDeleteRef = useRef(onDeleteFeature);
const onUpdateRef = useRef(onUpdateFeature);
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
useEffect(() => {
modeRef.current = mode;
}, [mode]);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
useEffect(() => {
onCreateRef.current = onCreateFeature;
}, [onCreateFeature]);
useEffect(() => {
onDeleteRef.current = onDeleteFeature;
}, [onDeleteFeature]);
useEffect(() => {
onUpdateRef.current = onUpdateFeature;
}, [onUpdateFeature]);
useEffect(() => {
if (!editingEngineRef.current) {
editingEngineRef.current = createEditingEngine({
mapRef,
onUpdate: (id, geometry) => onUpdateRef.current?.(id, geometry),
});
}
}, []);
/**
* Push given draft into map sources (idempotent).
* Always clear feature-state to avoid stale selection overlays after undo/replace.
*/
const applyDraftToMap = useCallback((fc: FeatureCollection) => {
const map = mapRef.current;
if (!map) return;
const countriesSource = map.getSource("countries") as maplibregl.GeoJSONSource | undefined;
const placesSource = map.getSource("places") as maplibregl.GeoJSONSource | undefined;
if (!countriesSource || !placesSource) return;
// clear all feature-state (selection) to prevent ghost layers after undo
map.removeFeatureState({ source: "countries" });
const { polygons, points } = splitDraftFeatures(fc);
countriesSource.setData(polygons);
placesSource.setData(points);
}, []);
useEffect(() => {
const map = new maplibregl.Map({
container: "map",
attributionControl: false,
style: {
version: 8,
sources: {
base: {
type: "vector",
tiles: ["http://localhost:3000/tiles/{z}/{x}/{y}"],
minzoom: 0,
maxzoom: 6,
},
},
layers: [
{
id: "background",
type: "background",
paint: {
"background-color": "#0b1220",
},
},
{
id: "land",
type: "fill",
source: "base",
"source-layer": "land",
paint: {
"fill-color": "#1e293b",
},
},
],
},
center: [0, 20],
zoom: 2,
});
mapRef.current = map;
map.on("load", async () => {
// preview (drawing)
map.addSource("draw-preview", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [],
},
});
map.addLayer({
id: "draw-preview-fill",
type: "fill",
source: "draw-preview",
paint: {
"fill-color": "#22c55e",
"fill-opacity": 0.4,
},
});
map.addLayer({
id: "draw-preview-line",
type: "line",
source: "draw-preview",
paint: {
"line-color": "#16a34a",
"line-width": 2,
},
});
// data thật
map.addSource("countries", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [],
},
promoteId: "id",
});
map.addLayer({
id: "countries-fill",
type: "fill",
source: "countries",
paint: {
"fill-color": [
"case",
["boolean", ["feature-state", "selected"], false],
"#22c55e", // selected
"#f59e0b", // normal
],
"fill-opacity": 0.5,
},
});
map.addLayer({
id: "countries-line",
type: "line",
source: "countries",
paint: {
"line-color": "#fbbf24",
"line-width": 2,
},
});
map.addSource("places", {
type: "geojson",
data: {
type: "FeatureCollection",
features: [],
},
});
// editing overlays
map.addSource("edit-shape", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addSource("edit-handles", {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
});
map.addLayer({
id: "edit-shape-line",
type: "line",
source: "edit-shape",
paint: {
"line-color": "#38bdf8",
"line-width": 3,
},
});
map.addLayer({
id: "edit-handles-circle",
type: "circle",
source: "edit-handles",
paint: {
"circle-color": "#f97316",
"circle-radius": 12,
"circle-stroke-color": "#0f172a",
"circle-stroke-width": 3,
},
});
// load icon từ /public
map.loadImage("/point.png", (err, image) => {
if (err) throw err;
if (!map.hasImage("point-icon")) {
map.addImage("point-icon", image);
}
});
// layer
map.addLayer({
id: "places-symbol",
type: "symbol",
source: "places",
layout: {
"icon-image": "point-icon",
"icon-size": 0.5,
"icon-anchor": "bottom",
},
});
// init drawing
const cleanup = initDrawing(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
type: "Feature",
properties: { id, kind: "country" },
geometry,
});
}
);
const cleanupSelect = initSelect(
map,
() => modeRef.current,
(id: string | number) => {
// ensure edit overlays are cleared when a feature gets removed
editingEngineRef.current?.clearEditing();
onDeleteRef.current(id);
},
(feature) => editingEngineRef.current?.beginEditing(feature)
);
const cleanupPoint = initPoint(
map,
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
type: "Feature",
properties: { id, kind: "place" },
geometry,
});
}
);
map.on("remove", cleanupPoint);
map.on("remove", cleanupSelect);
map.on("remove", cleanup);
// after everything mounted, push current draft to sources
applyDraftToMap(draftRef.current);
editingEngineRef.current?.bindEditEvents(map);
});
return () => map.remove();
}, [applyDraftToMap]);
// sync draft -> map sources and drop edit overlays if feature vanished
useEffect(() => {
applyDraftToMap(draft);
const editingId = editingEngineRef.current?.editingRef.current?.id;
if (editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId);
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [draft, applyDraftToMap]);
return <div id="map" style={{ flex: 1, height: "100vh" }} />;
}
function splitDraftFeatures(fc: FeatureCollection) {
const polygons = {
type: "FeatureCollection",
features: fc.features.filter((f) => f.geometry.type !== "Point"),
} as FeatureCollection;
const points = {
type: "FeatureCollection",
features: fc.features.filter((f) => f.geometry.type === "Point"),
} as FeatureCollection;
return { polygons, points };
}

1
data Submodule

Submodule data added at 3aab49c029

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

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

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6849
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "ultimate-history-map",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"maplibre-gl": "^5.20.2",
"next": "16.1.7",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.7",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

BIN
public/point.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}