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

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