840 lines
29 KiB
TypeScript
840 lines
29 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,
|
|
FeatureCollection,
|
|
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,
|
|
TimelineRange,
|
|
useEditorSessionState,
|
|
} from "@/lib/useEditorSessionState";
|
|
import {
|
|
getDefaultTypeIdForFeature,
|
|
normalizeFeatureBindingIds,
|
|
normalizeFeatureEntityIds,
|
|
uniqueEntityIds,
|
|
} from "@/lib/editor/snapshot/editorSnapshot";
|
|
import {
|
|
buildClientEntityId,
|
|
buildFeatureEntityPatch,
|
|
formatEntityNamesForDisplay,
|
|
mergeEntitiesWithPending,
|
|
mergeEntitySearchResults,
|
|
} from "@/lib/editor/entity/entityBinding";
|
|
import {
|
|
buildGeometryMetadataPatch,
|
|
formatBindingIdsForDisplay,
|
|
} from "@/lib/editor/geometry/geometryMetadata";
|
|
import {
|
|
loadBackgroundLayerVisibilityFromStorage,
|
|
persistBackgroundLayerVisibility,
|
|
} from "@/lib/editor/background/backgroundVisibilityStorage";
|
|
import { useSectionCommands } from "@/lib/editor/section/useSectionCommands";
|
|
|
|
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: -2000,
|
|
max: 2000,
|
|
};
|
|
const TIMELINE_DEBOUNCE_MS = 180;
|
|
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_FC,
|
|
defaultEditorUserId: DEFAULT_EDITOR_USER_ID,
|
|
fallbackTimelineRange: FALLBACK_TIMELINE_RANGE,
|
|
currentYear: CURRENT_YEAR,
|
|
});
|
|
const timelineFetchRequestRef = useRef(0);
|
|
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_FC,
|
|
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(clampYear(Math.trunc(nextYear), FALLBACK_TIMELINE_RANGE));
|
|
};
|
|
|
|
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 handleApplyGeometryMetadata = async () => {
|
|
if (!selectedFeature) {
|
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
|
return;
|
|
}
|
|
|
|
let metadata;
|
|
try {
|
|
metadata = buildGeometryMetadataPatch(geometryMetaForm);
|
|
} catch (err) {
|
|
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
|
|
return;
|
|
}
|
|
|
|
setIsEntitySubmitting(true);
|
|
setEntityFormStatus(null);
|
|
try {
|
|
editor.patchFeatureProperties(selectedFeature.properties.id, metadata.patch);
|
|
setGeometryMetaForm(metadata.formState);
|
|
setEntityFormStatus("Đã cập nhật thuộc tính GEO. Commit khi sẵn sàng.");
|
|
} finally {
|
|
setIsEntitySubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleApplyEntitiesForSelectedGeometry = async () => {
|
|
if (!selectedFeature) {
|
|
setEntityFormStatus("Hãy chọn một geometry trước.");
|
|
return;
|
|
}
|
|
|
|
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
|
setIsEntitySubmitting(true);
|
|
setEntityFormStatus(null);
|
|
try {
|
|
editor.patchFeatureProperties(
|
|
selectedFeature.properties.id,
|
|
buildFeatureEntityPatch(selectedFeature, entityIds, entities)
|
|
);
|
|
setSelectedGeometryEntityIds(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}`);
|
|
} else {
|
|
setEntityFormStatus("Lưu thất bại.");
|
|
}
|
|
} finally {
|
|
setIsEntitySubmitting(false);
|
|
}
|
|
};
|
|
|
|
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={handleApplyGeometryMetadata}
|
|
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
|
changeCount={editor.changeCount}
|
|
entityFormStatus={entityFormStatus}
|
|
/>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function normalizeEditorUserId(value: string): string {
|
|
const normalized = value.trim();
|
|
return normalized || DEFAULT_EDITOR_USER_ID;
|
|
}
|
|
|
|
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 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"];
|
|
}
|