diff --git a/src/uhm/api/relations.ts b/src/uhm/api/relations.ts index 8b77f00..8f0def3 100644 --- a/src/uhm/api/relations.ts +++ b/src/uhm/api/relations.ts @@ -2,6 +2,7 @@ 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"; +import type { Geometry } from "@/uhm/types/geo"; const RELATION_BATCH_SIZE = 10; const RELATION_BATCH_CONCURRENCY = 4; @@ -12,8 +13,42 @@ export type WikiContentPreview = { created_at?: string | null; }; +export type RelationGeometry = { + id: string; + draw_geometry: Geometry; + type?: string | null; + geo_type?: number | null; + bound_with?: string | null; + time_start?: number | null; + time_end?: number | null; +}; + +type RelationType = + | "wiki-entity" + | "entity-wiki" + | "geometry-entity" + | "entity-geometry" + | "entity-geometry-child" + | "entity-geometry-alone"; + const entitiesPromiseCache: Record> = {}; +export async function fetchRelationMap(type: RelationType, ids: string[]): Promise> { + const uniqueIds = uniqueStrings(ids); + if (!uniqueIds.length) return {}; + return requestJson>( + `${API_ENDPOINTS.relations}?type=${encodeURIComponent(type)}&${buildArrayQuery("ids", uniqueIds)}` + ); +} + +export async function fetchEntitiesByWikiIds(ids: string[]): Promise> { + return fetchRelationMap("wiki-entity", ids); +} + +export async function fetchGeometriesByEntityIds(ids: string[]): Promise> { + return fetchRelationMap("entity-geometry", ids); +} + export async function fetchEntitiesByGeometryIds(ids: string[]): Promise> { const uniqueIds = uniqueStrings(ids); const missingIds = uniqueIds.filter(id => !entitiesPromiseCache[id]); diff --git a/src/uhm/components/editor/ReplayPreviewOverlay.tsx b/src/uhm/components/editor/ReplayPreviewOverlay.tsx index 74eb073..35bf10c 100644 --- a/src/uhm/components/editor/ReplayPreviewOverlay.tsx +++ b/src/uhm/components/editor/ReplayPreviewOverlay.tsx @@ -15,6 +15,7 @@ type Props = { activeStepLabel: string | null; activeStepNumber: number | null; totalSteps: number; + playButtonLabel?: string; onPlayPreview: () => void; onStopPreview: () => void; onResetPreview: () => void; @@ -32,6 +33,7 @@ export default function ReplayPreviewOverlay({ activeStepLabel, activeStepNumber, totalSteps, + playButtonLabel = "Phát lại", onPlayPreview, onStopPreview, onResetPreview, @@ -279,7 +281,7 @@ export default function ReplayPreviewOverlay({ onClick={onPlayPreview} style={previewButtonStyle("#166534")} > - Phát lại + {playButtonLabel} )} + + + +
+ {isLoading ? ( +
+ {[0, 1, 2].map((index) => ( +
+ ))} +
+ ) : error ? ( +
+ {error} +
+ ) : rows.length ? ( +
+ {rows.map(({ entity, geometries }, index) => { + const adminText = getGeometryAdminText(geometries); + return ( +
0 ? 12 : 0, + marginTop: index > 0 ? 4 : 0, + borderTop: index > 0 ? "1px solid rgba(148, 163, 184, 0.16)" : "none", + }} + > + +
+ ); + })} +
+ ) : ( +
+ Wiki này chưa có entity hoặc geometry liên quan. +
+ )} +
+ + +
+ ); +} + +function formatEntityYears(entity: Entity): string { + const start = Number.isFinite(entity.time_start) ? String(entity.time_start) : ""; + const end = Number.isFinite(entity.time_end) ? String(entity.time_end) : ""; + if (!start && !end) return ""; + return `(${start || "?"}-${end || "?"})`; +} + +function getGeometryAdminText(geometries: GeometrySelectionGeometry[]): string { + const labels = geometries + .map((geometry) => geometry.adminLabel || geometry.adminAddress || "") + .map((label) => label.trim()) + .filter((label) => label.length > 0); + return Array.from(new Set(labels)).join(" / "); +} diff --git a/src/uhm/components/preview/PreviewLayout.tsx b/src/uhm/components/preview/PreviewLayout.tsx index 612e515..47e0bea 100644 --- a/src/uhm/components/preview/PreviewLayout.tsx +++ b/src/uhm/components/preview/PreviewLayout.tsx @@ -519,7 +519,7 @@ const PreviewLayout = forwardRef(({ const handleReplayPreviewWikiLinkRequest = useCallback(({ slug, rect }: { slug: string; rect: DOMRect }) => { const nextSlug = String(slug || "").trim(); if (!nextSlug.length) return; - + const localWiki = wikis.find((item) => String(item.slug || "").trim() === nextSlug) || null; if (!localWiki) { setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`); @@ -527,34 +527,28 @@ const PreviewLayout = forwardRef(({ } const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || []; - const linkedEntities = linkedEntityIds - .map((entityId) => previewRelations.entitiesById[entityId] || null) - .filter((entity): entity is Entity => Boolean(entity)); + const firstEntityId = linkedEntityIds[0] || null; + if (!firstEntityId) return; - if (linkedEntities.length === 1) { - selectReplayPreviewEntity(linkedEntities[0].id, { - preferredWikiId: localWiki.id, - focusMap: false, - }); - return; + setPreviewActiveEntityId(firstEntityId); + setIsPreviewEntitySidebarOpen(true); + setPreviewRightPanelMode("wiki"); + setPreviewWikiSelectionPanelAnchor(null); + setPreviewWikiError(null); + setPreviewLinkEntityPopup(null); + openReplayPreviewWikiPanelById(localWiki.id); + + const geometries = previewRelations.entityGeometriesById[firstEntityId]; + const map = mapHandleRef?.current?.getMap(); + if (map && geometries && geometries.features.length > 0) { + fitMapToFeatureCollection(map, geometries, 96, { duration: 1000, maxZoom: 8, pointZoom: 6 }); } - - if (!linkedEntities.length) return; - - const popupWidth = 240; - const popupHeight = Math.min(240, linkedEntities.length * 44 + 20); - const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight); - - setPreviewLinkEntityPopup({ - slug: nextSlug, - entities: linkedEntities, - top, - left, - }); }, [ - previewRelations.entitiesById, + mapHandleRef, + openReplayPreviewWikiPanelById, + previewRelations.entityGeometriesById, previewRelations.wikiEntityIdsBySlug, - selectReplayPreviewEntity, + setPreviewActiveEntityId, wikis, ]); diff --git a/src/uhm/components/preview/PreviewMapShell.tsx b/src/uhm/components/preview/PreviewMapShell.tsx index 18afaa3..6065012 100644 --- a/src/uhm/components/preview/PreviewMapShell.tsx +++ b/src/uhm/components/preview/PreviewMapShell.tsx @@ -45,6 +45,7 @@ type Props = { wikiError?: string | null; onCloseWikiSidebar?: () => void; onWikiLinkRequest?: (request: { slug: string; rect: DOMRect }) => void; + onWikiLinkEntitySelectionRequest?: (request: { slug: string; rect: DOMRect }) => void; sidebarWidth?: number; onSidebarWidthChange?: (width: number) => void; maxSidebarDragWidth?: number; @@ -59,6 +60,7 @@ type Props = { onLayerPanelVisibleChange?: (visible: boolean) => void; sidebarHeight?: number; onSidebarHeightChange?: (height: number) => void; + showViewportControls?: boolean; }; export default function PreviewMapShell({ @@ -91,6 +93,7 @@ export default function PreviewMapShell({ wikiError = null, onCloseWikiSidebar, onWikiLinkRequest, + onWikiLinkEntitySelectionRequest, sidebarWidth, onSidebarWidthChange, maxSidebarDragWidth, @@ -105,6 +108,7 @@ export default function PreviewMapShell({ onLayerPanelVisibleChange, sidebarHeight, onSidebarHeightChange, + showViewportControls = true, }: Props) { const [isMenuOpen, setIsMenuOpen] = useState(false); const [avatarUrl, setAvatarUrl] = useState(null); @@ -133,6 +137,8 @@ export default function PreviewMapShell({ fetchUserAvatar(); }, []); + const hasWikiSidebar = Boolean(activeEntity || activeWiki || isWikiLoading || wikiError); + const menuOptionStyle: CSSProperties = { width: 46, height: 46, @@ -172,7 +178,7 @@ export default function PreviewMapShell({ onHoverFeatureChange={onHoverFeatureChange} onPlayPreviewReplay={onPlayPreviewReplay} onLoad={onLoad} - showViewportControls={!isMobileOrTablet} + showViewportControls={showViewportControls && !isMobileOrTablet} height="100svh" /> @@ -207,7 +213,7 @@ export default function PreviewMapShell({ style={{ position: "absolute", top: 10, - bottom: ((activeEntity || activeWiki) && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20, + bottom: (hasWikiSidebar && isMobileOrTablet) ? `${(sidebarHeight || 400) + 20}px` : 20, left: 18, zIndex: 18, display: "flex", @@ -412,7 +418,7 @@ export default function PreviewMapShell({ {overlay} - {activeEntity || activeWiki ? ( + {hasWikiSidebar ? (