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 type GeometriesBBoxQuery = {
export type GeometryCreatePayload = {
geometry: Geometry;
type?: string | null;
time_start?: number | null;
time_end?: number | null;
binding?: string[];
@@ -22,6 +23,7 @@ export type GeometryCreatePayload = {
export type GeometryUpdatePayload = {
geometry: Geometry;
type?: string | null;
time_start?: number | null;
time_end?: number | null;
binding?: string[];

985
app/editor/page.tsx Normal file
View File

@@ -0,0 +1,985 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Map from "@/components/Map";
import Editor from "@/components/Editor";
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
import TimelineBar from "@/components/TimelineBar";
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
import {
Feature,
FeatureCollection,
useEditorState,
} from "@/lib/useEditorState";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
const WORLD_BBOX = {
minLng: -180,
minLat: -90,
maxLng: 180,
maxLat: 90,
} as const;
const CURRENT_YEAR = new Date().getUTCFullYear();
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
min: CURRENT_YEAR - 5000,
max: CURRENT_YEAR + 100,
};
const TIMELINE_DEBOUNCE_MS = 180;
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
type TimelineRange = {
min: number;
max: number;
};
type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false);
const [entities, setEntities] = useState<Entity[]>([]);
const [entityStatus, setEntityStatus] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
time_start: "",
time_end: "",
binding: "",
});
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
const [entitySearchQuery, setEntitySearchQuery] = useState("");
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
const [timelineYear, setTimelineYear] = useState<number>(() =>
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
);
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
);
const [isTimelineReady, setIsTimelineReady] = useState(false);
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0);
const skipSelectedFeatureSyncRef = useRef(false);
const editor = useEditorState(initialData);
const selectedFeature =
selectedFeatureId === null
? null
: editor.draft.features.find((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
) || null;
useEffect(() => {
let disposed = false;
async function loadEntities() {
try {
const rows = await fetchEntities();
if (disposed) return;
setEntities(rows);
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry.");
} catch (err) {
if (disposed) return;
console.error("Load entities failed", err);
setEntityStatus("Không tải được danh sách entity.");
}
}
loadEntities();
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (!selectedFeature) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
const keyword = entitySearchQuery.trim();
if (!keyword.length) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
let disposed = false;
const requestId = ++entitySearchRequestRef.current;
const timeoutId = window.setTimeout(async () => {
setIsEntitySearchLoading(true);
try {
const rows = await searchEntitiesByName(keyword, { limit: 30 });
if (disposed || requestId !== entitySearchRequestRef.current) return;
setEntitySearchResults(rows);
setSelectedSearchEntityId((prev) =>
prev && rows.some((entity) => entity.id === prev)
? prev
: rows[0]?.id || null
);
} catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err);
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
} finally {
if (!disposed && requestId === entitySearchRequestRef.current) {
setIsEntitySearchLoading(false);
}
}
}, 220);
return () => {
disposed = true;
window.clearTimeout(timeoutId);
};
}, [entitySearchQuery, selectedFeature]);
useEffect(() => {
if (selectedFeatureId === null) return;
const stillExists = editor.draft.features.some((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
);
if (!stillExists) {
setSelectedFeatureId(null);
}
}, [editor.draft, selectedFeatureId]);
useEffect(() => {
if (skipSelectedFeatureSyncRef.current) {
skipSelectedFeatureSyncRef.current = false;
return;
}
if (!selectedFeature) {
setEntityForm({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
return;
}
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
const primaryEntityId = featureEntityIds[0] || null;
const linkedEntity = primaryEntityId
? entities.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextTypeId =
linkedEntity?.type_id ||
selectedFeature.properties.type ||
selectedFeature.properties.entity_type_id ||
getDefaultTypeIdForFeature(selectedFeature);
setEntityForm({
name: "",
slug: linkedEntity?.slug || "",
type_id: nextTypeId,
});
setSelectedGeometryEntityIds(featureEntityIds);
setGeometryMetaForm({
time_start: selectedFeature.properties.time_start != null
? String(selectedFeature.properties.time_start)
: "",
time_end: selectedFeature.properties.time_end != null
? String(selectedFeature.properties.time_end)
: "",
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
if (!featureEntityIds.length) {
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
} else {
setEntityFormStatus(null);
}
}, [selectedFeature, entities]);
useEffect(() => {
let disposed = false;
async function loadTimelineBounds() {
setIsTimelineLoading(true);
try {
const data = await fetchGeometriesByBBox({
...WORLD_BBOX,
});
if (disposed) return;
const range = deriveTimelineRange(data);
const initialYear = clampYear(CURRENT_YEAR, range);
setTimelineRange(range);
setTimelineWindowStart(range.min);
setTimelineWindowEnd(range.max);
setTimelineYear(initialYear);
setTimelineDraftYear(initialYear);
setTimelineStatus(null);
} catch (err) {
if (disposed) return;
console.error("Load timeline bounds failed", err);
const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE);
setTimelineRange(FALLBACK_TIMELINE_RANGE);
setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min);
setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max);
setTimelineYear(fallbackYear);
setTimelineDraftYear(fallbackYear);
setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định.");
} finally {
if (!disposed) {
setIsTimelineLoading(false);
setIsTimelineReady(true);
}
}
}
loadTimelineBounds();
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (!isTimelineReady) return;
const timeoutId = window.setTimeout(() => {
if (timelineDraftYear !== timelineYear) {
setTimelineYear(timelineDraftYear);
}
}, TIMELINE_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear, isTimelineReady]);
useEffect(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, []);
useEffect(() => {
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper));
}, [timelineWindowStart, timelineWindowEnd]);
useEffect(() => {
if (!isTimelineReady) return;
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
async function loadByTimeline() {
setIsTimelineLoading(true);
setTimelineStatus(null);
try {
const data = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timelineYear,
});
if (disposed || requestId !== timelineFetchRequestRef.current) return;
setInitialData(data);
} catch (err) {
if (err instanceof ApiError) {
console.error("Load timeline data failed", err.body);
} else {
console.error("Load timeline data failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
}
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsTimelineLoading(false);
}
}
}
loadByTimeline();
return () => {
disposed = true;
};
}, [timelineYear, isTimelineReady]);
const handleSave = async () => {
const payload = editor.buildPayload();
if (!payload.length) return;
const invalid = payload.find((change) => {
if (change.action === "delete") return false;
const draftFeature = change.action === "create"
? change.feature
: editor.draft.features.find((feature) =>
String(feature.properties.id) === String(change.id)
);
if (!draftFeature) return false;
const entityIds = normalizeFeatureEntityIds(draftFeature);
return entityIds.length === 0;
});
if (invalid) {
const invalidId = invalid.action === "create"
? invalid.feature.properties.id
: invalid.id;
setSelectedFeatureId(invalidId);
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
return;
}
setIsSaving(true);
setEntityStatus(null);
try {
await saveGeometryBatchChanges(payload);
editor.clearChanges();
await reloadCurrentTimelineData();
} catch (err) {
if (err instanceof ApiError) {
console.error("Save failed", err.body);
setEntityStatus(`Save thất bại: ${err.body}`);
return;
}
console.error("Save error", err);
setEntityStatus("Save thất bại.");
} finally {
setIsSaving(false);
}
};
const updateBackgroundVisibility = (
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleShowAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
};
const handleHideAllBackgroundLayers = () => {
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
};
const handleTimelineWindowStartChange = (nextYear: number) => {
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
setTimelineWindowStart(Math.min(next, timelineWindowEnd));
};
const handleTimelineWindowEndChange = (nextYear: number) => {
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
setTimelineWindowEnd(Math.max(next, timelineWindowStart));
};
const handleTimelineYearChange = (nextYear: number) => {
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
};
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
};
const handleEntityIdsChange = (values: string[]) => {
setSelectedGeometryEntityIds(uniqueEntityIds(values));
};
const handleAddSelectedSearchEntity = () => {
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
if (!entityId.length) {
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
return;
}
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
setSelectedGeometryEntityIds(next);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
};
const reloadEntities = async () => {
const rows = await fetchEntities();
setEntities(rows);
return rows;
};
const reloadCurrentTimelineData = async () => {
const data = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timelineYear,
});
setInitialData(data);
};
const resetSelectedGeometryInputsAfterCreate = () => {
skipSelectedFeatureSyncRef.current = true;
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntityForm({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
};
const parseGeometryMetaFormRange = () => {
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
throw new Error("time_start phải <= time_end.");
}
return { timeStart, timeEnd };
};
const resolveGeometryTypeFromEntityIds = (
entityIds: string[],
entityRows: Entity[] = entities
): string | null => {
const primaryEntityId = entityIds[0] || null;
if (!primaryEntityId) return null;
const primaryEntity = entityRows.find((entity) => entity.id === primaryEntityId) || null;
return primaryEntity?.type_id || null;
};
const patchSelectedFeatureLocally = (
feature: Feature,
entityIds: string[],
timeStart: number | null,
timeEnd: number | null,
bindingIds: string[],
entityRows: Entity[] = entities
) => {
const primaryEntityId = entityIds[0] || null;
const primaryEntity = primaryEntityId
? entityRows.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entityRows) || feature.properties.type || null;
const entityNames = entityIds
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
editor.patchFeatureProperties(feature.properties.id, {
type: nextGeometryType,
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_type_id: primaryEntity?.type_id || null,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
});
setSelectedGeometryEntityIds(entityIds);
setGeometryMetaForm({
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
});
};
const handleApplyEntitiesForSelectedGeometry = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
if (!entityIds.length) {
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
return;
}
let timeStart: number | null;
let timeEnd: number | null;
let bindingIds: string[];
try {
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
bindingIds = parseBindingInput(geometryMetaForm.binding);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
if (editor.changeCount > 0) {
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
return;
}
await updateGeometry(selectedFeature.properties.id, {
geometry: selectedFeature.geometry,
type: nextGeometryType ?? undefined,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: entityIds,
});
await reloadCurrentTimelineData();
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
} else {
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
}
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
} else {
setEntityFormStatus("Lưu thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
};
const handleCreateEntityAndAttach = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
return;
}
const name = entityForm.name.trim();
if (!name) {
setEntityFormStatus("Tên entity là bắt buộc.");
return;
}
let timeStart: number | null;
let timeEnd: number | null;
let bindingIds: string[];
try {
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
bindingIds = parseBindingInput(geometryMetaForm.binding);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
const created = await createEntity({
name,
slug: entityForm.slug.trim() || null,
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
});
const rows = await reloadEntities();
const nextEntityIds = uniqueEntityIds([
...selectedGeometryEntityIds,
created.id,
]);
const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null;
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
await updateGeometry(selectedFeature.properties.id, {
geometry: selectedFeature.geometry,
type: nextGeometryType ?? undefined,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: nextEntityIds,
});
await reloadCurrentTimelineData();
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
} else {
patchSelectedFeatureLocally(
selectedFeature,
nextEntityIds,
timeStart,
timeEnd,
bindingIds,
rows
);
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
}
resetSelectedGeometryInputsAfterCreate();
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
} else {
setEntityFormStatus("Tạo/gắn entity thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
};
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
const timelineStatusText =
editor.changeCount > 0
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
: isSaving
? "Đang lưu thay đổi..."
: timelineStatus;
const handleCreateFeature = (feature: Feature) => {
editor.createFeature(feature);
setSelectedFeatureId(feature.properties.id);
};
return (
<div style={{ display: "flex", minHeight: "100vh" }}>
<Editor
mode={mode}
setMode={setMode}
entityStatus={entityStatus}
onUndo={editor.undo}
onSave={handleSave}
isSaving={isSaving}
changesCount={editor.changeCount}
undoStack={editor.undoStack}
/>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
{isBackgroundVisibilityReady ? (
<Map
mode={mode}
draft={editor.draft}
selectedFeatureId={selectedFeatureId}
onSelectFeatureId={setSelectedFeatureId}
onCreateFeature={handleCreateFeature}
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)}
<TimelineBar
minYear={timelineRange.min}
maxYear={timelineRange.max}
windowStartYear={timelineWindowStart}
windowEndYear={timelineWindowEnd}
onWindowStartYearChange={handleTimelineWindowStartChange}
onWindowEndYearChange={handleTimelineWindowEndChange}
year={timelineDraftYear}
onYearChange={handleTimelineYearChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
statusText={timelineStatusText}
/>
</div>
<BackgroundLayersPanel
visibility={backgroundVisibility}
onToggleLayer={handleToggleBackgroundLayer}
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
topContent={
<SelectedGeometryPanel
selectedFeature={selectedFeature}
selectedFeatureEntitySummary={
selectedFeature
? formatEntityNamesForDisplay(selectedFeature, entities)
: "Chưa gắn"
}
selectedFeatureBindingSummary={
selectedFeature
? formatBindingIdsForDisplay(selectedFeature)
: "Không có"
}
entities={entities}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onEntityIdsChange={handleEntityIdsChange}
entitySearchQuery={entitySearchQuery}
onEntitySearchQueryChange={setEntitySearchQuery}
entitySearchResults={entitySearchResults}
selectedSearchEntityId={selectedSearchEntityId}
onSelectSearchEntityId={setSelectedSearchEntityId}
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
isEntitySearchLoading={isEntitySearchLoading}
entityForm={entityForm}
onEntityFormChange={handleEntityFormChange}
entityTypeOptions={ENTITY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityAndAttach={handleCreateEntityAndAttach}
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
/>
}
/>
</div>
);
}
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const feature of collection.features) {
const { time_start, time_end } = feature.properties;
if (isYearNumber(time_start)) {
min = Math.min(min, time_start);
max = Math.max(max, time_start);
}
if (isYearNumber(time_end)) {
min = Math.min(min, time_end);
max = Math.max(max, time_end);
}
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return FALLBACK_TIMELINE_RANGE;
}
return {
min: Math.floor(min),
max: Math.ceil(max),
};
}
function clampYear(year: number, range: TimelineRange): number {
return clampYearValue(year, range.min, range.max);
}
function clampYearValue(year: number, minYear: number, maxYear: number): number {
const lower = Math.min(minYear, maxYear);
const upper = Math.max(minYear, maxYear);
if (year < lower) return lower;
if (year > upper) return upper;
return year;
}
function isYearNumber(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function getDefaultTypeIdForFeature(feature: Feature): string {
const preset = feature.properties.geometry_preset;
if (preset === "line") return "defense_line";
if (preset === "point") return "city";
if (preset === "circle-area") return "war";
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
const geometryType = feature.geometry.type;
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return "defense_line";
}
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "city";
}
return DEFAULT_ENTITY_TYPE_ID;
}
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
const value = raw.trim();
if (!value.length) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} phải là số.`);
}
return Math.trunc(parsed);
}
function normalizeFeatureEntityIds(feature: Feature): string[] {
const fromArray = Array.isArray(feature.properties.entity_ids)
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
: [];
if (fromArray.length) {
return uniqueEntityIds(fromArray);
}
const single = feature.properties.entity_id;
if (typeof single === "string" && single.trim().length > 0) {
return [single.trim()];
}
return [];
}
function normalizeFeatureBindingIds(feature: Feature): string[] {
const rawBinding = feature.properties.binding;
if (!Array.isArray(rawBinding)) return [];
return uniqueEntityIds(rawBinding
.map((id) => {
if (typeof id !== "string" && typeof id !== "number") return "";
return String(id).trim();
})
.filter((id) => id.length > 0));
}
function parseBindingInput(raw: string): string[] {
if (!raw.trim().length) return [];
return uniqueEntityIds(
raw
.split(/[,\n]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
);
}
function formatBindingIdsForDisplay(feature: Feature): string {
const bindingIds = normalizeFeatureBindingIds(feature);
if (!bindingIds.length) return "Không có";
return bindingIds.join(", ");
}
function uniqueEntityIds(ids: string[]): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of ids) {
const id = rawId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
const entityIds = normalizeFeatureEntityIds(feature);
if (!entityIds.length) return "Chưa gắn";
const names = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
.filter((name) => name.trim().length > 0);
return names.join(", ");
}
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
if (typeof window === "undefined") {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
try {
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
if (!raw) {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
const parsed = JSON.parse(raw) as unknown;
const normalized = normalizeBackgroundLayerVisibility(parsed);
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
} catch (err) {
console.warn("Load background layer visibility from storage failed", err);
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
}
}
function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
JSON.stringify(visibility)
);
} catch (err) {
console.warn("Persist background layer visibility failed", err);
}
}
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
if (!raw || typeof raw !== "object") return null;
const source = raw as Record<string, unknown>;
const next: BackgroundLayerVisibility = {
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
};
for (const layer of BACKGROUND_LAYER_OPTIONS) {
const value = source[layer.id];
if (typeof value === "boolean") {
next[layer.id] = value;
}
}
return next;
}

View File

@@ -1,27 +1,20 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import Map from "@/components/Map";
import Editor from "@/components/Editor";
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
import TimelineBar from "@/components/TimelineBar";
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel";
import { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { fetchGeometriesByBBox } from "@/api/geometries";
import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
import {
Feature,
FeatureCollection,
useEditorState,
} from "@/lib/useEditorState";
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
import {
BACKGROUND_LAYER_OPTIONS,
BackgroundLayerId,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
import { Feature, FeatureCollection } from "@/lib/useEditorState";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
const WORLD_BBOX = {
@@ -43,43 +36,9 @@ type TimelineRange = {
max: number;
};
type EntityFormState = {
name: string;
slug: string;
type_id: string;
};
type GeometryMetaFormState = {
time_start: string;
time_end: string;
binding: string;
};
export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false);
const [entities, setEntities] = useState<Entity[]>([]);
const [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
const [entityStatus, setEntityStatus] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [entityForm, setEntityForm] = useState<EntityFormState>({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
time_start: "",
time_end: "",
binding: "",
});
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
const [entitySearchQuery, setEntitySearchQuery] = useState("");
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
const [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
@@ -93,170 +52,27 @@ export default function Page() {
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => loadBackgroundLayerVisibilityFromStorage()
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0);
const skipSelectedFeatureSyncRef = useRef(false);
const editor = useEditorState(initialData);
const selectedEntity =
entities.find((entity) => entity.id === selectedEntityId) || null;
const selectedFeature =
selectedFeatureId === null
? null
: editor.draft.features.find((feature) =>
: initialData.features.find((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
) || null;
useEffect(() => {
let disposed = false;
async function loadEntities() {
try {
const rows = await fetchEntities();
if (disposed) return;
setEntities(rows);
setSelectedEntityId((prev) => {
if (prev && rows.some((entity) => entity.id === prev)) {
return prev;
}
return rows[0]?.id || null;
});
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry.");
} catch (err) {
if (disposed) return;
console.error("Load entities failed", err);
setEntityStatus("Không tải được danh sách entity.");
}
}
loadEntities();
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (!selectedFeature) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
const keyword = entitySearchQuery.trim();
if (!keyword.length) {
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setIsEntitySearchLoading(false);
return;
}
let disposed = false;
const requestId = ++entitySearchRequestRef.current;
const timeoutId = window.setTimeout(async () => {
setIsEntitySearchLoading(true);
try {
const rows = await searchEntitiesByName(keyword, { limit: 30 });
if (disposed || requestId !== entitySearchRequestRef.current) return;
setEntitySearchResults(rows);
setSelectedSearchEntityId((prev) =>
prev && rows.some((entity) => entity.id === prev)
? prev
: rows[0]?.id || null
);
setEntities((prev) => mergeEntitiesById(prev, rows));
} catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err);
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
} finally {
if (!disposed && requestId === entitySearchRequestRef.current) {
setIsEntitySearchLoading(false);
}
}
}, 220);
return () => {
disposed = true;
window.clearTimeout(timeoutId);
};
}, [entitySearchQuery, selectedFeature]);
useEffect(() => {
if (selectedFeatureId === null) return;
const stillExists = editor.draft.features.some((feature) =>
const stillExists = initialData.features.some((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
);
if (!stillExists) {
setSelectedFeatureId(null);
}
}, [editor.draft, selectedFeatureId]);
useEffect(() => {
if (skipSelectedFeatureSyncRef.current) {
skipSelectedFeatureSyncRef.current = false;
return;
}
if (!selectedFeature) {
setEntityForm({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
return;
}
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
const primaryEntityId = featureEntityIds[0] || null;
const linkedEntity = primaryEntityId
? entities.find((entity) => entity.id === primaryEntityId) || null
: null;
const nextTypeId =
linkedEntity?.type_id ||
selectedFeature.properties.entity_type_id ||
DEFAULT_ENTITY_TYPE_ID;
setEntityForm({
name: "",
slug: linkedEntity?.slug || "",
type_id: nextTypeId,
});
setSelectedGeometryEntityIds(featureEntityIds);
setGeometryMetaForm({
time_start: selectedFeature.properties.time_start != null
? String(selectedFeature.properties.time_start)
: "",
time_end: selectedFeature.properties.time_end != null
? String(selectedFeature.properties.time_end)
: "",
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
if (!featureEntityIds.length) {
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
} else {
setEntityFormStatus(null);
}
}, [selectedFeature, entities]);
}, [initialData, selectedFeatureId]);
useEffect(() => {
let disposed = false;
@@ -316,8 +132,9 @@ export default function Page() {
}, [timelineDraftYear, timelineYear, isTimelineReady]);
useEffect(() => {
persistBackgroundLayerVisibility(backgroundVisibility);
}, [backgroundVisibility]);
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, []);
useEffect(() => {
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
@@ -367,63 +184,29 @@ export default function Page() {
};
}, [timelineYear, isTimelineReady]);
const handleSave = async () => {
const payload = editor.buildPayload();
if (!payload.length) return;
const invalid = payload.find((change) => {
if (change.type === "delete") return false;
const draftFeature = change.type === "create"
? change.feature
: editor.draft.features.find((feature) =>
String(feature.properties.id) === String(change.id)
);
if (!draftFeature) return false;
const entityIds = normalizeFeatureEntityIds(draftFeature);
return entityIds.length === 0;
const updateBackgroundVisibility = (
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
if (invalid) {
const invalidId = invalid.type === "create"
? invalid.feature.properties.id
: invalid.id;
setSelectedFeatureId(invalidId);
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
return;
}
setIsSaving(true);
setEntityStatus(null);
try {
await saveGeometryBatchChanges(payload);
editor.clearChanges();
await reloadCurrentTimelineData();
} catch (err) {
if (err instanceof ApiError) {
console.error("Save failed", err.body);
setEntityStatus(`Save thất bại: ${err.body}`);
return;
}
console.error("Save error", err);
setEntityStatus("Save thất bại.");
} finally {
setIsSaving(false);
}
};
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
setBackgroundVisibility((prev) => ({
const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
updateBackgroundVisibility((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleShowAllBackgroundLayers = () => {
setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY });
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
};
const handleHideAllBackgroundLayers = () => {
setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY });
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
};
const handleTimelineWindowStartChange = (nextYear: number) => {
@@ -442,291 +225,28 @@ export default function Page() {
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
};
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
setEntityForm((prev) => ({ ...prev, [key]: value }));
};
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
};
const handleEntityIdsChange = (values: string[]) => {
setSelectedGeometryEntityIds(uniqueEntityIds(values));
};
const handleAddSelectedSearchEntity = () => {
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
if (!entityId.length) {
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
return;
}
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
setSelectedGeometryEntityIds(next);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
};
const reloadEntities = async () => {
const rows = await fetchEntities();
setEntities(rows);
setSelectedEntityId((prev) => {
if (prev && rows.some((entity) => entity.id === prev)) {
return prev;
}
return rows[0]?.id || null;
});
return rows;
};
const reloadCurrentTimelineData = async () => {
const data = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timelineYear,
});
setInitialData(data);
};
const resetSelectedGeometryInputsAfterCreate = () => {
skipSelectedFeatureSyncRef.current = true;
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntityForm({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
};
const parseGeometryMetaFormRange = () => {
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
throw new Error("time_start phải <= time_end.");
}
return { timeStart, timeEnd };
};
const patchSelectedFeatureLocally = (
feature: Feature,
entityIds: string[],
timeStart: number | null,
timeEnd: number | null,
bindingIds: string[],
entityRows: Entity[] = entities
) => {
const primaryEntityId = entityIds[0] || null;
const primaryEntity = primaryEntityId
? entityRows.find((entity) => entity.id === primaryEntityId) || null
: null;
const entityNames = entityIds
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
.filter((name) => name.length > 0);
editor.patchFeatureProperties(feature.properties.id, {
entity_id: primaryEntityId,
entity_ids: entityIds,
entity_name: primaryEntity?.name || null,
entity_names: entityNames,
entity_type_id: primaryEntity?.type_id || null,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
});
setSelectedGeometryEntityIds(entityIds);
setGeometryMetaForm({
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
});
};
const handleApplyEntitiesForSelectedGeometry = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
if (!entityIds.length) {
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
return;
}
let timeStart: number | null;
let timeEnd: number | null;
let bindingIds: string[];
try {
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
bindingIds = parseBindingInput(geometryMetaForm.binding);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
if (editor.changeCount > 0) {
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
return;
}
await updateGeometry(selectedFeature.properties.id, {
geometry: selectedFeature.geometry,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: entityIds,
});
await reloadCurrentTimelineData();
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
} else {
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
}
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
} else {
setEntityFormStatus("Lưu thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
};
const handleCreateEntityAndAttach = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
return;
}
const name = entityForm.name.trim();
if (!name) {
setEntityFormStatus("Tên entity là bắt buộc.");
return;
}
let timeStart: number | null;
let timeEnd: number | null;
let bindingIds: string[];
try {
({ timeStart, timeEnd } = parseGeometryMetaFormRange());
bindingIds = parseBindingInput(geometryMetaForm.binding);
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return;
}
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
const created = await createEntity({
name,
slug: entityForm.slug.trim() || null,
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
});
const rows = await reloadEntities();
const nextEntityIds = uniqueEntityIds([
...selectedGeometryEntityIds,
created.id,
]);
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
await updateGeometry(selectedFeature.properties.id, {
geometry: selectedFeature.geometry,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: nextEntityIds,
});
await reloadCurrentTimelineData();
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
} else {
patchSelectedFeatureLocally(
selectedFeature,
nextEntityIds,
timeStart,
timeEnd,
bindingIds,
rows
);
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
}
setSelectedEntityId(created.id);
resetSelectedGeometryInputsAfterCreate();
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
} else {
setEntityFormStatus("Tạo/gắn entity thất bại.");
}
} finally {
setIsEntitySubmitting(false);
}
};
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
const timelineStatusText =
editor.changeCount > 0
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
: isSaving
? "Đang lưu thay đổi..."
: timelineStatus;
const handleCreateFeature = (feature: Feature) => {
editor.createFeature(feature);
setSelectedFeatureId(feature.properties.id);
};
const timelineDisabled = !isTimelineReady;
const selectedType = resolveSelectedTypeSummary(selectedFeature);
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature);
return (
<div style={{ display: "flex", minHeight: "100vh" }}>
<Editor
mode={mode}
setMode={setMode}
entities={entities}
selectedEntityId={selectedEntityId}
onSelectEntityId={setSelectedEntityId}
entityStatus={entityStatus}
onUndo={editor.undo}
onSave={handleSave}
isSaving={isSaving}
changesCount={editor.changeCount}
undoStack={editor.undoStack}
/>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
{isBackgroundVisibilityReady ? (
<Map
mode={mode}
draft={editor.draft}
mode="select"
draft={initialData}
selectedFeatureId={selectedFeatureId}
selectedEntityId={selectedEntityId}
selectedEntityName={selectedEntity?.name || null}
selectedEntityTypeId={selectedEntity?.type_id || null}
onSelectFeatureId={setSelectedFeatureId}
onCreateFeature={handleCreateFeature}
onDeleteFeature={editor.deleteFeature}
onUpdateFeature={editor.updateFeature}
backgroundVisibility={backgroundVisibility}
allowGeometryEditing={false}
respectBindingFilter={false}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
)}
<TimelineBar
minYear={timelineRange.min}
maxYear={timelineRange.max}
@@ -738,7 +258,7 @@ export default function Page() {
onYearChange={handleTimelineYearChange}
isLoading={isTimelineLoading}
disabled={timelineDisabled}
statusText={timelineStatusText}
statusText={timelineStatus}
/>
</div>
@@ -748,39 +268,57 @@ export default function Page() {
onShowAll={handleShowAllBackgroundLayers}
onHideAll={handleHideAllBackgroundLayers}
topContent={
<SelectedGeometryPanel
selectedFeature={selectedFeature}
selectedFeatureEntitySummary={
selectedFeature
? formatEntityNamesForDisplay(selectedFeature, entities)
: "Chưa gắn"
}
selectedFeatureBindingSummary={
selectedFeature
? formatBindingIdsForDisplay(selectedFeature)
: "Không có"
}
entities={entities}
selectedGeometryEntityIds={selectedGeometryEntityIds}
onEntityIdsChange={handleEntityIdsChange}
entitySearchQuery={entitySearchQuery}
onEntitySearchQueryChange={setEntitySearchQuery}
entitySearchResults={entitySearchResults}
selectedSearchEntityId={selectedSearchEntityId}
onSelectSearchEntityId={setSelectedSearchEntityId}
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
isEntitySearchLoading={isEntitySearchLoading}
entityForm={entityForm}
onEntityFormChange={handleEntityFormChange}
entityTypeOptions={ENTITY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityAndAttach={handleCreateEntityAndAttach}
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
/>
<div
style={{
padding: "10px",
background: "#0b1220",
borderRadius: "8px",
border: "1px solid #1f2937",
display: "grid",
gap: "8px",
}}
>
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>
Viewer
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Click trực tiếp trên map đ chọn geometry.
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
Type: {selectedType}
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
Time: {selectedTimeSummary}
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
Entities: {selectedEntitySummary}
</div>
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
Binding: {selectedBindingSummary}
</div>
<Link
href="/editor"
style={{
marginTop: "4px",
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
borderRadius: "6px",
padding: "7px 8px",
background: "#1d4ed8",
color: "#ffffff",
textDecoration: "none",
fontSize: "13px",
fontWeight: 600,
}}
>
Mở Editor
</Link>
</div>
}
/>
</div>
@@ -831,93 +369,68 @@ function isYearNumber(value: number | null | undefined): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
const value = raw.trim();
if (!value.length) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(`${fieldName} phải là số.`);
}
return Math.trunc(parsed);
function formatSelectedTimeSummary(feature: Feature | null): string {
if (!feature) return "Không có";
const start = isYearNumber(feature.properties.time_start)
? String(feature.properties.time_start)
: "?";
const end = isYearNumber(feature.properties.time_end)
? String(feature.properties.time_end)
: "?";
return `${start}${end}`;
}
function normalizeFeatureEntityIds(feature: Feature): string[] {
const fromArray = Array.isArray(feature.properties.entity_ids)
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
function formatSelectedEntitySummary(feature: Feature | null): string {
if (!feature) return "Không có";
const names = Array.isArray(feature.properties.entity_names)
? feature.properties.entity_names.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
: [];
if (names.length) return names.join(", ");
if (fromArray.length) {
return uniqueEntityIds(fromArray);
if (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) {
return feature.properties.entity_name.trim();
}
const single = feature.properties.entity_id;
if (typeof single === "string" && single.trim().length > 0) {
return [single.trim()];
const ids = Array.isArray(feature.properties.entity_ids)
? feature.properties.entity_ids.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
: [];
if (ids.length) return ids.join(", ");
if (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0) {
return feature.properties.entity_id.trim();
}
return [];
return "Chưa gắn";
}
function normalizeFeatureBindingIds(feature: Feature): string[] {
const rawBinding = feature.properties.binding;
if (!Array.isArray(rawBinding)) return [];
return uniqueEntityIds(rawBinding
.map((id) => {
if (typeof id !== "string" && typeof id !== "number") return "";
return String(id).trim();
function formatSelectedBindingSummary(feature: Feature | null): string {
if (!feature) return "Không có";
const binding = Array.isArray(feature.properties.binding)
? feature.properties.binding
.map((item) => {
if (typeof item !== "string" && typeof item !== "number") return "";
return String(item).trim();
})
.filter((id) => id.length > 0));
}
function parseBindingInput(raw: string): string[] {
if (!raw.trim().length) return [];
return uniqueEntityIds(
raw
.split(/[,\n]/)
.map((item) => item.trim())
.filter((item) => item.length > 0)
);
: [];
if (!binding.length) return "Không có";
return binding.join(", ");
}
function formatBindingIdsForDisplay(feature: Feature): string {
const bindingIds = normalizeFeatureBindingIds(feature);
if (!bindingIds.length) return "Không có";
return bindingIds.join(", ");
function resolveSelectedTypeSummary(feature: Feature | null): string {
if (!feature) return "Không có";
const typeId = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (!typeId) return "Không có";
const option = findEntityTypeOption(typeId);
if (!option) return typeId;
return `${option.label} (${option.groupLabel})`;
}
function uniqueEntityIds(ids: string[]): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const rawId of ids) {
const id = rawId.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push(id);
}
return deduped;
}
function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
const entityIds = normalizeFeatureEntityIds(feature);
if (!entityIds.length) return "Chưa gắn";
const names = entityIds
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
.filter((name) => name.trim().length > 0);
return names.join(", ");
}
function mergeEntitiesById(current: Entity[], incoming: Entity[]): Entity[] {
if (!incoming.length) return current;
const map = new globalThis.Map<string, Entity>();
for (const entity of current) {
map.set(entity.id, entity);
}
for (const entity of incoming) {
map.set(entity.id, entity);
}
return Array.from(map.values());
function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
}
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {

View File

@@ -3,18 +3,10 @@
import { UndoAction } from "@/lib/useEditorState";
type Mode = "draw" | "select" | "idle" | "add-point" | "add-line" | "add-path" | "add-circle";
type EntityOption = {
id: string;
name: string;
geometry_count?: number;
};
type Props = {
mode: Mode;
setMode: (mode: Mode) => void;
entities: EntityOption[];
selectedEntityId: string | null;
onSelectEntityId: (entityId: string | null) => void;
entityStatus?: string | null;
onUndo: () => void;
onSave: () => void;
@@ -26,9 +18,6 @@ type Props = {
export default function Editor({
mode,
setMode,
entities,
selectedEntityId,
onSelectEntityId,
entityStatus,
onUndo,
onSave,
@@ -148,6 +137,7 @@ export default function Editor({
</div>
) : null}
{entityStatus ? (
<div
style={{
marginTop: "12px",
@@ -155,41 +145,13 @@ export default function Editor({
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
color: "#fca5a5",
fontSize: "12px",
}}
>
<div style={{ marginBottom: "6px", fontWeight: 600, fontSize: "13px", color: "#e2e8f0" }}>
Entity mặc đnh cho geometry mới
</div>
<select
value={selectedEntityId || ""}
onChange={(event) => onSelectEntityId(event.target.value || null)}
style={{
width: "100%",
padding: "6px 8px",
borderRadius: "4px",
border: "1px solid #334155",
background: "#111827",
color: "#f8fafc",
fontSize: "13px",
}}
>
<option value="">Không gắn entity</option>
{entities.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name}
{typeof entity.geometry_count === "number" ? ` (${entity.geometry_count})` : ""}
</option>
))}
</select>
<div style={{ marginTop: "6px", color: "#94a3b8", fontSize: "12px" }}>
Geometry mới tạo sẽ gắn sẵn entity này, bạn thể thêm nhiều entity panel bên phải.
</div>
{entityStatus ? (
<div style={{ marginTop: "6px", color: "#fca5a5", fontSize: "12px" }}>
{entityStatus}
</div>
) : null}
</div>
<div style={{ marginTop: "12px", display: "flex", gap: "8px" }}>
<button

View File

@@ -20,13 +20,12 @@ type MapProps = {
draft: FeatureCollection;
backgroundVisibility: BackgroundLayerVisibility;
selectedFeatureId: string | number | null;
selectedEntityId: string | null;
selectedEntityName: string | null;
selectedEntityTypeId: string | null;
onSelectFeatureId: (id: string | number | null) => void;
onCreateFeature: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature: (id: string | number) => void;
onUpdateFeature: (id: string | number, geometry: Geometry) => void;
onCreateFeature?: (feature: FeatureCollection["features"][number]) => void;
onDeleteFeature?: (id: string | number) => void;
onUpdateFeature?: (id: string | number, geometry: Geometry) => void;
allowGeometryEditing?: boolean;
respectBindingFilter?: boolean;
};
type PointIconSpec = {
@@ -40,6 +39,9 @@ const DEFAULT_POINT_ICON_ID = "point-icon-default";
const PATH_ARROW_ICON_ID = "path-arrow-icon";
const MAP_MIN_ZOOM = 1;
const MAP_MAX_ZOOM = 10;
const RASTER_BASE_SOURCE_ID = "rasterBase";
const RASTER_BASE_LAYER_ID = "raster-base-layer";
const RASTER_BASE_INSERT_BEFORE_LAYER_ID = "graticules-line";
const COUNTRY_COLOR_KEY_EXPRESSION: maplibregl.ExpressionSpecification = [
"coalesce",
["get", "MAPCOLOR7"],
@@ -103,6 +105,16 @@ const LINE_COLOR_BY_TYPE: Record<string, string> = {
shipping_route: "#2563eb",
};
const PATH_RENDER_BY_TYPE: Record<string, boolean> = {
attack_route: true,
retreat_route: true,
invasion_route: true,
migration_route: true,
refugee_route: true,
trade_route: true,
shipping_route: true,
};
const POINT_COLOR_BY_TYPE: Record<string, string> = {
person_deathplace: "#dc2626",
person_birthplace: "#2563eb",
@@ -130,7 +142,7 @@ const POINT_ICON_SPECS: PointIconSpec[] = [
{ id: "point-icon-port", fill: "#0c4a6e", stroke: "#082f49", label: "P" },
{ id: "point-icon-bridge", fill: "#9a3412", stroke: "#7c2d12", label: "Br" },
// Backward-compatible icon ids used by older type_id values.
// Backward-compatible icon ids used by older naming values.
{ id: "point-icon-country", fill: "#1d4ed8", stroke: "#1e3a8a", label: "C" },
{ id: "point-icon-kingdom", fill: "#ca8a04", stroke: "#854d0e", label: "K" },
{ id: "point-icon-region", fill: "#b91c1c", stroke: "#7f1d1d", label: "R" },
@@ -161,26 +173,22 @@ export default function Map({
draft,
backgroundVisibility,
selectedFeatureId,
selectedEntityId,
selectedEntityName,
selectedEntityTypeId,
onSelectFeatureId,
onCreateFeature,
onDeleteFeature,
onUpdateFeature,
allowGeometryEditing = true,
respectBindingFilter = true,
}: MapProps) {
const mapRef = useRef<maplibregl.Map | null>(null);
const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft);
const backgroundVisibilityRef = useRef<BackgroundLayerVisibility>(backgroundVisibility);
const selectedFeatureIdRef = useRef<string | number | null>(selectedFeatureId);
const selectedEntityIdRef = useRef<string | null>(selectedEntityId);
const selectedEntityNameRef = useRef<string | null>(selectedEntityName);
const selectedEntityTypeIdRef = useRef<string | null>(selectedEntityTypeId);
const onSelectFeatureIdRef = useRef(onSelectFeatureId);
const onCreateRef = useRef(onCreateFeature);
const onDeleteRef = useRef(onDeleteFeature);
const onUpdateRef = useRef(onUpdateFeature);
const onCreateRef = useRef<MapProps["onCreateFeature"]>(onCreateFeature);
const onDeleteRef = useRef<MapProps["onDeleteFeature"]>(onDeleteFeature);
const onUpdateRef = useRef<MapProps["onUpdateFeature"]>(onUpdateFeature);
const [zoomLevel, setZoomLevel] = useState(2);
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
@@ -192,7 +200,7 @@ export default function Map({
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!map || !map.isStyleLoaded()) return;
if (mode !== "add-line") {
(map.getSource("draw-line-preview") as maplibregl.GeoJSONSource | undefined)?.setData({
type: "FeatureCollection",
@@ -221,18 +229,6 @@ export default function Map({
selectedFeatureIdRef.current = selectedFeatureId;
}, [selectedFeatureId]);
useEffect(() => {
selectedEntityIdRef.current = selectedEntityId;
}, [selectedEntityId]);
useEffect(() => {
selectedEntityNameRef.current = selectedEntityName;
}, [selectedEntityName]);
useEffect(() => {
selectedEntityTypeIdRef.current = selectedEntityTypeId;
}, [selectedEntityTypeId]);
useEffect(() => {
onSelectFeatureIdRef.current = onSelectFeatureId;
}, [onSelectFeatureId]);
@@ -281,12 +277,14 @@ export default function Map({
// clear all feature-state (selection) to prevent ghost layers after undo
map.removeFeatureState({ source: "countries" });
const visibleDraft = filterDraftByBinding(fc, selectedFeatureIdRef.current);
const visibleDraft = respectBindingFilter
? filterDraftByBinding(fc, selectedFeatureIdRef.current)
: fc;
const { polygons, points } = splitDraftFeatures(visibleDraft);
countriesSource.setData(polygons);
placesSource.setData(points);
}, []);
}, [respectBindingFilter]);
useEffect(() => {
const map = new maplibregl.Map({
@@ -297,13 +295,6 @@ export default function Map({
style: {
version: 8,
sources: {
rasterBase: {
type: "raster",
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
},
base: {
type: "vector",
tiles: [getVectorTileTemplateUrl()],
@@ -319,15 +310,6 @@ export default function Map({
"background-color": "#0b1220",
},
},
{
id: "raster-base-layer",
type: "raster",
source: "rasterBase",
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear",
},
},
{
id: "graticules-line",
type: "line",
@@ -450,7 +432,6 @@ export default function Map({
mapRef.current = map;
map.on("load", async () => {
const geometryMinZoom = 5;
const syncZoomLevel = () => {
setZoomLevel(roundZoom(map.getZoom()));
};
@@ -633,7 +614,6 @@ export default function Map({
id: "routes-line",
type: "line",
source: "countries",
minzoom: geometryMinZoom,
filter: ["==", ["geometry-type"], "LineString"],
paint: {
"line-color": [
@@ -661,11 +641,10 @@ export default function Map({
id: "routes-arrow",
type: "symbol",
source: "countries",
minzoom: geometryMinZoom,
filter: [
"all",
["==", ["geometry-type"], "LineString"],
["==", ["coalesce", ["get", "line_mode"], "path"], "path"],
buildTypeMatchExpression(PATH_RENDER_BY_TYPE, false),
],
layout: {
"symbol-placement": "line",
@@ -723,7 +702,6 @@ export default function Map({
id: "places-circle",
type: "circle",
source: "places",
minzoom: geometryMinZoom,
paint: {
"circle-color": [
"case",
@@ -749,7 +727,6 @@ export default function Map({
id: "places-symbol",
type: "symbol",
source: "places",
minzoom: geometryMinZoom,
layout: {
"icon-image": buildPointIconExpression(),
"icon-size": 0.5,
@@ -765,14 +742,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
onCreateRef.current?.({
type: "Feature",
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
type: null,
geometry_preset: "polygon",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -783,13 +762,17 @@ export default function Map({
const cleanupSelect = initSelect(
map,
() => modeRef.current,
(id: string | number) => {
allowGeometryEditing
? (id: string | number) => {
// ensure edit overlays are cleared when a feature gets removed
editingEngineRef.current?.clearEditing();
onSelectFeatureIdRef.current?.(null);
onDeleteRef.current(id);
},
(feature) => editingEngineRef.current?.beginEditing(feature),
onDeleteRef.current?.(id);
}
: undefined,
allowGeometryEditing
? (feature) => editingEngineRef.current?.beginEditing(feature)
: undefined,
(id) => onSelectFeatureIdRef.current?.(id)
);
@@ -798,14 +781,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
onCreateRef.current?.({
type: "Feature",
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
type: null,
geometry_preset: "point",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -818,15 +803,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
onCreateRef.current?.({
type: "Feature",
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
line_mode: "line",
type: "defense_line",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -839,15 +825,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
onCreateRef.current?.({
type: "Feature",
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
line_mode: "path",
type: "attack_route",
geometry_preset: "line",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -860,14 +847,16 @@ export default function Map({
() => modeRef.current,
(geometry: Geometry) => {
const id = crypto.randomUUID ? crypto.randomUUID() : Date.now();
onCreateRef.current({
onCreateRef.current?.({
type: "Feature",
properties: {
id,
entity_id: selectedEntityIdRef.current || null,
entity_ids: selectedEntityIdRef.current ? [selectedEntityIdRef.current] : [],
entity_name: selectedEntityNameRef.current || null,
entity_type_id: selectedEntityTypeIdRef.current || null,
type: null,
geometry_preset: "circle-area",
entity_id: null,
entity_ids: [],
entity_name: null,
entity_type_id: null,
binding: [],
},
geometry,
@@ -888,11 +877,18 @@ export default function Map({
// after everything mounted, push current draft to sources
applyDraftToMap(draftRef.current);
if (allowGeometryEditing) {
editingEngineRef.current?.bindEditEvents(map);
}
});
return () => map.remove();
}, [applyDraftToMap]);
return () => {
if (mapRef.current === map) {
mapRef.current = null;
}
map.remove();
};
}, [allowGeometryEditing, applyDraftToMap]);
const handleZoomByStep = (delta: number) => {
const map = mapRef.current;
@@ -912,13 +908,13 @@ export default function Map({
useEffect(() => {
applyDraftToMap(draft);
const editingId = editingEngineRef.current?.editingRef.current?.id;
if (editingId !== undefined && editingId !== null) {
if (allowGeometryEditing && editingId !== undefined && editingId !== null) {
const stillExists = draft.features.some((f) => f.properties.id === editingId);
if (!stillExists) {
editingEngineRef.current?.clearEditing();
}
}
}, [draft, selectedFeatureId, applyDraftToMap]);
}, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]);
return (
<div style={{ width: "100%", height: "100vh", position: "relative" }}>
@@ -1004,7 +1000,10 @@ function applyBackgroundLayerVisibility(
map: maplibregl.Map,
visibility: BackgroundLayerVisibility
) {
syncRasterBaseVisibility(map, visibility[RASTER_BASE_LAYER_ID]);
for (const layer of BACKGROUND_LAYER_OPTIONS) {
if (layer.id === RASTER_BASE_LAYER_ID) continue;
if (!map.getLayer(layer.id)) continue;
map.setLayoutProperty(
layer.id,
@@ -1014,6 +1013,62 @@ function applyBackgroundLayerVisibility(
}
}
function syncRasterBaseVisibility(map: maplibregl.Map, shouldShow: boolean) {
if (shouldShow) {
ensureRasterBaseLayer(map);
return;
}
removeRasterBaseLayer(map);
}
function ensureRasterBaseLayer(map: maplibregl.Map) {
if (!map.getSource(RASTER_BASE_SOURCE_ID)) {
map.addSource(RASTER_BASE_SOURCE_ID, createRasterBaseSource());
}
if (!map.getLayer(RASTER_BASE_LAYER_ID)) {
const beforeId = map.getLayer(RASTER_BASE_INSERT_BEFORE_LAYER_ID)
? RASTER_BASE_INSERT_BEFORE_LAYER_ID
: undefined;
map.addLayer(createRasterBaseLayer(), beforeId);
}
map.setLayoutProperty(RASTER_BASE_LAYER_ID, "visibility", "visible");
}
function removeRasterBaseLayer(map: maplibregl.Map) {
if (map.getLayer(RASTER_BASE_LAYER_ID)) {
map.removeLayer(RASTER_BASE_LAYER_ID);
}
if (map.getSource(RASTER_BASE_SOURCE_ID)) {
map.removeSource(RASTER_BASE_SOURCE_ID);
}
}
function createRasterBaseSource() {
return {
type: "raster" as const,
tiles: [getRasterTileTemplateUrl()],
tileSize: 256,
minzoom: 0,
maxzoom: 6,
};
}
function createRasterBaseLayer() {
return {
id: RASTER_BASE_LAYER_ID,
type: "raster" as const,
source: RASTER_BASE_SOURCE_ID,
paint: {
"raster-opacity": 0.92,
"raster-resampling": "linear" as const,
},
};
}
function filterDraftByBinding(
fc: FeatureCollection,
selectedFeatureId: string | number | null
@@ -1158,7 +1213,7 @@ function createPointIconImageData(spec: PointIconSpec): ImageData | null {
}
function buildPointIconExpression(): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
const expression: unknown[] = ["match", getFeatureTypeExpression()];
for (const [typeId, iconId] of Object.entries(POINT_ICON_BY_TYPE)) {
expression.push(typeId, iconId);
@@ -1169,10 +1224,10 @@ function buildPointIconExpression(): maplibregl.ExpressionSpecification {
}
function buildTypeMatchExpression(
valueByType: Record<string, string | number>,
fallback: string | number
valueByType: Record<string, string | number | boolean>,
fallback: string | number | boolean
): maplibregl.ExpressionSpecification {
const expression: unknown[] = ["match", ["coalesce", ["get", "entity_type_id"], ""]];
const expression: unknown[] = ["match", getFeatureTypeExpression()];
for (const [typeId, value] of Object.entries(valueByType)) {
expression.push(typeId, value);
@@ -1182,6 +1237,15 @@ function buildTypeMatchExpression(
return expression as maplibregl.ExpressionSpecification;
}
function getFeatureTypeExpression(): maplibregl.ExpressionSpecification {
return [
"coalesce",
["get", "type"],
["get", "entity_type_id"],
"",
] as maplibregl.ExpressionSpecification;
}
function roundZoom(value: number): number {
return Math.round(value * 10) / 10;
}

View File

@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
import { Entity } from "@/api/entities";
import { Feature } from "@/lib/useEditorState";
import {
EntityGeometryPreset,
EntityTypeGroupId,
EntityTypeOption,
findEntityTypeOption,
@@ -74,19 +75,21 @@ export default function SelectedGeometryPanel({
entityFormStatus,
}: Props) {
const groupedEntityTypeOptions = groupEntityTypeOptions(entityTypeOptions);
const allowedGroupIds = selectedFeature
? getAllowedGroupIdsForGeometry(selectedFeature.geometry.type)
const featureGeometryPreset = selectedFeature
? resolveFeatureGeometryPreset(selectedFeature)
: null;
const allowedGroupIds = featureGeometryPreset
? getAllowedGroupIdsForPreset(featureGeometryPreset)
: [];
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
const hasCurrentTypeOption = entityTypeOptions.some((option) => option.value === entityForm.type_id);
const geometryBucket = selectedFeature ? toGeometryBucket(selectedFeature.geometry.type) : null;
const selectedTypeBucket = selectedTypeOption ? toTypeBucket(selectedTypeOption) : null;
const isGeometryCompatible =
geometryBucket && selectedTypeBucket
? geometryBucket === selectedTypeBucket
const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) =>
group.options.some((option) => option.value === entityForm.type_id)
);
const isGeometryCompatible = selectedTypeOption
? allowedGroupIds.includes(selectedTypeOption.groupId)
: true;
return (
@@ -117,6 +120,9 @@ export default function SelectedGeometryPanel({
<div style={{ color: "#cbd5e1" }}>
Binding hiện tại: {selectedFeatureBindingSummary}
</div>
<div style={{ color: "#cbd5e1" }}>
Geometry preset: {formatGeometryPresetLabel(featureGeometryPreset)}
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Entities đã chọn:
@@ -166,6 +172,66 @@ export default function SelectedGeometryPanel({
</div>
)}
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Geometry phải ít nhất 1 entity đ Save.
</div>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Metadata geometry (áp dụng cho cả 2 luồng bên dưới)
</div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
`time_start`, `time_end`, `binding` sẽ đưc dùng cho cả luồng gắn entity sẵn
luồng tạo entity mới.
</div>
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.binding}
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
</div>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1f3b5a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 1: Gắn entity sẵn
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
</div>
<input
value={entitySearchQuery}
onChange={(event) => onEntitySearchQueryChange(event.target.value)}
@@ -194,16 +260,46 @@ export default function SelectedGeometryPanel({
disabled={isEntitySubmitting || isEntitySearchLoading}
style={secondaryActionButtonStyle}
>
Chọn đ thêm
Thêm entity đã chọn vào danh sách gắn
</button>
{isEntitySearchLoading ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
Đang tìm entity...
</div>
) : null}
<button
onClick={onApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Áp dụng danh sách entity + metadata
</button>
</div>
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
Geometry phải ít nhất 1 entity đ Save.
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 2: Tạo entity mới rồi gắn
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi chưa entity phù hợp. Điền form bên dưới rồi bấm nút tạo + gắn.
</div>
<input
@@ -220,82 +316,8 @@ export default function SelectedGeometryPanel({
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #243244",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Entity type presets
</div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}>
Chọn nhanh theo nhóm hình học bạn cần vẽ.
</div>
{visibleGroupedEntityTypeOptions.map((group) => {
const color = GROUP_COLORS[group.id];
return (
<div
key={group.id}
style={{
border: `1px solid ${color.border}`,
borderRadius: "8px",
padding: "8px",
background: color.background,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: "8px",
alignItems: "center",
marginBottom: "6px",
}}
>
<span style={{ color: color.text, fontWeight: 600, fontSize: "12px" }}>
{group.label}
</span>
<span style={{ color: color.mutedText, fontSize: "11px" }}>
{group.geometryLabel}
</span>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "6px" }}>
{group.options.map((option) => {
const active = option.value === entityForm.type_id;
return (
<button
key={option.value}
type="button"
onClick={() => onEntityFormChange("type_id", option.value)}
disabled={isEntitySubmitting}
style={{
border: active
? `1px solid ${color.activeBorder}`
: `1px solid ${color.chipBorder}`,
borderRadius: "999px",
padding: "4px 9px",
background: active ? color.activeBackground : color.chipBackground,
color: active ? color.activeText : color.text,
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
fontSize: "11px",
fontWeight: active ? 600 : 500,
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
{option.label}
</button>
);
})}
</div>
</div>
);
})}
Chọn loại entity
</div>
<select
value={entityForm.type_id}
@@ -303,7 +325,7 @@ export default function SelectedGeometryPanel({
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentTypeOption && entityForm.type_id ? (
{!hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
@@ -334,32 +356,10 @@ export default function SelectedGeometryPanel({
{!isGeometryCompatible && selectedTypeOption ? (
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
Type <b>{selectedTypeOption.label}</b> hợp với {selectedTypeBucket}, nhưng geometry hiện tại {geometryBucket}.
Type <b>{selectedTypeOption.label}</b> thuộc nhóm <b>{selectedTypeOption.groupLabel}</b>, nhưng geometry hiện tại <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</div>
) : null}
<input
value={geometryMetaForm.time_start}
onChange={(event) => onGeometryMetaFormChange("time_start", event.target.value)}
placeholder="time_start"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.time_end}
onChange={(event) => onGeometryMetaFormChange("time_end", event.target.value)}
placeholder="time_end"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={geometryMetaForm.binding}
onChange={(event) => onGeometryMetaFormChange("binding", event.target.value)}
placeholder="binding ids (vd: geo-id-1, geo-id-2)"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<button
onClick={onCreateEntityAndAttach}
disabled={isEntitySubmitting}
@@ -371,26 +371,12 @@ export default function SelectedGeometryPanel({
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Tạo Entity + Gắn
</button>
<button
onClick={onApplyEntitiesForSelectedGeometry}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#0f766e",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
}}
>
Áp dụng Entities + Metadata
Tạo entity mới + gắn + áp metadata
</button>
</div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
@@ -438,80 +424,42 @@ const secondaryActionButtonStyle: CSSProperties = {
color: "#ffffff",
};
const GROUP_COLORS: Record<
EntityTypeGroupId,
{
background: string;
border: string;
text: string;
mutedText: string;
chipBackground: string;
chipBorder: string;
activeBackground: string;
activeBorder: string;
activeText: string;
function resolveFeatureGeometryPreset(feature: Feature): EntityGeometryPreset {
const explicitPreset = normalizeGeometryPreset(feature.properties.geometry_preset);
if (explicitPreset) return explicitPreset;
const semanticType = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
if (semanticType) {
const option = findEntityTypeOption(semanticType);
if (option) return option.geometryPreset;
}
> = {
split: {
background: "#3f1d24",
border: "#7f1d1d",
text: "#fecaca",
mutedText: "#fda4af",
chipBackground: "#4c1d27",
chipBorder: "#7f1d1d",
activeBackground: "#7f1d1d",
activeBorder: "#fecaca",
activeText: "#ffffff",
},
route: {
background: "#082f49",
border: "#0c4a6e",
text: "#bae6fd",
mutedText: "#7dd3fc",
chipBackground: "#0c4a6e",
chipBorder: "#155e75",
activeBackground: "#0369a1",
activeBorder: "#bae6fd",
activeText: "#f0f9ff",
},
area_polygon: {
background: "#1f3a2a",
border: "#166534",
text: "#bbf7d0",
mutedText: "#86efac",
chipBackground: "#14532d",
chipBorder: "#166534",
activeBackground: "#15803d",
activeBorder: "#bbf7d0",
activeText: "#f0fdf4",
},
area_circle: {
background: "#3f2b05",
border: "#92400e",
text: "#fde68a",
mutedText: "#fcd34d",
chipBackground: "#78350f",
chipBorder: "#92400e",
activeBackground: "#b45309",
activeBorder: "#fde68a",
activeText: "#fffbeb",
},
point: {
background: "#2e1065",
border: "#6d28d9",
text: "#ddd6fe",
mutedText: "#c4b5fd",
chipBackground: "#4c1d95",
chipBorder: "#6d28d9",
activeBackground: "#7c3aed",
activeBorder: "#ddd6fe",
activeText: "#f5f3ff",
},
};
type GeometryBucket = "point" | "line" | "polygon";
return mapGeometryTypeToPreset(feature.geometry.type);
}
function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBucket {
function normalizeGeometryPreset(value: unknown): EntityGeometryPreset | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (
normalized === "point" ||
normalized === "line" ||
normalized === "polygon" ||
normalized === "circle-area"
) {
return normalized;
}
return null;
}
function normalizeTypeId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized.length ? normalized : null;
}
function mapGeometryTypeToPreset(
geometryType: Feature["geometry"]["type"]
): EntityGeometryPreset {
if (geometryType === "Point" || geometryType === "MultiPoint") {
return "point";
}
@@ -521,26 +469,28 @@ function toGeometryBucket(geometryType: Feature["geometry"]["type"]): GeometryBu
return "polygon";
}
function toTypeBucket(option: EntityTypeOption): GeometryBucket {
if (option.geometryPreset === "point") {
return "point";
}
if (option.geometryPreset === "line") {
return "line";
}
return "polygon";
}
function getAllowedGroupIdsForGeometry(
geometryType: Feature["geometry"]["type"]
function getAllowedGroupIdsForPreset(
geometryPreset: EntityGeometryPreset
): EntityTypeGroupId[] {
if (geometryType === "Point" || geometryType === "MultiPoint") {
if (geometryPreset === "point") {
return ["point"];
}
if (geometryType === "LineString" || geometryType === "MultiLineString") {
return ["split", "route"];
if (geometryPreset === "line") {
return ["line"];
}
return ["area_polygon", "area_circle"];
if (geometryPreset === "circle-area") {
return ["circle"];
}
return ["polygon"];
}
function formatGeometryPresetLabel(preset: EntityGeometryPreset | null): string {
if (preset === "point") return "point - Điểm";
if (preset === "line") return "line - Tuyến";
if (preset === "circle-area") return "circle - Tròn";
if (preset === "polygon") return "polygon - Đa giác";
return "unknown";
}

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);
if (hasContextActions) {
map.on("contextmenu", onRightClick);
}
return () => {
map.off("click", onClick);
map.off("mousemove", onMove);
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,12 +177,15 @@ 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;
}
if (onDelete) {
menu.appendChild(
createItem(
selectedCount > 1 ? `Xóa ${selectedCount} mục` : "Xóa",
@@ -194,10 +200,15 @@ export function initSelect(
}
)
);
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);
}