"use client"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import Map from "@/components/Map"; import Editor from "@/components/Editor"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import TimelineBar from "@/components/TimelineBar"; import { createEntity, Entity, fetchEntities } 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"; 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"; const ENTITY_TYPE_OPTIONS = [ { value: "country", label: "Country" }, { value: "castle", label: "Castle" }, { value: "kingdom", label: "Kingdom" }, { value: "city", label: "City" }, { value: "region", label: "Region" }, { value: "event", label: "Event" }, ] as const; type TimelineRange = { min: number; max: number; }; type EntityFormState = { name: string; slug: string; type_id: string; }; type GeometryMetaFormState = { time_start: string; time_end: string; }; export default function Page() { const [mode, setMode] = useState<"idle" | "draw" | "select" | "add-point" | "add-path" | "add-circle">("idle"); const [initialData, setInitialData] = useState(EMPTY_FC); const [isSaving, setIsSaving] = useState(false); const [entities, setEntities] = useState([]); const [selectedEntityId, setSelectedEntityId] = useState(null); const [entityStatus, setEntityStatus] = useState(null); const [selectedFeatureId, setSelectedFeatureId] = useState(null); const [entityForm, setEntityForm] = useState({ name: "", slug: "", type_id: ENTITY_TYPE_OPTIONS[0].value, }); const [selectedGeometryEntityIds, setSelectedGeometryEntityIds] = useState([]); const [geometryMetaForm, setGeometryMetaForm] = useState({ time_start: "", time_end: "", }); const [isEntitySubmitting, setIsEntitySubmitting] = useState(false); const [entityFormStatus, setEntityFormStatus] = useState(null); const [timelineRange, setTimelineRange] = useState(FALLBACK_TIMELINE_RANGE); const [timelineWindowStart, setTimelineWindowStart] = useState(FALLBACK_TIMELINE_RANGE.min); const [timelineWindowEnd, setTimelineWindowEnd] = useState(FALLBACK_TIMELINE_RANGE.max); const [timelineYear, setTimelineYear] = useState(() => clampYearValue(CURRENT_YEAR, FALLBACK_TIMELINE_RANGE.min, FALLBACK_TIMELINE_RANGE.max) ); const [timelineDraftYear, setTimelineDraftYear] = useState(() => 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(null); const [backgroundVisibility, setBackgroundVisibility] = useState( () => loadBackgroundLayerVisibilityFromStorage() ); const timelineFetchRequestRef = useRef(0); const editor = useEditorState(initialData); const selectedEntity = entities.find((entity) => entity.id === selectedEntityId) || null; 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); 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 (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 (!selectedFeature) { setEntityForm({ name: "", slug: "", type_id: ENTITY_TYPE_OPTIONS[0].value, }); setSelectedGeometryEntityIds([]); setGeometryMetaForm({ time_start: "", time_end: "", }); 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 || ENTITY_TYPE_OPTIONS[0].value; 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) : "", }); 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(() => { persistBackgroundLayerVisibility(backgroundVisibility); }, [backgroundVisibility]); 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.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; }); 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) => ({ ...prev, [id]: !prev[id], })); }; const handleShowAllBackgroundLayers = () => { setBackgroundVisibility({ ...DEFAULT_BACKGROUND_LAYER_VISIBILITY }); }; const handleHideAllBackgroundLayers = () => { setBackgroundVisibility({ ...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", value: string) => { setGeometryMetaForm((prev) => ({ ...prev, [key]: value })); }; const handleEntityIdsChange = (values: string[]) => { setSelectedGeometryEntityIds(uniqueEntityIds(values)); }; 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 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, 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, }); setSelectedGeometryEntityIds(entityIds); setGeometryMetaForm({ time_start: timeStart != null ? String(timeStart) : "", time_end: timeEnd != null ? String(timeEnd) : "", }); }; 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; try { ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); } 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, entity_ids: entityIds, }); await reloadCurrentTimelineData(); setEntityFormStatus("Đã cập nhật entities + metadata geometry."); } else { patchSelectedFeatureLocally(selectedFeature, entityIds, timeStart, timeEnd); 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; try { ({ timeStart, timeEnd } = parseGeometryMetaFormRange()); } 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 || ENTITY_TYPE_OPTIONS[0].value, }); 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, entity_ids: nextEntityIds, }); await reloadCurrentTimelineData(); setEntityFormStatus("Đã tạo entity và gắn vào geometry."); } else { patchSelectedFeatureLocally(selectedFeature, nextEntityIds, timeStart, timeEnd, rows); setEntityFormStatus("Đã tạo entity và gắn local. Bấm Save để lưu geometry mới."); } setSelectedEntityId(created.id); setEntityForm((prev) => ({ ...prev, name: "", slug: "", })); } 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 (
Selected Geometry
{!selectedFeature ? (
Vào mode Select và chọn 1 geometry để điền entity.
) : (
ID: {String(selectedFeature.properties.id)}
Entities hiện tại: {formatEntityNamesForDisplay(selectedFeature, entities)}
Geometry phải có ít nhất 1 entity để Save.
handleEntityFormChange("name", event.target.value)} placeholder="Tên entity mới" disabled={isEntitySubmitting} style={entityInputStyle} /> handleEntityFormChange("slug", event.target.value)} placeholder="Slug" disabled={isEntitySubmitting} style={entityInputStyle} /> handleGeometryMetaFormChange("time_start", event.target.value)} placeholder="time_start" disabled={isEntitySubmitting} style={entityInputStyle} /> handleGeometryMetaFormChange("time_end", event.target.value)} placeholder="time_end" disabled={isEntitySubmitting} style={entityInputStyle} /> {editor.changeCount > 0 ? (
Geometry mới sẽ lưu entity khi bấm Save.
) : null} {entityFormStatus ? (
{entityFormStatus}
) : null}
)}
} /> ); } const entityInputStyle: CSSProperties = { width: "100%", borderRadius: "6px", border: "1px solid #334155", background: "#111827", color: "#f8fafc", padding: "6px 8px", fontSize: "13px", }; 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 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 uniqueEntityIds(ids: string[]): string[] { const deduped: string[] = []; const seen = new Set(); 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; 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; }