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

@@ -6,7 +6,9 @@ export const API_BASE_URL =
export const API_ENDPOINTS = { export const API_ENDPOINTS = {
geometries: `${API_BASE_URL}/geometries`, geometries: `${API_BASE_URL}/geometries`,
geometriesBatch: `${API_BASE_URL}/geometries/batch`, geometriesBatch: `${API_BASE_URL}/geometries/batch`,
geometriesBatchCombined: `${API_BASE_URL}/geometries/batch/combined`,
entities: `${API_BASE_URL}/entities`, entities: `${API_BASE_URL}/entities`,
entitiesBatch: `${API_BASE_URL}/entities/batch`,
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`, vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`, rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`, vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`,

View File

@@ -21,6 +21,43 @@ export type CreateEntityPayload = {
status?: number | null; status?: number | null;
}; };
export type EntityBatchCreateChange =
| ({
action: "create";
entity: CreateEntityPayload & { id?: string };
})
| ({
action: "create";
id?: string;
} & CreateEntityPayload);
export type EntityBatchUpdateChange =
| ({
action: "update";
id: string;
entity: Partial<CreateEntityPayload> & { id?: string };
})
| ({
action: "update";
id: string;
} & Partial<CreateEntityPayload>);
export type EntityBatchDeleteChange = {
action: "delete";
id: string;
};
export type EntityBatchChange =
| EntityBatchCreateChange
| EntityBatchUpdateChange
| EntityBatchDeleteChange;
export type EntityBatchSaveResponse = {
success: boolean;
applied: number;
created_entity_ids: string[];
};
export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> { export async function fetchEntities(query?: { q?: string }): Promise<Entity[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query?.q) { if (query?.q) {
@@ -61,3 +98,11 @@ export async function updateEntity(id: string, payload: CreateEntityPayload): Pr
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} }
export async function saveEntityBatchChanges(changes: EntityBatchChange[]): Promise<EntityBatchSaveResponse> {
return requestJson<EntityBatchSaveResponse>(API_ENDPOINTS.entitiesBatch, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
}

View File

@@ -1,4 +1,5 @@
import { API_ENDPOINTS } from "@/api/config"; import { API_ENDPOINTS } from "@/api/config";
import { EntityBatchChange } from "@/api/entities";
import { requestJson } from "@/api/http"; import { requestJson } from "@/api/http";
import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState"; import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState";
@@ -40,6 +41,14 @@ export type BatchSaveResponse = {
applied: number; applied: number;
}; };
export type CombinedBatchSaveResponse = {
success: boolean;
applied: number;
entity_applied: number;
geometry_applied: number;
created_entity_ids: string[];
};
function buildBBoxQueryString(params: GeometriesBBoxQuery): string { function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({ const query = new URLSearchParams({
minLng: String(params.minLng), minLng: String(params.minLng),
@@ -72,6 +81,20 @@ export async function saveGeometryBatchChanges(changes: Change[]): Promise<Batch
}); });
} }
export async function saveCombinedGeometryEntityBatchChanges(
entityChanges: EntityBatchChange[],
geometryChanges: Change[]
): Promise<CombinedBatchSaveResponse> {
return requestJson<CombinedBatchSaveResponse>(API_ENDPOINTS.geometriesBatchCombined, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
entity_changes: entityChanges,
geometry_changes: geometryChanges,
}),
});
}
export async function createGeometry(payload: GeometryCreatePayload): Promise<GeometryCreateResponse> { export async function createGeometry(payload: GeometryCreatePayload): Promise<GeometryCreateResponse> {
return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, { return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, {
method: "POST", method: "POST",

View File

@@ -1,14 +1,14 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import Map from "@/components/Map"; import Map from "@/components/Map";
import Editor from "@/components/Editor"; import Editor from "@/components/Editor";
import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel";
import TimelineBar from "@/components/TimelineBar"; import TimelineBar from "@/components/TimelineBar";
import SelectedGeometryPanel from "@/components/SelectedGeometryPanel"; 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 { ApiError } from "@/api/http";
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries"; import { fetchGeometriesByBBox, saveCombinedGeometryEntityBatchChanges, updateGeometry } from "@/api/geometries";
import { import {
Feature, Feature,
FeatureCollection, FeatureCollection,
@@ -21,7 +21,12 @@ import {
DEFAULT_BACKGROUND_LAYER_VISIBILITY, DEFAULT_BACKGROUND_LAYER_VISIBILITY,
HIDDEN_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/lib/backgroundLayers"; } 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 EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
const WORLD_BBOX = { const WORLD_BBOX = {
@@ -55,11 +60,30 @@ type GeometryMetaFormState = {
binding: string; 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() { export default function Page() {
const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle"); const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-line" | "add-path" | "add-circle">("idle");
const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC); const [initialData, setInitialData] = useState<FeatureCollection>(EMPTY_FC);
const [isSaving, setIsSaving] = useState(false); 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 [entityStatus, setEntityStatus] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null); const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
const [entityForm, setEntityForm] = useState<EntityFormState>({ const [entityForm, setEntityForm] = useState<EntityFormState>({
@@ -79,6 +103,9 @@ export default function Page() {
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]); const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null); const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false); 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 [timelineRange, setTimelineRange] = useState<TimelineRange>(FALLBACK_TIMELINE_RANGE);
const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min); const [timelineWindowStart, setTimelineWindowStart] = useState<number>(FALLBACK_TIMELINE_RANGE.min);
const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max); const [timelineWindowEnd, setTimelineWindowEnd] = useState<number>(FALLBACK_TIMELINE_RANGE.max);
@@ -97,9 +124,12 @@ export default function Page() {
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const timelineFetchRequestRef = useRef(0); const timelineFetchRequestRef = useRef(0);
const entitySearchRequestRef = useRef(0); const entitySearchRequestRef = useRef(0);
const skipSelectedFeatureSyncRef = useRef(false);
const editor = useEditorState(initialData); const editor = useEditorState(initialData);
const entities = useMemo(
() => mergeEntitiesWithPending(persistedEntities, pendingEntityCreates),
[persistedEntities, pendingEntityCreates]
);
const selectedFeature = const selectedFeature =
selectedFeatureId === null selectedFeatureId === null
? null ? null
@@ -107,6 +137,31 @@ export default function Page() {
String(feature.properties.id) === String(selectedFeatureId) String(feature.properties.id) === String(selectedFeatureId)
) || null; ) || 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(() => { useEffect(() => {
let disposed = false; let disposed = false;
@@ -115,8 +170,10 @@ export default function Page() {
const rows = await fetchEntities(); const rows = await fetchEntities();
if (disposed) return; if (disposed) return;
setEntities(rows); setPersistedEntities(rows);
setEntityStatus(rows.length ? null : "Chưa có entity. Cần tạo entity trước khi Save geometry."); setEntityStatus(rows.length
? null
: "Chưa có entity. Cần tạo entity trước khi Save geometry.");
} catch (err) { } catch (err) {
if (disposed) return; if (disposed) return;
console.error("Load entities failed", err); console.error("Load entities failed", err);
@@ -155,17 +212,45 @@ export default function Page() {
const rows = await searchEntitiesByName(keyword, { limit: 30 }); const rows = await searchEntitiesByName(keyword, { limit: 30 });
if (disposed || requestId !== entitySearchRequestRef.current) return; 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) => setSelectedSearchEntityId((prev) =>
prev && rows.some((entity) => entity.id === prev) prev && mergedRows.some((entity) => entity.id === prev)
? prev ? prev
: rows[0]?.id || null : mergedRows[0]?.id || null
); );
} catch (err) { } catch (err) {
if (disposed || requestId !== entitySearchRequestRef.current) return; if (disposed || requestId !== entitySearchRequestRef.current) return;
console.error("Search entity by name failed", err); console.error("Search entity by name failed", err);
setEntitySearchResults([]); const pendingMatches = pendingEntityCreates
setSelectedSearchEntityId(null); .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 { } finally {
if (!disposed && requestId === entitySearchRequestRef.current) { if (!disposed && requestId === entitySearchRequestRef.current) {
setIsEntitySearchLoading(false); setIsEntitySearchLoading(false);
@@ -177,7 +262,52 @@ export default function Page() {
disposed = true; disposed = true;
window.clearTimeout(timeoutId); 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(() => { useEffect(() => {
if (selectedFeatureId === null) return; if (selectedFeatureId === null) return;
@@ -190,17 +320,7 @@ export default function Page() {
}, [editor.draft, selectedFeatureId]); }, [editor.draft, selectedFeatureId]);
useEffect(() => { useEffect(() => {
if (skipSelectedFeatureSyncRef.current) {
skipSelectedFeatureSyncRef.current = false;
return;
}
if (!selectedFeature) { if (!selectedFeature) {
setEntityForm({
name: "",
slug: "",
type_id: DEFAULT_ENTITY_TYPE_ID,
});
setSelectedGeometryEntityIds([]); setSelectedGeometryEntityIds([]);
setGeometryMetaForm({ setGeometryMetaForm({
time_start: "", time_start: "",
@@ -210,26 +330,14 @@ export default function Page() {
setEntitySearchQuery(""); setEntitySearchQuery("");
setEntitySearchResults([]); setEntitySearchResults([]);
setSelectedSearchEntityId(null); setSelectedSearchEntityId(null);
setBindingGeometrySearchQuery("");
setBindingGeometrySearchResults([]);
setSelectedBindingGeometryId(null);
setEntityFormStatus(null); setEntityFormStatus(null);
return; return;
} }
const featureEntityIds = normalizeFeatureEntityIds(selectedFeature); 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); setSelectedGeometryEntityIds(featureEntityIds);
setGeometryMetaForm({ setGeometryMetaForm({
time_start: selectedFeature.properties.time_start != null time_start: selectedFeature.properties.time_start != null
@@ -243,12 +351,39 @@ export default function Page() {
setEntitySearchQuery(""); setEntitySearchQuery("");
setEntitySearchResults([]); setEntitySearchResults([]);
setSelectedSearchEntityId(null); setSelectedSearchEntityId(null);
setBindingGeometrySearchQuery("");
setBindingGeometrySearchResults([]);
setSelectedBindingGeometryId(null);
if (!featureEntityIds.length) { if (!featureEntityIds.length) {
setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save."); setEntityFormStatus("Geometry mới phải được gắn ít nhất 1 entity trước khi Save.");
} else { } else {
setEntityFormStatus(null); 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(() => { useEffect(() => {
let disposed = false; let disposed = false;
@@ -361,10 +496,21 @@ export default function Page() {
}, [timelineYear, isTimelineReady]); }, [timelineYear, isTimelineReady]);
const handleSave = async () => { const handleSave = async () => {
const payload = editor.buildPayload(); const geometryChanges = editor.buildPayload();
if (!payload.length) return; 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; if (change.action === "delete") return false;
const draftFeature = change.action === "create" const draftFeature = change.action === "create"
? change.feature ? change.feature
@@ -388,9 +534,16 @@ export default function Page() {
setIsSaving(true); setIsSaving(true);
setEntityStatus(null); setEntityStatus(null);
try { try {
await saveGeometryBatchChanges(payload); await saveCombinedGeometryEntityBatchChanges(entityChanges, geometryChanges);
if (geometryChanges.length) {
editor.clearChanges(); editor.clearChanges();
await reloadCurrentTimelineData(); await reloadCurrentTimelineData();
}
if (entityChanges.length) {
setPendingEntityCreates([]);
await reloadEntities();
setEntityFormStatus("Đã lưu batch entities + geometries.");
}
} catch (err) { } catch (err) {
if (err instanceof ApiError) { if (err instanceof ApiError) {
console.error("Save failed", err.body); console.error("Save failed", err.body);
@@ -470,9 +623,31 @@ export default function Page() {
setEntityFormStatus(null); 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 reloadEntities = async () => {
const rows = await fetchEntities(); const rows = await fetchEntities();
setEntities(rows); setPersistedEntities(rows);
return rows; return rows;
}; };
@@ -484,24 +659,6 @@ export default function Page() {
setInitialData(data); 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 parseGeometryMetaFormRange = () => {
const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start"); const timeStart = parseOptionalYearInput(geometryMetaForm.time_start, "time_start");
const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end"); const timeEnd = parseOptionalYearInput(geometryMetaForm.time_end, "time_end");
@@ -586,6 +743,12 @@ export default function Page() {
try { try {
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null; const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
if (editor.hasPersistedFeature(selectedFeature.properties.id)) { 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) { 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."); setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi cập nhật geometry đã tồn tại.");
return; return;
@@ -617,89 +780,79 @@ export default function Page() {
} }
}; };
const handleCreateEntityAndAttach = async () => { const handleCreateEntityOnly = async () => {
if (!selectedFeature) {
setEntityFormStatus("Hãy chọn một geometry trước.");
return;
}
if (editor.hasPersistedFeature(selectedFeature.properties.id) && editor.changeCount > 0) {
setEntityFormStatus("Hãy Save/Undo hết thay đổi bản đồ trước khi gắn entity cho geometry đã tồn tại.");
return;
}
const name = entityForm.name.trim(); const name = entityForm.name.trim();
if (!name) { if (!name) {
setEntityFormStatus("Tên entity là bắt buộc."); setEntityFormStatus("Tên entity là bắt buộc.");
return; return;
} }
let timeStart: number | null; const slug = entityForm.slug.trim() || null;
let timeEnd: number | null; const typeId = entityForm.type_id || DEFAULT_ENTITY_TYPE_ID;
let bindingIds: string[]; const normalizedName = name.toLowerCase();
try { const duplicatedName = entities.some((entity) => entity.name.trim().toLowerCase() === normalizedName);
({ timeStart, timeEnd } = parseGeometryMetaFormRange()); if (duplicatedName) {
bindingIds = parseBindingInput(geometryMetaForm.binding); setEntityFormStatus("Tên entity đã tồn tại.");
} catch (err) {
setEntityFormStatus(err instanceof Error ? err.message : "Thời gian không hợp lệ.");
return; 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); setIsEntitySubmitting(true);
setEntityFormStatus(null); setEntityFormStatus(null);
try { try {
const created = await createEntity({ setPendingEntityCreates((prev) => [pendingCreate, ...prev]);
name, setCreatedEntities((prev) => {
slug: entityForm.slug.trim() || null, if (prev.some((item) => item.id === pendingCreate.id)) return prev;
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID, return [
{
id: pendingCreate.id,
name: pendingCreate.name,
type_id: pendingCreate.type_id || null,
},
...prev,
];
}); });
const rows = await reloadEntities(); setEntityForm((prev) => ({
const nextEntityIds = uniqueEntityIds([ ...prev,
...selectedGeometryEntityIds, name: "",
created.id, slug: "",
]); }));
const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null; setEntityStatus(null);
setEntityFormStatus("Đã thêm entity mới vào danh sách chờ Save.");
if (editor.hasPersistedFeature(selectedFeature.properties.id)) { if (selectedFeature) {
await updateGeometry(selectedFeature.properties.id, { setEntitySearchQuery(pendingCreate.name);
geometry: selectedFeature.geometry, setSelectedSearchEntityId(pendingCreate.id);
type: nextGeometryType ?? undefined,
time_start: timeStart,
time_end: timeEnd,
binding: bindingIds,
entity_ids: nextEntityIds,
});
await reloadCurrentTimelineData();
setEntityFormStatus("Đã tạo entity và gắn vào geometry.");
} else {
patchSelectedFeatureLocally(
selectedFeature,
nextEntityIds,
timeStart,
timeEnd,
bindingIds,
rows
);
setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới.");
}
resetSelectedGeometryInputsAfterCreate();
} catch (err) {
if (err instanceof ApiError) {
setEntityFormStatus(`Tạo/gắn entity thất bại: ${err.body}`);
} else {
setEntityFormStatus("Tạo/gắn entity thất bại.");
} }
} finally { } finally {
setIsEntitySubmitting(false); setIsEntitySubmitting(false);
} }
}; };
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0; const pendingSaveCount = editor.changeCount + pendingEntityCreates.length;
const timelineDisabled = !isTimelineReady || isSaving || pendingSaveCount > 0;
const timelineStatusText = const timelineStatusText =
editor.changeCount > 0 pendingSaveCount > 0
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian." ? "Lưu hết thay đổi trước khi đổi mốc thời gian."
: isSaving : isSaving
? "Đang lưu thay đổi..." ? "Đang lưu thay đổi..."
: timelineStatus; : timelineStatus;
@@ -718,8 +871,10 @@ export default function Page() {
onUndo={editor.undo} onUndo={editor.undo}
onSave={handleSave} onSave={handleSave}
isSaving={isSaving} isSaving={isSaving}
changesCount={editor.changeCount} changesCount={pendingSaveCount}
undoStack={editor.undoStack} undoStack={editor.undoStack}
createdEntities={createdEntities}
createdGeometries={createdGeometries}
/> />
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}> <div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
@@ -785,8 +940,14 @@ export default function Page() {
entityTypeOptions={ENTITY_TYPE_OPTIONS} entityTypeOptions={ENTITY_TYPE_OPTIONS}
geometryMetaForm={geometryMetaForm} geometryMetaForm={geometryMetaForm}
onGeometryMetaFormChange={handleGeometryMetaFormChange} onGeometryMetaFormChange={handleGeometryMetaFormChange}
bindingGeometrySearchQuery={bindingGeometrySearchQuery}
onBindingGeometrySearchQueryChange={setBindingGeometrySearchQuery}
bindingGeometrySearchResults={bindingGeometrySearchResults}
selectedBindingGeometryId={selectedBindingGeometryId}
onSelectBindingGeometryId={setSelectedBindingGeometryId}
onAddSelectedBindingGeometry={handleAddSelectedBindingGeometry}
isEntitySubmitting={isEntitySubmitting} isEntitySubmitting={isEntitySubmitting}
onCreateEntityAndAttach={handleCreateEntityAndAttach} onCreateEntityOnly={handleCreateEntityOnly}
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry} onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
changeCount={editor.changeCount} changeCount={editor.changeCount}
entityFormStatus={entityFormStatus} entityFormStatus={entityFormStatus}
@@ -858,6 +1019,15 @@ function getDefaultTypeIdForFeature(feature: Feature): string {
return DEFAULT_ENTITY_TYPE_ID; 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 { function parseOptionalYearInput(raw: string, fieldName: string): number | null {
const value = raw.trim(); const value = raw.trim();
if (!value.length) return null; if (!value.length) return null;
@@ -934,6 +1104,64 @@ function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): stri
return names.join(", "); 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 { function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }; return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };

View File

@@ -13,6 +13,17 @@ type Props = {
isSaving: boolean; isSaving: boolean;
changesCount: number; changesCount: number;
undoStack: UndoAction[]; undoStack: UndoAction[];
createdEntities: Array<{
id: string;
name: string;
type_id?: string | null;
}>;
createdGeometries: Array<{
id: string | number;
geometryType: string;
semanticType?: string | null;
entityNames: string[];
}>;
}; };
export default function Editor({ export default function Editor({
@@ -24,6 +35,8 @@ export default function Editor({
isSaving, isSaving,
changesCount, changesCount,
undoStack, undoStack,
createdEntities,
createdGeometries,
}: Props) { }: Props) {
const toggleMode = (newMode: Mode) => { const toggleMode = (newMode: Mode) => {
if (mode === newMode) { if (mode === newMode) {
@@ -61,6 +74,8 @@ export default function Editor({
<div <div
style={{ style={{
width: "220px", width: "220px",
height: "100vh",
overflowY: "auto",
background: "#111", background: "#111",
color: "white", color: "white",
padding: "12px", padding: "12px",
@@ -210,6 +225,70 @@ export default function Editor({
</ul> </ul>
)} )}
</div> </div>
<div
style={{
marginTop: "16px",
padding: "10px",
background: "#0b1220",
borderRadius: "6px",
border: "1px solid #1f2937",
}}
>
<div style={{ marginBottom: "8px", fontWeight: 600, fontSize: "14px" }}>
Mới tạo trong phiên
</div>
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
Entities ({createdEntities.length})
</div>
{createdEntities.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px", marginBottom: "10px" }}>
Chưa tạo entity mới
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px", marginBottom: "10px" }}>
{createdEntities.map((entity) => (
<li
key={entity.id}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
title={entity.id}
>
{entity.name} ({entity.type_id || "country"})
</li>
))}
</ul>
)}
<div style={{ fontSize: "13px", color: "#cbd5e1", marginBottom: "6px" }}>
Geometries mới chưa lưu ({createdGeometries.length})
</div>
{createdGeometries.length === 0 ? (
<div style={{ color: "#64748b", fontSize: "12px" }}>
Chưa geometry mới chờ save
</div>
) : (
<ul style={{ listStyle: "none", margin: 0, padding: 0, fontSize: "12px" }}>
{createdGeometries.map((geometry) => (
<li
key={String(geometry.id)}
style={{
padding: "4px 0",
borderBottom: "1px solid #1f2937",
color: "#e2e8f0",
}}
>
#{geometry.id} [{geometry.geometryType}] {geometry.semanticType ? `- ${geometry.semanticType}` : ""}
{geometry.entityNames.length ? ` | ${geometry.entityNames.join(", ")}` : ""}
</li>
))}
</ul>
)}
</div>
</div> </div>
); );
} }

View File

@@ -42,8 +42,17 @@ type Props = {
entityTypeOptions: EntityTypeOption[]; entityTypeOptions: EntityTypeOption[];
geometryMetaForm: GeometryMetaFormState; geometryMetaForm: GeometryMetaFormState;
onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void; onGeometryMetaFormChange: (key: keyof GeometryMetaFormState, value: string) => void;
bindingGeometrySearchQuery: string;
onBindingGeometrySearchQueryChange: (value: string) => void;
bindingGeometrySearchResults: Array<{
id: string;
label: string;
}>;
selectedBindingGeometryId: string | null;
onSelectBindingGeometryId: (value: string | null) => void;
onAddSelectedBindingGeometry: () => void;
isEntitySubmitting: boolean; isEntitySubmitting: boolean;
onCreateEntityAndAttach: () => void; onCreateEntityOnly: () => void;
onApplyEntitiesForSelectedGeometry: () => void; onApplyEntitiesForSelectedGeometry: () => void;
changeCount: number; changeCount: number;
entityFormStatus: string | null; entityFormStatus: string | null;
@@ -68,8 +77,14 @@ export default function SelectedGeometryPanel({
entityTypeOptions, entityTypeOptions,
geometryMetaForm, geometryMetaForm,
onGeometryMetaFormChange, onGeometryMetaFormChange,
bindingGeometrySearchQuery,
onBindingGeometrySearchQueryChange,
bindingGeometrySearchResults,
selectedBindingGeometryId,
onSelectBindingGeometryId,
onAddSelectedBindingGeometry,
isEntitySubmitting, isEntitySubmitting,
onCreateEntityAndAttach, onCreateEntityOnly,
onApplyEntitiesForSelectedGeometry, onApplyEntitiesForSelectedGeometry,
changeCount, changeCount,
entityFormStatus, entityFormStatus,
@@ -84,13 +99,13 @@ export default function SelectedGeometryPanel({
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) => const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id) allowedGroupIds.includes(group.id)
); );
const groupedEntityTypeOptionsForCreate = selectedFeature
? visibleGroupedEntityTypeOptions
: groupedEntityTypeOptions;
const selectedTypeOption = findEntityTypeOption(entityForm.type_id); const selectedTypeOption = findEntityTypeOption(entityForm.type_id);
const hasCurrentVisibleTypeOption = visibleGroupedEntityTypeOptions.some((group) => const hasCurrentVisibleTypeOption = groupedEntityTypeOptionsForCreate.some((group) =>
group.options.some((option) => option.value === entityForm.type_id) group.options.some((option) => option.value === entityForm.type_id)
); );
const isGeometryCompatible = selectedTypeOption
? allowedGroupIds.includes(selectedTypeOption.groupId)
: true;
return ( return (
<div <div
@@ -102,12 +117,12 @@ export default function SelectedGeometryPanel({
}} }}
> >
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}> <div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Selected Geometry Entity & Geometry
</div> </div>
{!selectedFeature ? ( {!selectedFeature ? (
<div style={{ color: "#94a3b8", fontSize: "13px" }}> <div style={{ color: "#94a3b8", fontSize: "13px" }}>
Vào mode Select chọn 1 geometry đ điền entity. Chưa chọn geometry. Tạo entity mới khối bên dưới, hoặc vào mode Select đ bind entity cho geometry.
</div> </div>
) : ( ) : (
<div style={{ display: "grid", gap: "8px", fontSize: "13px" }}> <div style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
@@ -187,11 +202,10 @@ export default function SelectedGeometryPanel({
}} }}
> >
<div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}> <div style={{ color: "#e2e8f0", fontWeight: 700, fontSize: "12px" }}>
Metadata geometry (áp dụng cho cả 2 luồng bên dưới) Metadata geometry (chỉ áp dụng khi bind entity)
</div> </div>
<div style={{ color: "#94a3b8", fontSize: "11px" }}> <div style={{ color: "#94a3b8", fontSize: "11px" }}>
`time_start`, `time_end`, `binding` sẽ đưc dùng cho cả luồng gắn entity có sẵn `time_start`, `time_end`, `binding` chỉ đưc áp dng khi bấm nút bind entity cho geometry.
luồng tạo entity mới.
</div> </div>
<input <input
value={geometryMetaForm.time_start} value={geometryMetaForm.time_start}
@@ -214,6 +228,38 @@ export default function SelectedGeometryPanel({
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
/> />
<input
value={bindingGeometrySearchQuery}
onChange={(event) =>
onBindingGeometrySearchQueryChange(event.target.value)
}
placeholder="Search geometry để thêm vào binding..."
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<select
value={selectedBindingGeometryId || ""}
onChange={(event) =>
onSelectBindingGeometryId(event.target.value ? event.target.value : null)
}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
<option value="">-- Chọn geometry từ kết quả search binding --</option>
{bindingGeometrySearchResults.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
<button
type="button"
onClick={onAddSelectedBindingGeometry}
disabled={isEntitySubmitting}
style={secondaryActionButtonStyle}
>
Thêm geometry đã chọn vào binding
</button>
</div> </div>
<div <div
@@ -227,7 +273,7 @@ export default function SelectedGeometryPanel({
}} }}
> >
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}> <div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 1: Gắn entity sẵn Bind entity sẵn
</div> </div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}> <div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng. Dùng khi entity đã tồn tại. Tìm kiếm, thêm vào danh sách rồi bấm nút áp dụng.
@@ -285,6 +331,14 @@ export default function SelectedGeometryPanel({
</button> </button>
</div> </div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Geometry mới sẽ lưu entity khi bấm Save.
</div>
) : null}
</div>
)}
<div <div
style={{ style={{
display: "grid", display: "grid",
@@ -293,14 +347,20 @@ export default function SelectedGeometryPanel({
borderRadius: "8px", borderRadius: "8px",
padding: "8px", padding: "8px",
background: "#0f172a", background: "#0f172a",
marginTop: "10px",
}} }}
> >
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}> <div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 2: Tạo entity mới rồi gắn Tạo entity mới (đc lập)
</div> </div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}> <div style={{ color: "#93c5fd", fontSize: "11px" }}>
Dùng khi chưa entity phù hợp. Điền form bên dưới rồi bấm nút tạo + gắn. Chỉ tạo entity, không tự bind vào geometry.
</div> </div>
{selectedFeature ? (
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</div>
) : null}
<input <input
value={entityForm.name} value={entityForm.name}
@@ -325,12 +385,12 @@ export default function SelectedGeometryPanel({
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={entityInputStyle} style={entityInputStyle}
> >
{!hasCurrentVisibleTypeOption && entityForm.type_id ? ( {!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}> <option value={entityForm.type_id}>
Custom Type ({entityForm.type_id}) Custom Type ({entityForm.type_id})
</option> </option>
) : null} ) : null}
{visibleGroupedEntityTypeOptions.map((group) => ( {groupedEntityTypeOptionsForCreate.map((group) => (
<optgroup <optgroup
key={group.id} key={group.id}
label={`${group.label} (${group.geometryLabel})`} label={`${group.label} (${group.geometryLabel})`}
@@ -354,14 +414,8 @@ export default function SelectedGeometryPanel({
</div> </div>
) : null} ) : null}
{!isGeometryCompatible && selectedTypeOption ? (
<div style={{ color: "#fbbf24", fontSize: "12px" }}>
Type <b>{selectedTypeOption.label}</b> thuộc nhóm <b>{selectedTypeOption.groupLabel}</b>, nhưng geometry hiện tại <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</div>
) : null}
<button <button
onClick={onCreateEntityAndAttach} onClick={onCreateEntityOnly}
disabled={isEntitySubmitting} disabled={isEntitySubmitting}
style={{ style={{
border: "none", border: "none",
@@ -374,24 +428,16 @@ export default function SelectedGeometryPanel({
fontWeight: 600, fontWeight: 600,
}} }}
> >
Tạo entity mới + gắn + áp metadata Tạo entity mới
</button> </button>
</div> </div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Geometry mới sẽ lưu entity khi bấm Save.
</div>
) : null}
{entityFormStatus ? ( {entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}> <div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
{entityFormStatus} {entityFormStatus}
</div> </div>
) : null} ) : null}
</div> </div>
)}
</div>
); );
} }