"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 { BACKGROUND_LAYER_OPTIONS, BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/lib/backgroundLayers"; import { Feature, FeatureCollection } from "@/lib/useEditorState"; 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; }; export default function Page() { const [initialData, setInitialData] = useState(EMPTY_FC); const [selectedFeatureId, setSelectedFeatureId] = 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( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); 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(() => { 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 updateBackgroundVisibility = ( updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility ) => { setBackgroundVisibility((prev) => { const next = updater(prev); persistBackgroundLayerVisibility(next); return next; }); }; const handleToggleBackgroundLayer = (id: (typeof BACKGROUND_LAYER_OPTIONS)[number]["id"]) => { 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 timelineDisabled = !isTimelineReady; 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 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 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; } 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; }