"use client"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import Map from "@/components/Map"; import BackgroundLayersPanel from "@/components/BackgroundLayersPanel"; import TimelineBar from "@/components/TimelineBar"; import { fetchGeometriesByBBox } from "@/api/geometries"; import { ApiError } from "@/api/http"; import { findEntityTypeOption } from "@/lib/entityTypeOptions"; import { BackgroundLayerId, BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/lib/backgroundLayers"; import { Feature, FeatureCollection } from "@/lib/useEditorState"; import { loadBackgroundLayerVisibilityFromStorage, persistBackgroundLayerVisibility, } from "@/lib/editor/background/backgroundVisibilityStorage"; import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/lib/geo/constants"; import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/lib/timeline"; const CURRENT_YEAR = new Date().getUTCFullYear(); export default function Page() { // Dữ liệu GeoJSON hiện đang hiển thị trên map (theo timelineYear). const [initialData, setInitialData] = useState(EMPTY_FEATURE_COLLECTION); // ID geometry đang được chọn trên map (null nếu chưa chọn). const [selectedFeatureId, setSelectedFeatureId] = useState(null); // Năm timeline "đã chốt" để trigger fetch dữ liệu. const [timelineYear, setTimelineYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR) ); // Năm timeline đang kéo/nhập (debounce rồi mới đẩy sang timelineYear). const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR) ); // Cờ loading khi fetch geometries theo timeline. const [isTimelineLoading, setIsTimelineLoading] = useState(false); // Thông báo trạng thái/lỗi khi load timeline. const [timelineStatus, setTimelineStatus] = useState(null); // Trạng thái bật/tắt các layer nền. const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); // Đảm bảo đã load backgroundVisibility từ storage trước khi render map. const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); // Counter để bỏ qua response cũ khi user đổi timeline liên tục. const timelineFetchRequestRef = useRef(0); const selectedFeature = selectedFeatureId === null ? null : initialData.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId) ) || null; useEffect(() => { if (selectedFeatureId === null) return; const stillExists = initialData.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId) ); if (!stillExists) { setSelectedFeatureId(null); } }, [initialData, selectedFeatureId]); useEffect(() => { const timeoutId = window.setTimeout(() => { if (timelineDraftYear !== timelineYear) { setTimelineYear(timelineDraftYear); } }, TIMELINE_DEBOUNCE_MS); return () => window.clearTimeout(timeoutId); }, [timelineDraftYear, timelineYear]); useEffect(() => { setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setIsBackgroundVisibilityReady(true); }, []); useEffect(() => { 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]); 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 handleTimelineYearChange = (nextYear: number) => { setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear))); }; const selectedType = resolveSelectedTypeSummary(selectedFeature); const selectedTimeSummary = formatSelectedTimeSummary(selectedFeature); const selectedEntitySummary = formatSelectedEntitySummary(selectedFeature); const selectedBindingSummary = formatSelectedBindingSummary(selectedFeature); return (
{isBackgroundVisibilityReady ? ( ) : (
)}
Viewer
Click trực tiếp trên map để chọn geometry.
{selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"}
Type: {selectedType}
Time: {selectedTimeSummary}
Entities: {selectedEntitySummary}
Binding: {selectedBindingSummary}
Mở Editor
} />
); } function isYearNumber(value: number | null | undefined): value is number { return typeof value === "number" && Number.isFinite(value); } 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 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 (typeof feature.properties.entity_name === "string" && feature.properties.entity_name.trim().length > 0) { return feature.properties.entity_name.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 "Chưa gắn"; } 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 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 normalizeTypeId(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase(); return normalized.length ? normalized : null; }