preview map editor 60%
This commit is contained in:
985
app/editor/page.tsx
Normal file
985
app/editor/page.tsx
Normal file
@@ -0,0 +1,985 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, 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 { ApiError } from "@/api/http";
|
||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
||||
import {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
useEditorState,
|
||||
} from "@/lib/useEditorState";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/lib/backgroundLayers";
|
||||
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
|
||||
|
||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
const WORLD_BBOX = {
|
||||
minLng: -180,
|
||||
minLat: -90,
|
||||
maxLng: 180,
|
||||
maxLat: 90,
|
||||
} as const;
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
const FALLBACK_TIMELINE_RANGE: TimelineRange = {
|
||||
min: CURRENT_YEAR - 5000,
|
||||
max: CURRENT_YEAR + 100,
|
||||
};
|
||||
const TIMELINE_DEBOUNCE_MS = 180;
|
||||
const BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY = "uhm.backgroundLayerVisibility.v1";
|
||||
|
||||
type TimelineRange = {
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
type EntityFormState = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type_id: string;
|
||||
};
|
||||
|
||||
type GeometryMetaFormState = {
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
binding: string;
|
||||
};
|
||||
|
||||
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 [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||
});
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
});
|
||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||
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);
|
||||
const [timelineYear, setTimelineYear] = useState<number>(() =>
|
||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
||||
);
|
||||
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() =>
|
||||
clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max)
|
||||
);
|
||||
const [isTimelineReady, setIsTimelineReady] = useState(false);
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||
);
|
||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||
const timelineFetchRequestRef = useRef(0);
|
||||
const entitySearchRequestRef = useRef(0);
|
||||
const skipSelectedFeatureSyncRef = useRef(false);
|
||||
|
||||
const editor = useEditorState(initialData);
|
||||
const selectedFeature =
|
||||
selectedFeatureId === null
|
||||
? null
|
||||
: editor.draft.features.find((feature) =>
|
||||
String(feature.properties.id) === String(selectedFeatureId)
|
||||
) || null;
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadEntities() {
|
||||
try {
|
||||
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.");
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
console.error("Load entities failed", err);
|
||||
setEntityStatus("Không tải được danh sách entity.");
|
||||
}
|
||||
}
|
||||
|
||||
loadEntities();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeature) {
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
setIsEntitySearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyword = entitySearchQuery.trim();
|
||||
if (!keyword.length) {
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
setIsEntitySearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++entitySearchRequestRef.current;
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setIsEntitySearchLoading(true);
|
||||
try {
|
||||
const rows = await searchEntitiesByName(keyword, { limit: 30 });
|
||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||
|
||||
setEntitySearchResults(rows);
|
||||
setSelectedSearchEntityId((prev) =>
|
||||
prev && rows.some((entity) => entity.id === prev)
|
||||
? prev
|
||||
: rows[0]?.id || null
|
||||
);
|
||||
} catch (err) {
|
||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||
console.error("Search entity by name failed", err);
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
} finally {
|
||||
if (!disposed && requestId === entitySearchRequestRef.current) {
|
||||
setIsEntitySearchLoading(false);
|
||||
}
|
||||
}
|
||||
}, 220);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [entitySearchQuery, selectedFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFeatureId === null) return;
|
||||
const stillExists = editor.draft.features.some((feature) =>
|
||||
String(feature.properties.id) === String(selectedFeatureId)
|
||||
);
|
||||
if (!stillExists) {
|
||||
setSelectedFeatureId(null);
|
||||
}
|
||||
}, [editor.draft, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipSelectedFeatureSyncRef.current) {
|
||||
skipSelectedFeatureSyncRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFeature) {
|
||||
setEntityForm({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||
});
|
||||
setSelectedGeometryEntityIds([]);
|
||||
setGeometryMetaForm({
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
});
|
||||
setEntitySearchQuery("");
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(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
|
||||
? String(selectedFeature.properties.time_start)
|
||||
: "",
|
||||
time_end: selectedFeature.properties.time_end != null
|
||||
? String(selectedFeature.properties.time_end)
|
||||
: "",
|
||||
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
||||
});
|
||||
setEntitySearchQuery("");
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadTimelineBounds() {
|
||||
setIsTimelineLoading(true);
|
||||
try {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
});
|
||||
if (disposed) return;
|
||||
|
||||
const range = deriveTimelineRange(data);
|
||||
const initialYear = clampYear(CURRENT_YEAR, range);
|
||||
|
||||
setTimelineRange(range);
|
||||
setTimelineWindowStart(range.min);
|
||||
setTimelineWindowEnd(range.max);
|
||||
setTimelineYear(initialYear);
|
||||
setTimelineDraftYear(initialYear);
|
||||
setTimelineStatus(null);
|
||||
} catch (err) {
|
||||
if (disposed) return;
|
||||
console.error("Load timeline bounds failed", err);
|
||||
const fallbackYear = clampYear(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE);
|
||||
setTimelineRange(FALLBACK_TIMELINE_RANGE);
|
||||
setTimelineWindowStart(FALLBACK_TIMELINE_RANGE.min);
|
||||
setTimelineWindowEnd(FALLBACK_TIMELINE_RANGE.max);
|
||||
setTimelineYear(fallbackYear);
|
||||
setTimelineDraftYear(fallbackYear);
|
||||
setTimelineStatus("Không thể lấy phạm vi thời gian, đang dùng mốc mặc định.");
|
||||
} finally {
|
||||
if (!disposed) {
|
||||
setIsTimelineLoading(false);
|
||||
setIsTimelineReady(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadTimelineBounds();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimelineReady) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
if (timelineDraftYear !== timelineYear) {
|
||||
setTimelineYear(timelineDraftYear);
|
||||
}
|
||||
}, TIMELINE_DEBOUNCE_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||
setIsBackgroundVisibilityReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
||||
setTimelineDraftYear((prev) => clampYearValue(prev, lower, upper));
|
||||
}, [timelineWindowStart, timelineWindowEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimelineReady) return;
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++timelineFetchRequestRef.current;
|
||||
|
||||
async function loadByTimeline() {
|
||||
setIsTimelineLoading(true);
|
||||
setTimelineStatus(null);
|
||||
|
||||
try {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
time: timelineYear,
|
||||
});
|
||||
|
||||
if (disposed || requestId !== timelineFetchRequestRef.current) return;
|
||||
setInitialData(data);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Load timeline data failed", err.body);
|
||||
} else {
|
||||
console.error("Load timeline data failed", err);
|
||||
}
|
||||
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn.");
|
||||
}
|
||||
} finally {
|
||||
if (!disposed && requestId === timelineFetchRequestRef.current) {
|
||||
setIsTimelineLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadByTimeline();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [timelineYear, isTimelineReady]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = editor.buildPayload();
|
||||
if (!payload.length) return;
|
||||
|
||||
const invalid = payload.find((change) => {
|
||||
if (change.action === "delete") return false;
|
||||
const draftFeature = change.action === "create"
|
||||
? change.feature
|
||||
: editor.draft.features.find((feature) =>
|
||||
String(feature.properties.id) === String(change.id)
|
||||
);
|
||||
if (!draftFeature) return false;
|
||||
const entityIds = normalizeFeatureEntityIds(draftFeature);
|
||||
return entityIds.length === 0;
|
||||
});
|
||||
|
||||
if (invalid) {
|
||||
const invalidId = invalid.action === "create"
|
||||
? invalid.feature.properties.id
|
||||
: invalid.id;
|
||||
setSelectedFeatureId(invalidId);
|
||||
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
await saveGeometryBatchChanges(payload);
|
||||
editor.clearChanges();
|
||||
await reloadCurrentTimelineData();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Save failed", err.body);
|
||||
setEntityStatus(`Save thất bại: ${err.body}`);
|
||||
return;
|
||||
}
|
||||
console.error("Save error", err);
|
||||
setEntityStatus("Save thất bại.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateBackgroundVisibility = (
|
||||
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
||||
) => {
|
||||
setBackgroundVisibility((prev) => {
|
||||
const next = updater(prev);
|
||||
persistBackgroundLayerVisibility(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||
updateBackgroundVisibility((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleShowAllBackgroundLayers = () => {
|
||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleHideAllBackgroundLayers = () => {
|
||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleTimelineWindowStartChange = (nextYear: number) => {
|
||||
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
||||
setTimelineWindowStart(Math.min(next, timelineWindowEnd));
|
||||
};
|
||||
|
||||
const handleTimelineWindowEndChange = (nextYear: number) => {
|
||||
const next = clampYearValue(Math.trunc(nextYear), timelineRange.min, timelineRange.max);
|
||||
setTimelineWindowEnd(Math.max(next, timelineWindowStart));
|
||||
};
|
||||
|
||||
const handleTimelineYearChange = (nextYear: number) => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
const upper = Math.max(timelineWindowStart, timelineWindowEnd);
|
||||
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||
};
|
||||
|
||||
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
|
||||
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleEntityIdsChange = (values: string[]) => {
|
||||
setSelectedGeometryEntityIds(uniqueEntityIds(values));
|
||||
};
|
||||
|
||||
const handleAddSelectedSearchEntity = () => {
|
||||
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
|
||||
if (!entityId.length) {
|
||||
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
|
||||
setSelectedGeometryEntityIds(next);
|
||||
setSelectedSearchEntityId(null);
|
||||
setEntityFormStatus(null);
|
||||
};
|
||||
|
||||
const reloadEntities = async () => {
|
||||
const rows = await fetchEntities();
|
||||
setEntities(rows);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const reloadCurrentTimelineData = async () => {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
time: timelineYear,
|
||||
});
|
||||
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");
|
||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||
throw new Error("time_start phải <= time_end.");
|
||||
}
|
||||
return { timeStart, timeEnd };
|
||||
};
|
||||
|
||||
const resolveGeometryTypeFromEntityIds = (
|
||||
entityIds: string[],
|
||||
entityRows: Entity[] = entities
|
||||
): string | null => {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
if (!primaryEntityId) return null;
|
||||
const primaryEntity = entityRows.find((entity) => entity.id === primaryEntityId) || null;
|
||||
return primaryEntity?.type_id || null;
|
||||
};
|
||||
|
||||
const patchSelectedFeatureLocally = (
|
||||
feature: Feature,
|
||||
entityIds: string[],
|
||||
timeStart: number | null,
|
||||
timeEnd: number | null,
|
||||
bindingIds: string[],
|
||||
entityRows: Entity[] = entities
|
||||
) => {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
const primaryEntity = primaryEntityId
|
||||
? entityRows.find((entity) => entity.id === primaryEntityId) || null
|
||||
: null;
|
||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds, entityRows) || feature.properties.type || null;
|
||||
const entityNames = entityIds
|
||||
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
editor.patchFeatureProperties(feature.properties.id, {
|
||||
type: nextGeometryType,
|
||||
entity_id: primaryEntityId,
|
||||
entity_ids: entityIds,
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
entity_type_id: primaryEntity?.type_id || null,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
});
|
||||
|
||||
setSelectedGeometryEntityIds(entityIds);
|
||||
setGeometryMetaForm({
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
binding: bindingIds.join(", "),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyEntitiesForSelectedGeometry = async () => {
|
||||
if (!selectedFeature) {
|
||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||
if (!entityIds.length) {
|
||||
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
|
||||
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ệ.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(entityIds) || selectedFeature.properties.type || null;
|
||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||
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;
|
||||
}
|
||||
|
||||
await updateGeometry(selectedFeature.properties.id, {
|
||||
geometry: selectedFeature.geometry,
|
||||
type: nextGeometryType ?? undefined,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
entity_ids: entityIds,
|
||||
});
|
||||
|
||||
await reloadCurrentTimelineData();
|
||||
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
||||
} else {
|
||||
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
|
||||
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityFormStatus("Lưu thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const 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 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ệ.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
const created = await createEntity({
|
||||
name,
|
||||
slug: entityForm.slug.trim() || null,
|
||||
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
|
||||
});
|
||||
|
||||
const rows = await reloadEntities();
|
||||
const nextEntityIds = uniqueEntityIds([
|
||||
...selectedGeometryEntityIds,
|
||||
created.id,
|
||||
]);
|
||||
const nextGeometryType = resolveGeometryTypeFromEntityIds(nextEntityIds, rows) || selectedFeature.properties.type || null;
|
||||
|
||||
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.");
|
||||
}
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
|
||||
const timelineStatusText =
|
||||
editor.changeCount > 0
|
||||
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
|
||||
: isSaving
|
||||
? "Đang lưu thay đổi..."
|
||||
: timelineStatus;
|
||||
|
||||
const handleCreateFeature = (feature: Feature) => {
|
||||
editor.createFeature(feature);
|
||||
setSelectedFeatureId(feature.properties.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Editor
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
entityStatus={entityStatus}
|
||||
onUndo={editor.undo}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
changesCount={editor.changeCount}
|
||||
undoStack={editor.undoStack}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
mode={mode}
|
||||
draft={editor.draft}
|
||||
selectedFeatureId={selectedFeatureId}
|
||||
onSelectFeatureId={setSelectedFeatureId}
|
||||
onCreateFeature={handleCreateFeature}
|
||||
onDeleteFeature={editor.deleteFeature}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||
)}
|
||||
<TimelineBar
|
||||
minYear={timelineRange.min}
|
||||
maxYear={timelineRange.max}
|
||||
windowStartYear={timelineWindowStart}
|
||||
windowEndYear={timelineWindowEnd}
|
||||
onWindowStartYearChange={handleTimelineWindowStartChange}
|
||||
onWindowEndYearChange={handleTimelineWindowEndChange}
|
||||
year={timelineDraftYear}
|
||||
onYearChange={handleTimelineYearChange}
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={timelineDisabled}
|
||||
statusText={timelineStatusText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BackgroundLayersPanel
|
||||
visibility={backgroundVisibility}
|
||||
onToggleLayer={handleToggleBackgroundLayer}
|
||||
onShowAll={handleShowAllBackgroundLayers}
|
||||
onHideAll={handleHideAllBackgroundLayers}
|
||||
topContent={
|
||||
<SelectedGeometryPanel
|
||||
selectedFeature={selectedFeature}
|
||||
selectedFeatureEntitySummary={
|
||||
selectedFeature
|
||||
? formatEntityNamesForDisplay(selectedFeature, entities)
|
||||
: "Chưa gắn"
|
||||
}
|
||||
selectedFeatureBindingSummary={
|
||||
selectedFeature
|
||||
? formatBindingIdsForDisplay(selectedFeature)
|
||||
: "Không có"
|
||||
}
|
||||
entities={entities}
|
||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||
onEntityIdsChange={handleEntityIdsChange}
|
||||
entitySearchQuery={entitySearchQuery}
|
||||
onEntitySearchQueryChange={setEntitySearchQuery}
|
||||
entitySearchResults={entitySearchResults}
|
||||
selectedSearchEntityId={selectedSearchEntityId}
|
||||
onSelectSearchEntityId={setSelectedSearchEntityId}
|
||||
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
|
||||
isEntitySearchLoading={isEntitySearchLoading}
|
||||
entityForm={entityForm}
|
||||
onEntityFormChange={handleEntityFormChange}
|
||||
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
||||
geometryMetaForm={geometryMetaForm}
|
||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||
isEntitySubmitting={isEntitySubmitting}
|
||||
onCreateEntityAndAttach={handleCreateEntityAndAttach}
|
||||
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
||||
changeCount={editor.changeCount}
|
||||
entityFormStatus={entityFormStatus}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveTimelineRange(collection: FeatureCollection): TimelineRange {
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const feature of collection.features) {
|
||||
const { time_start, time_end } = feature.properties;
|
||||
|
||||
if (isYearNumber(time_start)) {
|
||||
min = Math.min(min, time_start);
|
||||
max = Math.max(max, time_start);
|
||||
}
|
||||
|
||||
if (isYearNumber(time_end)) {
|
||||
min = Math.min(min, time_end);
|
||||
max = Math.max(max, time_end);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
return FALLBACK_TIMELINE_RANGE;
|
||||
}
|
||||
|
||||
return {
|
||||
min: Math.floor(min),
|
||||
max: Math.ceil(max),
|
||||
};
|
||||
}
|
||||
|
||||
function clampYear(year: number, range: TimelineRange): number {
|
||||
return clampYearValue(year, range.min, range.max);
|
||||
}
|
||||
|
||||
function clampYearValue(year: number, minYear: number, maxYear: number): number {
|
||||
const lower = Math.min(minYear, maxYear);
|
||||
const upper = Math.max(minYear, maxYear);
|
||||
if (year < lower) return lower;
|
||||
if (year > upper) return upper;
|
||||
return year;
|
||||
}
|
||||
|
||||
function isYearNumber(value: number | null | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function getDefaultTypeIdForFeature(feature: Feature): string {
|
||||
const preset = feature.properties.geometry_preset;
|
||||
if (preset === "line") return "defense_line";
|
||||
if (preset === "point") return "city";
|
||||
if (preset === "circle-area") return "war";
|
||||
if (preset === "polygon") return DEFAULT_ENTITY_TYPE_ID;
|
||||
|
||||
const geometryType = feature.geometry.type;
|
||||
if (geometryType === "LineString" || geometryType === "MultiLineString") {
|
||||
return "defense_line";
|
||||
}
|
||||
if (geometryType === "Point" || geometryType === "MultiPoint") {
|
||||
return "city";
|
||||
}
|
||||
return DEFAULT_ENTITY_TYPE_ID;
|
||||
}
|
||||
|
||||
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||
const value = raw.trim();
|
||||
if (!value.length) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${fieldName} phải là số.`);
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeFeatureEntityIds(feature: Feature): string[] {
|
||||
const fromArray = Array.isArray(feature.properties.entity_ids)
|
||||
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (fromArray.length) {
|
||||
return uniqueEntityIds(fromArray);
|
||||
}
|
||||
|
||||
const single = feature.properties.entity_id;
|
||||
if (typeof single === "string" && single.trim().length > 0) {
|
||||
return [single.trim()];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeFeatureBindingIds(feature: Feature): string[] {
|
||||
const rawBinding = feature.properties.binding;
|
||||
if (!Array.isArray(rawBinding)) return [];
|
||||
return uniqueEntityIds(rawBinding
|
||||
.map((id) => {
|
||||
if (typeof id !== "string" && typeof id !== "number") return "";
|
||||
return String(id).trim();
|
||||
})
|
||||
.filter((id) => id.length > 0));
|
||||
}
|
||||
|
||||
function parseBindingInput(raw: string): string[] {
|
||||
if (!raw.trim().length) return [];
|
||||
return uniqueEntityIds(
|
||||
raw
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function formatBindingIdsForDisplay(feature: Feature): string {
|
||||
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||
if (!bindingIds.length) return "Không có";
|
||||
return bindingIds.join(", ");
|
||||
}
|
||||
|
||||
function uniqueEntityIds(ids: string[]): string[] {
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of ids) {
|
||||
const id = rawId.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
if (!entityIds.length) return "Chưa gắn";
|
||||
|
||||
const names = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
|
||||
.filter((name) => name.trim().length > 0);
|
||||
return names.join(", ");
|
||||
}
|
||||
|
||||
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||
if (typeof window === "undefined") {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
const normalized = normalizeBackgroundLayerVisibility(parsed);
|
||||
return normalized || { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
} catch (err) {
|
||||
console.warn("Load background layer visibility from storage failed", err);
|
||||
return { ...DEFAULT_BACKGROUND_LAYER_VISIBILITY };
|
||||
}
|
||||
}
|
||||
|
||||
function persistBackgroundLayerVisibility(visibility: BackgroundLayerVisibility) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
BACKGROUND_LAYER_VISIBILITY_STORAGE_KEY,
|
||||
JSON.stringify(visibility)
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("Persist background layer visibility failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBackgroundLayerVisibility(raw: unknown): BackgroundLayerVisibility | null {
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
|
||||
const source = raw as Record<string, unknown>;
|
||||
const next: BackgroundLayerVisibility = {
|
||||
...DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
};
|
||||
|
||||
for (const layer of BACKGROUND_LAYER_OPTIONS) {
|
||||
const value = source[layer.id];
|
||||
if (typeof value === "boolean") {
|
||||
next[layer.id] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
767
app/page.tsx
767
app/page.tsx
@@ -1,27 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, 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 { fetchGeometriesByBBox } from "@/api/geometries";
|
||||
import { ApiError } from "@/api/http";
|
||||
import { fetchGeometriesByBBox, saveGeometryBatchChanges, updateGeometry } from "@/api/geometries";
|
||||
import {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
useEditorState,
|
||||
} from "@/lib/useEditorState";
|
||||
import { findEntityTypeOption } from "@/lib/entityTypeOptions";
|
||||
import {
|
||||
BACKGROUND_LAYER_OPTIONS,
|
||||
BackgroundLayerId,
|
||||
BackgroundLayerVisibility,
|
||||
DEFAULT_BACKGROUND_LAYER_VISIBILITY,
|
||||
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
|
||||
} from "@/lib/backgroundLayers";
|
||||
import { DEFAULT_ENTITY_TYPE_ID, ENTITY_TYPE_OPTIONS } from "@/lib/entityTypeOptions";
|
||||
import { Feature, FeatureCollection } from "@/lib/useEditorState";
|
||||
|
||||
const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] };
|
||||
const WORLD_BBOX = {
|
||||
@@ -43,43 +36,9 @@ type TimelineRange = {
|
||||
max: number;
|
||||
};
|
||||
|
||||
type EntityFormState = {
|
||||
name: string;
|
||||
slug: string;
|
||||
type_id: string;
|
||||
};
|
||||
|
||||
type GeometryMetaFormState = {
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
binding: string;
|
||||
};
|
||||
|
||||
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 [selectedEntityId, setSelectedEntityId] = useState<string | null>(null);
|
||||
const [entityStatus, setEntityStatus] = useState<string | null>(null);
|
||||
const [selectedFeatureId, setSelectedFeatureId] = useState<string | number | null>(null);
|
||||
const [entityForm, setEntityForm] = useState<EntityFormState>({
|
||||
name: "",
|
||||
slug: "",
|
||||
type_id: DEFAULT_ENTITY_TYPE_ID,
|
||||
});
|
||||
const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState<string[]>([]);
|
||||
const [geometryMetaForm, setGeometryMetaForm] = useState<GeometryMetaFormState>({
|
||||
time_start: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
});
|
||||
const [isEntitySubmitting, setIsEntitySubmitting] = useState(false);
|
||||
const [entityFormStatus, setEntityFormStatus] = useState<string | null>(null);
|
||||
const [entitySearchQuery, setEntitySearchQuery] = useState("");
|
||||
const [entitySearchResults, setEntitySearchResults] = useState<Entity[]>([]);
|
||||
const [selectedSearchEntityId, setSelectedSearchEntityId] = useState<string | null>(null);
|
||||
const [isEntitySearchLoading, setIsEntitySearchLoading] = useState(false);
|
||||
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);
|
||||
@@ -93,170 +52,27 @@ export default function Page() {
|
||||
const [isTimelineLoading, setIsTimelineLoading] = useState(false);
|
||||
const [timelineStatus, setTimelineStatus] = useState<string | null>(null);
|
||||
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
|
||||
() => loadBackgroundLayerVisibilityFromStorage()
|
||||
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
|
||||
);
|
||||
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
|
||||
const timelineFetchRequestRef = useRef(0);
|
||||
const entitySearchRequestRef = useRef(0);
|
||||
const skipSelectedFeatureSyncRef = useRef(false);
|
||||
|
||||
const editor = useEditorState(initialData);
|
||||
const selectedEntity =
|
||||
entities.find((entity) => entity.id === selectedEntityId) || null;
|
||||
const selectedFeature =
|
||||
selectedFeatureId === null
|
||||
? null
|
||||
: editor.draft.features.find((feature) =>
|
||||
: initialData.features.find((feature) =>
|
||||
String(feature.properties.id) === String(selectedFeatureId)
|
||||
) || null;
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
async function loadEntities() {
|
||||
try {
|
||||
const rows = await fetchEntities();
|
||||
if (disposed) return;
|
||||
|
||||
setEntities(rows);
|
||||
setSelectedEntityId((prev) => {
|
||||
if (prev && rows.some((entity) => entity.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return rows[0]?.id || null;
|
||||
});
|
||||
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);
|
||||
setEntityStatus("Không tải được danh sách entity.");
|
||||
}
|
||||
}
|
||||
|
||||
loadEntities();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFeature) {
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
setIsEntitySearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyword = entitySearchQuery.trim();
|
||||
if (!keyword.length) {
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
setIsEntitySearchLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
const requestId = ++entitySearchRequestRef.current;
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setIsEntitySearchLoading(true);
|
||||
try {
|
||||
const rows = await searchEntitiesByName(keyword, { limit: 30 });
|
||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||
|
||||
setEntitySearchResults(rows);
|
||||
setSelectedSearchEntityId((prev) =>
|
||||
prev && rows.some((entity) => entity.id === prev)
|
||||
? prev
|
||||
: rows[0]?.id || null
|
||||
);
|
||||
setEntities((prev) => mergeEntitiesById(prev, rows));
|
||||
} catch (err) {
|
||||
if (disposed || requestId !== entitySearchRequestRef.current) return;
|
||||
console.error("Search entity by name failed", err);
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
} finally {
|
||||
if (!disposed && requestId === entitySearchRequestRef.current) {
|
||||
setIsEntitySearchLoading(false);
|
||||
}
|
||||
}
|
||||
}, 220);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [entitySearchQuery, selectedFeature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFeatureId === null) return;
|
||||
const stillExists = editor.draft.features.some((feature) =>
|
||||
const stillExists = initialData.features.some((feature) =>
|
||||
String(feature.properties.id) === String(selectedFeatureId)
|
||||
);
|
||||
if (!stillExists) {
|
||||
setSelectedFeatureId(null);
|
||||
}
|
||||
}, [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: "",
|
||||
time_end: "",
|
||||
binding: "",
|
||||
});
|
||||
setEntitySearchQuery("");
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(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.entity_type_id ||
|
||||
DEFAULT_ENTITY_TYPE_ID;
|
||||
|
||||
setEntityForm({
|
||||
name: "",
|
||||
slug: linkedEntity?.slug || "",
|
||||
type_id: nextTypeId,
|
||||
});
|
||||
setSelectedGeometryEntityIds(featureEntityIds);
|
||||
setGeometryMetaForm({
|
||||
time_start: selectedFeature.properties.time_start != null
|
||||
? String(selectedFeature.properties.time_start)
|
||||
: "",
|
||||
time_end: selectedFeature.properties.time_end != null
|
||||
? String(selectedFeature.properties.time_end)
|
||||
: "",
|
||||
binding: normalizeFeatureBindingIds(selectedFeature).join(", "),
|
||||
});
|
||||
setEntitySearchQuery("");
|
||||
setEntitySearchResults([]);
|
||||
setSelectedSearchEntityId(null);
|
||||
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]);
|
||||
}, [initialData, selectedFeatureId]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
@@ -316,8 +132,9 @@ export default function Page() {
|
||||
}, [timelineDraftYear, timelineYear, isTimelineReady]);
|
||||
|
||||
useEffect(() => {
|
||||
persistBackgroundLayerVisibility(backgroundVisibility);
|
||||
}, [backgroundVisibility]);
|
||||
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
|
||||
setIsBackgroundVisibilityReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const lower = Math.min(timelineWindowStart, timelineWindowEnd);
|
||||
@@ -367,63 +184,29 @@ export default function Page() {
|
||||
};
|
||||
}, [timelineYear, isTimelineReady]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = editor.buildPayload();
|
||||
if (!payload.length) return;
|
||||
|
||||
const invalid = payload.find((change) => {
|
||||
if (change.type === "delete") return false;
|
||||
const draftFeature = change.type === "create"
|
||||
? change.feature
|
||||
: editor.draft.features.find((feature) =>
|
||||
String(feature.properties.id) === String(change.id)
|
||||
);
|
||||
if (!draftFeature) return false;
|
||||
const entityIds = normalizeFeatureEntityIds(draftFeature);
|
||||
return entityIds.length === 0;
|
||||
const updateBackgroundVisibility = (
|
||||
updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility
|
||||
) => {
|
||||
setBackgroundVisibility((prev) => {
|
||||
const next = updater(prev);
|
||||
persistBackgroundLayerVisibility(next);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (invalid) {
|
||||
const invalidId = invalid.type === "create"
|
||||
? invalid.feature.properties.id
|
||||
: invalid.id;
|
||||
setSelectedFeatureId(invalidId);
|
||||
setEntityStatus("Không thể Save: mỗi geometry phải có ít nhất 1 entity.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
setEntityStatus(null);
|
||||
try {
|
||||
await saveGeometryBatchChanges(payload);
|
||||
editor.clearChanges();
|
||||
await reloadCurrentTimelineData();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
console.error("Save failed", err.body);
|
||||
setEntityStatus(`Save thất bại: ${err.body}`);
|
||||
return;
|
||||
}
|
||||
console.error("Save error", err);
|
||||
setEntityStatus("Save thất bại.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
|
||||
setBackgroundVisibility((prev) => ({
|
||||
const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => {
|
||||
updateBackgroundVisibility((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleShowAllBackgroundLayers = () => {
|
||||
setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY });
|
||||
updateBackgroundVisibility(() => ({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleHideAllBackgroundLayers = () => {
|
||||
setBackgroundVisibility({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY });
|
||||
updateBackgroundVisibility(() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }));
|
||||
};
|
||||
|
||||
const handleTimelineWindowStartChange = (nextYear: number) => {
|
||||
@@ -442,291 +225,28 @@ export default function Page() {
|
||||
setTimelineDraftYear(clampYearValue(Math.trunc(nextYear), lower, upper));
|
||||
};
|
||||
|
||||
const handleEntityFormChange = (key: keyof EntityFormState, value: string) => {
|
||||
setEntityForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleGeometryMetaFormChange = (key: "time_start" | "time_end" | "binding", value: string) => {
|
||||
setGeometryMetaForm((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleEntityIdsChange = (values: string[]) => {
|
||||
setSelectedGeometryEntityIds(uniqueEntityIds(values));
|
||||
};
|
||||
|
||||
const handleAddSelectedSearchEntity = () => {
|
||||
const entityId = selectedSearchEntityId ? selectedSearchEntityId.trim() : "";
|
||||
if (!entityId.length) {
|
||||
setEntityFormStatus("Hãy chọn một entity từ kết quả search trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const next = uniqueEntityIds([...selectedGeometryEntityIds, entityId]);
|
||||
setSelectedGeometryEntityIds(next);
|
||||
setSelectedSearchEntityId(null);
|
||||
setEntityFormStatus(null);
|
||||
};
|
||||
|
||||
const reloadEntities = async () => {
|
||||
const rows = await fetchEntities();
|
||||
setEntities(rows);
|
||||
setSelectedEntityId((prev) => {
|
||||
if (prev && rows.some((entity) => entity.id === prev)) {
|
||||
return prev;
|
||||
}
|
||||
return rows[0]?.id || null;
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
const reloadCurrentTimelineData = async () => {
|
||||
const data = await fetchGeometriesByBBox({
|
||||
...WORLD_BBOX,
|
||||
time: timelineYear,
|
||||
});
|
||||
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");
|
||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||
throw new Error("time_start phải <= time_end.");
|
||||
}
|
||||
return { timeStart, timeEnd };
|
||||
};
|
||||
|
||||
const patchSelectedFeatureLocally = (
|
||||
feature: Feature,
|
||||
entityIds: string[],
|
||||
timeStart: number | null,
|
||||
timeEnd: number | null,
|
||||
bindingIds: string[],
|
||||
entityRows: Entity[] = entities
|
||||
) => {
|
||||
const primaryEntityId = entityIds[0] || null;
|
||||
const primaryEntity = primaryEntityId
|
||||
? entityRows.find((entity) => entity.id === primaryEntityId) || null
|
||||
: null;
|
||||
const entityNames = entityIds
|
||||
.map((id) => entityRows.find((entity) => entity.id === id)?.name || "")
|
||||
.filter((name) => name.length > 0);
|
||||
|
||||
editor.patchFeatureProperties(feature.properties.id, {
|
||||
entity_id: primaryEntityId,
|
||||
entity_ids: entityIds,
|
||||
entity_name: primaryEntity?.name || null,
|
||||
entity_names: entityNames,
|
||||
entity_type_id: primaryEntity?.type_id || null,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
});
|
||||
|
||||
setSelectedGeometryEntityIds(entityIds);
|
||||
setGeometryMetaForm({
|
||||
time_start: timeStart != null ? String(timeStart) : "",
|
||||
time_end: timeEnd != null ? String(timeEnd) : "",
|
||||
binding: bindingIds.join(", "),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplyEntitiesForSelectedGeometry = async () => {
|
||||
if (!selectedFeature) {
|
||||
setEntityFormStatus("Hãy chọn một geometry trước.");
|
||||
return;
|
||||
}
|
||||
|
||||
const entityIds = uniqueEntityIds(selectedGeometryEntityIds);
|
||||
if (!entityIds.length) {
|
||||
setEntityFormStatus("Geometry phải có ít nhất 1 entity.");
|
||||
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ệ.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||
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;
|
||||
}
|
||||
|
||||
await updateGeometry(selectedFeature.properties.id, {
|
||||
geometry: selectedFeature.geometry,
|
||||
time_start: timeStart,
|
||||
time_end: timeEnd,
|
||||
binding: bindingIds,
|
||||
entity_ids: entityIds,
|
||||
});
|
||||
|
||||
await reloadCurrentTimelineData();
|
||||
setEntityFormStatus("Đã cập nhật entities + metadata geometry.");
|
||||
} else {
|
||||
patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd, bindingIds);
|
||||
setEntityFormStatus("Đã cập nhật local. Bấm Save để lưu geometry mới.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setEntityFormStatus(`Lưu thất bại: ${err.body}`);
|
||||
} else {
|
||||
setEntityFormStatus("Lưu thất bại.");
|
||||
}
|
||||
} finally {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const 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 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ệ.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsEntitySubmitting(true);
|
||||
setEntityFormStatus(null);
|
||||
try {
|
||||
const created = await createEntity({
|
||||
name,
|
||||
slug: entityForm.slug.trim() || null,
|
||||
type_id: entityForm.type_id || DEFAULT_ENTITY_TYPE_ID,
|
||||
});
|
||||
|
||||
const rows = await reloadEntities();
|
||||
const nextEntityIds = uniqueEntityIds([
|
||||
...selectedGeometryEntityIds,
|
||||
created.id,
|
||||
]);
|
||||
|
||||
if (editor.hasPersistedFeature(selectedFeature.properties.id)) {
|
||||
await updateGeometry(selectedFeature.properties.id, {
|
||||
geometry: selectedFeature.geometry,
|
||||
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.");
|
||||
}
|
||||
|
||||
setSelectedEntityId(created.id);
|
||||
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 {
|
||||
setIsEntitySubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timelineDisabled = !isTimelineReady || isSaving || editor.changeCount > 0;
|
||||
const timelineStatusText =
|
||||
editor.changeCount > 0
|
||||
? "Lưu hoặc undo hết thay đổi trước khi đổi mốc thời gian."
|
||||
: isSaving
|
||||
? "Đang lưu thay đổi..."
|
||||
: timelineStatus;
|
||||
|
||||
const handleCreateFeature = (feature: Feature) => {
|
||||
editor.createFeature(feature);
|
||||
setSelectedFeatureId(feature.properties.id);
|
||||
};
|
||||
const timelineDisabled = !isTimelineReady;
|
||||
const selectedType = resolveSelectedTypeSummary(selectedFeature);
|
||||
const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature);
|
||||
const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature);
|
||||
const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh" }}>
|
||||
<Editor
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
entities={entities}
|
||||
selectedEntityId={selectedEntityId}
|
||||
onSelectEntityId={setSelectedEntityId}
|
||||
entityStatus={entityStatus}
|
||||
onUndo={editor.undo}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
changesCount={editor.changeCount}
|
||||
undoStack={editor.undoStack}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||
<Map
|
||||
mode={mode}
|
||||
draft={editor.draft}
|
||||
selectedFeatureId={selectedFeatureId}
|
||||
selectedEntityId={selectedEntityId}
|
||||
selectedEntityName={selectedEntity?.name || null}
|
||||
selectedEntityTypeId={selectedEntity?.type_id || null}
|
||||
onSelectFeatureId={setSelectedFeatureId}
|
||||
onCreateFeature={handleCreateFeature}
|
||||
onDeleteFeature={editor.deleteFeature}
|
||||
onUpdateFeature={editor.updateFeature}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
/>
|
||||
{isBackgroundVisibilityReady ? (
|
||||
<Map
|
||||
mode="select"
|
||||
draft={initialData}
|
||||
selectedFeatureId={selectedFeatureId}
|
||||
onSelectFeatureId={setSelectedFeatureId}
|
||||
backgroundVisibility={backgroundVisibility}
|
||||
allowGeometryEditing={false}
|
||||
respectBindingFilter={false}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||
)}
|
||||
<TimelineBar
|
||||
minYear={timelineRange.min}
|
||||
maxYear={timelineRange.max}
|
||||
@@ -738,7 +258,7 @@ export default function Page() {
|
||||
onYearChange={handleTimelineYearChange}
|
||||
isLoading={isTimelineLoading}
|
||||
disabled={timelineDisabled}
|
||||
statusText={timelineStatusText}
|
||||
statusText={timelineStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -748,39 +268,57 @@ export default function Page() {
|
||||
onShowAll={handleShowAllBackgroundLayers}
|
||||
onHideAll={handleHideAllBackgroundLayers}
|
||||
topContent={
|
||||
<SelectedGeometryPanel
|
||||
selectedFeature={selectedFeature}
|
||||
selectedFeatureEntitySummary={
|
||||
selectedFeature
|
||||
? formatEntityNamesForDisplay(selectedFeature, entities)
|
||||
: "Chưa gắn"
|
||||
}
|
||||
selectedFeatureBindingSummary={
|
||||
selectedFeature
|
||||
? formatBindingIdsForDisplay(selectedFeature)
|
||||
: "Không có"
|
||||
}
|
||||
entities={entities}
|
||||
selectedGeometryEntityIds={selectedGeometryEntityIds}
|
||||
onEntityIdsChange={handleEntityIdsChange}
|
||||
entitySearchQuery={entitySearchQuery}
|
||||
onEntitySearchQueryChange={setEntitySearchQuery}
|
||||
entitySearchResults={entitySearchResults}
|
||||
selectedSearchEntityId={selectedSearchEntityId}
|
||||
onSelectSearchEntityId={setSelectedSearchEntityId}
|
||||
onAddSelectedSearchEntity={handleAddSelectedSearchEntity}
|
||||
isEntitySearchLoading={isEntitySearchLoading}
|
||||
entityForm={entityForm}
|
||||
onEntityFormChange={handleEntityFormChange}
|
||||
entityTypeOptions={ENTITY_TYPE_OPTIONS}
|
||||
geometryMetaForm={geometryMetaForm}
|
||||
onGeometryMetaFormChange={handleGeometryMetaFormChange}
|
||||
isEntitySubmitting={isEntitySubmitting}
|
||||
onCreateEntityAndAttach={handleCreateEntityAndAttach}
|
||||
onApplyEntitiesForSelectedGeometry={handleApplyEntitiesForSelectedGeometry}
|
||||
changeCount={editor.changeCount}
|
||||
entityFormStatus={entityFormStatus}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
background: "#0b1220",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #1f2937",
|
||||
display: "grid",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: "14px", color: "#f8fafc" }}>
|
||||
Viewer
|
||||
</div>
|
||||
<div style={{ color: "#94a3b8", fontSize: "12px" }}>
|
||||
Click trực tiếp trên map để chọn geometry.
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Type: {selectedType}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Time: {selectedTimeSummary}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Entities: {selectedEntitySummary}
|
||||
</div>
|
||||
<div style={{ color: "#cbd5e1", fontSize: "13px" }}>
|
||||
Binding: {selectedBindingSummary}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/editor"
|
||||
style={{
|
||||
marginTop: "4px",
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: "6px",
|
||||
padding: "7px 8px",
|
||||
background: "#1d4ed8",
|
||||
color: "#ffffff",
|
||||
textDecoration: "none",
|
||||
fontSize: "13px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Mở Editor
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -831,93 +369,68 @@ function isYearNumber(value: number | null | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function parseOptionalYearInput(raw: string, fieldName: string): number | null {
|
||||
const value = raw.trim();
|
||||
if (!value.length) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error(`${fieldName} phải là số.`);
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
function formatSelectedTimeSummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
const start = isYearNumber(feature.properties.time_start)
|
||||
? String(feature.properties.time_start)
|
||||
: "?";
|
||||
const end = isYearNumber(feature.properties.time_end)
|
||||
? String(feature.properties.time_end)
|
||||
: "?";
|
||||
return `${start} → ${end}`;
|
||||
}
|
||||
|
||||
function normalizeFeatureEntityIds(feature: Feature): string[] {
|
||||
const fromArray = Array.isArray(feature.properties.entity_ids)
|
||||
? feature.properties.entity_ids.filter((id): id is string => typeof id === "string" && id.trim().length > 0)
|
||||
function formatSelectedEntitySummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
|
||||
const names = Array.isArray(feature.properties.entity_names)
|
||||
? feature.properties.entity_names.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||
: [];
|
||||
if (names.length) return names.join(", ");
|
||||
|
||||
if (fromArray.length) {
|
||||
return uniqueEntityIds(fromArray);
|
||||
if (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) {
|
||||
return feature.properties.entity_name.trim();
|
||||
}
|
||||
|
||||
const single = feature.properties.entity_id;
|
||||
if (typeof single === "string" && single.trim().length > 0) {
|
||||
return [single.trim()];
|
||||
const ids = Array.isArray(feature.properties.entity_ids)
|
||||
? feature.properties.entity_ids.filter((item): item is string => typeof item === "string" && item.trim().length > 0)
|
||||
: [];
|
||||
if (ids.length) return ids.join(", ");
|
||||
|
||||
if (typeof feature.properties.entity_id === "string" && feature.properties.entity_id.trim().length > 0) {
|
||||
return feature.properties.entity_id.trim();
|
||||
}
|
||||
|
||||
return [];
|
||||
return "Chưa gắn";
|
||||
}
|
||||
|
||||
function normalizeFeatureBindingIds(feature: Feature): string[] {
|
||||
const rawBinding = feature.properties.binding;
|
||||
if (!Array.isArray(rawBinding)) return [];
|
||||
return uniqueEntityIds(rawBinding
|
||||
.map((id) => {
|
||||
if (typeof id !== "string" && typeof id !== "number") return "";
|
||||
return String(id).trim();
|
||||
})
|
||||
.filter((id) => id.length > 0));
|
||||
}
|
||||
|
||||
function parseBindingInput(raw: string): string[] {
|
||||
if (!raw.trim().length) return [];
|
||||
return uniqueEntityIds(
|
||||
raw
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
function formatSelectedBindingSummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
const binding = Array.isArray(feature.properties.binding)
|
||||
? feature.properties.binding
|
||||
.map((item) => {
|
||||
if (typeof item !== "string" && typeof item !== "number") return "";
|
||||
return String(item).trim();
|
||||
})
|
||||
.filter((item) => item.length > 0)
|
||||
);
|
||||
: [];
|
||||
if (!binding.length) return "Không có";
|
||||
return binding.join(", ");
|
||||
}
|
||||
|
||||
function formatBindingIdsForDisplay(feature: Feature): string {
|
||||
const bindingIds = normalizeFeatureBindingIds(feature);
|
||||
if (!bindingIds.length) return "Không có";
|
||||
return bindingIds.join(", ");
|
||||
function resolveSelectedTypeSummary(feature: Feature | null): string {
|
||||
if (!feature) return "Không có";
|
||||
const typeId = normalizeTypeId(feature.properties.type) || normalizeTypeId(feature.properties.entity_type_id);
|
||||
if (!typeId) return "Không có";
|
||||
const option = findEntityTypeOption(typeId);
|
||||
if (!option) return typeId;
|
||||
return `${option.label} (${option.groupLabel})`;
|
||||
}
|
||||
|
||||
function uniqueEntityIds(ids: string[]): string[] {
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rawId of ids) {
|
||||
const id = rawId.trim();
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
deduped.push(id);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function formatEntityNamesForDisplay(feature: Feature, entities: Entity[]): string {
|
||||
const entityIds = normalizeFeatureEntityIds(feature);
|
||||
if (!entityIds.length) return "Chưa gắn";
|
||||
|
||||
const names = entityIds
|
||||
.map((id) => entities.find((entity) => entity.id === id)?.name || id)
|
||||
.filter((name) => name.trim().length > 0);
|
||||
return names.join(", ");
|
||||
}
|
||||
|
||||
function mergeEntitiesById(current: Entity[], incoming: Entity[]): Entity[] {
|
||||
if (!incoming.length) return current;
|
||||
|
||||
const map = new globalThis.Map<string, Entity>();
|
||||
for (const entity of current) {
|
||||
map.set(entity.id, entity);
|
||||
}
|
||||
for (const entity of incoming) {
|
||||
map.set(entity.id, entity);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
function normalizeTypeId(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function loadBackgroundLayerVisibilityFromStorage(): BackgroundLayerVisibility {
|
||||
|
||||
Reference in New Issue
Block a user