"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Map, { type MapHoverPayload } from "@/uhm/components/Map"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import mapLayersStyles from "@/styles/MapLayers.module.css"; import { fetchEntities, type Entity } from "@/uhm/api/entities"; import { fetchGeometriesByBBox } from "@/uhm/api/geometries"; import { ApiError } from "@/uhm/api/http"; import { fetchWikiBySlug, getContentByVersionWikiId, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { BACKGROUND_LAYER_OPTIONS, type BackgroundLayerId, type BackgroundLayerVisibility, DEFAULT_BACKGROUND_LAYER_VISIBILITY, HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/uhm/lib/map/styles/backgroundLayers"; import { loadBackgroundLayerVisibilityFromStorage, persistBackgroundLayerVisibility, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constants"; import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap"; import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline"; import type { FeatureCollection } from "@/uhm/types/geo"; const CURRENT_YEAR = new Date().getUTCFullYear(); const ENTITY_PAGE_LIMIT = 100; const WIKI_PAGE_LIMIT = 100; const RELATION_CONCURRENCY = 6; type RelationIndex = { entitiesById: Record; entityGeometriesById: Record; entityWikisById: Record; geometryEntityIds: Record; wikiEntityIdsBySlug: Record; wikiBySlug: Record; }; type LinkEntityPopupState = { slug: string; entities: Entity[]; top: number; left: number; }; type CachedWiki = Wiki & { __fetched?: boolean }; const EMPTY_RELATIONS: RelationIndex = { entitiesById: {}, entityGeometriesById: {}, entityWikisById: {}, geometryEntityIds: {}, wikiEntityIdsBySlug: {}, wikiBySlug: {}, }; export default function Page() { const [data, setData] = useState(EMPTY_FEATURE_COLLECTION); const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]); const [timelineYear, setTimelineYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineDraftYear, setTimelineDraftYear] = useState(() => clampYearToFixedRange(CURRENT_YEAR)); const [timeRange, setTimeRange] = useState(0); const [isTimelineLoading, setIsTimelineLoading] = useState(false); const [timelineStatus, setTimelineStatus] = useState(null); const [backgroundVisibility, setBackgroundVisibility] = useState( () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) ); const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); const [geometryVisibility, setGeometryVisibility] = useState>(() => { const init: Record = {}; for (const key of GEO_TYPE_KEYS) init[key] = true; return init; }); const [relations, setRelations] = useState(EMPTY_RELATIONS); const [isRelationsLoading, setIsRelationsLoading] = useState(false); const [relationsStatus, setRelationsStatus] = useState(null); const [relationsProgress, setRelationsProgress] = useState<{ completed: number; total: number }>({ completed: 0, total: 0, }); const [hoverAnchor, setHoverAnchor] = useState(null); const [isMapLayersCollapsed, setIsMapLayersCollapsed] = useState(false); const [activeEntityId, setActiveEntityId] = useState(null); const [activeWikiSlug, setActiveWikiSlug] = useState(null); const [wikiCache, setWikiCache] = useState>({}); const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false); const [activeWikiError, setActiveWikiError] = useState(null); const [linkEntityPopup, setLinkEntityPopup] = useState(null); const [entityFocusToken, setEntityFocusToken] = useState(0); const [sidebarWidth, setSidebarWidth] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("public-wiki-sidebar-width"); if (saved) { const parsed = parseInt(saved, 10); if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) { return parsed; } } } return 420; }); const [isLargeScreen, setIsLargeScreen] = useState(false); useEffect(() => { if (typeof window === "undefined") return; const handleResize = () => { setIsLargeScreen(window.innerWidth >= 1024); }; handleResize(); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); const maxDragWidth = typeof window !== "undefined" ? Math.min(800, window.innerWidth - 340) : 800; const timelineFetchRequestRef = useRef(0); const hoverHideTimerRef = useRef(null); const hoverPopupHoveredRef = useRef(false); const linkEntityPopupRef = useRef(null); useEffect(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; const stillExistIds = selectedFeatureIds.filter(id => data.features.some(feature => String(feature.properties.id) === String(id)) ); if (stillExistIds.length !== selectedFeatureIds.length) { setSelectedFeatureIds(stillExistIds); } }, [data.features, selectedFeatureIds]); 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 next = await fetchGeometriesByBBox({ ...WORLD_BBOX, time: timelineYear, timeRange }); if (disposed || requestId !== timelineFetchRequestRef.current) return; setData(next); } 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, timeRange]); useEffect(() => { let disposed = false; async function loadRelations() { setIsRelationsLoading(true); setRelationsStatus(null); setRelationsProgress({ completed: 0, total: 0 }); try { const entities = await fetchAllEntities(); if (disposed) return; const next: RelationIndex = { entitiesById: {}, entityGeometriesById: {}, entityWikisById: {}, geometryEntityIds: {}, wikiEntityIdsBySlug: {}, wikiBySlug: {}, }; for (const entity of entities) { next.entitiesById[entity.id] = entity; } setRelationsProgress({ completed: 0, total: entities.length }); await mapWithConcurrency(entities, RELATION_CONCURRENCY, async (entity, index) => { const [geometries, wikis] = await Promise.all([ fetchGeometriesByBBox({ ...WORLD_BBOX, entity_id: entity.id }), fetchAllWikisForEntity(entity.id), ]); if (disposed) return; next.entityGeometriesById[entity.id] = geometries; next.entityWikisById[entity.id] = wikis; for (const feature of geometries.features) { pushUniqueString(next.geometryEntityIds, String(feature.properties.id), entity.id); } for (const wiki of wikis) { const slug = String(wiki.slug || "").trim(); if (!slug.length) continue; next.wikiBySlug[slug] = wiki; pushUniqueString(next.wikiEntityIdsBySlug, slug, entity.id); } const completed = index + 1; if (completed === entities.length || completed % 5 === 0) { setRelationsProgress({ completed, total: entities.length }); } }); if (disposed) return; normalizeRelationArrays(next.geometryEntityIds); normalizeRelationArrays(next.wikiEntityIdsBySlug); setRelations(next); setWikiCache((prev) => ({ ...next.wikiBySlug, ...prev })); } catch (err) { console.error("Load relation index failed", err); if (!disposed) { setRelationsStatus("Không tải được liên kết entity/wiki cho bản đồ."); } } finally { if (!disposed) { setIsRelationsLoading(false); } } } loadRelations(); return () => { disposed = true; }; }, []); const hoverEntityIds = useMemo(() => { if (!hoverAnchor) return []; return relations.geometryEntityIds[String(hoverAnchor.featureId)] || []; }, [hoverAnchor, relations.geometryEntityIds]); const hoverEntities = useMemo(() => { return hoverEntityIds .map((entityId) => relations.entitiesById[entityId] || null) .filter((entity): entity is Entity => Boolean(entity)); }, [hoverEntityIds, relations.entitiesById]); const activeEntity = activeEntityId ? relations.entitiesById[activeEntityId] || null : null; const activeEntityGeometries = activeEntityId ? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION : EMPTY_FEATURE_COLLECTION; const mapLabelContextDraft = useMemo( () => buildEntityLabelContextDraft(data, relations.geometryEntityIds, relations.entitiesById), [data, relations.entitiesById, relations.geometryEntityIds] ); const activeWiki = useMemo(() => { if (!activeWikiSlug) return null; return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null; }, [activeWikiSlug, relations.wikiBySlug, wikiCache]); 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 handleTimeRangeChange = (nextRange: number) => { const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0; setTimeRange(Math.max(0, Math.min(30, safe))); }; const clearHoverHideTimer = useCallback(() => { if (hoverHideTimerRef.current !== null) { window.clearTimeout(hoverHideTimerRef.current); hoverHideTimerRef.current = null; } }, []); const selectEntity = useCallback(( entityId: string, options?: { sourceFeatureId?: string | number | null; preferredWikiSlug?: string | null; focusMap?: boolean; selectGeometry?: boolean; } ) => { const entity = relations.entitiesById[entityId] || null; if (!entity) return; const linkedWikis = relations.entityWikisById[entityId] || []; const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim(); const nextWikiSlug = (preferredWikiSlug && linkedWikis.some((wiki) => String(wiki.slug || "").trim() === preferredWikiSlug) ? preferredWikiSlug : "") || linkedWikis.map((wiki) => String(wiki.slug || "").trim()).find((slug) => slug.length > 0) || null; setActiveEntityId(entityId); setActiveWikiSlug(nextWikiSlug); setActiveWikiError(null); setLinkEntityPopup(null); if (options?.focusMap !== false) { setEntityFocusToken((prev) => prev + 1); } if (options?.selectGeometry && options?.sourceFeatureId != null) { setSelectedFeatureIds([options.sourceFeatureId]); } }, [relations.entitiesById, relations.entityWikisById]); useEffect(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; // For UI simplicity in viewer, just link to the first selected geometry const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureIds[0])] || []; if (linkedEntityIds.length !== 1) return; const onlyEntityId = linkedEntityIds[0]; if (activeEntityId === onlyEntityId) return; selectEntity(onlyEntityId, { sourceFeatureId: selectedFeatureIds[0], focusMap: true, selectGeometry: false, }); }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureIds]); const handleMapHoverChange = useCallback((payload: MapHoverPayload | null) => { clearHoverHideTimer(); if (payload) { setHoverAnchor(payload); return; } if (hoverPopupHoveredRef.current) return; hoverHideTimerRef.current = window.setTimeout(() => { setHoverAnchor(null); }, 120); }, [clearHoverHideTimer]); useEffect(() => { return () => { if (hoverHideTimerRef.current !== null) { window.clearTimeout(hoverHideTimerRef.current); } }; }, []); useEffect(() => { if (!linkEntityPopup) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setLinkEntityPopup(null); }; const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node | null; if (target && linkEntityPopupRef.current?.contains(target)) return; setLinkEntityPopup(null); }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("pointerdown", handlePointerDown); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("pointerdown", handlePointerDown); }; }, [linkEntityPopup]); const cachedWiki = activeWikiSlug ? wikiCache[activeWikiSlug] : undefined; useEffect(() => { if (!activeWikiSlug) { setIsActiveWikiLoading(false); setActiveWikiError(null); return; } if (cachedWiki && (cachedWiki.__fetched || cachedWiki.id === "__not_found__")) { setIsActiveWikiLoading(false); if (cachedWiki.id === "__not_found__") { setActiveWikiError("Không tìm thấy wiki cho entity đã chọn."); } else { setActiveWikiError(null); } return; } let disposed = false; (async () => { setIsActiveWikiLoading(true); setActiveWikiError(null); try { const row = await fetchWikiBySlug(activeWikiSlug); if (disposed) return; if (row) { let versionContent = row.content; try { if (row.content_sample?.[0]?.id) { const res = await getContentByVersionWikiId(row.content_sample[0].id); if (res?.data?.content) { versionContent = res.data.content; } } } catch (err) { console.error("Failed to fetch version content:", err); } if (disposed) return; setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: { ...row, content: versionContent, __fetched: true }, })); } else { setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: { id: "__not_found__", project_id: "" }, })); setActiveWikiError("Không tìm thấy wiki cho entity đã chọn."); } } catch (err) { if (disposed) return; setActiveWikiError(err instanceof Error ? err.message : "Không tải được wiki."); } finally { if (!disposed) setIsActiveWikiLoading(false); } })(); return () => { disposed = true; }; }, [activeWikiSlug, cachedWiki]); const handleWikiLinkRequest = useCallback(async ({ slug, rect }: { slug: string; rect: DOMRect }) => { const linkedEntityIds = relations.wikiEntityIdsBySlug[slug] || []; const linkedEntities = linkedEntityIds .map((entityId) => relations.entitiesById[entityId] || null) .filter((entity): entity is Entity => Boolean(entity)); if (linkedEntities.length === 1) { selectEntity(linkedEntities[0].id, { preferredWikiSlug: slug }); return; } if (!wikiCache[slug] && !relations.wikiBySlug[slug]) { try { const row = await fetchWikiBySlug(slug); if (row) setWikiCache((prev) => ({ ...prev, [slug]: row })); } catch (err) { console.error("Load wiki by slug failed", err); } } if (!linkedEntities.length) return; const popupWidth = 240; const popupHeight = Math.min(240, linkedEntities.length * 44 + 20); const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight); setLinkEntityPopup({ slug, entities: linkedEntities, top, left, }); }, [relations.entitiesById, relations.wikiBySlug, relations.wikiEntityIdsBySlug, selectEntity, wikiCache]); const helperText = isRelationsLoading ? `Đang index entity/wiki ${relationsProgress.completed}/${relationsProgress.total || "?"}` : relationsStatus || `Features: ${data.features.length}`; return (
{isBackgroundVisibilityReady ? ( ) : (
)}
Map Layers
{helperText}
Background
{BACKGROUND_LAYER_OPTIONS.map((layer) => { const active = Boolean(backgroundVisibility[layer.id]); return ( ); })}
Geometry
{GEO_TYPE_KEYS.map((typeKey) => { const active = geometryVisibility[typeKey] !== false; return ( ); })}
{hoverAnchor && hoverEntities.length > 0 ? (
{ hoverPopupHoveredRef.current = true; clearHoverHideTimer(); }} onMouseLeave={() => { hoverPopupHoveredRef.current = false; setHoverAnchor(null); }} >
{hoverEntities.length > 1 ? (
Related Entities
Geometry #{String(hoverAnchor.featureId)}
) : null}
{hoverEntities.map((entity) => ( ))}
) : null} {activeEntity ? ( ) : null}
{linkEntityPopup ? (
Related Entities
/wiki/{linkEntityPopup.slug}
{linkEntityPopup.entities.map((entity) => ( ))}
) : null}
); } async function fetchAllEntities(): Promise { const items: Entity[] = []; const seen = new Set(); let cursor: string | undefined; while (true) { const page = await fetchEntities({ q: "", limit: ENTITY_PAGE_LIMIT, cursor }); if (!page.length) break; for (const entity of page) { if (!entity?.id || seen.has(entity.id)) continue; seen.add(entity.id); items.push(entity); } if (page.length < ENTITY_PAGE_LIMIT) break; const nextCursor = page[page.length - 1]?.id; if (!nextCursor || nextCursor === cursor) break; cursor = nextCursor; } return items; } async function fetchAllWikisForEntity(entityId: string): Promise { const items: Wiki[] = []; const seen = new Set(); let cursor: string | undefined; while (true) { const page = await searchWikisByTitle("", { entityId, limit: WIKI_PAGE_LIMIT, cursor, }); if (!page.length) break; for (const wiki of page) { if (!wiki?.id || seen.has(wiki.id)) continue; seen.add(wiki.id); items.push(wiki); } if (page.length < WIKI_PAGE_LIMIT) break; const nextCursor = page[page.length - 1]?.id; if (!nextCursor || nextCursor === cursor) break; cursor = nextCursor; } return items; } async function mapWithConcurrency( items: T[], concurrency: number, worker: (item: T, index: number) => Promise ): Promise { const runnerCount = Math.max(1, Math.min(concurrency, items.length)); let nextIndex = 0; await Promise.all( Array.from({ length: runnerCount }, async () => { while (true) { const current = nextIndex++; if (current >= items.length) return; await worker(items[current], current); } }) ); } function pushUniqueString(target: Record, key: string, value: string) { if (!target[key]) { target[key] = [value]; return; } if (!target[key].includes(value)) { target[key].push(value); } } function normalizeRelationArrays(target: Record) { for (const key of Object.keys(target)) { target[key] = Array.from(new Set(target[key])); } } function buildEntityLabelContextDraft( draft: FeatureCollection, geometryEntityIds: Record, entitiesById: Record ): FeatureCollection { if (!draft.features.length) return draft; return { ...draft, features: draft.features.map((feature) => { const entityIds = geometryEntityIds[String(feature.properties.id)] || []; if (!entityIds.length) return feature; const candidates = entityIds.map((id) => { const entity = entitiesById[id] || null; const name = String(entity?.name || id).trim(); if (!name) return null; return { id, name, time_start: entity?.time_start ?? null, time_end: entity?.time_end ?? null, }; }).filter((candidate) => candidate !== null); return { ...feature, properties: { ...feature.properties, entity_id: entityIds[0] || null, entity_ids: entityIds, entity_name: candidates[0]?.name || null, entity_names: candidates.map((candidate) => candidate.name), entity_label_candidates: candidates, }, }; }), }; } function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; if (value < min) return min; if (value > max) return max; return value; } function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) { const margin = 12; const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440; const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900; const preferredLeft = rect.right + margin; const maxLeft = Math.max(margin, viewportWidth - width - margin); const left = Math.min(preferredLeft, maxLeft); const preferredTop = rect.top; const maxTop = Math.max(margin, viewportHeight - height - margin); const top = Math.max(margin, Math.min(preferredTop, maxTop)); return { top, left }; }