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 = {
geometries: `${API_BASE_URL}/geometries`,
geometriesBatch: `${API_BASE_URL}/geometries/batch`,
geometriesBatchCombined: `${API_BASE_URL}/geometries/batch/combined`,
entities: `${API_BASE_URL}/entities`,
entitiesBatch: `${API_BASE_URL}/entities/batch`,
vectorTiles: `${API_BASE_URL}/tiles/{z}/{x}/{y}`,
rasterTiles: `${API_BASE_URL}/raster-tiles/{z}/{x}/{y}`,
vectorTilesMetadata: `${API_BASE_URL}/tiles/metadata/info`,

View File

@@ -21,6 +21,43 @@ export type CreateEntityPayload = {
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[]> {
const params = new URLSearchParams();
if (query?.q) {
@@ -61,3 +98,11 @@ export async function updateEntity(id: string, payload: CreateEntityPayload): Pr
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 { EntityBatchChange } from "@/api/entities";
import { requestJson } from "@/api/http";
import { Change, FeatureCollection, Geometry } from "@/lib/useEditorState";
@@ -40,6 +41,14 @@ export type BatchSaveResponse = {
applied: number;
};
export type CombinedBatchSaveResponse = {
success: boolean;
applied: number;
entity_applied: number;
geometry_applied: number;
created_entity_ids: string[];
};
function buildBBoxQueryString(params: GeometriesBBoxQuery): string {
const query = new URLSearchParams({
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> {
return requestJson<GeometryCreateResponse>(API_ENDPOINTS.geometries, {
method: "POST",

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 };

View File

@@ -13,6 +13,17 @@ type Props = {
isSaving: boolean;
changesCount: number;
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({
@@ -24,6 +35,8 @@ export default function Editor({
isSaving,
changesCount,
undoStack,
createdEntities,
createdGeometries,
}: Props) {
const toggleMode = (newMode: Mode) => {
if (mode === newMode) {
@@ -61,6 +74,8 @@ export default function Editor({
<div
style={{
width: "220px",
height: "100vh",
overflowY: "auto",
background: "#111",
color: "white",
padding: "12px",
@@ -210,6 +225,70 @@ export default function Editor({
</ul>
)}
</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>
);
}

View File

@@ -42,8 +42,17 @@ type Props = {
entityTypeOptions: EntityTypeOption[];
geometryMetaForm: GeometryMetaFormState;
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;
onCreateEntityAndAttach: () => void;
onCreateEntityOnly: () => void;
onApplyEntitiesForSelectedGeometry: () => void;
changeCount: number;
entityFormStatus: string | null;
@@ -68,8 +77,14 @@ export default function SelectedGeometryPanel({
entityTypeOptions,
geometryMetaForm,
onGeometryMetaFormChange,
bindingGeometrySearchQuery,
onBindingGeometrySearchQueryChange,
bindingGeometrySearchResults,
selectedBindingGeometryId,
onSelectBindingGeometryId,
onAddSelectedBindingGeometry,
isEntitySubmitting,
onCreateEntityAndAttach,
onCreateEntityOnly,
onApplyEntitiesForSelectedGeometry,
changeCount,
entityFormStatus,
@@ -84,13 +99,13 @@ export default function SelectedGeometryPanel({
const visibleGroupedEntityTypeOptions = groupedEntityTypeOptions.filter((group) =>
allowedGroupIds.includes(group.id)
);
const groupedEntityTypeOptionsForCreate = selectedFeature
? visibleGroupedEntityTypeOptions
: groupedEntityTypeOptions;
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)
);
const isGeometryCompatible = selectedTypeOption
? allowedGroupIds.includes(selectedTypeOption.groupId)
: true;
return (
<div
@@ -102,12 +117,12 @@ export default function SelectedGeometryPanel({
}}
>
<div style={{ fontWeight: 700, marginBottom: "8px", fontSize: "14px" }}>
Selected Geometry
Entity & Geometry
</div>
{!selectedFeature ? (
<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 style={{ display: "grid", gap: "8px", fontSize: "13px" }}>
@@ -187,11 +202,10 @@ export default function SelectedGeometryPanel({
}}
>
<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 style={{ color: "#94a3b8", fontSize: "11px" }}>
`time_start`, `time_end`, `binding` sẽ đưc dùng cho cả luồng gắn entity có sẵn
luồng tạo entity mới.
`time_start`, `time_end`, `binding` chỉ đưc áp dng khi bấm nút bind entity cho geometry.
</div>
<input
value={geometryMetaForm.time_start}
@@ -214,6 +228,38 @@ export default function SelectedGeometryPanel({
disabled={isEntitySubmitting}
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
@@ -227,7 +273,7 @@ export default function SelectedGeometryPanel({
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 1: Gắn entity sẵn
Bind entity sẵn
</div>
<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.
@@ -285,112 +331,112 @@ export default function SelectedGeometryPanel({
</button>
</div>
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Luồng 2: Tạo entity mới rồi gắn
</div>
<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.
</div>
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => onEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Chọn loại entity
</div>
<select
value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
) : null}
{visibleGroupedEntityTypeOptions.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : entityForm.type_id ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{entityForm.type_id}</b>
</div>
) : 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
onClick={onCreateEntityAndAttach}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Tạo entity mới + gắn + áp metadata
</button>
</div>
{changeCount > 0 ? (
<div style={{ color: "#fca5a5", fontSize: "12px" }}>
Geometry mới sẽ lưu entity khi bấm Save.
</div>
) : null}
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px" }}>
{entityFormStatus}
</div>
) : null}
</div>
)}
<div
style={{
display: "grid",
gap: "8px",
border: "1px solid #1e3a8a",
borderRadius: "8px",
padding: "8px",
background: "#0f172a",
marginTop: "10px",
}}
>
<div style={{ color: "#bfdbfe", fontWeight: 700, fontSize: "12px" }}>
Tạo entity mới (đc lập)
</div>
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Chỉ tạo entity, không tự bind vào geometry.
</div>
{selectedFeature ? (
<div style={{ color: "#93c5fd", fontSize: "11px" }}>
Type đang bị giới hạn theo geometry: <b>{formatGeometryPresetLabel(featureGeometryPreset)}</b>.
</div>
) : null}
<input
value={entityForm.name}
onChange={(event) => onEntityFormChange("name", event.target.value)}
placeholder="Tên entity mới"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<input
value={entityForm.slug}
onChange={(event) => onEntityFormChange("slug", event.target.value)}
placeholder="Slug"
disabled={isEntitySubmitting}
style={entityInputStyle}
/>
<div style={{ color: "#e2e8f0", fontWeight: 600, fontSize: "12px" }}>
Chọn loại entity
</div>
<select
value={entityForm.type_id}
onChange={(event) => onEntityFormChange("type_id", event.target.value)}
disabled={isEntitySubmitting}
style={entityInputStyle}
>
{!selectedFeature && !hasCurrentVisibleTypeOption && entityForm.type_id ? (
<option value={entityForm.type_id}>
Custom Type ({entityForm.type_id})
</option>
) : null}
{groupedEntityTypeOptionsForCreate.map((group) => (
<optgroup
key={group.id}
label={`${group.label} (${group.geometryLabel})`}
>
{group.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))}
</select>
{selectedTypeOption ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{selectedTypeOption.label}</b> ({selectedTypeOption.groupLabel})
</div>
) : entityForm.type_id ? (
<div style={{ color: "#cbd5e1", fontSize: "12px" }}>
Type đang chọn: <b>{entityForm.type_id}</b>
</div>
) : null}
<button
onClick={onCreateEntityOnly}
disabled={isEntitySubmitting}
style={{
border: "none",
borderRadius: "6px",
padding: "7px 8px",
cursor: isEntitySubmitting ? "not-allowed" : "pointer",
background: "#2563eb",
color: "#ffffff",
opacity: isEntitySubmitting ? 0.7 : 1,
fontWeight: 600,
}}
>
Tạo entity mới
</button>
</div>
{entityFormStatus ? (
<div style={{ color: "#93c5fd", fontSize: "12px", marginTop: "8px" }}>
{entityFormStatus}
</div>
) : null}
</div>
);
}