diff --git a/src/app/page.tsx b/src/app/page.tsx index 6388ed2..6a1d000 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,205 +1,788 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import Map from "@/uhm/components/Map"; -import BackgroundLayersPanel from "@/uhm/components/BackgroundLayersPanel"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import Map, { type MapHoverPayload } from "@/uhm/components/Map"; +import PublicWikiSidebar from "@/uhm/components/PublicWikiSidebar"; import TimelineBar from "@/uhm/components/TimelineBar"; +import { fetchEntities, type Entity } from "@/uhm/api/entities"; import { fetchGeometriesByBBox } from "@/uhm/api/geometries"; import { ApiError } from "@/uhm/api/http"; -import { API_BASE_URL } from "@/uhm/api/config"; +import { fetchWikiBySlug, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis"; import { - BackgroundLayerId, - BackgroundLayerVisibility, - DEFAULT_BACKGROUND_LAYER_VISIBILITY, - HIDDEN_BACKGROUND_LAYER_VISIBILITY, + BACKGROUND_LAYER_OPTIONS, + type BackgroundLayerId, + type BackgroundLayerVisibility, + DEFAULT_BACKGROUND_LAYER_VISIBILITY, + HIDDEN_BACKGROUND_LAYER_VISIBILITY, } from "@/uhm/lib/backgroundLayers"; import { - loadBackgroundLayerVisibilityFromStorage, - persistBackgroundLayerVisibility, + loadBackgroundLayerVisibilityFromStorage, + persistBackgroundLayerVisibility, } from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/geo/constants"; -import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/timeline"; import { GEO_TYPE_KEYS } from "@/uhm/lib/geoTypeMap"; -import type { Feature, FeatureCollection } from "@/uhm/types/geo"; +import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/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; +}; + +const EMPTY_RELATIONS: RelationIndex = { + entitiesById: {}, + entityGeometriesById: {}, + entityWikisById: {}, + geometryEntityIds: {}, + wikiEntityIdsBySlug: {}, + wikiBySlug: {}, +}; export default function Page() { - const [data, setData] = useState(EMPTY_FEATURE_COLLECTION); - const [selectedFeatureId, setSelectedFeatureId] = useState(null); - 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 timelineFetchRequestRef = useRef(0); - const [lastLoadedAt, setLastLoadedAt] = useState(null); - const [geometryVisibility, setGeometryVisibility] = useState>(() => { - const init: Record = {}; - for (const key of GEO_TYPE_KEYS) init[key] = true; - return init; - }); - - const selectedFeature: Feature | null = useMemo(() => { - if (selectedFeatureId === null) return null; - return ( - data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null + const [data, setData] = useState(EMPTY_FEATURE_COLLECTION); + const [selectedFeatureId, setSelectedFeatureId] = useState(null); + 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 }) ); - }, [data.features, selectedFeatureId]); + 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 [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); - useEffect(() => { - if (selectedFeatureId === null) return; - const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId)); - if (!stillExists) setSelectedFeatureId(null); - }, [data.features, selectedFeatureId]); + const timelineFetchRequestRef = useRef(0); + const hoverHideTimerRef = useRef(null); + const hoverPopupHoveredRef = useRef(false); + const linkEntityPopupRef = useRef(null); - useEffect(() => { - const timeoutId = window.setTimeout(() => { - if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear); - }, TIMELINE_DEBOUNCE_MS); - return () => window.clearTimeout(timeoutId); - }, [timelineDraftYear, timelineYear]); + const selectedFeature = useMemo(() => { + if (selectedFeatureId === null) return null; + return ( + data.features.find((feature) => String(feature.properties.id) === String(selectedFeatureId)) || null + ); + }, [data.features, selectedFeatureId]); - useEffect(() => { - setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); - setIsBackgroundVisibilityReady(true); - }, []); + useEffect(() => { + if (selectedFeatureId === null) return; + const stillExists = data.features.some((feature) => String(feature.properties.id) === String(selectedFeatureId)); + if (!stillExists) setSelectedFeatureId(null); + }, [data.features, selectedFeatureId]); - useEffect(() => { - let disposed = false; - const requestId = ++timelineFetchRequestRef.current; + useEffect(() => { + const timeoutId = window.setTimeout(() => { + if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear); + }, TIMELINE_DEBOUNCE_MS); + return () => window.clearTimeout(timeoutId); + }, [timelineDraftYear, timelineYear]); - 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); - setLastLoadedAt(new Date().toISOString()); - } catch (err) { - if (err instanceof ApiError) { - console.error("Load timeline data failed", err.body); - } else { - console.error("Load timeline data failed", err); + 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); + } + } } - if (!disposed && requestId === timelineFetchRequestRef.current) { - setTimelineStatus("Không tải được geometry tại mốc thời gian đã chọn."); + + 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); + } + } } - } finally { - if (!disposed && requestId === timelineFetchRequestRef.current) { - setIsTimelineLoading(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 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 !== undefined) { + setSelectedFeatureId(options.sourceFeatureId); + } + }, [relations.entitiesById, relations.entityWikisById]); + + useEffect(() => { + if (selectedFeatureId === null) return; + const linkedEntityIds = relations.geometryEntityIds[String(selectedFeatureId)] || []; + if (linkedEntityIds.length !== 1) return; + + const onlyEntityId = linkedEntityIds[0]; + if (activeEntityId === onlyEntityId) return; + + selectEntity(onlyEntityId, { + sourceFeatureId: selectedFeatureId, + focusMap: false, + selectGeometry: false, + }); + }, [activeEntityId, relations.geometryEntityIds, selectEntity, selectedFeatureId]); + + 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]); + + useEffect(() => { + if (!activeWikiSlug) { + setIsActiveWikiLoading(false); + setActiveWikiError(null); + return; + } + + const cached = wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null; + if (cached?.content) { + setIsActiveWikiLoading(false); + setActiveWikiError(null); + return; + } + + let disposed = false; + (async () => { + setIsActiveWikiLoading(true); + setActiveWikiError(null); + try { + const row = await fetchWikiBySlug(activeWikiSlug); + if (disposed) return; + if (row) { + setWikiCache((prev) => ({ ...prev, [activeWikiSlug]: row })); + } else { + 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, relations.wikiBySlug, wikiCache]); + + 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; } - loadByTimeline(); - return () => { - disposed = true; - }; - }, [timelineYear, timeRange]); - - 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))); - }; - - return ( -
-
- {isBackgroundVisibilityReady ? ( - - ) : ( -
- )} - - -
- - { - setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false })); - }} - topContent={ -
-
Viewer
-
- API: {API_BASE_URL} -
-
- Year: {timelineYear} | Features: {data.features.length} -
-
- {isTimelineLoading ? "Loading geometries..." : lastLoadedAt ? `Loaded: ${lastLoadedAt}` : "Not loaded yet"} -
-
- {selectedFeature ? `ID: ${String(selectedFeature.properties.id)}` : "Chưa chọn geometry"} -
-
- {selectedFeature?.properties?.type ? `Type: ${String(selectedFeature.properties.type)}` : "Type: -"} -
-
- } - /> -
- ); + 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 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 }; } diff --git a/src/uhm/api/entities.ts b/src/uhm/api/entities.ts index b843037..5a8112e 100644 --- a/src/uhm/api/entities.ts +++ b/src/uhm/api/entities.ts @@ -4,11 +4,25 @@ import type { Entity } from "@/uhm/types/entities"; export type { Entity } from "@/uhm/types/entities"; -export async function fetchEntities(query?: { q?: string }): Promise { +export async function fetchEntities(query?: { + q?: string; + limit?: number; + cursor?: string; + projectId?: string; +}): Promise { const params = new URLSearchParams(); // API mới dùng `name` thay vì `q`. - if (query?.q) { - params.set("name", query.q); + if (query && "q" in query) { + params.set("name", String(query.q ?? "")); + } + if (query?.limit && Number.isFinite(query.limit)) { + params.set("limit", String(Math.trunc(query.limit))); + } + if (query?.cursor) { + params.set("cursor", query.cursor); + } + if (query?.projectId) { + params.set("project_id", query.projectId); } const suffix = params.toString(); const url = suffix ? `${API_ENDPOINTS.entities}?${suffix}` : API_ENDPOINTS.entities; diff --git a/src/uhm/api/wikis.ts b/src/uhm/api/wikis.ts index 626c9cc..a3903dc 100644 --- a/src/uhm/api/wikis.ts +++ b/src/uhm/api/wikis.ts @@ -14,8 +14,6 @@ export type Wiki = { export async function searchWikisByTitle(title: string, options?: { limit?: number; cursor?: string; entityId?: string }): Promise { const keyword = title.trim(); - if (!keyword.length) return []; - const params = new URLSearchParams({ title: keyword }); if (options?.limit && Number.isFinite(options.limit)) params.set("limit", String(Math.trunc(options.limit))); if (options?.cursor) params.set("cursor", options.cursor); @@ -52,11 +50,11 @@ export async function checkWikiSlugExists(slug: string): Promise { if (typeof payload === "boolean") return payload; if (payload && typeof payload === "object") { - const anyPayload = payload as any; - if (typeof anyPayload.exists === "boolean") return anyPayload.exists; - if (typeof anyPayload.exists === "number") return anyPayload.exists !== 0; - if (typeof anyPayload.is_exists === "boolean") return anyPayload.is_exists; - if (typeof anyPayload.is_exists === "number") return anyPayload.is_exists !== 0; + const source = payload as Record; + if (typeof source.exists === "boolean") return source.exists; + if (typeof source.exists === "number") return source.exists !== 0; + if (typeof source.is_exists === "boolean") return source.is_exists; + if (typeof source.is_exists === "number") return source.is_exists !== 0; } // Be conservative: unknown payload shape, treat as "exists" to prevent creating conflicting slugs. diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index e976f56..e8a3183 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -53,6 +53,18 @@ type MapProps = { height?: CSSProperties["height"]; fitToDraftBounds?: boolean; fitBoundsKey?: string | number | null; + onHoverFeatureChange?: ((payload: MapHoverPayload | null) => void) | undefined; + highlightFeatures?: FeatureCollection | null; + focusFeatureCollection?: FeatureCollection | null; + focusRequestKey?: string | number | null; + focusPadding?: number | maplibregl.PaddingOptions; +}; + +export type MapHoverPayload = { + featureId: string | number; + feature: Feature | null; + point: { x: number; y: number }; + lngLat: { lng: number; lat: number }; }; type EngineBinding = { @@ -82,6 +94,11 @@ export default function Map({ height = "100vh", fitToDraftBounds = false, fitBoundsKey = null, + onHoverFeatureChange, + highlightFeatures = null, + focusFeatureCollection = null, + focusRequestKey = null, + focusPadding, }: MapProps) { // DOM container của map (dùng ref để tránh collision khi render nhiều map). const containerRef = useRef(null); @@ -102,10 +119,15 @@ export default function Map({ const backgroundVisibilityRef = useRef(backgroundVisibility); // Mirror of geometry visibility for type filtering. const geometryVisibilityRef = useRef(geometryVisibility); + const highlightFeaturesRef = useRef(highlightFeatures); + const focusFeatureCollectionRef = useRef(focusFeatureCollection); + const focusRequestKeyRef = useRef(focusRequestKey); + const focusPaddingRef = useRef(focusPadding); // Mirror của selectedFeatureId để filter/select trên map (không phụ thuộc re-render). const selectedFeatureIdRef = useRef(selectedFeatureId); // Mirror của callback onSelectFeatureId. const onSelectFeatureIdRef = useRef(onSelectFeatureId); + const onHoverFeatureChangeRef = useRef(onHoverFeatureChange); // Mirror của callback onCreateFeature. const onCreateRef = useRef(onCreateFeature); // Mirror của callback onDeleteFeature. @@ -206,6 +228,10 @@ export default function Map({ selectedFeatureIdRef.current = selectedFeatureId; }, [selectedFeatureId]); + useEffect(() => { + onHoverFeatureChangeRef.current = onHoverFeatureChange; + }, [onHoverFeatureChange]); + useEffect(() => { if (mode !== "select" || selectedFeatureId === null) { editingEngineRef.current?.clearEditing(); @@ -228,12 +254,24 @@ export default function Map({ }, [backgroundVisibility]); useEffect(() => { - geometryVisibilityRef.current = geometryVisibility; - // When toggling geometry types, refresh sources immediately (without waiting for parent re-mount). + highlightFeaturesRef.current = highlightFeatures; const map = mapRef.current; - if (!map) return; - applyDraftToMap(draftRef.current); - }, [geometryVisibility]); + if (!map || !map.isStyleLoaded()) return; + const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined; + source?.setData(highlightFeatures || EMPTY_FEATURE_COLLECTION); + }, [highlightFeatures]); + + useEffect(() => { + focusFeatureCollectionRef.current = focusFeatureCollection; + }, [focusFeatureCollection]); + + useEffect(() => { + focusRequestKeyRef.current = focusRequestKey; + }, [focusRequestKey]); + + useEffect(() => { + focusPaddingRef.current = focusPadding; + }, [focusPadding]); useEffect(() => { onCreateRef.current = onCreateFeature; @@ -299,6 +337,22 @@ export default function Map({ } }, []); + const applyHighlightToMap = useCallback((fc: FeatureCollection) => { + const map = mapRef.current; + if (!map) return; + + const source = map.getSource("entity-focus") as maplibregl.GeoJSONSource | undefined; + if (!source) return; + source.setData(fc); + }, []); + + useEffect(() => { + geometryVisibilityRef.current = geometryVisibility; + const map = mapRef.current; + if (!map) return; + applyDraftToMap(draftRef.current); + }, [applyDraftToMap, geometryVisibility]); + const tryCenterToUserLocation = useCallback(() => { if (geolocationCenteredRef.current) return; // Nếu đang "fit to draft bounds" thì không nên override center. @@ -899,7 +953,56 @@ export default function Map({ }, }); + map.addSource("entity-focus", { + type: "geojson", + data: EMPTY_FEATURE_COLLECTION, + }); + + map.addLayer({ + id: "entity-focus-fill", + type: "fill", + source: "entity-focus", + filter: ["==", ["geometry-type"], "Polygon"], + paint: { + "fill-color": "#fde047", + "fill-opacity": 0.2, + }, + }); + + map.addLayer({ + id: "entity-focus-line", + type: "line", + source: "entity-focus", + paint: { + "line-color": "#f59e0b", + "line-width": [ + "interpolate", + ["linear"], + ["zoom"], + 1, 2.4, + 4, 4, + 6, 5.5, + ], + "line-opacity": 0.98, + }, + }); + + map.addLayer({ + id: "entity-focus-points", + type: "circle", + source: "entity-focus", + filter: ["==", ["geometry-type"], "Point"], + paint: { + "circle-color": "#f8fafc", + "circle-radius": 8, + "circle-stroke-color": "#f59e0b", + "circle-stroke-width": 3, + "circle-opacity": 1, + }, + }); + addPointSymbolLayer(map); + applyHighlightToMap(highlightFeaturesRef.current || EMPTY_FEATURE_COLLECTION); // init drawing const drawingEngine = initDrawing( @@ -1047,10 +1150,59 @@ export default function Map({ () => map.off("zoom", syncZoomLevel), ]; + const handleHoverMove = (event: maplibregl.MapMouseEvent) => { + const callback = onHoverFeatureChangeRef.current; + if (!callback) return; + + const selectableLayers = getSelectableLayers(map); + if (!selectableLayers.length) { + callback(null); + return; + } + + const features = map.queryRenderedFeatures(event.point, { + layers: selectableLayers, + }) as maplibregl.MapGeoJSONFeature[]; + + const feature = features[0]; + const rawFeatureId = feature?.id ?? feature?.properties?.id; + if (rawFeatureId === undefined || rawFeatureId === null) { + callback(null); + return; + } + + const currentFeature = + draftRef.current.features.find( + (item) => String(item.properties.id) === String(rawFeatureId) + ) || null; + + callback({ + featureId: rawFeatureId, + feature: currentFeature, + point: { x: event.point.x, y: event.point.y }, + lngLat: { lng: event.lngLat.lng, lat: event.lngLat.lat }, + }); + }; + + const handleCanvasMouseLeave = () => { + onHoverFeatureChangeRef.current?.(null); + }; + + map.on("mousemove", handleHoverMove); + mapCleanupFnsRef.current.push(() => map.off("mousemove", handleHoverMove)); + + map.getCanvasContainer().addEventListener("mouseleave", handleCanvasMouseLeave); + mapCleanupFnsRef.current.push(() => { + map.getCanvasContainer().removeEventListener("mouseleave", handleCanvasMouseLeave); + }); + // after everything mounted, push current draft to sources applyDraftToMap(draftRef.current); // Khi vao web, thu auto center theo vi tri user (neu co quyen). tryCenterToUserLocation(); + if (focusRequestKeyRef.current !== null && focusRequestKeyRef.current !== undefined && focusFeatureCollectionRef.current?.features.length) { + fitMapToFeatureCollection(map, focusFeatureCollectionRef.current, focusPaddingRef.current); + } if (allowGeometryEditing) { editingEngineRef.current?.bindEditEvents(map); @@ -1072,7 +1224,7 @@ export default function Map({ } map.remove(); }; - }, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]); + }, [allowGeometryEditing, applyDraftToMap, applyHighlightToMap, tryCenterToUserLocation]); useEffect(() => { const map = mapRef.current; @@ -1126,6 +1278,15 @@ export default function Map({ } }, [allowGeometryEditing, draft, selectedFeatureId, applyDraftToMap]); + useEffect(() => { + if (focusRequestKey === null || focusRequestKey === undefined) return; + const map = mapRef.current; + if (!map || !map.isStyleLoaded()) return; + const target = focusFeatureCollectionRef.current; + if (!target || !target.features.length) return; + fitMapToFeatureCollection(map, target, focusPaddingRef.current); + }, [focusRequestKey]); + return (
@@ -1374,6 +1535,19 @@ function createRasterBaseLayer() { }; } +function getSelectableLayers(map: maplibregl.Map): string[] { + return [ + "countries-fill", + "countries-line", + "routes-line", + "routes-path-arrow-fill", + "routes-path-arrow-line", + "routes-path-hit", + "places-circle", + "places-symbol", + ].filter((layerId) => Boolean(map.getLayer(layerId))); +} + function filterDraftByBinding( fc: FeatureCollection, selectedFeatureId: string | number | null @@ -1470,16 +1644,26 @@ function setSelectedFeatureState( } } -function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection): boolean { +function fitMapToFeatureCollection( + map: maplibregl.Map, + fc: FeatureCollection, + padding?: number | maplibregl.PaddingOptions +): boolean { const bbox = getFeatureCollectionBBox(fc); if (!bbox) return false; + const resolvedPadding = + typeof padding === "number" || padding + ? padding + : 58; + const lngSpan = Math.abs(bbox.maxLng - bbox.minLng); const latSpan = Math.abs(bbox.maxLat - bbox.minLat); if (lngSpan < 0.000001 && latSpan < 0.000001) { map.easeTo({ center: [bbox.minLng, bbox.minLat], zoom: 6, + padding: resolvedPadding, duration: 0, }); return true; @@ -1491,7 +1675,7 @@ function fitMapToFeatureCollection(map: maplibregl.Map, fc: FeatureCollection): [bbox.maxLng, bbox.maxLat], ], { - padding: 58, + padding: resolvedPadding, maxZoom: 7, duration: 0, } diff --git a/src/uhm/components/PublicWikiSidebar.tsx b/src/uhm/components/PublicWikiSidebar.tsx new file mode 100644 index 0000000..16d333c --- /dev/null +++ b/src/uhm/components/PublicWikiSidebar.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +import type { Entity } from "@/uhm/api/entities"; +import type { Wiki } from "@/uhm/api/wikis"; + +type TocItem = { + id: string; + level: number; + text: string; +}; + +type Props = { + entity: Entity | null; + wiki: Wiki | null; + isLoading: boolean; + error?: string | null; + onClose: () => void; + onWikiLinkRequest: (request: { slug: string; rect: DOMRect }) => void; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function tiptapJsonToPlainText(node: unknown): string { + if (node == null) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(tiptapJsonToPlainText).join(""); + + if (isRecord(node)) { + if (node.type === "text" && typeof node.text === "string") return node.text; + if (node.type === "hardBreak") return "\n"; + if ("content" in node) return tiptapJsonToPlainText(node.content); + } + + return ""; +} + +function escapeHtml(input: string): string { + return input + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function normalizeWikiContentToHtml(raw: string | null | undefined): string { + const value = String(raw || "").trim(); + if (!value.length) return ""; + + if (value[0] === "<") return value; + + if (value[0] === "{") { + try { + const json: unknown = JSON.parse(value); + const text = tiptapJsonToPlainText(json).trim(); + if (!text.length) return ""; + return `

${escapeHtml(text).replace(/\n/g, "
")}

`; + } catch { + // fall through + } + } + + return `

${escapeHtml(value).replace(/\n/g, "
")}

`; +} + +function slugifyHeading(raw: string): string { + const input = String(raw || "").trim(); + if (!input.length) return ""; + return input + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 80); +} + +function isExternalHref(href: string): boolean { + const h = href.trim().toLowerCase(); + return ( + h.startsWith("http://") || + h.startsWith("https://") || + h.startsWith("mailto:") || + h.startsWith("tel:") || + h.startsWith("sms:") + ); +} + +function prepareWikiHtml(inputHtml: string): { html: string; toc: TocItem[] } { + const parser = new DOMParser(); + const doc = parser.parseFromString(inputHtml, "text/html"); + + for (const el of Array.from(doc.querySelectorAll("script"))) el.remove(); + + for (const a of Array.from(doc.querySelectorAll("a[href]"))) { + const href = String(a.getAttribute("href") || "").trim(); + if (!href.length) continue; + if (href === "__missing__") continue; + if (href.startsWith("#")) continue; + if (href.startsWith("/")) continue; + + if (isExternalHref(href)) { + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "noopener noreferrer"); + continue; + } + + const match = href.match(/^([^?#]+)([?#].*)?$/); + const slugPart = String(match?.[1] || "").replace(/^\/+/, "").trim(); + if (!slugPart.length) continue; + a.setAttribute("href", `#wiki:${slugPart}`); + a.setAttribute("data-wiki-slug", slugPart); + a.setAttribute("target", "_self"); + } + + const toc: TocItem[] = []; + const seen = new Map(); + const headings = Array.from(doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6")); + for (const h of headings) { + const text = String(h.textContent || "").trim(); + if (!text.length) continue; + + const level = Number(String(h.tagName || "").replace(/^H/i, "")) || 1; + const existingId = String(h.getAttribute("id") || "").trim(); + if (existingId) { + toc.push({ id: existingId, level, text }); + continue; + } + + const base = slugifyHeading(text) || "heading"; + const nextCount = (seen.get(base) || 0) + 1; + seen.set(base, nextCount); + const id = nextCount === 1 ? base : `${base}-${nextCount}`; + + h.setAttribute("id", id); + toc.push({ id, level, text }); + } + + return { html: doc.body.innerHTML, toc }; +} + +export default function PublicWikiSidebar({ + entity, + wiki, + isLoading, + error, + onClose, + onWikiLinkRequest, +}: Props) { + const contentRootRef = useRef(null); + const [activeHeadingId, setActiveHeadingId] = useState(null); + const processedWiki = useMemo(() => { + if (!wiki) return { html: "", toc: [] as TocItem[] }; + + const html = normalizeWikiContentToHtml(wiki.content ?? ""); + try { + return prepareWikiHtml(html); + } catch (err) { + console.error("Failed to process sidebar wiki HTML", err); + return { html, toc: [] as TocItem[] }; + } + }, [wiki]); + const renderHtml = processedWiki.html; + const toc = processedWiki.toc; + const effectiveActiveHeadingId = toc.some((item) => item.id === activeHeadingId) + ? activeHeadingId + : (toc[0]?.id ?? null); + + useEffect(() => { + if (!toc.length) return; + const root = contentRootRef.current; + if (!root) return; + + const headings = toc + .map((item) => root.querySelector(`#${CSS.escape(item.id)}`)) + .filter((item): item is HTMLElement => Boolean(item)); + if (!headings.length) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => (a.boundingClientRect.top ?? 0) - (b.boundingClientRect.top ?? 0)); + const top = visible[0]?.target as HTMLElement | undefined; + if (top?.id) setActiveHeadingId(top.id); + }, + { root: null, rootMargin: "-18% 0px -70% 0px", threshold: [0, 1] } + ); + + for (const heading of headings) observer.observe(heading); + return () => observer.disconnect(); + }, [toc]); + + useEffect(() => { + const root = contentRootRef.current; + if (!root) return; + + const handleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + const link = target?.closest?.("a[data-wiki-slug]") as HTMLAnchorElement | null; + if (!link) return; + event.preventDefault(); + + const slug = String(link.getAttribute("data-wiki-slug") || "").trim(); + if (!slug.length) return; + onWikiLinkRequest({ slug, rect: link.getBoundingClientRect() }); + }; + + root.addEventListener("click", handleClick); + return () => root.removeEventListener("click", handleClick); + }, [onWikiLinkRequest, renderHtml]); + + return ( +
+
+
+
+
+ Wiki +
+
+ {entity?.name?.trim() || wiki?.title?.trim() || "Wiki"} +
+ {entity?.description?.trim() ? ( +
+ {entity.description.trim()} +
+ ) : null} + {wiki?.title?.trim() && wiki.title.trim() !== entity?.name?.trim() ? ( +
+ {wiki.title.trim()} +
+ ) : null} +
+ + +
+
+ + {toc.length ? ( +
+
+ {toc.slice(0, 8).map((item) => { + const isActive = effectiveActiveHeadingId === item.id; + return ( + + {item.text} + + ); + })} +
+
+ ) : null} + +
+ {isLoading ? ( +
+
+
+
+
+ ) : error ? ( +
+ {error} +
+ ) : wiki ? ( +
+ ) : ( +
+ Entity này chưa có wiki liên kết. +
+ )} +
+ + +
+ ); +}