refactor: pre serve route /

This commit is contained in:
taDuc
2026-05-27 13:58:36 +07:00
parent 4c60e2d773
commit b3d2f56797
5 changed files with 362 additions and 251 deletions
+30 -132
View File
@@ -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<MapHandle["getViewState"]>;
};
type PreviewRelationIndex = {
entitiesById: Record<string, Entity>;
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
geometryEntityIds: Record<string, string[]>;
wikiEntityIdsById: Record<string, string[]>;
wikiEntityIdsBySlug: Record<string, string[]>;
wikiById: Record<string, Wiki>;
wikiBySlug: Record<string, Wiki>;
};
export default function Page() {
return (
<EditorStoreProvider
@@ -547,7 +539,7 @@ function EditorPageContent() {
return 420;
});
const [isLargeScreen, setIsLargeScreen] = useState(false);
const previewLayoutRef = useRef<any>(null);
const previewLayoutRef = useRef<PreviewLayoutHandle | null>(null);
// Responsive listener for preview sidebar/viewport offsets
useEffect(() => {
@@ -601,7 +593,7 @@ function EditorPageContent() {
const [previewWikiCache, setPreviewWikiCache] = useState<Record<string, Wiki>>({});
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<string | number>();
@@ -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<number> {
});
}
function buildPreviewRelationIndex(options: {
draft: FeatureCollection;
entities: Entity[];
wikis: WikiSnapshot[];
entityWikiLinks: EntityWikiLinkSnapshot[];
wikiCache: Record<string, Wiki>;
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<string, Wiki>, 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<string, string[]>, key: string, value: string) {
if (!target[key]) {
target[key] = [value];
return;
}
if (!target[key].includes(value)) {
target[key].push(value);
}
}
function normalizeRelationArrays(target: Record<string, string[]>) {
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();
+20 -103
View File
@@ -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<string, Entity>;
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
geometryEntityIds: Record<string, string[]>;
wikiEntityIdsBySlug: Record<string, string[]>;
wikiBySlug: Record<string, Wiki>;
};
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<FeatureCollection>(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<RelationIndex>(EMPTY_RELATIONS);
const [relations, setRelations] = useState<PreviewRelationIndex>(EMPTY_PREVIEW_RELATIONS);
const [isRelationsLoading, setIsRelationsLoading] = useState(false);
const [relationsStatus, setRelationsStatus] = useState<string | null>(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<string, FeatureCollection> = {};
const entityWikisById: Record<string, Wiki[]> = {};
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<T>(
);
}
function pushUniqueString(target: Record<string, string[]>, key: string, value: string) {
if (!target[key]) {
target[key] = [value];
return;
}
if (!target[key].includes(value)) {
target[key].push(value);
}
}
function normalizeRelationArrays(target: Record<string, string[]>) {
for (const key of Object.keys(target)) {
target[key] = Array.from(new Set(target[key]));
}
}
function buildEntityLabelContextDraft(
draft: FeatureCollection,
geometryEntityIds: Record<string, string[]>,
entitiesById: Record<string, Entity>
): 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;
+35 -16
View File
@@ -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<string, boolean>;
onGeometryVisibilityChange: (vis: Record<string, boolean>) => 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<MapHandle | null>;
previewRelations: PreviewRelationIndex;
previewActiveEntityId: string | null;
setPreviewActiveEntityId: (id: string | null) => void;
setPreviewEntityFocusToken: React.Dispatch<React.SetStateAction<number>>;
previewEntityFocusToken?: number;
setPreviewEntityFocusToken: Dispatch<SetStateAction<number>>;
previewSidebarWidth: number;
setPreviewSidebarWidth: React.Dispatch<React.SetStateAction<number>>;
setPreviewSidebarWidth: Dispatch<SetStateAction<number>>;
previewWikiCache: Record<string, Wiki>;
setPreviewWikiCache: React.Dispatch<React.SetStateAction<Record<string, Wiki>>>;
setPreviewWikiCache: Dispatch<SetStateAction<Record<string, Wiki>>>;
isLargeScreen?: boolean;
setIsLargeScreen?: Dispatch<SetStateAction<boolean>>;
};
type PreviewRelationIndex = {
entitiesById: Record<string, Entity>;
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
geometryEntityIds: Record<string, string[]>;
wikiEntityIdsById: Record<string, string[]>;
wikiEntityIdsBySlug: Record<string, string[]>;
wikiById: Record<string, Wiki>;
wikiBySlug: Record<string, Wiki>;
export type PreviewLayoutHandle = {
handleFeatureClick: (payload: MapFeaturePayload | null) => void;
getHoverPopupContent: (feature: Feature) => MapHoverPopupContent | null;
handlePlaySelectedReplay: (replay: BattleReplay) => void;
};
const PreviewLayout = forwardRef<any, Props>(({
const PreviewLayout = forwardRef<PreviewLayoutHandle, Props>(({
projectId,
mode,
onModeChange,
@@ -636,6 +646,8 @@ const PreviewLayout = forwardRef<any, Props>(({
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;
}
+249
View File
@@ -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<string, Wiki>;
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<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
}): 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<string, Entity>();
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<string, string[]>, 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<string, string[]>) {
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<string, Wiki>, 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<string, string[]>) {
for (const key of Object.keys(target)) {
target[key] = Array.from(new Set(target[key]));
}
}
function isPreviewRelationIndex(value: PreviewRelationIndex | Record<string, string[]>): value is PreviewRelationIndex {
return "geometryEntityIds" in value && "wikiEntityIdsById" in value;
}
+28
View File
@@ -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<string, Entity>;
entityGeometriesById: Record<string, FeatureCollection>;
entityWikisById: Record<string, Wiki[]>;
geometryEntityIds: Record<string, string[]>;
wikiEntityIdsById: Record<string, string[]>;
wikiEntityIdsBySlug: Record<string, string[]>;
wikiById: Record<string, Wiki>;
wikiBySlug: Record<string, Wiki>;
};
export const EMPTY_PREVIEW_RELATIONS: PreviewRelationIndex = {
entitiesById: {},
entityGeometriesById: {},
entityWikisById: {},
geometryEntityIds: {},
wikiEntityIdsById: {},
wikiEntityIdsBySlug: {},
wikiById: {},
wikiBySlug: {},
};