pre updating version control

This commit is contained in:
taDuc
2026-04-17 20:55:59 +07:00
parent 458de8dadc
commit c88a6497f7
6 changed files with 662 additions and 239 deletions

View File

@@ -1,14 +1,14 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { 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 { createEntity, Entity, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { Entity, EntityBatchChange, fetchEntities, searchEntitiesByName } from "@/api/entities";
import { ApiError } from "@/api/http";
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
import { fetchGeometriesByBBox, saveCombinedGeometryEntityBatchChanges, updateGeometry } from "@/api/geometries";
import {
Feature,
FeatureCollection,
@@ -21,7 +21,12 @@ import {
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers";
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
import {
DEFAULT_ENTITY_TYPE_ID,
ENTITY_TYPE_OPTIONS,
EntityTypeGroupId,
findEntityTypeOption,
} from "@/lib/entityTypeOptions";
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
const WORLD_BBOX = {
@@ -55,11 +60,30 @@ type GeometryMetaFormState = {
binding: string;
};
type BindingGeometrySearchOption = {
id: string;
label: string;
};
type PendingEntityCreate = {
id: string;
name: string;
slug: string | null;
type_id: string;
status: number;
};
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 [persistedEntities, setPersistedEntities] = useState<Entity[]>([]);
const [pendingEntityCreates, setPendingEntityCreates] = useState<PendingEntityCreate[]>([]);
const [createdEntities, setCreatedEntities] = useState<Array<{
id: string;
name: string;
type_id?: string | null;
}>>([]);
const [entityStatus, setEntityStatus] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [entityForm, setEntityForm] = useState<EntityFormState>({
@@ -79,6 +103,9 @@ 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);
@@ -97,9 +124,12 @@ export default function Page() {
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0);
const skipSelectedFeatureSyncRef = useRef(false);
const editor = useEditorState(initialData);
const entities = useMemo(
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
[persistedEntities, pendingEntityCreates]
);
const selectedFeature =
selectedFeatureId === null
? null
@@ -107,6 +137,31 @@ export default function Page() {
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]);
useEffect(() => {
let disposed = false;
@@ -115,8 +170,10 @@ export default function Page() {
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.");
setPersistedEntities(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);
@@ -155,17 +212,45 @@ export default function Page() {
const rows = await searchEntitiesByName(keyword, { limit: 30 });
if (disposed || requestId !== entitySearchRequestRef.current) return;
setEntitySearchResults(rows);
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 && rows.some((entity) => entity.id === prev)
prev && mergedRows.some((entity) => entity.id === prev)
? prev
: rows[0]?.id || null
: mergedRows[0]?.id || null
);
} catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err);
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
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);
@@ -177,7 +262,52 @@ export default function Page() {
disposed = true;
window.clearTimeout(timeoutId);
};
}, [entitySearchQuery, selectedFeature]);
}, [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;
@@ -190,17 +320,7 @@ export default function Page() {
}, [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: "",
@@ -210,26 +330,14 @@ export default function Page() {
setEntitySearchQuery("");
setEntitySearchResults([]);
setSelectedSearchEntityId(null);
setBindingGeometrySearchQuery("");
setBindingGeometrySearchResults([]);
setSelectedBindingGeometryId(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
@@ -243,12 +351,39 @@ 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);
}
}, [selectedFeature, entities]);
}, [selectedFeature]);
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]);
useEffect(() => {
let disposed = false;
@@ -361,10 +496,21 @@ export default function Page() {
}, [timelineYear, isTimelineReady]);
const handleSave = async () => {
const payload = editor.buildPayload();
if (!payload.length) return;
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,
},
}));
const invalid = payload.find((change) => {
if (!geometryChanges.length && !entityChanges.length) return;
const invalid = geometryChanges.find((change) => {
if (change.action === "delete") return false;
const draftFeature = change.action === "create"
? change.feature
@@ -388,9 +534,16 @@ export default function Page() {
setIsSaving(true);
setEntityStatus(null);
try {
await saveGeometryBatchChanges(payload);
editor.clearChanges();
await reloadCurrentTimelineData();
await saveCombinedGeometryEntityBatchChanges(entityChanges, geometryChanges);
if (geometryChanges.length) {
editor.clearChanges();
await reloadCurrentTimelineData();
}
if (entityChanges.length) {
setPendingEntityCreates([]);
await reloadEntities();
setEntityFormStatus("Đã lưu batch entities + geometries.");
}
} catch (err) {
if (err instanceof ApiError) {
console.error("Save failed", err.body);
@@ -470,9 +623,31 @@ 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();
setEntities(rows);
setPersistedEntities(rows);
return rows;
};
@@ -484,24 +659,6 @@ export default function Page() {
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");
@@ -586,6 +743,12 @@ export default function Page() {
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;
@@ -617,89 +780,79 @@ export default function Page() {
}
};
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 handleCreateEntityOnly = async () => {
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ệ.");
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 {
const created = await createEntity({
name,
slug: entityForm.slug.trim() || null,
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
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,
];
});
const rows = await reloadEntities();
const nextEntityIds = uniqueEntityIds([
...selectedGeometryEntityIds,
created.id,
]);
const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null;
setEntityForm((prev) => ({
...prev,
name: "",
slug: "",
}));
setEntityStatus(null);
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Save.");
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.");
if (selectedFeature) {
setEntitySearchQuery(pendingCreate.name);
setSelectedSearchEntityId(pendingCreate.id);
}
} finally {
setIsEntitySubmitting(false);
}
};
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
const timelineStatusText =
editor.changeCount > 0
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
pendingSaveCount > 0
? "Lưu hết thay đổi trước khi đổi mốc thời gian."
: isSaving
? "Đang lưu thay đổi..."
: timelineStatus;
@@ -718,8 +871,10 @@ export default function Page() {
onUndo={editor.undo}
onSave={handleSave}
isSaving={isSaving}
changesCount={editor.changeCount}
changesCount={pendingSaveCount}
undoStack={editor.undoStack}
createdEntities={createdEntities}
createdGeometries={createdGeometries}
/>
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
@@ -785,8 +940,14 @@ 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}
onCreateEntityAndAttach={handleCreateEntityAndAttach}
onCreateEntityOnly={handleCreateEntityOnly}
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
changeCount={editor.changeCount}
entityFormStatus={entityFormStatus}
@@ -858,6 +1019,15 @@ function getDefaultTypeIdForFeature(feature: Feature): string {
return DEFAULT_ENTITY_TYPE_ID;
}
function getAllowedEntityTypeGroupIdsForFeature(feature: Feature): EntityTypeGroupId[] {
const defaultTypeId = getDefaultTypeIdForFeature(feature);
const defaultTypeOption = findEntityTypeOption(defaultTypeId);
if (defaultTypeOption) {
return [defaultTypeOption.groupId];
}
return ["polygon"];
}
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
const value = raw.trim();
if (!value.length) return null;
@@ -934,6 +1104,64 @@ function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): stri
return names.join(", ");
}
function mergeEntitiesWithPending(
persistedEntities: Entity[],
pendingCreates: PendingEntityCreate[]
): Entity[] {
if (!pendingCreates.length) {
return persistedEntities;
}
const seen = new Set<string>();
const pendingAsEntities: Entity[] = [];
for (const pending of pendingCreates) {
if (seen.has(pending.id)) continue;
seen.add(pending.id);
pendingAsEntities.push({
id: pending.id,
name: pending.name,
slug: pending.slug,
type_id: pending.type_id,
status: pending.status,
geometry_count: 0,
created_at: undefined,
updated_at: undefined,
});
}
const nextPersisted = persistedEntities.filter((entity) => !seen.has(entity.id));
return [...pendingAsEntities, ...nextPersisted];
}
function mergeEntitySearchResults(
remoteRows: Entity[],
localRows: Entity[]
): Entity[] {
const merged: Entity[] = [];
const seen = new Set<string>();
for (const row of localRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
for (const row of remoteRows) {
if (!row.id || seen.has(row.id)) continue;
seen.add(row.id);
merged.push(row);
}
return merged;
}
function buildClientEntityId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `entity-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
}
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
if (typeof window === "undefined") {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };