Files
History-client/app/editor/page.tsx
2026-04-21 16:07:21 +07:00

777 lines
27 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef } 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, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox } from "@/api/geometries";
import {
fetchSections,
SectionCommit,
} from "@/api/sections";
import {
Feature,
useEditorState,
} from "@/lib/useEditorState";
import {
BackgroundLayerId,
BackgroundLayerVisibility,
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
import {
DEFAULT_ENTITY_TYPE_ID,
ENTITY_TYPE_OPTIONS,
EntityTypeGroupId,
findEntityTypeOption,
} from "@/lib/entityTypeOptions";
import {
EntityFormState,
PendingEntityCreate,
useEditorSessionState,
} from "@/lib/useEditorSessionState";
import {
getDefaultTypeIdForFeature,
normalizeFeatureBindingIds,
normalizeFeatureEntityIds,
uniqueEntityIds,
} from "@/lib/editor/snapshot/editorSnapshot";
import {
buildClientEntityId,
formatEntityNamesForDisplay,
mergeEntitiesWithPending,
mergeEntitySearchResults,
} from "@/lib/editor/entity/entityBinding";
import {
formatBindingIdsForDisplay,
} from "@/lib/editor/geometry/geometryMetadata";
import {
loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
} from "@/lib/editor/background/backgroundVisibilityStorage";
import { useSectionCommands } from "@/lib/editor/section/useSectionCommands";
import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants";
import { FIXED_TIMELINE_RANGE, clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline";
import { useFeatureCommands } from "./featureCommands";
const CURRENT_YEAR = new Date().getUTCFullYear();
const DEFAULT_EDITOR_USER_ID = "local-editor";
export default function Page() {
const {
mode,
setMode,
initialData,
setInitialData,
isSaving,
setIsSaving,
isSubmitting,
setIsSubmitting,
isOpeningSection,
setIsOpeningSection,
availableSections,
setAvailableSections,
selectedSectionId,
setSelectedSectionId,
newSectionTitle,
setNewSectionTitle,
commitTitle,
setCommitTitle,
commitNote,
setCommitNote,
editorUserIdInput,
setEditorUserIdInput,
activeSection,
setActiveSection,
sectionState,
setSectionState,
sectionCommits,
setSectionCommits,
lastSectionSnapshot,
setLastSectionSnapshot,
persistedEntities,
setPersistedEntities,
pendingEntityCreates,
setPendingEntityCreates,
createdEntities,
setCreatedEntities,
entityStatus,
setEntityStatus,
selectedFeatureId,
setSelectedFeatureId,
entityForm,
setEntityForm,
selectedGeometryEntityIds,
setSelectedGeometryEntityIds,
geometryMetaForm,
setGeometryMetaForm,
isEntitySubmitting,
setIsEntitySubmitting,
entityFormStatus,
setEntityFormStatus,
entitySearchQuery,
setEntitySearchQuery,
entitySearchResults,
setEntitySearchResults,
selectedSearchEntityId,
setSelectedSearchEntityId,
isEntitySearchLoading,
setIsEntitySearchLoading,
timelineYear,
setTimelineYear,
timelineDraftYear,
setTimelineDraftYear,
isTimelineLoading,
setIsTimelineLoading,
timelineStatus,
setTimelineStatus,
backgroundVisibility,
setBackgroundVisibility,
isBackgroundVisibilityReady,
setIsBackgroundVisibilityReady,
} = useEditorSessionState({
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
fallbackTimelineRange: FIXED_TIMELINE_RANGE,
currentYear: CURRENT_YEAR,
});
// Counter để bỏ qua response cũ khi user đổi timeline/section liên tục.
const timelineFetchRequestRef = useRef(0);
// Counter để bỏ qua response cũ khi user gõ search entity liên tục.
const entitySearchRequestRef = useRef(0);
const editor = useEditorState(initialData);
const editorUserId = normalizeEditorUserId(editorUserIdInput);
const entities = useMemo(
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
[persistedEntities, pendingEntityCreates]
);
const selectedFeature =
selectedFeatureId === null
? null
: editor.draft.features.find((feature) =>
String(feature.properties.id) === String(selectedFeatureId)
) || null;
const createdGeometries = useMemo(() => {
const rows: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}> = [];
for (const change of editor.changes.values()) {
if (change.action !== "create") continue;
const feature = change.feature;
const entityNames = normalizeFeatureEntityIds(feature)
.map((entityId) => entities.find((entity) => entity.id === entityId)?.name || entityId);
rows.push({
id: feature.properties.id,
geometryType: feature.geometry.type,
semanticType: feature.properties.type || getDefaultTypeIdForFeature(feature),
entityNames,
});
}
return rows;
}, [editor.changes, entities]);
const sectionCommands = useSectionCommands({
editor,
editorUserId,
emptyFeatureCollection: EMPTY_FEATURE_COLLECTION,
activeSection,
sectionState,
selectedSectionId,
newSectionTitle,
pendingSaveCount: editor.changeCount + pendingEntityCreates.length,
pendingEntityCreates,
lastSectionSnapshot,
commitTitle,
commitNote,
setActiveSection,
setSelectedSectionId,
setSectionState,
setLastSectionSnapshot,
setInitialData,
setSectionCommits,
setPendingEntityCreates,
setCreatedEntities,
setEntityFormStatus,
setSelectedFeatureId,
setEntityStatus,
setIsSaving,
setIsSubmitting,
setIsOpeningSection,
setAvailableSections,
setNewSectionTitle,
setCommitTitle,
setCommitNote,
});
useEffect(() => {
let disposed = false;
async function loadSections() {
try {
setIsOpeningSection(true);
const sections = await fetchSections();
if (disposed) return;
setAvailableSections(sections);
setSelectedSectionId(sections[0]?.id || "");
setEntityStatus(null);
} catch (err) {
if (disposed) return;
if (err instanceof ApiError) {
setEntityStatus(`Không tải được danh sách section: ${err.body || err.message}`);
} else {
console.error("Load sections failed", err);
setEntityStatus("Không tải được danh sách section.");
}
} finally {
if (!disposed) {
setIsOpeningSection(false);
}
}
}
loadSections();
return () => {
disposed = true;
};
}, [
setAvailableSections,
setEntityStatus,
setIsOpeningSection,
setSelectedSectionId,
]);
useEffect(() => {
let disposed = false;
async function loadEntities() {
try {
const rows = await fetchEntities();
if (disposed) return;
setPersistedEntities(rows);
setEntityStatus(null);
} 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;
};
}, [setEntityStatus, setPersistedEntities]);
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;
const pendingMatches = pendingEntityCreates
.filter((entity) =>
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
)
.map<Entity>((entity) => ({
id: entity.id,
name: entity.name,
slug: entity.slug,
type_id: entity.type_id,
status: entity.status,
geometry_count: 0,
}));
const mergedRows = mergeEntitySearchResults(rows, pendingMatches);
setEntitySearchResults(mergedRows);
setSelectedSearchEntityId((prev) =>
prev && mergedRows.some((entity) => entity.id === prev)
? prev
: mergedRows[0]?.id || null
);
} catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err);
const pendingMatches = pendingEntityCreates
.filter((entity) =>
entity.name.toLowerCase().includes(keyword.toLowerCase()) ||
(entity.slug || "").toLowerCase().includes(keyword.toLowerCase())
)
.map<Entity>((entity) => ({
id: entity.id,
name: entity.name,
slug: entity.slug,
type_id: entity.type_id,
status: entity.status,
geometry_count: 0,
}));
setEntitySearchResults(pendingMatches);
setSelectedSearchEntityId(pendingMatches[0]?.id || null);
} finally {
if (!disposed && requestId === entitySearchRequestRef.current) {
setIsEntitySearchLoading(false);
}
}
}, 220);
return () => {
disposed = true;
window.clearTimeout(timeoutId);
};
}, [
entitySearchQuery,
selectedFeature,
pendingEntityCreates,
setEntitySearchResults,
setIsEntitySearchLoading,
setSelectedSearchEntityId,
]);
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, setSelectedFeatureId]);
useEffect(() => {
if (!selectedFeature) {
setSelectedGeometryEntityIds([]);
setGeometryMetaForm({
time_start: "",
time_end: "",
binding: "",
});
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setEntityFormStatus(null);
return;
}
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature);
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);
setEntityFormStatus(null);
}, [
selectedFeature,
setEntityFormStatus,
setEntitySearchQuery,
setEntitySearchResults,
setGeometryMetaForm,
setSelectedGeometryEntityIds,
setSelectedSearchEntityId,
]);
useEffect(() => {
if (!selectedFeature) return;
const allowedGroupIds = getAllowedEntityTypeGroupIdsForFeature(selectedFeature);
const fallbackOption = ENTITY_TYPE_OPTIONS.find((option) =>
allowedGroupIds.includes(option.groupId)
);
if (!fallbackOption) return;
setEntityForm((prev) => {
const currentOption = findEntityTypeOption(prev.type_id);
const isCurrentAllowed = currentOption
? allowedGroupIds.includes(currentOption.groupId)
: false;
if (isCurrentAllowed || prev.type_id === fallbackOption.value) {
return prev;
}
return {
...prev,
type_id: fallbackOption.value,
};
});
}, [selectedFeature, setEntityForm]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
if (timelineDraftYear !== timelineYear) {
setTimelineYear(timelineDraftYear);
}
}, TIMELINE_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear, setTimelineYear]);
useEffect(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, [setBackgroundVisibility, setIsBackgroundVisibilityReady]);
useEffect(() => {
if (activeSection) return;
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
async function loadGlobalByTimeline() {
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 global timeline data failed", err.body);
} else {
console.error("Load global timeline data failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setTimelineStatus("Không tải được geometry global tại mốc thời gian đã chọn.");
}
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsTimelineLoading(false);
}
}
}
loadGlobalByTimeline();
return () => {
disposed = true;
};
}, [
timelineYear,
activeSection,
setInitialData,
setIsTimelineLoading,
setTimelineStatus,
]);
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 handleTimelineYearChange = (nextYear: number) => {
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
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 featureCommands = useFeatureCommands({
editor,
selectedFeature,
geometryMetaForm,
setGeometryMetaForm,
selectedGeometryEntityIds,
setSelectedGeometryEntityIds,
entities,
setIsEntitySubmitting,
setEntityFormStatus,
});
const handleCreateEntityOnly = async () => {
const name = entityForm.name.trim();
if (!name) {
setEntityFormStatus("Tên entity là bắt buộc.");
return;
}
const slug = entityForm.slug.trim() || null;
const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID;
const normalizedName = name.toLowerCase();
const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName);
if (duplicatedName) {
setEntityFormStatus("Tên entity đã tồn tại.");
return;
}
if (slug) {
const normalizedSlug = slug.toLowerCase();
const duplicatedSlug = entities.some((entity) =>
(entity.slug || "").trim().toLowerCase() === normalizedSlug
);
if (duplicatedSlug) {
setEntityFormStatus("Slug entity đã tồn tại.");
return;
}
}
const entityId = buildClientEntityId();
const pendingCreate: PendingEntityCreate = {
id: entityId,
name,
slug,
type_id: typeId,
status: 1,
};
setIsEntitySubmitting(true);
setEntityFormStatus(null);
try {
setPendingEntityCreates((prev) => [pendingCreate, ...prev]);
setCreatedEntities((prev) => {
if (prev.some((item) => item.id === pendingCreate.id)) return prev;
return [
{
id: pendingCreate.id,
name: pendingCreate.name,
type_id: pendingCreate.type_id || null,
},
...prev,
];
});
setEntityForm((prev) => ({
...prev,
name: "",
slug: "",
}));
setEntityStatus(null);
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Commit.");
if (selectedFeature) {
setEntitySearchQuery(pendingCreate.name);
setSelectedSearchEntityId(pendingCreate.id);
}
} finally {
setIsEntitySubmitting(false);
}
};
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
const headCommit = sectionState?.head_commit_id
? sectionCommits.find((commit) => commit.id === sectionState.head_commit_id) || null
: null;
const timelineDisabled = isSaving || pendingSaveCount > 0;
const timelineStatusText =
pendingSaveCount > 0
? "Commit 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}
onCommit={sectionCommands.commitSection}
onSubmit={sectionCommands.submitCurrentSection}
onRestoreCommit={sectionCommands.restoreCommit}
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={sectionCommands.openSelectedSection}
onCreateSection={sectionCommands.createAndOpenSection}
commitCount={sectionCommits.length}
hasHeadCommit={Boolean(sectionState?.head_commit_id)}
headCommitId={sectionState?.head_commit_id || null}
latestCommitLabel={headCommit ? `Head: ${formatCommitTitle(headCommit)}` : null}
commits={sectionCommits}
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
createdEntities={createdEntities}
createdGeometries={createdGeometries}
/>
<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
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}
onCreateEntityOnly={handleCreateEntityOnly}
onApplyGeometryMetadata={featureCommands.applyGeometryMetadata}
onApplyEntitiesForSelectedGeometry={featureCommands.applyEntitiesToSelectedGeometry}
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
/>
}
/>
</div>
);
}
function normalizeEditorUserId(value: string): string {
const normalized = value.trim();
return normalized || DEFAULT_EDITOR_USER_ID;
}
function formatCommitTitle(commit: SectionCommit): string {
return commit.title?.trim() || `Commit #${commit.commit_no}`;
}
function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] {
const defaultTypeId = getDefaultTypeIdForFeature(feature);
const defaultTypeOption = findEntityTypeOption(defaultTypeId);
if (defaultTypeOption) {
return [defaultTypeOption.groupId];
}
return ["polygon"];
}