tons of feature
This commit is contained in:
174
components/Editor.tsx
Normal file
174
components/Editor.tsx
Normal 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ụ có thể undo ({recentUndoLabels.length})
|
||||
</div>
|
||||
{recentUndoLabels.length === 0 ? (
|
||||
<div style={{ color: "#94a3b8", fontSize: "13px" }}>Chưa có 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
328
components/Map.tsx
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user