reduce api | version control

This commit is contained in:
taDuc
2026-04-19 00:13:22 +07:00
parent c88a6497f7
commit bc98830871
9 changed files with 1141 additions and 449 deletions

View File

@@ -1,15 +1,28 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, 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 { Entity, EntityBatchChange, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox, saveCombinedGeometryEntityBatchChanges, updateGeometry } from "@/api/geometries";
import { fetchGeometriesByBBox } from "@/api/geometries";
import {
createSection,
createSectionCommit,
fetchSections,
fetchSectionCommits,
openSectionEditor,
restoreSectionCommit,
Section,
SectionCommit,
SectionState,
submitSection,
} from "@/api/sections";
import {
Change,
Feature,
FeatureCollection,
useEditorState,
@@ -42,6 +55,8 @@ const FALLBACK_TIMELINE_RANGE: TimelineRange = {
};
const TIMELINE_DEBOUNCE_MS = 180;
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
const DEFAULT_SECTION_TITLE = "Main editor section";
const DEFAULT_EDITOR_USER_ID = "local-editor";
type TimelineRange = {
min: number;
@@ -60,11 +75,6 @@ type GeometryMetaFormState = {
binding: string;
};
type BindingGeometrySearchOption = {
id: string;
label: string;
};
type PendingEntityCreate = {
id: string;
name: string;
@@ -73,10 +83,34 @@ type PendingEntityCreate = {
status: number;
};
type EditorSnapshot = {
schema_version: number;
section: {
id: string;
title: string;
};
editor_feature_collection?: FeatureCollection;
entities?: Array<Record<string, unknown>>;
geometries?: Array<Record<string, unknown>>;
link_scopes?: Array<Record<string, unknown>>;
};
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 [isSubmitting, setIsSubmitting] = useState(false);
const [isOpeningSection, setIsOpeningSection] = useState(false);
const [availableSections, setAvailableSections] = useState<Section[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState("");
const [newSectionTitle, setNewSectionTitle] = useState("");
const [commitTitle, setCommitTitle] = useState("");
const [commitNote, setCommitNote] = useState("");
const [editorUserIdInput, setEditorUserIdInput] = useState(DEFAULT_EDITOR_USER_ID);
const [activeSection, setActiveSection] = useState<Section | null>(null);
const [sectionState, setSectionState] = useState<SectionState | null>(null);
const [sectionCommits, setSectionCommits] = useState<SectionCommit[]>([]);
const [lastSectionSnapshot, setLastSectionSnapshot] = useState<EditorSnapshot | null>(null);
const [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
const [createdEntities, setCreatedEntities] = useState<Array<{
@@ -103,9 +137,6 @@ export default function Page() {
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
const [bindingGeometrySearchQuery, setBindingGeometrySearchQuery] = useState("");
const [bindingGeometrySearchResults, setBindingGeometrySearchResults] = useState<BindingGeometrySearchOption[]>([]);
const [selectedBindingGeometryId, setSelectedBindingGeometryId] = useState<string | null>(null);
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);
@@ -124,8 +155,11 @@ export default function Page() {
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0);
const timelineYearRef = useRef(timelineYear);
const editorUserIdRef = useRef(DEFAULT_EDITOR_USER_ID);
const editor = useEditorState(initialData);
const editorUserId = normalizeEditorUserId(editorUserIdInput);
const entities = useMemo(
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
[persistedEntities, pendingEntityCreates]
@@ -162,6 +196,102 @@ export default function Page() {
return rows;
}, [editor.changes, entities]);
useEffect(() => {
timelineYearRef.current = timelineYear;
}, [timelineYear]);
useEffect(() => {
editorUserIdRef.current = editorUserId;
}, [editorUserId]);
const openSectionForEditing = useCallback(async (sectionId: string) => {
const editorPayload = await openSectionEditor(sectionId, editorUserIdRef.current);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
const commits = await fetchSectionCommits(sectionId);
let nextInitialData = snapshot?.editor_feature_collection || null;
if (!nextInitialData) {
nextInitialData = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timelineYearRef.current,
});
}
setActiveSection(editorPayload.section);
setSelectedSectionId(editorPayload.section.id);
setSectionState(editorPayload.state);
setLastSectionSnapshot(snapshot);
setInitialData(nextInitialData);
setSectionCommits(commits);
setPendingEntityCreates([]);
setCreatedEntities([]);
setSelectedFeatureId(null);
setEntityFormStatus(null);
}, []);
useEffect(() => {
let disposed = false;
async function loadSection() {
try {
setIsOpeningSection(true);
const sections = await fetchSections();
if (disposed) return;
setAvailableSections(sections);
const section = await resolveDefaultSection(sections, editorUserIdRef.current);
if (disposed) return;
setAvailableSections((prev) =>
prev.some((item) => item.id === section.id)
? prev
: [section, ...prev]
);
try {
await openSectionForEditing(section.id);
} catch (err) {
if (!(err instanceof ApiError) || err.status !== 409) {
throw err;
}
const fallbackSection = await resolveOpenableSectionAfterConflict(
sections,
section.id,
editorUserIdRef.current
);
if (disposed) return;
setAvailableSections((prev) =>
prev.some((item) => item.id === fallbackSection.id)
? prev
: [fallbackSection, ...prev]
);
await openSectionForEditing(fallbackSection.id);
if (!disposed) {
setEntityStatus("Section mặc định đang bị lock, đã mở section khác để chỉnh sửa.");
}
}
} catch (err) {
if (disposed) return;
if (err instanceof ApiError) {
setEntityStatus(`Không tải được section editor: ${err.body || err.message}`);
} else {
console.error("Load section editor failed", err);
setEntityStatus("Không tải được section editor.");
}
} finally {
if (!disposed) {
setIsOpeningSection(false);
}
}
}
loadSection();
return () => {
disposed = true;
};
}, [openSectionForEditing]);
useEffect(() => {
let disposed = false;
@@ -171,9 +301,7 @@ export default function Page() {
if (disposed) return;
setPersistedEntities(rows);
setEntityStatus(rows.length
? null
: "Chưa có entity. Cần tạo entity trước khi Save geometry.");
setEntityStatus(null);
} catch (err) {
if (disposed) return;
console.error("Load entities failed", err);
@@ -264,51 +392,6 @@ export default function Page() {
};
}, [entitySearchQuery, selectedFeature, pendingEntityCreates]);
useEffect(() => {
if (!selectedFeature) {
setBindingGeometrySearchResults([]);
setSelectedBindingGeometryId(null);
return;
}
const keyword = bindingGeometrySearchQuery.trim().toLowerCase();
if (!keyword.length) {
setBindingGeometrySearchResults([]);
setSelectedBindingGeometryId(null);
return;
}
const currentBindingIds = new Set(parseBindingInput(geometryMetaForm.binding));
const rows = editor.draft.features
.filter((feature) => String(feature.properties.id) !== String(selectedFeature.properties.id))
.map((feature) => {
const id = String(feature.properties.id);
const typeLabel = feature.properties.type || feature.geometry.type;
const entityLabel = Array.isArray(feature.properties.entity_names) && feature.properties.entity_names.length
? feature.properties.entity_names.join(", ")
: (feature.properties.entity_name || "");
const label = `#${id} [${feature.geometry.type}] ${typeLabel}${entityLabel ? ` - ${entityLabel}` : ""}`;
const searchable = `${id} ${feature.geometry.type} ${typeLabel} ${entityLabel}`.toLowerCase();
return { id, label, searchable };
})
.filter((item) => !currentBindingIds.has(item.id))
.filter((item) => item.searchable.includes(keyword))
.slice(0, 40)
.map((item) => ({ id: item.id, label: item.label }));
setBindingGeometrySearchResults(rows);
setSelectedBindingGeometryId((prev) =>
prev && rows.some((item) => item.id === prev)
? prev
: rows[0]?.id || null
);
}, [
bindingGeometrySearchQuery,
geometryMetaForm.binding,
editor.draft,
selectedFeature,
]);
useEffect(() => {
if (selectedFeatureId === null) return;
const stillExists = editor.draft.features.some((feature) =>
@@ -330,9 +413,6 @@ export default function Page() {
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setBindingGeometrySearchQuery("");
setBindingGeometrySearchResults([]);
setSelectedBindingGeometryId(null);
setEntityFormStatus(null);
return;
}
@@ -351,14 +431,7 @@ export default function Page() {
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setBindingGeometrySearchQuery("");
setBindingGeometrySearchResults([]);
setSelectedBindingGeometryId(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);
}
setEntityFormStatus(null);
}, [selectedFeature]);
useEffect(() => {
@@ -470,7 +543,9 @@ export default function Page() {
});
if (disposed || requestId !== timelineFetchRequestRef.current) return;
setInitialData(data);
if (!lastSectionSnapshot?.editor_feature_collection) {
setInitialData(data);
}
} catch (err) {
if (err instanceof ApiError) {
console.error("Load timeline data failed", err.body);
@@ -493,65 +568,54 @@ export default function Page() {
return () => {
disposed = true;
};
}, [timelineYear, isTimelineReady]);
}, [timelineYear, isTimelineReady, lastSectionSnapshot]);
const handleSave = async () => {
const geometryChanges = editor.buildPayload();
const entityChanges: EntityBatchChange[] = pendingEntityCreates.map((entity) => ({
action: "create",
entity: {
id: entity.id,
name: entity.name,
slug: entity.slug,
type_id: entity.type_id,
status: entity.status,
},
}));
if (!geometryChanges.length && !entityChanges.length) return;
const invalid = geometryChanges.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.");
const handleCommitSection = async () => {
if (!activeSection || !sectionState) {
setEntityStatus("Chưa mở được section editor.");
return;
}
const geometryChanges = editor.buildPayload();
setIsSaving(true);
setEntityStatus(null);
try {
await saveCombinedGeometryEntityBatchChanges(entityChanges, geometryChanges);
if (geometryChanges.length) {
editor.clearChanges();
await reloadCurrentTimelineData();
}
if (entityChanges.length) {
setPendingEntityCreates([]);
await reloadEntities();
setEntityFormStatus("Đã lưu batch entities + geometries.");
}
const snapshot = buildEditorSnapshot({
section: activeSection,
draft: editor.draft,
changes: geometryChanges,
pendingEntities: pendingEntityCreates,
previousSnapshot: lastSectionSnapshot,
hasPersistedFeature: editor.hasPersistedFeature,
});
const result = await createSectionCommit(activeSection.id, {
snapshot,
created_by: editorUserId,
expected_version: sectionState.version,
expected_head_commit_id: sectionState.head_commit_id,
title: commitTitle.trim() || `Commit ${new Date().toLocaleString()}`,
note: commitNote.trim() || null,
});
setSectionState(result.state);
setLastSectionSnapshot(snapshot);
setInitialData(editor.draft);
editor.clearChanges();
setPendingEntityCreates([]);
setCreatedEntities([]);
setCommitTitle("");
setCommitNote("");
setSectionCommits(await fetchSectionCommits(activeSection.id));
setEntityFormStatus(`Đã tạo commit #${result.commit.commit_no}.`);
} catch (err) {
if (err instanceof ApiError) {
console.error("Save failed", err.body);
setEntityStatus(`Save thất bại: ${err.body}`);
console.error("Commit failed", err.body);
setEntityStatus(`Commit thất bại: ${err.body}`);
return;
}
console.error("Save error", err);
setEntityStatus("Save thất bại.");
console.error("Commit error", err);
setEntityStatus("Commit thất bại.");
} finally {
setIsSaving(false);
}
@@ -623,42 +687,6 @@ export default function Page() {
setEntityFormStatus(null);
};
const handleAddSelectedBindingGeometry = () => {
const geometryId = selectedBindingGeometryId ? selectedBindingGeometryId.trim() : "";
if (!geometryId.length) {
setEntityFormStatus("Hãy chọn một geometry từ kết quả search binding trước.");
return;
}
if (selectedFeature && String(selectedFeature.properties.id) === geometryId) {
setEntityFormStatus("Không thể tự bind geometry với chính nó.");
return;
}
const currentBindingIds = parseBindingInput(geometryMetaForm.binding);
const nextBindingIds = uniqueEntityIds([...currentBindingIds, geometryId]);
setGeometryMetaForm((prev) => ({
...prev,
binding: nextBindingIds.join(", "),
}));
setSelectedBindingGeometryId(null);
setEntityFormStatus(null);
};
const reloadEntities = async () => {
const rows = await fetchEntities();
setPersistedEntities(rows);
return rows;
};
const reloadCurrentTimelineData = async () => {
const data = await fetchGeometriesByBBox({
...WORLD_BBOX,
time: timelineYear,
});
setInitialData(data);
};
const parseGeometryMetaFormRange = () => {
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
@@ -678,12 +706,28 @@ export default function Page() {
return primaryEntity?.type_id || null;
};
const patchSelectedFeatureLocally = (
const patchSelectedGeometryMetadataLocally = (
feature: Feature,
entityIds: string[],
timeStart: number | null,
timeEnd: number | null,
bindingIds: string[],
bindingIds: string[]
) => {
editor.patchFeatureProperties(feature.properties.id, {
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
});
setGeometryMetaForm({
time_start: timeStart != null ? String(timeStart) : "",
time_end: timeEnd != null ? String(timeEnd) : "",
binding: bindingIds.join(", "),
});
};
const patchSelectedFeatureEntitiesLocally = (
feature: Feature,
entityIds: string[],
entityRows: Entity[] = entities
) => {
const primaryEntityId = entityIds[0] || null;
@@ -702,31 +746,17 @@ export default function Page() {
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 () => {
const handleApplyGeometryMetadata = 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[];
@@ -741,34 +771,25 @@ export default function Page() {
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
const pendingEntityIdSet = new Set(pendingEntityCreates.map((entity) => entity.id));
const hasPendingEntity = entityIds.some((entityId) => pendingEntityIdSet.has(entityId));
if (hasPendingEntity) {
setEntityFormStatus("Danh sách gắn có entity chưa lưu. Hãy bấm Save trước, rồi áp dụng lại.");
return;
}
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;
}
patchSelectedGeometryMetadataLocally(selectedFeature, timeStart, timeEnd, bindingIds);
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
} finally {
setIsEntitySubmitting(false);
}
};
await updateGeometry(selectedFeature.properties.id, {
geometry: selectedFeature.geometry,
type: nextGeometryType ?? undefined,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: entityIds,
});
const handleApplyEntitiesForSelectedGeometry = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
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.");
}
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
patchSelectedFeatureEntitiesLocally(selectedFeature, entityIds);
setEntityFormStatus("Đã cập nhật danh sách entity. Commit khi sẵn sàng.");
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
@@ -837,7 +858,7 @@ export default function Page() {
slug: "",
}));
setEntityStatus(null);
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Save.");
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit.");
if (selectedFeature) {
setEntitySearchQuery(pendingCreate.name);
@@ -848,11 +869,142 @@ export default function Page() {
}
};
const handleOpenSelectedSection = async () => {
const sectionId = selectedSectionId.trim();
if (!sectionId) {
setEntityStatus("Hãy chọn section để mở.");
return;
}
if (pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Mở section khác sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
setIsOpeningSection(true);
setEntityStatus(null);
try {
await openSectionForEditing(sectionId);
setEntityStatus("Đã mở section để chỉnh sửa.");
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Mở section thất bại: ${err.body}`);
} else {
setEntityStatus("Mở section thất bại.");
}
} finally {
setIsOpeningSection(false);
}
};
const handleCreateAndOpenSection = async () => {
const title = newSectionTitle.trim();
if (!title) {
setEntityStatus("Tên section là bắt buộc.");
return;
}
if (pendingSaveCount > 0) {
const confirmed = window.confirm("Section hiện tại có thay đổi chưa Commit. Tạo section mới sẽ bỏ các thay đổi này. Tiếp tục?");
if (!confirmed) return;
}
setIsOpeningSection(true);
setEntityStatus(null);
try {
const section = await createSection({
title,
user_id: editorUserId,
created_by: editorUserId,
});
const sections = await fetchSections();
setAvailableSections(sections);
setNewSectionTitle("");
await openSectionForEditing(section.id);
setEntityStatus("Đã tạo và mở section mới.");
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Tạo section thất bại: ${err.body}`);
} else {
setEntityStatus("Tạo section thất bại.");
}
} finally {
setIsOpeningSection(false);
}
};
const handleSubmitSection = async () => {
if (!activeSection || !sectionState?.head_commit_id) {
setEntityStatus("Chưa có commit để submit.");
return;
}
if (pendingSaveCount > 0) {
setEntityStatus("Hãy Commit các thay đổi trước khi Submit.");
return;
}
setIsSubmitting(true);
setEntityStatus(null);
try {
const submission = await submitSection(activeSection.id, {
submitted_by: editorUserId,
commit_id: sectionState.head_commit_id,
});
setSectionState((prev) => prev ? { ...prev, status: "submitted" } : prev);
setEntityStatus(`Đã submit section, submission ${submission.id}.`);
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Submit thất bại: ${err.body}`);
} else {
setEntityStatus("Submit thất bại.");
}
} finally {
setIsSubmitting(false);
}
};
const handleRestoreCommit = async (commitId: string) => {
if (!activeSection || !sectionState) {
setEntityStatus("Chưa mở được section editor.");
return;
}
if (pendingSaveCount > 0) {
setEntityStatus("Hãy Commit hoặc Undo thay đổi hiện tại trước khi Restore.");
return;
}
setIsSaving(true);
setEntityStatus(null);
try {
const result = await restoreSectionCommit(activeSection.id, {
commit_id: commitId,
created_by: editorUserId,
expected_version: sectionState.version,
expected_head_commit_id: sectionState.head_commit_id,
});
const editorPayload = await openSectionEditor(activeSection.id, editorUserId);
const snapshot = normalizeEditorSnapshot(editorPayload.snapshot);
setSectionState(result.state);
setLastSectionSnapshot(snapshot);
if (snapshot?.editor_feature_collection) {
setInitialData(snapshot.editor_feature_collection);
}
setSectionCommits(await fetchSectionCommits(activeSection.id));
setEntityFormStatus(`Đã restore thành commit #${result.commit.commit_no}.`);
} catch (err) {
if (err instanceof ApiError) {
setEntityStatus(`Restore thất bại: ${err.body}`);
} else {
setEntityStatus("Restore thất bại.");
}
} finally {
setIsSaving(false);
}
};
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
const timelineStatusText =
pendingSaveCount > 0
? "Lưu hết thay đổi trước khi đổi mốc thời gian."
? "Commit hoặc Undo hết thay đổi trước khi đổi mốc thời gian."
: isSaving
? "Đang lưu thay đổi..."
: timelineStatus;
@@ -869,8 +1021,30 @@ export default function Page() {
setMode={setMode}
entityStatus={entityStatus}
onUndo={editor.undo}
onSave={handleSave}
onCommit={handleCommitSection}
onSubmit={handleSubmitSection}
onRestoreCommit={handleRestoreCommit}
isSaving={isSaving}
isSubmitting={isSubmitting}
isOpeningSection={isOpeningSection}
sectionTitle={activeSection?.title || "Đang tải section"}
sectionStatus={sectionState?.status || "editing"}
selectedSectionId={selectedSectionId}
editorUserId={editorUserIdInput}
sectionOptions={availableSections}
newSectionTitle={newSectionTitle}
commitTitle={commitTitle}
commitNote={commitNote}
onEditorUserIdChange={setEditorUserIdInput}
onSelectedSectionIdChange={setSelectedSectionId}
onNewSectionTitleChange={setNewSectionTitle}
onCommitTitleChange={setCommitTitle}
onCommitNoteChange={setCommitNote}
onOpenSection={handleOpenSelectedSection}
onCreateSection={handleCreateAndOpenSection}
commitCount={sectionCommits.length}
latestCommitLabel={sectionCommits[0] ? `Head #${sectionCommits[0].commit_no}` : null}
commits={sectionCommits}
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
createdEntities={createdEntities}
@@ -940,14 +1114,9 @@ export default function Page() {
entityTypeOptions={ENTITY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange}
bindingGeometrySearchQuery={bindingGeometrySearchQuery}
onBindingGeometrySearchQueryChange={setBindingGeometrySearchQuery}
bindingGeometrySearchResults={bindingGeometrySearchResults}
selectedBindingGeometryId={selectedBindingGeometryId}
onSelectBindingGeometryId={setSelectedBindingGeometryId}
onAddSelectedBindingGeometry={handleAddSelectedBindingGeometry}
isEntitySubmitting={isEntitySubmitting}
onCreateEntityOnly={handleCreateEntityOnly}
onApplyGeometryMetadata={handleApplyGeometryMetadata}
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
@@ -958,6 +1127,233 @@ export default function Page() {
);
}
async function resolveDefaultSection(existingSections?: Section[], userId = DEFAULT_EDITOR_USER_ID): Promise<Section> {
const sections = existingSections || await fetchSections();
const existingDefault = sections.find((section) => section.title === DEFAULT_SECTION_TITLE);
if (existingDefault) {
return existingDefault;
}
return createSection({
title: DEFAULT_SECTION_TITLE,
user_id: userId,
created_by: userId,
description: "Default section used by the map editor UI.",
});
}
async function resolveOpenableSectionAfterConflict(
sections: Section[],
blockedSectionId: string,
userId: string
): Promise<Section> {
const now = Date.now();
const fallback = sections.find((section) =>
section.id !== blockedSectionId &&
section.state.status === "editing" &&
(
!section.state.locked_by ||
section.state.locked_by === userId ||
(
section.state.lock_expires_at !== null &&
Date.parse(section.state.lock_expires_at) <= now
)
)
);
if (fallback) return fallback;
return createSection({
title: `${DEFAULT_SECTION_TITLE} (${userId})`,
user_id: userId,
created_by: userId,
description: "Fallback section created because the default section is locked.",
});
}
function normalizeEditorUserId(value: string): string {
const normalized = value.trim();
return normalized || DEFAULT_EDITOR_USER_ID;
}
function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const snapshot = raw as EditorSnapshot;
if (
snapshot.editor_feature_collection &&
snapshot.editor_feature_collection.type === "FeatureCollection" &&
Array.isArray(snapshot.editor_feature_collection.features)
) {
return snapshot;
}
return {
...snapshot,
editor_feature_collection: undefined,
};
}
function buildEditorSnapshot(options: {
section: Section;
draft: FeatureCollection;
changes: Change[];
pendingEntities: PendingEntityCreate[];
previousSnapshot: EditorSnapshot | null;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
}): EditorSnapshot {
const changedIds = new Set(options.changes.map((change) => String(change.action === "create" ? change.feature.properties.id : change.id)));
const deletedIds = new Set(
options.changes
.filter((change): change is Extract<Change, { action: "delete" }> => change.action === "delete")
.map((change) => String(change.id))
);
const currentDraftIds = new Set(options.draft.features.map((feature) => String(feature.properties.id)));
const previousFeatures = new globalThis.Map<string, Feature>();
for (const feature of options.previousSnapshot?.editor_feature_collection?.features || []) {
previousFeatures.set(String(feature.properties.id), feature);
if (!currentDraftIds.has(String(feature.properties.id))) {
deletedIds.add(String(feature.properties.id));
}
}
const previousGeometryOps = new globalThis.Map<string, string>();
for (const item of options.previousSnapshot?.geometries || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
const operation = typeof item.operation === "string" ? item.operation : "";
if (id && operation) previousGeometryOps.set(id, operation);
}
const pendingEntityIds = new Set(options.pendingEntities.map((entity) => entity.id));
const entityRows = new globalThis.Map<string, Record<string, unknown>>();
for (const item of options.previousSnapshot?.entities || []) {
const id = typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : "";
if (id) entityRows.set(id, { ...item });
}
for (const entity of options.pendingEntities) {
entityRows.set(entity.id, {
id: entity.id,
operation: "create",
name: entity.name,
slug: entity.slug,
description: null,
type_id: entity.type_id,
status: entity.status,
is_deleted: 0,
});
}
for (const feature of options.draft.features) {
for (const entityId of normalizeFeatureEntityIds(feature)) {
if (entityRows.has(entityId)) continue;
entityRows.set(entityId, {
id: entityId,
operation: "reference",
name: feature.properties.entity_names?.[0] || feature.properties.entity_name || entityId,
slug: null,
description: null,
type_id: feature.properties.entity_type_id || feature.properties.type || DEFAULT_ENTITY_TYPE_ID,
status: 1,
is_deleted: 0,
});
}
}
const geometries: Array<Record<string, unknown>> = options.draft.features.map((feature) => {
const id = String(feature.properties.id);
const previousOperation = previousGeometryOps.get(id);
const previousFeature = previousFeatures.get(id);
const changedFromPreviousSnapshot = previousFeature
? JSON.stringify(previousFeature) !== JSON.stringify(feature)
: false;
const operation = previousOperation === "create"
? "create"
: !previousFeature && (options.previousSnapshot || !options.hasPersistedFeature(feature.properties.id))
? "create"
: changedIds.has(id) || changedFromPreviousSnapshot
? "update"
: "reference";
const bbox = getFeatureBBox(feature);
return {
id,
operation,
type: feature.properties.type || getDefaultTypeIdForFeature(feature),
draw_geometry: feature.geometry,
binding: normalizeFeatureBindingIds(feature),
time_start: feature.properties.time_start ?? null,
time_end: feature.properties.time_end ?? null,
bbox: bbox
? {
min_lng: bbox.minLng,
min_lat: bbox.minLat,
max_lng: bbox.maxLng,
max_lat: bbox.maxLat,
}
: null,
is_deleted: 0,
};
});
for (const id of deletedIds) {
geometries.push({
id,
operation: "delete",
is_deleted: 1,
});
}
const linkScopes = options.draft.features
.map((feature) => ({
geometry_id: String(feature.properties.id),
operation: "replace",
entity_ids: normalizeFeatureEntityIds(feature),
}))
.filter((scope) => scope.entity_ids.length > 0);
return {
schema_version: 1,
section: {
id: options.section.id,
title: options.section.title,
},
editor_feature_collection: JSON.parse(JSON.stringify(options.draft)) as FeatureCollection,
entities: Array.from(entityRows.values()).map((entity) => {
const id = String(entity.id || "");
if (pendingEntityIds.has(id)) return entity;
return entity;
}),
geometries,
link_scopes: linkScopes,
};
}
function getFeatureBBox(feature: Feature): { minLng: number; minLat: number; maxLng: number; maxLat: number } | null {
const points = collectCoordinatePairs(feature.geometry.coordinates);
if (!points.length) return null;
let minLng = Number.POSITIVE_INFINITY;
let minLat = Number.POSITIVE_INFINITY;
let maxLng = Number.NEGATIVE_INFINITY;
let maxLat = Number.NEGATIVE_INFINITY;
for (const [lng, lat] of points) {
minLng = Math.min(minLng, lng);
minLat = Math.min(minLat, lat);
maxLng = Math.max(maxLng, lng);
maxLat = Math.max(maxLat, lat);
}
return { minLng, minLat, maxLng, maxLat };
}
function collectCoordinatePairs(value: unknown): Array<[number, number]> {
if (!Array.isArray(value)) return [];
if (
value.length >= 2 &&
typeof value[0] === "number" &&
typeof value[1] === "number" &&
Number.isFinite(value[0]) &&
Number.isFinite(value[1])
) {
return [[value[0], value[1]]];
}
return value.flatMap((item) => collectCoordinatePairs(item));
}
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;