diff --git a/doc/in_flight_promise_caching.md b/doc/in_flight_promise_caching.md new file mode 100644 index 0000000..61a1df2 --- /dev/null +++ b/doc/in_flight_promise_caching.md @@ -0,0 +1,78 @@ +# Data Fetching Optimization: In-flight Promise Caching + +## 1. Vấn đề (The Problem) +Trong quá trình tương tác với bản đồ (ví dụ: kéo thả nhanh từ khu vực A sang B rồi sang C), các tính năng lấy dữ liệu quan hệ (entities, wikis) thường phải tải hàng chục đến hàng trăm item thông qua mảng ID (`geometryIds`). + +Nếu chỉ sử dụng cơ chế Cache Data tĩnh (lưu kết quả sau khi API trả về), ta sẽ gặp phải bài toán **Race Condition** với các request đang bay (In-flight requests): +- **A -> B**: Hệ thống gọi API xin 5 ID mới. Request mất 500ms để hoàn thành. +- **B -> C** (xảy ra ở mốc 200ms): Lúc này request của B chưa xong, Cache tĩnh chưa có dữ liệu của 5 ID đó. +- Hệ thống gửi tiếp API xin 10 ID mới (bao gồm 5 ID của C và **5 ID của B**). +=> Hậu quả: Lãng phí băng thông, tải lại dữ liệu dư thừa. + +## 2. Giải pháp (The Solution: DataLoader Pattern) +Để khắc phục triệt để, hệ thống sử dụng **In-flight Promise Caching** tại tầng API (`src/uhm/api/relations.ts`). Thay vì chỉ lưu trữ Data, hệ thống lưu trữ **Tiến trình (Promise)**. + +### Cơ chế hoạt động: +1. **Kiểm tra Cache:** Khi nhận mảng `ids` cần tải, hệ thống kiểm tra xem ID nào đã có Promise tương ứng trong Cache (nghĩa là đang được tải hoặc đã tải xong). +2. **Lọc Missing IDs:** Chỉ những ID chưa có Promise trong Cache mới được đưa vào mảng `missingIds` để gọi API. +3. **Tạo Batch Promise:** Một HTTP Request duy nhất được gửi đi để tải `missingIds`. (Trả về `batchPromise`). +4. **Chia tách Promise (Demultiplexing):** Với mỗi ID trong `missingIds`, hệ thống gán cho nó một Promise con (tách ra từ `batchPromise` cha) có nhiệm vụ chỉ extract dữ liệu của riêng ID đó. Các Promise con này lập tức được lưu vào Cache. +5. **Đợi kết quả:** Hàm gọi `await Promise.all()` để chờ tất cả các Promise của `ids` yêu cầu hoàn thành và trả về. + +## 3. Các "Hố Tử Thần" (Edge Cases) & Cách Xử Lý (Production-Ready) + +Khi triển khai Promise Caching, có 3 rủi ro cực kỳ lớn cần phải xử lý để hệ thống không bị crash hoặc dính lỗi logic: + +### 3.1. Hiệu ứng Domino của `Promise.all` (Sập cả Viewport) +**Rủi ro:** Nếu một ID trong mảng bị lỗi mạng (`throw err`), `Promise.all` sẽ ngắt mạch (short-circuit) và vứt bỏ toàn bộ kết quả của các ID khác, khiến bản đồ trắng xóa. +**Cách xử lý:** Trong khối `.catch()` của từng Promise con, tuyệt đối không được `throw err`. Thay vào đó, **phải trả về một giá trị an toàn (ví dụ mảng rỗng `[]`)** để cứu các Promise còn lại. +```typescript +.catch(err => { + delete promiseCache[id]; // Xóa khỏi cache để lần sau thử lại + return []; // Trả về fallback thay vì ném lỗi +}) +``` + +### 3.2. Nhiễm độc Cache vĩnh viễn (Zombie Cache) & Negative Cache +**Rủi ro:** Nếu API trả về HTTP 200, nhưng một `geometryId` không hề có dữ liệu (thực tế rất nhiều vùng biển không có thực thể), biến `res[id]` sẽ là `undefined`. Nếu ta xóa Cache đi vì tưởng là lỗi, lần sau kéo lại, hệ thống sẽ tiếp tục gọi API xin dữ liệu của vùng biển đó => Spam API vô tận. +**Cách xử lý (Negative Cache):** Ép giá trị `undefined` thành `[]` và **VẪN LƯU VÀO CACHE**. Bằng cách này, hệ thống "nhớ" rằng vị trí này trống rỗng và sẽ không bao giờ tốn công gọi API lên Domain nữa. +```typescript +.then(res => res[id] || []) // Lưu hẳn mảng rỗng vào Cache +``` + +### 3.3. Vấn đề Gom cụm (Scope Batching) +**Rủi ro:** Nếu hệ thống kích hoạt API lẻ tẻ cách nhau vài mili-giây, code sẽ không gom (batch) được request lại với nhau. +**Đặc thù dự án:** May mắn là UI Hook (`usePublicPreviewData`) đã thu thập đủ toàn bộ các Geometries trên bản đồ thành 1 mảng tĩnh duy nhất trước khi gọi xuống hàm API. Do đó, mảng `ids` truyền vào bản thân nó đã là một Batch hoàn chỉnh, không cần phải dùng đến Event Loop Tick (`setTimeout(0)`) để gom cụm như thư viện DataLoader nguyên gốc. + +## 4. Mã nguồn chuẩn Production (Tham khảo) + +```typescript +const entitiesPromiseCache: Record> = {}; + +export async function fetchEntitiesByGeometryIds(ids: string[]): Promise> { + const uniqueIds = uniqueStrings(ids); + const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]); + + if (missingIds.length > 0) { + // 1. Tạo request cha + const batchPromise = fetchFromServer(missingIds); + + // 2. Chia nhỏ thành request con và lưu cache + for (const id of missingIds) { + entitiesPromiseCache[id] = batchPromise + .then(res => res[id] || []) // Negative Cache: Ép mảng rỗng + .catch(err => { + delete entitiesPromiseCache[id]; // Xóa cache lỗi + return []; // Chặn Domino Effect của Promise.all + }); + } + } + + const result: Record = {}; + // 3. Đợi toàn bộ hoàn thành an toàn + await Promise.all(uniqueIds.map(async id => { + result[id] = await entitiesPromiseCache[id]; + })); + return result; +} +``` diff --git a/src/app/page.tsx b/src/app/page.tsx index 75b86ef..2952681 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,61 +1,33 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useState } from "react"; -import Map, { type MapFeaturePayload } 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 PreviewMapShell from "@/uhm/components/preview/PreviewMapShell"; +import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; +import { usePublicPreviewData } from "@/uhm/components/preview/hooks/usePublicPreviewData"; +import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; +import type { MapHandle } from "@/uhm/components/Map"; +import { useRef, useMemo, useCallback } from "react"; +import { usePublicPreviewInteraction } from "@/uhm/components/preview/hooks/usePublicPreviewInteraction"; 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"; -import { - buildEntityLabelContextDraft, - buildPublicPreviewRelationIndex, -} from "@/uhm/lib/preview/relationIndex"; -import { - EMPTY_PREVIEW_RELATIONS, - type PreviewRelationIndex, -} from "@/uhm/lib/preview/types"; const CURRENT_YEAR = new Date().getUTCFullYear(); -const ENTITY_PAGE_LIMIT = 100; -const WIKI_PAGE_LIMIT = 100; -const RELATION_CONCURRENCY = 6; - -type LinkEntityPopupState = { - slug: string; - entities: Entity[]; - top: number; - left: number; -}; - -type CachedWiki = Wiki & { __fetched?: boolean }; 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 }) ); @@ -65,36 +37,107 @@ export default function Page() { for (const key of GEO_TYPE_KEYS) init[key] = true; return init; }); - const [relations, setRelations] = useState(EMPTY_PREVIEW_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; - } + if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) return parsed; } } return 420; }); const [isLargeScreen, setIsLargeScreen] = useState(false); + + const mapHandleRef = useRef(null); + const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle"); + const [selectedReplayStageId, setSelectedReplayStageId] = useState(null); + const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState(null); + + const [searchTimelineYear, setSearchTimelineYear] = useState(timelineYear); + useEffect(() => { + if (replayMode !== "playing") { + setSearchTimelineYear(timelineYear); + } + }, [timelineYear, replayMode]); + + const { + data, + renderDraft, + labelContextDraft, + relations, + setRelations, + isTimelineLoading, + timelineStatus, + isRelationsLoading, + relationsStatus, + replays, + } = usePublicPreviewData({ timelineYear: searchTimelineYear, timeRange }); + + const activeReplay = useMemo(() => { + if (!selectedFeatureIds.length || !replays?.length) return null; + for (const featureId of selectedFeatureIds) { + const id = String(featureId); + // 1. Direct geometry_id match (priority) + for (const replay of replays) { + if (String(replay.geometry_id || "").trim() === id) { + const firstStage = replay.detail?.find((s) => Array.isArray(s?.steps) && s.steps.length > 0); + if (firstStage) { + return { replay, stageId: firstStage.id, stepIndex: 0 }; + } + } + } + // 2. Fallback: Check inside steps parameters + for (const replay of replays) { + for (const stage of replay.detail || []) { + for (let stepIndex = 0; stepIndex < (stage.steps || []).length; stepIndex++) { + const step = stage.steps[stepIndex]; + if (step?.use_geo_function?.some((g) => g.params && Array.isArray(g.params) && g.params.some((p) => String(p) === id))) { + return { replay, stageId: stage.id, stepIndex }; + } + } + } + } + } + return null; + }, [replays, selectedFeatureIds]); + + const replayPreview = useReplayPreview({ + replay: activeReplay?.replay || null, + draft: renderDraft, + getMapInstance: () => mapHandleRef.current?.getMap() || null, + initialTimelineYear: timelineDraftYear, + initialTimelineFilterEnabled: false, + initialMapViewState: null, + selectedStageId: selectedReplayStageId, + selectedStepIndex: selectedReplayStepIndex, + onSelectStep: (stageId, stepIndex) => { + setSelectedReplayStageId(stageId); + setSelectedReplayStepIndex(stepIndex); + }, + }); + + const { + activeEntity, + activeWiki, + isActiveWikiLoading, + activeWikiError, + linkEntityPopup, + linkEntityPopupRef, + getHoverPopupContent, + selectEntity, + handleWikiLinkRequest, + closeWikiSidebar, + setLinkEntityPopup, + } = usePublicPreviewInteraction({ + data, + relations, + setRelations, + selectedFeatureIds, + setSelectedFeatureIds, + replayActiveWikiId: replayPreview.activeWikiId, + replayMode, + }); useEffect(() => { if (typeof window === "undefined") return; @@ -106,25 +149,6 @@ export default function Page() { 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); @@ -133,129 +157,16 @@ export default function Page() { }, [timelineDraftYear, timelineYear]); useEffect(() => { - setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); - setIsBackgroundVisibilityReady(true); + const timeoutId = window.setTimeout(() => { + setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); + setIsBackgroundVisibilityReady(true); + }, 0); + return () => window.clearTimeout(timeoutId); }, []); - 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 entityGeometriesById: Record = {}; - const entityWikisById: Record = {}; - - - 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; - - entityGeometriesById[entity.id] = geometries; - entityWikisById[entity.id] = wikis; - - const completed = index + 1; - if (completed === entities.length || completed % 5 === 0) { - setRelationsProgress({ completed, total: entities.length }); - } - }); - - if (disposed) return; - - const next = buildPublicPreviewRelationIndex({ - entities, - entityGeometriesById, - entityWikisById, - }); - - 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), - [data, relations] - ); - - const activeWiki = useMemo(() => { - if (!activeWikiSlug) return null; - return wikiCache[activeWikiSlug] || relations.wikiBySlug[activeWikiSlug] || null; - }, [activeWikiSlug, relations.wikiBySlug, wikiCache]); + const maxDragWidth = typeof window !== "undefined" + ? Math.min(800, window.innerWidth - 340) + : 800; const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => { setBackgroundVisibility((prev) => { @@ -269,14 +180,6 @@ export default function Page() { 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))); }; @@ -286,521 +189,115 @@ export default function Page() { 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: MapFeaturePayload | null) => { - clearHoverHideTimer(); - - if (payload) { - setHoverAnchor(payload); - return; + if (replayMode === "playing" && !replayPreview.isPlaying) { + replayPreview.playFromSelection(); } + }, [replayMode, replayPreview.isPlaying, replayPreview.playFromSelection]); - if (hoverPopupHoveredRef.current) return; - hoverHideTimerRef.current = window.setTimeout(() => { - setHoverAnchor(null); - }, 120); - }, [clearHoverHideTimer]); + const handlePlayPreviewReplay = useCallback(() => { + if (!activeReplay) return; + setReplayMode("playing"); + setSelectedReplayStageId(activeReplay.stageId); + setSelectedReplayStepIndex(activeReplay.stepIndex); + }, [activeReplay]); - useEffect(() => { - return () => { - if (hoverHideTimerRef.current !== null) { - window.clearTimeout(hoverHideTimerRef.current); - } - }; - }, []); + const handleExitReplay = useCallback(() => { + setReplayMode("idle"); + replayPreview.resetPreview(); + }, [replayPreview]); - 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; + const filteredRenderDraft = useMemo(() => { + if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) { + return renderDraft; } - - 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; + const hiddenIds = new Set(replayPreview.hiddenGeometryIds); + return { + type: "FeatureCollection" as const, + features: renderDraft.features.filter( + (feature) => !hiddenIds.has(String(feature.properties.id)) + ), }; - }, [activeWikiSlug, cachedWiki]); + }, [replayMode, renderDraft, replayPreview.hiddenGeometryIds]); - 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; + const filteredLabelContextDraft = useMemo(() => { + if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) { + return labelContextDraft; } + const hiddenIds = new Set(replayPreview.hiddenGeometryIds); + return { + type: "FeatureCollection" as const, + features: labelContextDraft.features.filter( + (feature) => !hiddenIds.has(String(feature.properties.id)) + ), + }; + }, [replayMode, labelContextDraft, replayPreview.hiddenGeometryIds]); - 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}`; + const currentTimelineYear = replayMode === "playing" ? replayPreview.timelineYear : timelineDraftYear; return ( -
-
- {isBackgroundVisibilityReady ? ( - - ) : ( -
- )} - - + {isBackgroundVisibilityReady ? ( + { + setGeometryVisibility((prev) => ({ + ...prev, + [typeKey]: prev[typeKey] === false, + })); + }} + timelineYear={currentTimelineYear} + onTimelineYearChange={handleTimelineYearChange} + timelineTimeRange={timeRange} + onTimelineTimeRangeChange={handleTimeRangeChange} + isTimelineLoading={isTimelineLoading || isRelationsLoading} + timelineStatusText={relationsStatus || timelineStatus} + timelineStyle={activeEntity && isLargeScreen ? { right: `${sidebarWidth + 32}px` } : undefined} + hoverPopupEnabled + getHoverPopupContent={getHoverPopupContent} + activeEntity={replayMode === "playing" ? (replayPreview.sidebarOpen ? activeEntity : null) : activeEntity} + activeWiki={replayMode === "playing" ? (replayPreview.sidebarOpen ? activeWiki : null) : activeWiki} + isWikiLoading={isActiveWikiLoading} + wikiError={activeWikiError} + onCloseWikiSidebar={closeWikiSidebar} + onWikiLinkRequest={handleWikiLinkRequest} + sidebarWidth={sidebarWidth} + onSidebarWidthChange={setSidebarWidth} + maxSidebarDragWidth={maxDragWidth} + onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined} + timelineDisabled={replayMode === "playing"} + overlay={ + replayMode === "playing" ? ( + + ) : null + } /> - -
-
-
-
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 ? (
) : 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 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/battleReplays.ts b/src/uhm/api/battleReplays.ts new file mode 100644 index 0000000..61e1070 --- /dev/null +++ b/src/uhm/api/battleReplays.ts @@ -0,0 +1,61 @@ +import { API_ENDPOINTS } from "@/uhm/api/config"; +import { requestJson } from "@/uhm/api/http"; +import type { BattleReplay } from "@/uhm/types/projects"; + +const BATCH_SIZE = 20; +const BATCH_CONCURRENCY = 6; + +export async function fetchBattleReplaysByGeometryIds(geometryIds: string[]): Promise> { + const uniqueIds = Array.from(new Set( + (geometryIds || []) + .map((id) => String(id || "").trim()) + .filter((id) => id.length > 0) + )); + + if (!uniqueIds.length) { + return {}; + } + + const chunks: string[][] = []; + for (let index = 0; index < uniqueIds.length; index += BATCH_SIZE) { + chunks.push(uniqueIds.slice(index, index + BATCH_SIZE)); + } + + const results: Array> = new Array(chunks.length); + const runnerCount = Math.max(1, Math.min(BATCH_CONCURRENCY, chunks.length)); + let nextIndex = 0; + + await Promise.all( + Array.from({ length: runnerCount }, async () => { + while (true) { + const current = nextIndex++; + if (current >= chunks.length) return; + + const batch = chunks[current]; + const params = new URLSearchParams(); + for (const id of batch) { + params.append("geometry_ids", id); + } + + try { + results[current] = await requestJson>( + `${API_ENDPOINTS.battleReplays}/geometries?${params.toString()}` + ); + } catch (err) { + console.error("Failed to fetch battle replays batch", err); + results[current] = {}; + } + } + }) + ); + + const merged: Record = {}; + for (const res of results) { + if (!res) continue; + for (const [key, list] of Object.entries(res)) { + merged[key] = list || []; + } + } + + return merged; +} diff --git a/src/uhm/api/config.ts b/src/uhm/api/config.ts index 391a642..95fde6f 100644 --- a/src/uhm/api/config.ts +++ b/src/uhm/api/config.ts @@ -3,7 +3,6 @@ import { API_URL_ROOT } from "../../../api"; const GOONG_TILES_BASE_URL = "https://tiles.goong.io"; export const API_BASE_URL = normalizeApiBaseUrl(API_URL_ROOT); -const GOONG_PROXY_BASE_PATH = `${API_BASE_URL}/proxy`; export const GOONG_SATELLITE_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_satellite.json`; export const GOONG_VECTOR_OVERLAY_STYLE_UPSTREAM_URL = `${GOONG_TILES_BASE_URL}/assets/goong_map_web.json`; @@ -45,6 +44,7 @@ export const API_ENDPOINTS = { geometries: `${API_BASE_URL}/geometries`, entities: `${API_BASE_URL}/entities`, wikis: `${API_BASE_URL}/wikis`, + relations: `${API_BASE_URL}/relations`, wikiContent: (id: string) => `${API_BASE_URL}/wikis/content/${id}`, // New API uses projects + commits + submissions (JWT-protected). authSignin: `${API_BASE_URL}/auth/signin`, @@ -54,4 +54,5 @@ export const API_ENDPOINTS = { currentUserProjects: `${API_BASE_URL}/users/current/project`, projects: `${API_BASE_URL}/projects`, submissions: `${API_BASE_URL}/submissions`, + battleReplays: `${API_BASE_URL}/battle-replays`, } as const; diff --git a/src/uhm/api/geometries.ts b/src/uhm/api/geometries.ts index fc67ea5..5932647 100644 --- a/src/uhm/api/geometries.ts +++ b/src/uhm/api/geometries.ts @@ -1,7 +1,7 @@ import { API_ENDPOINTS } from "@/uhm/api/config"; import { requestJson } from "@/uhm/api/http"; import type { GeometriesBBoxQuery } from "@/uhm/types/api"; -import type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo"; +import type { Feature, FeatureCollection, FeatureEntityPreview, FeatureProperties, FeatureWikiPreview, Geometry } from "@/uhm/types/geo"; import { geoTypeCodeToTypeKey } from "@/uhm/lib/map/geo/geoTypeMap"; export type { GeometriesBBoxQuery } from "@/uhm/types/api"; @@ -90,17 +90,28 @@ export async function searchGeometriesByEntityName( return { ...response, - items: (response.items || []).map((item) => ({ - ...item, - geometries: (item.geometries || []).map((geometry) => ({ - id: geometry.id, - type: geoTypeCodeToTypeKey(geometry.geo_type) || null, - draw_geometry: geometry.draw_geometry, - bound_with: normalizeBoundWith(geometry.bound_with), - time_start: geometry.time_start ?? null, - time_end: geometry.time_end ?? null, - })), - })), + items: normalizeEntityGeometryItems(response.items), + }; +} + +export async function fetchEntityGeometryIndexPage(options?: { + cursor?: string; + limit?: number; +}): Promise { + const params = new URLSearchParams(); + if (options?.cursor) params.set("cursor", options.cursor); + if (options?.limit && Number.isFinite(options.limit)) { + params.set("limit", String(Math.trunc(options.limit))); + } + + const suffix = params.toString(); + const response = await requestJson( + `${API_ENDPOINTS.geometries}/entity${suffix ? `?${suffix}` : ""}` + ); + + return { + ...response, + items: normalizeEntityGeometryItems(response.items), }; } @@ -118,6 +129,32 @@ type GeometryRow = { max_lng: number; max_lat: number; } | null; + entity_id?: string | null; + entity_name?: string | null; + entity_description?: string | null; + entities?: GeometryRowEntity[]; +}; + +type GeometryRowEntity = { + id?: string | null; + entity_id?: string | null; + name?: string | null; + entity_name?: string | null; + description?: string | null; + entity_description?: string | null; + time_start?: number | null; + time_end?: number | null; + wikis?: GeometryRowWiki[]; +}; + +type GeometryRowWiki = { + id?: string | null; + wiki_id?: string | null; + title?: string | null; + slug?: string | null; + preview_quote?: string | null; + blockquote_preview?: string | null; + content?: string | null; }; function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection { @@ -129,6 +166,9 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection { const boundWith = normalizeBoundWith(row.bound_with); const typeKey = geoTypeCodeToTypeKey(row.geo_type) || null; + const entityPreviews = normalizeGeometryRowEntities(row); + const entityIds = entityPreviews.map((entity) => entity.id); + const entityNames = entityPreviews.map((entity) => entity.name); const properties: FeatureProperties = { id: row.id, @@ -136,6 +176,21 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection { time_start: row.time_start ?? null, time_end: row.time_end ?? null, bound_with: boundWith, + ...(entityPreviews.length + ? { + entity_id: entityIds[0] || null, + entity_ids: entityIds, + entity_name: entityNames[0] || null, + entity_names: entityNames, + entity_label_candidates: entityPreviews.map((entity) => ({ + id: entity.id, + name: entity.name, + time_start: entity.time_start ?? null, + time_end: entity.time_end ?? null, + })), + public_entity_previews: entityPreviews, + } + : {}), }; features.push({ @@ -148,6 +203,67 @@ function geometriesToFeatureCollection(rows: GeometryRow[]): FeatureCollection { return { type: "FeatureCollection", features }; } +function normalizeGeometryRowEntities(row: GeometryRow): FeatureEntityPreview[] { + const candidates: GeometryRowEntity[] = Array.isArray(row.entities) ? row.entities : []; + if (!candidates.length && (row.entity_id || row.entity_name)) { + candidates.push({ + entity_id: row.entity_id, + entity_name: row.entity_name, + entity_description: row.entity_description, + }); + } + + const byId = new Map(); + for (const candidate of candidates) { + const id = normalizeString(candidate.id ?? candidate.entity_id); + if (!id) continue; + const name = normalizeString(candidate.name ?? candidate.entity_name) || id; + byId.set(id, { + id, + name, + description: normalizeNullableString(candidate.description ?? candidate.entity_description), + time_start: normalizeNumber(candidate.time_start), + time_end: normalizeNumber(candidate.time_end), + wikis: normalizeGeometryRowWikis(candidate.wikis), + }); + } + + return Array.from(byId.values()); +} + +function normalizeGeometryRowWikis(wikis: GeometryRowWiki[] | undefined): FeatureWikiPreview[] { + if (!Array.isArray(wikis)) return []; + + const byId = new Map(); + for (const wiki of wikis) { + const id = normalizeString(wiki.id ?? wiki.wiki_id); + if (!id) continue; + byId.set(id, { + id, + title: normalizeNullableString(wiki.title) ?? undefined, + slug: normalizeNullableString(wiki.slug), + preview_quote: normalizeNullableString(wiki.preview_quote ?? wiki.blockquote_preview), + content: normalizeNullableString(wiki.content), + }); + } + + return Array.from(byId.values()); +} + +function normalizeString(value: unknown): string { + if (typeof value !== "string" && typeof value !== "number") return ""; + return String(value).trim(); +} + +function normalizeNullableString(value: unknown): string | null { + const normalized = normalizeString(value); + return normalized.length ? normalized : null; +} + +function normalizeNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function normalizeGeometry(value: unknown): Geometry | null { if (!value || typeof value !== "object") return null; const g = value as Record; @@ -162,3 +278,17 @@ function normalizeBoundWith(value: unknown): string | null { const id = String(value).trim(); return id.length ? id : null; } + +function normalizeEntityGeometryItems(items: EntityGeometriesSearchItemRow[] | undefined): EntityGeometriesSearchItem[] { + return (items || []).map((item) => ({ + ...item, + geometries: (item.geometries || []).map((geometry) => ({ + id: geometry.id, + type: geoTypeCodeToTypeKey(geometry.geo_type) || null, + draw_geometry: geometry.draw_geometry, + bound_with: normalizeBoundWith(geometry.bound_with), + time_start: geometry.time_start ?? null, + time_end: geometry.time_end ?? null, + })), + })); +} diff --git a/src/uhm/api/relations.ts b/src/uhm/api/relations.ts new file mode 100644 index 0000000..ab20d5a --- /dev/null +++ b/src/uhm/api/relations.ts @@ -0,0 +1,196 @@ +import { API_ENDPOINTS } from "@/uhm/api/config"; +import { requestJson } from "@/uhm/api/http"; +import type { Entity } from "@/uhm/api/entities"; +import type { Wiki } from "@/uhm/api/wikis"; + +const RELATION_BATCH_SIZE = 20; +const RELATION_BATCH_CONCURRENCY = 10; + +export type WikiContentPreview = { + id: string; + preview?: string | null; + created_at?: string | null; +}; + +const entitiesPromiseCache: Record> = {}; + +export async function fetchEntitiesByGeometryIds(ids: string[]): Promise> { + const uniqueIds = uniqueStrings(ids); + const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]); + + if (missingIds.length > 0) { + const batchPromise = (async () => { + const result: Record = {}; + const pages = await mapWithConcurrency( + chunkIds(missingIds), + RELATION_BATCH_CONCURRENCY, + (batch) => requestJson>( + `${API_ENDPOINTS.relations}/entities-by-geometries?${buildArrayQuery("geometry_ids", batch)}` + ) + ); + for (const rows of pages) { + mergeRelationRecord(result, rows); + } + return result; + })(); + batchPromise.catch(() => {}); + + for (const id of missingIds) { + entitiesPromiseCache[id] = batchPromise + .then(res => res[id] || []) + .catch(err => { + // Xóa khỏi cache để lần sau thử lại + delete entitiesPromiseCache[id]; + // Trả về [] để không làm sập Promise.all của UI + return []; + }); + } + } + + const result: Record = {}; + await Promise.all(uniqueIds.map(async id => { + result[id] = await entitiesPromiseCache[id]; + })); + return result; +} + +export async function fetchWikisByEntityIds(ids: string[]): Promise> { + const result: Record = {}; + const pages = await mapWithConcurrency( + chunkIds(ids), + RELATION_BATCH_CONCURRENCY, + (batch) => requestJson>( + `${API_ENDPOINTS.relations}/wikis-by-entities?${buildArrayQuery("entity_ids", batch)}` + ) + ); + for (const rows of pages) { + mergeRelationRecord(result, rows); + } + return result; +} + +export async function fetchWikiContentPreviewsByIds(ids: string[]): Promise { + const result: WikiContentPreview[] = []; + const seen = new Set(); + const pages = await mapWithConcurrency( + chunkIds(ids), + RELATION_BATCH_CONCURRENCY, + (batch) => requestJson( + `${API_ENDPOINTS.relations}/wiki-contents/preview?${buildArrayQuery("ids", batch)}` + ) + ); + for (const rows of pages) { + for (const row of rows || []) { + const id = String(row?.id || "").trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + result.push(row); + } + } + return result; +} + +const wikisWithPreviewPromiseCache: Record> = {}; + +export async function fetchWikisByEntityIdsWithPreviews(ids: string[]): Promise> { + const uniqueIds = uniqueStrings(ids); + const missingIds = uniqueIds.filter(id => !wikisWithPreviewPromiseCache[id]); + + if (missingIds.length > 0) { + const batchPromise = (async () => { + const wikisByEntityId = await fetchWikisByEntityIds(missingIds); + const previewContentIds = uniqueStrings( + Object.values(wikisByEntityId || {}) + .flat() + .map((wiki) => wiki.content_sample?.[0]?.id) + ); + if (!previewContentIds.length) return wikisByEntityId; + + const previews = await fetchWikiContentPreviewsByIds(previewContentIds); + const previewById = new Map( + previews.map((item) => [String(item.id), String(item.preview || "").trim()]) + ); + + const result: Record = {}; + for (const [entityId, wikis] of Object.entries(wikisByEntityId || {})) { + result[entityId] = (wikis || []).map((wiki) => { + const previewId = wiki.content_sample?.[0]?.id; + const preview = previewId ? previewById.get(String(previewId)) || "" : ""; + return preview ? { ...wiki, preview_quote: preview } : wiki; + }); + } + return result; + })(); + batchPromise.catch(() => {}); + + for (const id of missingIds) { + wikisWithPreviewPromiseCache[id] = batchPromise + .then(res => res[id] || []) + .catch(err => { + // Xóa khỏi cache để lần sau thử lại + delete wikisWithPreviewPromiseCache[id]; + // Trả về [] để không làm sập Promise.all của UI + return []; + }); + } + } + + const result: Record = {}; + await Promise.all(uniqueIds.map(async id => { + result[id] = await wikisWithPreviewPromiseCache[id]; + })); + return result; +} + +function buildArrayQuery(key: string, values: string[]): string { + const query = new URLSearchParams(); + for (const value of uniqueStrings(values)) { + query.append(key, value); + } + return query.toString(); +} + +function chunkIds(ids: string[]): string[][] { + const values = uniqueStrings(ids); + const chunks: string[][] = []; + for (let index = 0; index < values.length; index += RELATION_BATCH_SIZE) { + chunks.push(values.slice(index, index + RELATION_BATCH_SIZE)); + } + return chunks; +} + +function uniqueStrings(values: Array): string[] { + return Array.from(new Set( + values + .map((value) => String(value || "").trim()) + .filter((value) => value.length > 0) + )); +} + +function mergeRelationRecord(target: Record, source: Record | undefined) { + for (const [key, rows] of Object.entries(source || {})) { + target[key] = rows || []; + } +} + +async function mapWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T) => Promise +): Promise { + const results: R[] = new Array(items.length); + const runnerCount = Math.max(1, Math.min(Math.trunc(concurrency), items.length)); + let nextIndex = 0; + + await Promise.all( + Array.from({ length: runnerCount }, async () => { + while (true) { + const current = nextIndex++; + if (current >= items.length) return; + results[current] = await worker(items[current]); + } + }) + ); + + return results; +} diff --git a/src/uhm/api/wikis.ts b/src/uhm/api/wikis.ts index 8cef7da..6175652 100644 --- a/src/uhm/api/wikis.ts +++ b/src/uhm/api/wikis.ts @@ -8,6 +8,7 @@ export type Wiki = { title?: string; slug?: string | null; content?: string; + preview_quote?: string | null; is_deleted?: boolean; created_at?: string; updated_at?: string; @@ -71,4 +72,4 @@ export async function checkWikiSlugExists(slug: string): Promise { export const getContentByVersionWikiId = async (id: string) => { const response = await api.get(API_ENDPOINTS.wikiContent(id)); return response?.data; -}; \ No newline at end of file +}; diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index a3d3fbe..f490efc 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -336,15 +336,115 @@ const Map = memo(forwardRef(function Map({ display: "flex", alignItems: "center", gap: "10px", - background: "rgba(15, 23, 42, 0.88)", - border: "1px solid rgba(148, 163, 184, 0.38)", - borderRadius: "999px", - padding: "8px 12px", - color: "#e2e8f0", - backdropFilter: "blur(3px)", + background: "linear-gradient(135deg, rgba(30, 30, 30, 0.72) 0%, rgba(20, 20, 20, 0.85) 100%)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "50px", + padding: "8px 16px", + color: "#f8fafc", + boxShadow: "0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 1px 0 rgba(255, 255, 255, 0.05)", + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", pointerEvents: "auto", }} > +