From b3d2f56797757e2bcb08d80b92f5c8b76ac10e28 Mon Sep 17 00:00:00 2001 From: taDuc Date: Wed, 27 May 2026 13:58:36 +0700 Subject: [PATCH] refactor: pre serve route / --- src/app/editor/[id]/page.tsx | 162 +++--------- src/app/page.tsx | 123 ++------- src/uhm/components/preview/PreviewLayout.tsx | 51 ++-- src/uhm/lib/preview/relationIndex.ts | 249 +++++++++++++++++++ src/uhm/lib/preview/types.ts | 28 +++ 5 files changed, 362 insertions(+), 251 deletions(-) create mode 100644 src/uhm/lib/preview/relationIndex.ts create mode 100644 src/uhm/lib/preview/types.ts diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 08522e0..a673339 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -10,7 +10,7 @@ import TimelineBar from "@/uhm/components/ui/TimelineBar"; import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel"; import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar"; import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar"; -import PreviewLayout from "@/uhm/components/preview/PreviewLayout"; +import PreviewLayout, { type PreviewLayoutHandle } from "@/uhm/components/preview/PreviewLayout"; import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel"; @@ -81,6 +81,10 @@ import { normalizeReplaysForCompare, normalizeWikisForCompare, } from "@/uhm/lib/editor/editorPageUtils"; +import { + buildEntityLabelContextDraft as buildPreviewEntityLabelContextDraft, + buildSnapshotPreviewRelationIndex, +} from "@/uhm/lib/preview/relationIndex"; const CURRENT_YEAR = new Date().getUTCFullYear(); const DEFAULT_EDITOR_USER_ID = "local-editor"; @@ -99,18 +103,6 @@ type ReplayPreviewSession = { mapViewState: ReturnType; }; -type PreviewRelationIndex = { - entitiesById: Record; - entityGeometriesById: Record; - entityWikisById: Record; - geometryEntityIds: Record; - wikiEntityIdsById: Record; - wikiEntityIdsBySlug: Record; - wikiById: Record; - wikiBySlug: Record; -}; - - export default function Page() { return ( (null); + const previewLayoutRef = useRef(null); // Responsive listener for preview sidebar/viewport offsets useEffect(() => { @@ -601,7 +593,7 @@ function EditorPageContent() { const [previewWikiCache, setPreviewWikiCache] = useState>({}); const previewRelations = useMemo(() => { - return buildPreviewRelationIndex({ + return buildSnapshotPreviewRelationIndex({ draft: previewSession?.draft || EMPTY_FEATURE_COLLECTION, entities: previewSession?.entities || [], wikis: previewSession?.wikis || [], @@ -635,12 +627,12 @@ function EditorPageContent() { const activeTimelineYear = isReplayPreviewMode ? replayPreviewTimelineYear : isViewerPreviewMode - ? previewSession?.timelineYear ?? timelineDraftYear + ? replayPreviewTimelineYear : timelineDraftYear; const activeTimelineFilterEnabled = isReplayPreviewMode ? replayPreviewTimelineFilterEnabled : isViewerPreviewMode - ? previewSession?.timelineFilterEnabled ?? timelineFilterEnabled + ? replayPreviewTimelineFilterEnabled : timelineFilterEnabled; // Render draft is the only FeatureCollection that decides what appears on the map. @@ -817,12 +809,30 @@ function EditorPageContent() { const activeMapDraft = useMemo(() => { if (isAnyPreviewMode) { - return isReplayPreviewMode + const previewDraft = isReplayPreviewMode ? replayPreviewDraft : (previewSession?.draft || EMPTY_FEATURE_COLLECTION); + if (!activeTimelineFilterEnabled) { + return previewDraft; + } + const safeYear = clampYearToFixedRange(Math.trunc(activeTimelineYear)); + return { + ...previewDraft, + features: previewDraft.features.filter((feature) => + isFeatureVisibleAtYear(feature, safeYear) + ), + }; } return mapRenderDraft; - }, [isAnyPreviewMode, isReplayPreviewMode, replayPreviewDraft, previewSession?.draft, mapRenderDraft]); + }, [ + activeTimelineFilterEnabled, + activeTimelineYear, + isAnyPreviewMode, + isReplayPreviewMode, + mapRenderDraft, + previewSession?.draft, + replayPreviewDraft, + ]); const localFeatureIds = useMemo(() => { const ids = new Set(); @@ -2420,7 +2430,7 @@ function EditorPageContent() { const entitiesForLabel = isAnyPreviewMode ? previewSession?.entities || [] : entities; - return buildEntityLabelContextDraft(labelContextBaseDraft, entitiesForLabel); + return buildPreviewEntityLabelContextDraft(labelContextBaseDraft, entitiesForLabel); }, [entities, isAnyPreviewMode, labelContextBaseDraft, previewSession?.entities]); if (blockedPendingSubmissionId) { @@ -2909,118 +2919,6 @@ function readImageAspectRatio(url: string): Promise { }); } -function buildPreviewRelationIndex(options: { - draft: FeatureCollection; - entities: Entity[]; - wikis: WikiSnapshot[]; - entityWikiLinks: EntityWikiLinkSnapshot[]; - wikiCache: Record; - projectId: string; -}): PreviewRelationIndex { - const next: PreviewRelationIndex = { - entitiesById: {}, - entityGeometriesById: {}, - entityWikisById: {}, - geometryEntityIds: {}, - wikiEntityIdsById: {}, - wikiEntityIdsBySlug: {}, - wikiById: {}, - wikiBySlug: {}, - }; - - for (const entity of options.entities || []) { - const id = String(entity?.id || "").trim(); - if (!id) continue; - next.entitiesById[id] = entity; - } - - for (const wikiSnapshot of options.wikis || []) { - if (!wikiSnapshot || wikiSnapshot.operation === "delete") continue; - const wiki = snapshotWikiToWiki(wikiSnapshot, options.wikiCache, options.projectId); - if (!wiki?.id) continue; - next.wikiById[wiki.id] = wiki; - const slug = String(wiki.slug || "").trim(); - if (slug) next.wikiBySlug[slug] = wiki; - } - - for (const feature of options.draft.features || []) { - const geometryId = String(feature.properties.id); - for (const entityId of normalizeFeatureEntityIds(feature)) { - if (!next.entitiesById[entityId]) { - next.entitiesById[entityId] = { id: entityId, name: entityId }; - } - pushUniqueString(next.geometryEntityIds, geometryId, entityId); - if (!next.entityGeometriesById[entityId]) { - next.entityGeometriesById[entityId] = { type: "FeatureCollection", features: [] }; - } - if (!next.entityGeometriesById[entityId].features.some((item) => String(item.properties.id) === geometryId)) { - next.entityGeometriesById[entityId].features.push(feature); - } - } - } - - for (const link of options.entityWikiLinks || []) { - if (!link || link.operation === "delete") continue; - const entityId = String(link.entity_id || "").trim(); - const wikiId = String(link.wiki_id || "").trim(); - const entity = next.entitiesById[entityId] || null; - const wiki = next.wikiById[wikiId] || null; - if (!entity || !wiki) continue; - - if (!next.entityWikisById[entityId]) next.entityWikisById[entityId] = []; - if (!next.entityWikisById[entityId].some((item) => item.id === wiki.id)) { - next.entityWikisById[entityId].push(wiki); - } - - pushUniqueString(next.wikiEntityIdsById, wiki.id, entityId); - const slug = String(wiki.slug || "").trim(); - if (slug) pushUniqueString(next.wikiEntityIdsBySlug, slug, entityId); - } - - normalizeRelationArrays(next.geometryEntityIds); - normalizeRelationArrays(next.wikiEntityIdsById); - normalizeRelationArrays(next.wikiEntityIdsBySlug); - return next; -} - -function snapshotWikiToWiki(snapshot: WikiSnapshot, wikiCache: Record, projectId: string): Wiki { - if (typeof snapshot.doc === "string") { - return { - id: snapshot.id, - project_id: projectId, - title: snapshot.title, - slug: snapshot.slug ?? null, - content: snapshot.doc || "", - }; - } - - return wikiCache[snapshot.id] || { - id: snapshot.id, - project_id: projectId, - title: snapshot.title, - slug: snapshot.slug ?? null, - content: "", - }; -} - - -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 isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; const tagName = target.tagName.toLowerCase(); diff --git a/src/app/page.tsx b/src/app/page.tsx index 0acf540..75b86ef 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -25,21 +25,20 @@ import { EMPTY_FEATURE_COLLECTION, WORLD_BBOX } from "@/uhm/lib/map/geo/constant 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 RelationIndex = { - entitiesById: Record; - entityGeometriesById: Record; - entityWikisById: Record; - geometryEntityIds: Record; - wikiEntityIdsBySlug: Record; - wikiBySlug: Record; -}; - type LinkEntityPopupState = { slug: string; entities: Entity[]; @@ -49,15 +48,6 @@ type LinkEntityPopupState = { type CachedWiki = Wiki & { __fetched?: boolean }; -const EMPTY_RELATIONS: RelationIndex = { - entitiesById: {}, - entityGeometriesById: {}, - entityWikisById: {}, - geometryEntityIds: {}, - wikiEntityIdsBySlug: {}, - wikiBySlug: {}, -}; - export default function Page() { const [data, setData] = useState(EMPTY_FEATURE_COLLECTION); const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]); @@ -75,7 +65,7 @@ export default function Page() { for (const key of GEO_TYPE_KEYS) init[key] = true; return init; }); - const [relations, setRelations] = useState(EMPTY_RELATIONS); + 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 }>({ @@ -192,18 +182,9 @@ export default function Page() { const entities = await fetchAllEntities(); if (disposed) return; - const next: RelationIndex = { - entitiesById: {}, - entityGeometriesById: {}, - entityWikisById: {}, - geometryEntityIds: {}, - wikiEntityIdsBySlug: {}, - wikiBySlug: {}, - }; + const entityGeometriesById: Record = {}; + const entityWikisById: Record = {}; - for (const entity of entities) { - next.entitiesById[entity.id] = entity; - } setRelationsProgress({ completed: 0, total: entities.length }); @@ -214,19 +195,8 @@ export default function Page() { ]); 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); - } + entityGeometriesById[entity.id] = geometries; + entityWikisById[entity.id] = wikis; const completed = index + 1; if (completed === entities.length || completed % 5 === 0) { @@ -236,8 +206,11 @@ export default function Page() { if (disposed) return; - normalizeRelationArrays(next.geometryEntityIds); - normalizeRelationArrays(next.wikiEntityIdsBySlug); + const next = buildPublicPreviewRelationIndex({ + entities, + entityGeometriesById, + entityWikisById, + }); setRelations(next); setWikiCache((prev) => ({ ...next.wikiBySlug, ...prev })); @@ -275,8 +248,8 @@ export default function Page() { ? relations.entityGeometriesById[activeEntityId] || EMPTY_FEATURE_COLLECTION : EMPTY_FEATURE_COLLECTION; const mapLabelContextDraft = useMemo( - () => buildEntityLabelContextDraft(data, relations.geometryEntityIds, relations.entitiesById), - [data, relations.entitiesById, relations.geometryEntityIds] + () => buildEntityLabelContextDraft(data, relations), + [data, relations] ); const activeWiki = useMemo(() => { @@ -937,62 +910,6 @@ async function mapWithConcurrency( ); } -function pushUniqueString(target: Record, key: string, value: string) { - if (!target[key]) { - target[key] = [value]; - return; - } - if (!target[key].includes(value)) { - target[key].push(value); - } -} - -function normalizeRelationArrays(target: Record) { - for (const key of Object.keys(target)) { - target[key] = Array.from(new Set(target[key])); - } -} - -function buildEntityLabelContextDraft( - draft: FeatureCollection, - geometryEntityIds: Record, - entitiesById: Record -): FeatureCollection { - if (!draft.features.length) return draft; - - return { - ...draft, - features: draft.features.map((feature) => { - const entityIds = geometryEntityIds[String(feature.properties.id)] || []; - if (!entityIds.length) return feature; - - const candidates = entityIds.map((id) => { - const entity = entitiesById[id] || null; - const name = String(entity?.name || id).trim(); - if (!name) return null; - return { - id, - name, - time_start: entity?.time_start ?? null, - time_end: entity?.time_end ?? null, - }; - }).filter((candidate) => candidate !== null); - - return { - ...feature, - properties: { - ...feature.properties, - entity_id: entityIds[0] || null, - entity_ids: entityIds, - entity_name: candidates[0]?.name || null, - entity_names: candidates.map((candidate) => candidate.name), - entity_label_candidates: candidates, - }, - }; - }), - }; -} - function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; if (value < min) return min; diff --git a/src/uhm/components/preview/PreviewLayout.tsx b/src/uhm/components/preview/PreviewLayout.tsx index 4d9556a..e87a710 100644 --- a/src/uhm/components/preview/PreviewLayout.tsx +++ b/src/uhm/components/preview/PreviewLayout.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react"; +import type { RefObject, Dispatch, SetStateAction } from "react"; import { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map"; +import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup"; import PresentPlaceSearch, { type HistoricalGeometryFocusPayload, type PresentPlaceSelection } from "@/uhm/components/editor/PresentPlaceSearch"; import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel"; @@ -10,14 +12,15 @@ import TimelineBar from "@/uhm/components/ui/TimelineBar"; import RelatedEntityPopup from "./RelatedEntityPopup"; import PinnedWikiPopup from "./PinnedWikiPopup"; -import { type Wiki } from "@/uhm/api/wikis"; +import { fetchWikiById, type Wiki } from "@/uhm/api/wikis"; import type { Entity } from "@/uhm/api/entities"; import type { FeatureCollection } from "@/uhm/types/geo"; -import type { BattleReplay } from "@/uhm/types/projects"; +import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import { type BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers"; -import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import type { PreviewRelationIndex } from "@/uhm/lib/preview/types"; +import type { Feature } from "@/uhm/lib/editor/state/useEditorState"; type Props = { projectId: string; @@ -28,36 +31,43 @@ type Props = { replays: BattleReplay[]; entities: Entity[]; wikis: WikiSnapshot[]; + entityWikiLinks?: EntityWikiLinkSnapshot[]; backgroundVisibility: BackgroundLayerVisibility; onBackgroundVisibilityChange: (vis: BackgroundLayerVisibility) => void; geometryVisibility: Record; onGeometryVisibilityChange: (vis: Record) => void; + viewMode?: "local" | "global"; + onViewModeChange?: (mode: "local" | "global") => void; + globalGeometries?: FeatureCollection; + isGlobalLoading?: boolean; + baseline?: FeatureCollection; activeReplay?: BattleReplay | null; + selectedStageId?: number | null; + selectedStepIndex?: number | null; autoplayMode?: "start" | "selection" | null; replayPreview: any; + mapHandleRef?: RefObject; previewRelations: PreviewRelationIndex; previewActiveEntityId: string | null; setPreviewActiveEntityId: (id: string | null) => void; - setPreviewEntityFocusToken: React.Dispatch>; + previewEntityFocusToken?: number; + setPreviewEntityFocusToken: Dispatch>; previewSidebarWidth: number; - setPreviewSidebarWidth: React.Dispatch>; + setPreviewSidebarWidth: Dispatch>; previewWikiCache: Record; - setPreviewWikiCache: React.Dispatch>>; + setPreviewWikiCache: Dispatch>>; + isLargeScreen?: boolean; + setIsLargeScreen?: Dispatch>; }; -type PreviewRelationIndex = { - entitiesById: Record; - entityGeometriesById: Record; - entityWikisById: Record; - geometryEntityIds: Record; - wikiEntityIdsById: Record; - wikiEntityIdsBySlug: Record; - wikiById: Record; - wikiBySlug: Record; +export type PreviewLayoutHandle = { + handleFeatureClick: (payload: MapFeaturePayload | null) => void; + getHoverPopupContent: (feature: Feature) => MapHoverPopupContent | null; + handlePlaySelectedReplay: (replay: BattleReplay) => void; }; -const PreviewLayout = forwardRef(({ +const PreviewLayout = forwardRef(({ projectId, mode, onModeChange, @@ -636,6 +646,8 @@ const PreviewLayout = forwardRef(({ isLoading={false} disabled={isReplayPreviewMode} statusText={null} + filterEnabled={replayPreview.timelineFilterEnabled} + onFilterEnabledChange={replayPreview.setTimelineFilterEnabled} style={ isReplayPreviewWikiSidebarOpen ? { right: `${previewSidebarWidth + 32}px` } @@ -704,3 +716,10 @@ function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) return { top, left }; } + +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; +} diff --git a/src/uhm/lib/preview/relationIndex.ts b/src/uhm/lib/preview/relationIndex.ts new file mode 100644 index 0000000..3a451b1 --- /dev/null +++ b/src/uhm/lib/preview/relationIndex.ts @@ -0,0 +1,249 @@ +import type { Entity } from "@/uhm/api/entities"; +import type { Wiki } from "@/uhm/api/wikis"; +import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot"; +import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline"; +import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; +import type { FeatureCollection } from "@/uhm/types/geo"; +import type { WikiSnapshot } from "@/uhm/types/wiki"; +import type { PreviewRelationIndex } from "./types"; + +export function buildSnapshotPreviewRelationIndex(options: { + draft: FeatureCollection; + entities: Entity[]; + wikis: WikiSnapshot[]; + entityWikiLinks: EntityWikiLinkSnapshot[]; + wikiCache: Record; + projectId: string; +}): PreviewRelationIndex { + const next = createEmptyPreviewRelationIndex(); + + for (const entity of options.entities || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + next.entitiesById[id] = entity; + } + + for (const wikiSnapshot of options.wikis || []) { + if (!wikiSnapshot || wikiSnapshot.operation === "delete") continue; + const wiki = snapshotWikiToWiki(wikiSnapshot, options.wikiCache, options.projectId); + if (!wiki?.id) continue; + next.wikiById[wiki.id] = wiki; + const slug = String(wiki.slug || "").trim(); + if (slug) next.wikiBySlug[slug] = wiki; + } + + for (const feature of options.draft.features || []) { + const geometryId = String(feature.properties.id); + for (const entityId of normalizeFeatureEntityIds(feature)) { + if (!next.entitiesById[entityId]) { + next.entitiesById[entityId] = { id: entityId, name: entityId }; + } + pushUniqueString(next.geometryEntityIds, geometryId, entityId); + pushFeatureForEntity(next, entityId, feature); + } + } + + for (const link of options.entityWikiLinks || []) { + if (!link || link.operation === "delete") continue; + const entityId = String(link.entity_id || "").trim(); + const wikiId = String(link.wiki_id || "").trim(); + const entity = next.entitiesById[entityId] || null; + const wiki = next.wikiById[wikiId] || null; + if (!entity || !wiki) continue; + + pushWikiForEntity(next, entityId, wiki); + } + + normalizePreviewRelationArrays(next); + return next; +} + +export function buildPublicPreviewRelationIndex(options: { + entities: Entity[]; + entityGeometriesById: Record; + entityWikisById: Record; +}): PreviewRelationIndex { + const next = createEmptyPreviewRelationIndex(); + + for (const entity of options.entities || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + next.entitiesById[id] = entity; + } + + for (const [entityId, geometries] of Object.entries(options.entityGeometriesById || {})) { + const id = String(entityId || "").trim(); + if (!id) continue; + if (!next.entitiesById[id]) next.entitiesById[id] = { id, name: id }; + + for (const feature of geometries.features || []) { + const geometryId = String(feature.properties.id); + pushUniqueString(next.geometryEntityIds, geometryId, id); + pushFeatureForEntity(next, id, feature); + } + } + + for (const [entityId, wikis] of Object.entries(options.entityWikisById || {})) { + const id = String(entityId || "").trim(); + if (!id) continue; + if (!next.entitiesById[id]) next.entitiesById[id] = { id, name: id }; + + for (const wiki of wikis || []) { + if (!wiki?.id) continue; + next.wikiById[wiki.id] = wiki; + const slug = String(wiki.slug || "").trim(); + if (slug) next.wikiBySlug[slug] = wiki; + pushWikiForEntity(next, id, wiki); + } + } + + normalizePreviewRelationArrays(next); + return next; +} + +export function buildEntityLabelContextDraft( + draft: FeatureCollection, + relationsOrEntities: PreviewRelationIndex | Entity[] +): FeatureCollection { + if (!draft.features.length) return draft; + + const resolveEntityIds = Array.isArray(relationsOrEntities) + ? (feature: FeatureCollection["features"][number]) => normalizeFeatureEntityIds(feature) + : (feature: FeatureCollection["features"][number]) => + relationsOrEntities.geometryEntityIds[String(feature.properties.id)] || normalizeFeatureEntityIds(feature); + + const entityById = new globalThis.Map(); + if (Array.isArray(relationsOrEntities)) { + for (const entity of relationsOrEntities || []) { + const id = String(entity?.id || "").trim(); + if (!id) continue; + entityById.set(id, entity); + } + } else { + for (const [id, entity] of Object.entries(relationsOrEntities.entitiesById)) { + if (!id || !entity) continue; + entityById.set(id, entity); + } + } + + return { + ...draft, + features: draft.features.map((feature) => { + const entityIds = resolveEntityIds(feature); + if (!entityIds.length) return feature; + + const candidates = entityIds.map((id) => { + const entity = entityById.get(id) || null; + const name = String(entity?.name || id).trim(); + if (!name) return null; + return { + id, + name, + time_start: normalizeTimelineYearValue(entity?.time_start), + time_end: normalizeTimelineYearValue(entity?.time_end), + }; + }).filter((candidate) => candidate !== null); + + return { + ...feature, + properties: { + ...feature.properties, + entity_id: entityIds[0] || null, + entity_ids: entityIds, + entity_name: candidates[0]?.name || null, + entity_names: candidates.map((candidate) => candidate.name), + entity_label_candidates: candidates, + }, + }; + }), + }; +} + +export function createEmptyPreviewRelationIndex(): PreviewRelationIndex { + return { + entitiesById: {}, + entityGeometriesById: {}, + entityWikisById: {}, + geometryEntityIds: {}, + wikiEntityIdsById: {}, + wikiEntityIdsBySlug: {}, + wikiById: {}, + wikiBySlug: {}, + }; +} + +export function pushUniqueString(target: Record, key: string, value: string) { + if (!target[key]) { + target[key] = [value]; + return; + } + if (!target[key].includes(value)) { + target[key].push(value); + } +} + +export function normalizePreviewRelationArrays(target: PreviewRelationIndex | Record) { + if (isPreviewRelationIndex(target)) { + normalizeRecordArrays(target.geometryEntityIds); + normalizeRecordArrays(target.wikiEntityIdsById); + normalizeRecordArrays(target.wikiEntityIdsBySlug); + return; + } + normalizeRecordArrays(target); +} + +function pushFeatureForEntity( + target: PreviewRelationIndex, + entityId: string, + feature: FeatureCollection["features"][number] +) { + if (!target.entityGeometriesById[entityId]) { + target.entityGeometriesById[entityId] = { type: "FeatureCollection", features: [] }; + } + const geometryId = String(feature.properties.id); + if (!target.entityGeometriesById[entityId].features.some((item) => String(item.properties.id) === geometryId)) { + target.entityGeometriesById[entityId].features.push(feature); + } +} + +function pushWikiForEntity(target: PreviewRelationIndex, entityId: string, wiki: Wiki) { + if (!target.entityWikisById[entityId]) target.entityWikisById[entityId] = []; + if (!target.entityWikisById[entityId].some((item) => item.id === wiki.id)) { + target.entityWikisById[entityId].push(wiki); + } + + pushUniqueString(target.wikiEntityIdsById, wiki.id, entityId); + const slug = String(wiki.slug || "").trim(); + if (slug) pushUniqueString(target.wikiEntityIdsBySlug, slug, entityId); +} + +function snapshotWikiToWiki(snapshot: WikiSnapshot, wikiCache: Record, projectId: string): Wiki { + if (typeof snapshot.doc === "string") { + return { + id: snapshot.id, + project_id: projectId, + title: snapshot.title, + slug: snapshot.slug ?? null, + content: snapshot.doc || "", + }; + } + + return wikiCache[snapshot.id] || { + id: snapshot.id, + project_id: projectId, + title: snapshot.title, + slug: snapshot.slug ?? null, + content: "", + }; +} + +function normalizeRecordArrays(target: Record) { + for (const key of Object.keys(target)) { + target[key] = Array.from(new Set(target[key])); + } +} + +function isPreviewRelationIndex(value: PreviewRelationIndex | Record): value is PreviewRelationIndex { + return "geometryEntityIds" in value && "wikiEntityIdsById" in value; +} + diff --git a/src/uhm/lib/preview/types.ts b/src/uhm/lib/preview/types.ts new file mode 100644 index 0000000..52ee96a --- /dev/null +++ b/src/uhm/lib/preview/types.ts @@ -0,0 +1,28 @@ +import type { Entity } from "@/uhm/api/entities"; +import type { Wiki } from "@/uhm/api/wikis"; +import type { FeatureCollection } from "@/uhm/types/geo"; + +export type PreviewDataScope = "project-snapshot" | "public-atlas"; + +export type PreviewRelationIndex = { + entitiesById: Record; + entityGeometriesById: Record; + entityWikisById: Record; + geometryEntityIds: Record; + wikiEntityIdsById: Record; + wikiEntityIdsBySlug: Record; + wikiById: Record; + wikiBySlug: Record; +}; + +export const EMPTY_PREVIEW_RELATIONS: PreviewRelationIndex = { + entitiesById: {}, + entityGeometriesById: {}, + entityWikisById: {}, + geometryEntityIds: {}, + wikiEntityIdsById: {}, + wikiEntityIdsBySlug: {}, + wikiById: {}, + wikiBySlug: {}, +}; +