feat: add timeline filter support to replay dispatcher and clean up unused dependencies
This commit is contained in:
@@ -3,27 +3,23 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import Map, { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map";
|
||||
import Map, { type MapHandle } from "@/uhm/components/Map";
|
||||
import Editor from "@/uhm/components/Editor";
|
||||
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
||||
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 ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
|
||||
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
|
||||
import PreviewLayout from "@/uhm/components/preview/PreviewLayout";
|
||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||
import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
|
||||
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
|
||||
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
|
||||
import GeometryBindingPanel from "@/uhm/components/editor/GeometryBindingPanel";
|
||||
import ImageOverlayPanel from "@/uhm/components/editor/ImageOverlayPanel";
|
||||
import PresentPlaceSearch, { type HistoricalGeometryFocusPayload, type PresentPlaceSelection } from "@/uhm/components/editor/PresentPlaceSearch";
|
||||
import { Entity, fetchEntities, searchEntitiesByName } from "@/uhm/api/entities";
|
||||
import { ApiError } from "@/uhm/api/http";
|
||||
import { fetchCurrentUser } from "@/uhm/api/auth";
|
||||
import { fetchWikiById, searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import { searchWikisByTitle, type Wiki } from "@/uhm/api/wikis";
|
||||
import { searchGeometriesByEntityName, fetchGeometriesByBBox, type EntityGeometriesSearchItem, type EntityGeometrySearchGeo } from "@/uhm/api/geometries";
|
||||
import { WORLD_BBOX } from "@/uhm/lib/map/geo/constants";
|
||||
import {
|
||||
@@ -50,7 +46,6 @@ import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
import {
|
||||
loadBackgroundLayerVisibilityFromStorage,
|
||||
persistBackgroundLayerVisibility,
|
||||
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
|
||||
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
|
||||
@@ -86,7 +81,6 @@ import {
|
||||
normalizeReplaysForCompare,
|
||||
normalizeWikisForCompare,
|
||||
} from "@/uhm/lib/editor/editorPageUtils";
|
||||
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
|
||||
|
||||
const CURRENT_YEAR = new Date().getUTCFullYear();
|
||||
const DEFAULT_EDITOR_USER_ID = "local-editor";
|
||||
@@ -116,12 +110,6 @@ type PreviewRelationIndex = {
|
||||
wikiBySlug: Record<string, Wiki>;
|
||||
};
|
||||
|
||||
type PreviewLinkEntityPopupState = {
|
||||
slug: string;
|
||||
entities: Entity[];
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
@@ -542,27 +530,6 @@ function EditorPageContent() {
|
||||
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
|
||||
}, [setTimelineDraftYear]);
|
||||
|
||||
const handleViewerPreviewTimelineYearChange = useCallback((nextYear: number) => {
|
||||
setPreviewSession((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
timelineYear: clampYearToFixedRange(Math.trunc(nextYear)),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleViewerPreviewTimelineFilterChange = useCallback((enabled: boolean) => {
|
||||
setPreviewSession((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
timelineFilterEnabled: enabled,
|
||||
}
|
||||
: prev
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Preview specific UI states
|
||||
const [previewActiveEntityId, setPreviewActiveEntityId] = useState<string | null>(null);
|
||||
@@ -613,9 +580,7 @@ function EditorPageContent() {
|
||||
hiddenGeometryIds: replayPreviewHiddenGeometryIds,
|
||||
timelineYear: replayPreviewTimelineYear,
|
||||
timelineFilterEnabled: replayPreviewTimelineFilterEnabled,
|
||||
activeCursor: replayPreviewActiveCursor,
|
||||
activeWikiId: replayPreviewActiveWikiId,
|
||||
sidebarOpen: replayPreviewSidebarOpen,
|
||||
} = replayPreview;
|
||||
|
||||
// Draft hiển thị trong preview có thể ẩn bớt geometry theo action replay.
|
||||
@@ -661,12 +626,6 @@ function EditorPageContent() {
|
||||
return activeWikiEntityIds[0] || previewActiveEntityId;
|
||||
}, [previewActiveEntityId, previewRelations.wikiEntityIdsById, replayPreviewActiveWikiId]);
|
||||
|
||||
const replayPreviewActiveEntity = useMemo(() => {
|
||||
return replayPreviewActiveEntityId
|
||||
? previewRelations.entitiesById[replayPreviewActiveEntityId] || null
|
||||
: null;
|
||||
}, [replayPreviewActiveEntityId, previewRelations.entitiesById]);
|
||||
|
||||
const replayPreviewActiveEntityGeometries = useMemo(() => {
|
||||
return replayPreviewActiveEntityId
|
||||
? previewRelations.entityGeometriesById[replayPreviewActiveEntityId] || EMPTY_FEATURE_COLLECTION
|
||||
@@ -3044,25 +3003,6 @@ function snapshotWikiToWiki(snapshot: WikiSnapshot, wikiCache: Record<string, Wi
|
||||
};
|
||||
}
|
||||
|
||||
function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
const blockquoteMatch = content.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i);
|
||||
const rawText = blockquoteMatch?.[1]?.trim() || "";
|
||||
if (!rawText) return "";
|
||||
|
||||
return rawText
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pushUniqueString(target: Record<string, string[]>, key: string, value: string) {
|
||||
if (!target[key]) {
|
||||
@@ -3080,20 +3020,6 @@ function normalizeRelationArrays(target: Record<string, string[]>) {
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
|
||||
@@ -41,7 +41,23 @@ export function Panel({
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span>{title}</span>
|
||||
{badge && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
background: "#374151",
|
||||
color: "#cbd5e1",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
width: 22,
|
||||
|
||||
@@ -108,11 +108,6 @@ const uiSimpleOptionValues: UIOptionName[] = [
|
||||
"zoom_panel",
|
||||
];
|
||||
|
||||
const uiInputOptionValues: UIOptionName[] = [
|
||||
"wiki",
|
||||
"toast",
|
||||
];
|
||||
|
||||
const mapCameraOptionChoices: Array<{ label: string; value: MapCameraOptionName }> = [
|
||||
{ label: "LngLat", value: "center" },
|
||||
{ label: "Zoom", value: "zoom" },
|
||||
@@ -758,8 +753,6 @@ function MapFunctionShortcutPanel({
|
||||
currentTimelineYear: number;
|
||||
onAppendActions: (actions: ReplayAction<MapFunctionName>[], label: string) => void;
|
||||
}) {
|
||||
const safeYear = Math.trunc(currentTimelineYear);
|
||||
|
||||
return (
|
||||
<Panel title="Map Functions" defaultOpen>
|
||||
<div style={{ display: "grid", gap: 10 }}>
|
||||
@@ -819,7 +812,6 @@ function GeoFunctionShortcutPanel({
|
||||
}) {
|
||||
const selectedIds = selectedGeometries.map((item) => item.id);
|
||||
const selectedCount = selectedIds.length;
|
||||
const firstId = selectedIds[0] || "";
|
||||
const hasSelection = selectedCount > 0;
|
||||
|
||||
return (
|
||||
@@ -1538,24 +1530,6 @@ function asString(value: unknown) {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function toInputNumber(value: unknown, fallback: string) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "string" && value.trim().length) return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown) {
|
||||
const raw = asString(value).trim();
|
||||
if (!raw.length) return undefined;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function toNumberOr(value: unknown, fallback: number) {
|
||||
const parsed = toOptionalNumber(value);
|
||||
return parsed == null ? fallback : parsed;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
@@ -1563,26 +1537,6 @@ function toStringArray(value: unknown): string[] {
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function emptyToNull(value: string) {
|
||||
return value.trim().length ? value : null;
|
||||
}
|
||||
|
||||
function emptyToUndefined(value: string) {
|
||||
return value.trim().length ? value : undefined;
|
||||
}
|
||||
|
||||
function compactTrailingUndefined(values: unknown[]) {
|
||||
const next = [...values];
|
||||
while (next.length > 0 && next[next.length - 1] === undefined) {
|
||||
next.pop();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeSelectValue(value: string, fallback: string) {
|
||||
return value.trim().length ? value : fallback;
|
||||
}
|
||||
|
||||
function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffectsDraftState {
|
||||
const selected = buildEmptyUiOptionSelection();
|
||||
const visible = buildDefaultUiVisibilityState();
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react";
|
||||
import EditorMap, { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map";
|
||||
import { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map";
|
||||
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";
|
||||
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
|
||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
|
||||
import RelatedEntityPopup from "./RelatedEntityPopup";
|
||||
import PinnedWikiPopup from "./PinnedWikiPopup";
|
||||
|
||||
import { fetchWikiById, type Wiki } from "@/uhm/api/wikis";
|
||||
import { type Wiki } from "@/uhm/api/wikis";
|
||||
import type { Entity } from "@/uhm/api/entities";
|
||||
import type { Feature, FeatureCollection } from "@/uhm/types/geo";
|
||||
import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import type { BattleReplay } from "@/uhm/types/projects";
|
||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||
import { type BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
|
||||
import { persistBackgroundLayerVisibility } from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
|
||||
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
|
||||
import {
|
||||
clampNumber,
|
||||
isFeatureVisibleAtYear,
|
||||
} from "@/uhm/lib/editor/editorPageUtils";
|
||||
import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import { normalizeTimelineYearValue } from "@/uhm/lib/utils/timeline";
|
||||
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
|
||||
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
@@ -37,32 +28,20 @@ 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: React.RefObject<MapHandle | null>;
|
||||
previewRelations: PreviewRelationIndex;
|
||||
previewActiveEntityId: string | null;
|
||||
setPreviewActiveEntityId: (id: string | null) => void;
|
||||
previewEntityFocusToken: number;
|
||||
setPreviewEntityFocusToken: React.Dispatch<React.SetStateAction<number>>;
|
||||
previewSidebarWidth: number;
|
||||
setPreviewSidebarWidth: React.Dispatch<React.SetStateAction<number>>;
|
||||
isLargeScreen: boolean;
|
||||
setIsLargeScreen: (isLarge: boolean) => void;
|
||||
previewWikiCache: Record<string, Wiki>;
|
||||
setPreviewWikiCache: React.Dispatch<React.SetStateAction<Record<string, Wiki>>>;
|
||||
};
|
||||
@@ -83,36 +62,21 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
mode,
|
||||
onModeChange,
|
||||
onExitPreview,
|
||||
draft,
|
||||
replays,
|
||||
entities,
|
||||
wikis,
|
||||
entityWikiLinks,
|
||||
backgroundVisibility,
|
||||
onBackgroundVisibilityChange,
|
||||
geometryVisibility,
|
||||
onGeometryVisibilityChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
globalGeometries = EMPTY_FEATURE_COLLECTION,
|
||||
isGlobalLoading = false,
|
||||
baseline = EMPTY_FEATURE_COLLECTION,
|
||||
activeReplay,
|
||||
selectedStageId = null,
|
||||
selectedStepIndex = null,
|
||||
autoplayMode = null,
|
||||
|
||||
replayPreview,
|
||||
mapHandleRef,
|
||||
previewRelations,
|
||||
previewActiveEntityId,
|
||||
setPreviewActiveEntityId,
|
||||
previewEntityFocusToken,
|
||||
setPreviewEntityFocusToken,
|
||||
previewSidebarWidth,
|
||||
setPreviewSidebarWidth,
|
||||
isLargeScreen,
|
||||
setIsLargeScreen,
|
||||
previewWikiCache,
|
||||
setPreviewWikiCache,
|
||||
}: Props, ref) => {
|
||||
@@ -167,9 +131,7 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
}, [autoplayMode, isReplayPreviewMode, currentActiveReplay, replayPreview]);
|
||||
|
||||
const {
|
||||
hiddenGeometryIds: replayPreviewHiddenGeometryIds,
|
||||
timelineYear: replayPreviewTimelineYear,
|
||||
timelineFilterEnabled: replayPreviewTimelineFilterEnabled,
|
||||
resetPreview: resetReplayPreview,
|
||||
playbackSpeed: replayPreviewPlaybackSpeed,
|
||||
activeCursor: replayPreviewActiveCursor,
|
||||
@@ -181,7 +143,6 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
|
||||
// Timeline bar parameters
|
||||
const activeTimelineYear = isReplayPreviewMode ? replayPreviewTimelineYear : replayPreviewTimelineYear;
|
||||
const activeTimelineFilterEnabled = isReplayPreviewMode ? replayPreviewTimelineFilterEnabled : true;
|
||||
|
||||
// Timeline bar visibility
|
||||
const timelineBarVisible = !isReplayPreviewMode || replayPreview.timelineVisible;
|
||||
@@ -302,15 +263,9 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
? previewRelations.entitiesById[replayPreviewActiveEntityId] || null
|
||||
: null;
|
||||
|
||||
const replayPreviewActiveEntityGeometries = replayPreviewActiveEntityId
|
||||
? previewRelations.entityGeometriesById[replayPreviewActiveEntityId] || EMPTY_FEATURE_COLLECTION
|
||||
: EMPTY_FEATURE_COLLECTION;
|
||||
|
||||
const isReplayPreviewWikiSidebarOpen = mode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen);
|
||||
|
||||
// Selected feature ids
|
||||
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
|
||||
|
||||
// Handle replay preview entity selection
|
||||
const selectReplayPreviewEntity = useCallback((
|
||||
entityId: string,
|
||||
@@ -344,9 +299,6 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
if (options?.focusMap === true) {
|
||||
setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1);
|
||||
}
|
||||
if (options?.selectGeometry && options.sourceFeatureId != null) {
|
||||
setSelectedFeatureIds([options.sourceFeatureId]);
|
||||
}
|
||||
if (nextWiki) {
|
||||
openReplayPreviewWikiPanelById(nextWiki.id);
|
||||
}
|
||||
@@ -354,7 +306,8 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
openReplayPreviewWikiPanelById,
|
||||
previewRelations.entitiesById,
|
||||
previewRelations.entityWikisById,
|
||||
setSelectedFeatureIds,
|
||||
setPreviewActiveEntityId,
|
||||
setPreviewEntityFocusToken,
|
||||
]);
|
||||
|
||||
// Handle close sidebar
|
||||
@@ -364,8 +317,7 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
setIsPreviewEntitySidebarOpen(false);
|
||||
setPreviewWikiError(null);
|
||||
setPreviewLinkEntityPopup(null);
|
||||
setSelectedFeatureIds([]);
|
||||
}, [closeReplayPreviewWikiPanel, setSelectedFeatureIds]);
|
||||
}, [closeReplayPreviewWikiPanel, setPreviewActiveEntityId]);
|
||||
|
||||
// Play selected battle replay
|
||||
const handlePlaySelectedReplay = useCallback((replay: BattleReplay) => {
|
||||
@@ -499,94 +451,7 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
wikis,
|
||||
]);
|
||||
|
||||
// Render Draft geometries builder
|
||||
const replayPreviewDraft = useMemo(() => {
|
||||
const sourceDraft = draft;
|
||||
if (!isReplayPreviewMode || replayPreviewHiddenGeometryIds.length === 0) {
|
||||
return sourceDraft;
|
||||
}
|
||||
const hiddenIds = new Set(replayPreviewHiddenGeometryIds);
|
||||
return {
|
||||
...sourceDraft,
|
||||
features: sourceDraft.features.filter(
|
||||
(feature) => !hiddenIds.has(String(feature.properties.id))
|
||||
),
|
||||
};
|
||||
}, [isReplayPreviewMode, draft, replayPreviewHiddenGeometryIds]);
|
||||
|
||||
const mapRenderDraft = useMemo(() => {
|
||||
if (isReplayPreviewMode) {
|
||||
return replayPreviewDraft;
|
||||
}
|
||||
|
||||
const sourceDraft = draft;
|
||||
if (!activeTimelineFilterEnabled) {
|
||||
return sourceDraft;
|
||||
}
|
||||
|
||||
return {
|
||||
...sourceDraft,
|
||||
features: sourceDraft.features.filter((feature) =>
|
||||
isFeatureVisibleAtYear(feature, activeTimelineYear)
|
||||
),
|
||||
};
|
||||
}, [
|
||||
activeTimelineFilterEnabled,
|
||||
activeTimelineYear,
|
||||
draft,
|
||||
isReplayPreviewMode,
|
||||
replayPreviewDraft,
|
||||
]);
|
||||
|
||||
// Build label context
|
||||
const labelContextBaseDraft = useMemo(() => {
|
||||
if (viewMode === "local") {
|
||||
return draft;
|
||||
}
|
||||
|
||||
const localFeatureIds = new Set<string>();
|
||||
for (const f of draft.features) {
|
||||
if (f.properties?.id != null) {
|
||||
localFeatureIds.add(String(f.properties.id));
|
||||
}
|
||||
}
|
||||
if (baseline && baseline.features) {
|
||||
for (const f of baseline.features) {
|
||||
if (f.properties?.id != null) {
|
||||
localFeatureIds.add(String(f.properties.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mergedFeatures = [...draft.features];
|
||||
for (const globalFeature of globalGeometries.features) {
|
||||
const globalId = globalFeature.properties?.id != null ? String(globalFeature.properties.id) : null;
|
||||
if (globalId === null || !localFeatureIds.has(globalId)) {
|
||||
mergedFeatures.push(globalFeature);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...draft,
|
||||
features: mergedFeatures,
|
||||
};
|
||||
}, [viewMode, draft, baseline, globalGeometries.features]);
|
||||
|
||||
const mapLabelContextDraft = useMemo(() => {
|
||||
return buildEntityLabelContextDraft(labelContextBaseDraft, entities);
|
||||
}, [entities, labelContextBaseDraft]);
|
||||
|
||||
// Replay matching the selected feature
|
||||
const viewerPreviewSelectedReplay = useMemo(() => {
|
||||
if (isReplayPreviewMode || !selectedFeatureIds.length) return null;
|
||||
const selectedGeometryId = String(selectedFeatureIds[0] ?? "").trim();
|
||||
if (!selectedGeometryId.length) return null;
|
||||
return replays.find(
|
||||
(r) =>
|
||||
String(r?.geometry_id || "").trim() === selectedGeometryId &&
|
||||
hasPlayableReplaySteps(r)
|
||||
) || null;
|
||||
}, [isReplayPreviewMode, replays, selectedFeatureIds]);
|
||||
|
||||
// Search and focus place
|
||||
const handleFocusPresentPlace = useCallback((place: PresentPlaceSelection) => {
|
||||
@@ -599,7 +464,6 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
|
||||
const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => {
|
||||
setFocusedPresentPlace(null);
|
||||
setSelectedFeatureIds([payload.geometry.id]);
|
||||
setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1);
|
||||
|
||||
const linkedEntityIds = previewRelations.geometryEntityIds[String(payload.geometry.id)] || [];
|
||||
@@ -610,17 +474,13 @@ const PreviewLayout = forwardRef<any, Props>(({
|
||||
selectGeometry: false,
|
||||
});
|
||||
}
|
||||
}, [previewRelations.geometryEntityIds, selectReplayPreviewEntity]);
|
||||
}, [previewRelations.geometryEntityIds, selectReplayPreviewEntity, setPreviewEntityFocusToken]);
|
||||
|
||||
const effectiveGeometryVisibility = useMemo(() => {
|
||||
return geometryVisibility;
|
||||
}, [geometryVisibility]);
|
||||
|
||||
const handleSetMode = useCallback((m: EditorMode) => {
|
||||
if (m === "preview" || m === "replay_preview") {
|
||||
onModeChange(m);
|
||||
}
|
||||
}, [onModeChange]);
|
||||
|
||||
|
||||
// Popup PinnedWikiPopup rows
|
||||
const previewPinnedWikiPopupRows = useMemo(() => {
|
||||
@@ -810,26 +670,6 @@ export default PreviewLayout;
|
||||
// Helper functions
|
||||
// ==========================================
|
||||
|
||||
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 extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
if (!content) return "";
|
||||
|
||||
@@ -850,22 +690,6 @@ function extractWikiBlockquoteText(content: string | null | undefined): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
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 computeFixedPopupPosition(rect: DOMRect, width: number, height: number) {
|
||||
const margin = 12;
|
||||
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440;
|
||||
@@ -880,127 +704,3 @@ function computeFixedPopupPosition(rect: DOMRect, width: number, height: number)
|
||||
|
||||
return { top, left };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const wikiMap = new Map<string, Wiki>();
|
||||
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 hasPlayableReplaySteps(replay: BattleReplay | null | undefined) {
|
||||
return Boolean(
|
||||
replay?.detail?.some((stage) => Array.isArray(stage?.steps) && stage.steps.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function buildEntityLabelContextDraft(draft: FeatureCollection, entities: Entity[]): FeatureCollection {
|
||||
if (!draft.features.length) return draft;
|
||||
|
||||
const entityById = new globalThis.Map<string, Entity>();
|
||||
for (const entity of entities || []) {
|
||||
const id = String(entity?.id || "").trim();
|
||||
if (!id) continue;
|
||||
entityById.set(id, entity);
|
||||
}
|
||||
|
||||
return {
|
||||
...draft,
|
||||
features: draft.features.map((feature) => {
|
||||
const entityIds = normalizeFeatureEntityIds(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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export const POINT_GEOTYPE_ICON_PATHS: Partial<Record<PointGeotypeId, string>> =
|
||||
port: "/images/mapIcon/point/port.png",
|
||||
};
|
||||
|
||||
type PointIconVariant = "default" | "draft";
|
||||
|
||||
type PointLayerOptions = {
|
||||
iconScale?: number;
|
||||
@@ -42,8 +41,6 @@ const TYPE_MATCH_EXPR: maplibregl.ExpressionSpecification = ["coalesce", ["get",
|
||||
const SELECTED_EXPR: maplibregl.ExpressionSpecification = ["boolean", ["feature-state", "selected"], false];
|
||||
|
||||
const ICON_CANVAS_SIZE = 48;
|
||||
const DRAFT_FILL = "#ef4444";
|
||||
const DRAFT_RIM = "#7f1d1d";
|
||||
const POINT_GEOMETRY_FILTER: maplibregl.ExpressionSpecification = [
|
||||
"any",
|
||||
["==", ["geometry-type"], "Point"],
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { UIOptionName } from "@/uhm/types/projects";
|
||||
|
||||
export const REPLAY_UI_OPTIONS = [
|
||||
"timeline",
|
||||
"layer_panel",
|
||||
"zoom_panel",
|
||||
"wiki",
|
||||
"toast",
|
||||
] as const satisfies UIOptionName[];
|
||||
|
||||
export function normalizeReplayUiOption(value: unknown): UIOptionName | null {
|
||||
return REPLAY_UI_OPTIONS.includes(value as UIOptionName)
|
||||
? value as UIOptionName
|
||||
: null;
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import type maplibregl from "maplibre-gl";
|
||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||
import type {
|
||||
GeoFunctionName,
|
||||
MapFunctionName,
|
||||
NarrativeFunctionName,
|
||||
ReplayAction,
|
||||
UIOptionName,
|
||||
DialogState,
|
||||
} from "@/uhm/types/projects";
|
||||
import { mapActions } from "./mapActions";
|
||||
@@ -64,6 +60,9 @@ export const dispatchReplayAction = (
|
||||
case "set_labels_visible":
|
||||
mapActions.set_labels_visible(map, asBooleanValue(params[0], true));
|
||||
return;
|
||||
case "set_timeline_filter":
|
||||
controllers.setTimelineFilterEnabled(asBooleanValue(params[0], true));
|
||||
return;
|
||||
case "fly_to_geometries":
|
||||
mapActions.fly_to_geometries(
|
||||
map,
|
||||
|
||||
Reference in New Issue
Block a user