feat: add timeline filter support to replay dispatcher and clean up unused dependencies

This commit is contained in:
taDuc
2026-05-27 12:48:17 +07:00
parent ef3766bc2a
commit 4c60e2d773
8 changed files with 915 additions and 454 deletions
+17 -1
View File
@@ -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();
+9 -309
View File
@@ -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"],
-15
View File
@@ -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;
}
+3 -4
View File
@@ -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,