init replay mode draft

This commit is contained in:
taDuc
2026-05-16 12:08:21 +07:00
parent a8097c95d4
commit 7424bc43b0
8 changed files with 592 additions and 166 deletions
+45 -2
View File
@@ -46,7 +46,7 @@ import { FIXED_TIMELINE_RANGE, clampYearToFixedRange } from "@/uhm/lib/utils/tim
import { useFeatureCommands } from "./featureCommands"; import { useFeatureCommands } from "./featureCommands";
import { deleteSubmission } from "@/uhm/api/projects"; import { deleteSubmission } from "@/uhm/api/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import UnifiedSearchBar from "@/uhm/components/ui/UnifiedSearchBar"; import UnifiedSearchBar from "@/uhm/components/ui/UnifiedSearchBar";
import { import {
EditorStoreProvider, EditorStoreProvider,
@@ -424,11 +424,22 @@ function EditorPageContent() {
} }
}, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]); }, [snapshotEntityWikiLinks, baselineSnapshot?.entity_wiki]);
const replayDirty = useMemo(() => {
const prev = normalizeReplaysForCompare(baselineSnapshot?.replays);
const next = normalizeReplaysForCompare(editor.effectiveReplays);
try {
return JSON.stringify(prev) !== JSON.stringify(next);
} catch {
return true;
}
}, [baselineSnapshot?.replays, editor.effectiveReplays]);
const pendingSaveCount = const pendingSaveCount =
editor.changeCount editor.changeCount
+ (wikiDirty ? 1 : 0) + (wikiDirty ? 1 : 0)
+ (entitiesDirty ? 1 : 0) + (entitiesDirty ? 1 : 0)
+ (entityWikiDirty ? 1 : 0); + (entityWikiDirty ? 1 : 0)
+ (replayDirty ? 1 : 0);
const sectionCommands = useProjectCommands({ const sectionCommands = useProjectCommands({
editor, editor,
@@ -1380,6 +1391,8 @@ function EditorPageContent() {
focusPadding={96} focusPadding={96}
hideOutside={hideOutside} hideOutside={hideOutside}
onToggleHideOutside={onToggleHideOutside} onToggleHideOutside={onToggleHideOutside}
onUndoReplay={editor.undo}
canUndoReplay={editor.canUndoReplay}
/> />
) : ( ) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} /> <div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
@@ -1802,6 +1815,36 @@ function normalizeEntityWikiLinksForCompare(input: Array<{ entity_id: string; wi
return normalized; return normalized;
} }
function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
const list = Array.isArray(input) ? input : [];
return list
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
.map((replay) => ({
geometry_id: replay.geometry_id,
detail: Array.isArray(replay.detail) ? replay.detail : [],
replay_features: normalizeReplayFeatureCollection(replay.replay_features),
}))
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
}
function normalizeReplayFeatureCollection(input: FeatureCollection | null | undefined) {
const features = Array.isArray(input?.features) ? input.features : [];
return {
type: "FeatureCollection" as const,
features: features
.filter((feature) => feature && feature.properties && (typeof feature.properties.id === "string" || typeof feature.properties.id === "number"))
.map((feature) => ({
type: "Feature" as const,
properties: {
...feature.properties,
id: String(feature.properties.id),
},
geometry: feature.geometry,
}))
.sort((a, b) => String(a.properties.id).localeCompare(String(b.properties.id))),
};
}
function normalizeGeoSearchGeometry(value: unknown): Geometry | null { function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
if (!value || typeof value !== "object") return null; if (!value || typeof value !== "object") return null;
const g = value as Record<string, unknown>; const g = value as Record<string, unknown>;
+27 -1
View File
@@ -53,6 +53,8 @@ type MapProps = {
focusPadding?: number | import("maplibre-gl").PaddingOptions; focusPadding?: number | import("maplibre-gl").PaddingOptions;
hideOutside?: boolean; hideOutside?: boolean;
onToggleHideOutside?: () => void; onToggleHideOutside?: () => void;
onUndoReplay?: () => void;
canUndoReplay?: boolean;
}; };
const Map = forwardRef<MapHandle, MapProps>(function Map({ const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -79,6 +81,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
focusPadding, focusPadding,
hideOutside = false, hideOutside = false,
onToggleHideOutside, onToggleHideOutside,
onUndoReplay,
canUndoReplay = false,
}, ref) { }, ref) {
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft); const draftRef = useRef<FeatureCollection>(draft);
@@ -188,7 +192,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
// Trigger resize after a short delay to allow layout to settle // Trigger resize after a short delay to allow layout to settle
setTimeout(() => map.resize(), 100); setTimeout(() => map.resize(), 100);
} }
}, [mode, isMapLoaded]); }, [mode, isMapLoaded, mapRef]);
return ( return (
<div style={{ width: "100%", height, position: "relative" }}> <div style={{ width: "100%", height, position: "relative" }}>
@@ -295,6 +299,28 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
Capture View Capture View
</button> </button>
<button
type="button"
onClick={onUndoReplay}
disabled={!onUndoReplay || !canUndoReplay}
title="Undo thao tác replay gần nhất"
style={{
...zoomButtonStyle,
width: "auto",
padding: "0 12px",
fontSize: "12px",
fontWeight: 700,
background: !onUndoReplay || !canUndoReplay ? "#0f172a" : "#1e293b",
color: !onUndoReplay || !canUndoReplay ? "#64748b" : "#f8fafc",
border: "1px solid #334155",
borderRadius: "999px",
cursor: !onUndoReplay || !canUndoReplay ? "not-allowed" : "pointer",
marginRight: "8px",
}}
>
Undo Replay
</button>
<div <div
onClick={onToggleHideOutside} onClick={onToggleHideOutside}
style={{ style={{
@@ -48,6 +48,8 @@ export function formatUndoLabel(action: UndoAction) {
case "snapshot_entities": case "snapshot_entities":
case "snapshot_wikis": case "snapshot_wikis":
case "snapshot_entity_wiki": case "snapshot_entity_wiki":
case "replay":
case "replay_session":
case "group": case "group":
return action.label; return action.label;
default: default:
+3 -1
View File
@@ -6,7 +6,7 @@ import type {
} from "@/uhm/types/geo"; } from "@/uhm/types/geo";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
export type Change = GeometryChange; export type Change = GeometryChange;
@@ -15,6 +15,8 @@ export type UndoAction =
| { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties } | { type: "properties"; id: FeatureProperties["id"]; prevProperties: FeatureProperties }
| { type: "delete"; feature: Feature } | { type: "delete"; feature: Feature }
| { type: "create"; id: FeatureProperties["id"] } | { type: "create"; id: FeatureProperties["id"] }
| { type: "replay"; geometryId: string; label: string; prevReplay: BattleReplay | null }
| { type: "replay_session"; geometryId: string; label: string; prevReplay: BattleReplay | null }
// Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly) // Snapshot-scoped undo (affects commit snapshot but not GeoJSON draft directly)
| { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] } | { type: "snapshot_entities"; label: string; prev: EntitySnapshot[] }
| { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] } | { type: "snapshot_wikis"; label: string; prev: WikiSnapshot[] }
+16
View File
@@ -88,6 +88,22 @@ function isSameUndo(a: UndoAction | undefined, b: UndoAction) {
const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>; const next = b as Extract<UndoAction, { type: "snapshot_entity_wiki" }>;
return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev); return a.label === next.label && JSON.stringify(a.prev) === JSON.stringify(next.prev);
} }
case "replay": {
const next = b as Extract<UndoAction, { type: "replay" }>;
return (
a.geometryId === next.geometryId
&& a.label === next.label
&& JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay)
);
}
case "replay_session": {
const next = b as Extract<UndoAction, { type: "replay_session" }>;
return (
a.geometryId === next.geometryId
&& a.label === next.label
&& JSON.stringify(a.prevReplay) === JSON.stringify(next.prevReplay)
);
}
case "group": { case "group": {
const next = b as Extract<UndoAction, { type: "group" }>; const next = b as Extract<UndoAction, { type: "group" }>;
return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions); return a.label === next.label && JSON.stringify(a.actions) === JSON.stringify(next.actions);
@@ -20,6 +20,7 @@ type EditorDraftApi = {
draft: FeatureCollection; draft: FeatureCollection;
mainDraft: FeatureCollection; mainDraft: FeatureCollection;
replays: BattleReplay[]; replays: BattleReplay[];
effectiveReplays: BattleReplay[];
buildPayload: () => Change[]; buildPayload: () => Change[];
clearChanges: () => void; clearChanges: () => void;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean; hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
@@ -78,7 +79,7 @@ export function useProjectCommands(options: Options) {
snapshotEntities: state.snapshotEntities, snapshotEntities: state.snapshotEntities,
snapshotWikis: state.snapshotWikis, snapshotWikis: state.snapshotWikis,
snapshotEntityWikiLinks: state.snapshotEntityWikiLinks, snapshotEntityWikiLinks: state.snapshotEntityWikiLinks,
replays: options.editor.replays, replays: options.editor.effectiveReplays,
previousSnapshot: state.baselineSnapshot, previousSnapshot: state.baselineSnapshot,
hasPersistedFeature: options.editor.hasPersistedFeature, hasPersistedFeature: options.editor.hasPersistedFeature,
}); });
@@ -113,7 +114,7 @@ export function useProjectCommands(options: Options) {
state.setSnapshotEntities(sessionSnapshot.entities || []); state.setSnapshotEntities(sessionSnapshot.entities || []);
state.setSnapshotWikis(sessionSnapshot.wikis || []); state.setSnapshotWikis(sessionSnapshot.wikis || []);
state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []); state.setSnapshotEntityWikiLinks(sessionSnapshot.entity_wiki || []);
state.setInitialData(options.editor.draft); state.setInitialData(options.editor.mainDraft);
options.editor.clearChanges(); options.editor.clearChanges();
state.setCommitTitle(""); state.setCommitTitle("");
state.setProjectCommits(await fetchProjectCommits(state.activeSection.id)); state.setProjectCommits(await fetchProjectCommits(state.activeSection.id));
+3 -25
View File
@@ -3,7 +3,7 @@ import { normalizeGeoTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/map/geo/geo
import type { Change } from "@/uhm/lib/editor/draft/editorTypes"; import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities"; import type { EntitySnapshotOperation } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, Geometry, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo"; import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { BattleReplay, EditorSnapshot, Project } from "@/uhm/types/projects"; import type { BattleReplay, EditorSnapshot, Project } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
@@ -25,20 +25,6 @@ interface RawEntityRow extends UnknownRecord {
status?: number; status?: number;
} }
interface RawGeometryRow extends UnknownRecord {
id?: string | number;
operation?: string;
source?: string;
ref?: { id?: string };
type?: string | number;
geo_type?: string | number;
draw_geometry?: Geometry;
geometry?: Geometry;
binding?: string[];
time_start?: number;
time_end?: number;
}
interface RawWikiRow extends UnknownRecord { interface RawWikiRow extends UnknownRecord {
id?: string; id?: string;
operation?: string; operation?: string;
@@ -364,9 +350,9 @@ export function buildEditorSnapshot(options: {
if (prev.source !== "inline") continue; if (prev.source !== "inline") continue;
// Carry forward as current-state inline entity; operation is a per-commit delta signal. // Carry forward as current-state inline entity; operation is a per-commit delta signal.
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot; const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
const { operation: _op, ...rest } = cloned; delete cloned.operation;
entityRows.set(id, { entityRows.set(id, {
...rest, ...cloned,
id, id,
source: "inline", source: "inline",
operation: "reference", operation: "reference",
@@ -689,14 +675,6 @@ export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
}); });
} }
if (Array.isArray(cloned.replays)) {
cloned.replays = cloned.replays.map((replay) => {
// Strip local-only replay_features before sending to BE
const { replay_features: _, ...rest } = replay;
return rest;
});
}
return cloned; return cloned;
} }
+493 -135
View File
@@ -11,7 +11,7 @@ import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes"; import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities"; import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki"; import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { BattleReplay, EditorSnapshot, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants"; import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes"; import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
@@ -32,10 +32,14 @@ type FeaturePropertiesPatch = {
patch: Partial<FeatureProperties>; patch: Partial<FeatureProperties>;
}; };
type DraftRef = { current: FeatureCollection };
type DraftCommit = (next: FeatureCollection) => void;
type ReplayDraftSyncMode = "none" | "reset";
// State trung tâm của editor: // State trung tâm của editor:
// - draft: dữ liệu nguồn để render UI (chuyển đổi giữa main và replay) // - main draft: dữ liệu section thông thường
// - changes: map các thay đổi chờ lưu // - active replay draft: bản sao đầy đủ của toàn bộ BattleReplay đang chỉnh
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác // - replay feature draft: FeatureCollection con để map/editor hiện tại thao tác
export function useEditorState( export function useEditorState(
initialData: FeatureCollection, initialData: FeatureCollection,
options: { options: {
@@ -47,27 +51,87 @@ export function useEditorState(
const { snapshotUndo, initialReplays, mode } = options; const { snapshotUndo, initialReplays, mode } = options;
const mainDraftState = useDraftState(initialData); const mainDraftState = useDraftState(initialData);
const replayDraftState = useDraftState(EMPTY_FEATURE_COLLECTION); const replayFeatureDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
const {
draft: mainDraft,
draftRef: mainDraftRef,
commitDraft: commitMainDraft,
resetDraft: resetMainDraft,
} = mainDraftState;
const {
draft: replayDraft,
draftRef: replayDraftRef,
commitDraft: commitReplayDraft,
resetDraft: resetReplayDraft,
} = replayFeatureDraftState;
const [replays, setReplays] = useState<BattleReplay[]>(initialReplays || []); const [replays, setReplays] = useState<BattleReplay[]>(initialReplays || []);
const [activeReplayId, setActiveReplayId] = useState<string | number | null>(null); const [activeReplayId, setActiveReplayId] = useState<string | number | null>(null);
const [activeReplayDraft, setActiveReplayDraft] = useState<BattleReplay | null>(null);
const activeDraftState = mode === "replay" ? replayDraftState : mainDraftState; const replaysRef = useRef<BattleReplay[]>(initialReplays || []);
const { draft, draftRef, commitDraft, resetDraft } = activeDraftState; const activeReplayDraftRef = useRef<BattleReplay | null>(null);
const activeReplayOriginRef = useRef<BattleReplay | null>(null);
const activeReplaySeedRef = useRef<BattleReplay | null>(null);
// Map baseline (id -> feature) để diff draft hiện tại ra changes. const activeDraft = mode === "replay" ? replayDraft : mainDraft;
const activeDraftRef = mode === "replay" ? replayDraftRef : mainDraftRef;
const activeCommitDraft = mode === "replay" ? commitReplayDraft : commitMainDraft;
const updateReplaysState = useCallback((next: SetStateAction<BattleReplay[]>) => {
const resolved = resolveStateAction(next, replaysRef.current);
const cloned = deepClone(resolved || []);
replaysRef.current = cloned;
setReplays(cloned);
return cloned;
}, []);
const syncReplayFeatureDraft = useCallback((nextFeatures: FeatureCollection) => {
resetReplayDraft(deepClone(nextFeatures));
}, [resetReplayDraft]);
const setActiveReplayDraftState = useCallback((
next: SetStateAction<BattleReplay | null>,
syncMode: ReplayDraftSyncMode = "reset"
) => {
const resolved = resolveStateAction(next, activeReplayDraftRef.current);
const cloned = resolved ? deepClone(resolved) : null;
activeReplayDraftRef.current = cloned;
setActiveReplayDraft(cloned);
if (syncMode === "reset") {
syncReplayFeatureDraft(cloned?.replay_features || EMPTY_FEATURE_COLLECTION);
}
return cloned;
}, [syncReplayFeatureDraft]);
useEffect(() => {
replaysRef.current = replays;
}, [replays]);
useEffect(() => {
activeReplayDraftRef.current = activeReplayDraft;
}, [activeReplayDraft]);
// Map baseline (id -> feature) để diff main draft ra changes.
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>( const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
buildInitialMap(initialData) buildInitialMap(initialData)
); );
// Version counter để ép diff recalculation sau khi reset/clear baseline. // Version counter để ép diff recalculation sau khi reset/clear baseline.
const [baselineVersion, setBaselineVersion] = useState(0); const [baselineVersion, setBaselineVersion] = useState(0);
const applyUndoAction = useCallback((action: UndoAction): boolean => { const applyUndoActionToDraft = useCallback((
action: UndoAction,
targetDraftRef: DraftRef,
targetCommitDraft: DraftCommit,
allowSnapshotUndo: boolean
): boolean => {
switch (action.type) { switch (action.type) {
case "create": { case "create": {
commitDraft({ targetCommitDraft({
...draftRef.current, ...targetDraftRef.current,
features: draftRef.current.features.filter((feature) => features: targetDraftRef.current.features.filter((feature) =>
feature.properties.id !== action.id feature.properties.id !== action.id
), ),
}); });
@@ -75,54 +139,54 @@ export function useEditorState(
} }
case "delete": { case "delete": {
const feature = deepClone(action.feature); const feature = deepClone(action.feature);
commitDraft({ targetCommitDraft({
...draftRef.current, ...targetDraftRef.current,
features: [...draftRef.current.features, feature], features: [...targetDraftRef.current.features, feature],
}); });
return true; return true;
} }
case "update": { case "update": {
const idx = draftRef.current.features.findIndex((feature) => const idx = targetDraftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id feature.properties.id === action.id
); );
if (idx === -1) return false; if (idx === -1) return false;
const nextFeatures = [...draftRef.current.features]; const nextFeatures = [...targetDraftRef.current.features];
nextFeatures[idx] = { nextFeatures[idx] = {
...nextFeatures[idx], ...nextFeatures[idx],
geometry: deepClone(action.prevGeometry), geometry: deepClone(action.prevGeometry),
}; };
commitDraft({ ...draftRef.current, features: nextFeatures }); targetCommitDraft({ ...targetDraftRef.current, features: nextFeatures });
return true; return true;
} }
case "properties": { case "properties": {
const idx = draftRef.current.features.findIndex((feature) => const idx = targetDraftRef.current.features.findIndex((feature) =>
feature.properties.id === action.id feature.properties.id === action.id
); );
if (idx === -1) return false; if (idx === -1) return false;
const nextFeatures = [...draftRef.current.features]; const nextFeatures = [...targetDraftRef.current.features];
nextFeatures[idx] = { nextFeatures[idx] = {
...nextFeatures[idx], ...nextFeatures[idx],
properties: deepClone(action.prevProperties), properties: deepClone(action.prevProperties),
}; };
commitDraft({ ...draftRef.current, features: nextFeatures }); targetCommitDraft({ ...targetDraftRef.current, features: nextFeatures });
return true; return true;
} }
case "snapshot_entities": { case "snapshot_entities": {
if (!snapshotUndo) return false; if (!allowSnapshotUndo || !snapshotUndo) return false;
const prev = deepClone(action.prev); const prev = deepClone(action.prev);
snapshotUndo.snapshotEntitiesRef.current = prev; snapshotUndo.snapshotEntitiesRef.current = prev;
snapshotUndo.setSnapshotEntities(prev); snapshotUndo.setSnapshotEntities(prev);
return true; return true;
} }
case "snapshot_wikis": { case "snapshot_wikis": {
if (!snapshotUndo) return false; if (!allowSnapshotUndo || !snapshotUndo) return false;
const prev = deepClone(action.prev); const prev = deepClone(action.prev);
snapshotUndo.snapshotWikisRef.current = prev; snapshotUndo.snapshotWikisRef.current = prev;
snapshotUndo.setSnapshotWikis(prev); snapshotUndo.setSnapshotWikis(prev);
return true; return true;
} }
case "snapshot_entity_wiki": { case "snapshot_entity_wiki": {
if (!snapshotUndo) return false; if (!allowSnapshotUndo || !snapshotUndo) return false;
const prev = deepClone(action.prev); const prev = deepClone(action.prev);
snapshotUndo.snapshotEntityWikiLinksRef.current = prev; snapshotUndo.snapshotEntityWikiLinksRef.current = prev;
snapshotUndo.setSnapshotEntityWikiLinks(prev); snapshotUndo.setSnapshotEntityWikiLinks(prev);
@@ -131,41 +195,187 @@ export function useEditorState(
case "group": { case "group": {
let applied = true; let applied = true;
for (let i = action.actions.length - 1; i >= 0; i -= 1) { for (let i = action.actions.length - 1; i >= 0; i -= 1) {
applied = applyUndoAction(action.actions[i]) && applied; applied = applyUndoActionToDraft(
action.actions[i],
targetDraftRef,
targetCommitDraft,
allowSnapshotUndo
) && applied;
} }
return applied; return applied;
} }
case "replay":
case "replay_session":
default: default:
return false; return false;
} }
}, [commitDraft, draftRef, snapshotUndo]); }, [snapshotUndo]);
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction }); const applyMainUndoAction = useCallback((action: UndoAction): boolean => {
if (action.type === "replay") {
const restoredReplay = action.prevReplay ? deepClone(action.prevReplay) : null;
updateReplaysState((prev) =>
replaceReplayByGeometryId(prev, action.geometryId, restoredReplay)
);
if (activeReplayId != null && String(activeReplayId) === action.geometryId) {
activeReplayOriginRef.current = restoredReplay ? deepClone(restoredReplay) : null;
activeReplaySeedRef.current = restoredReplay ? deepClone(restoredReplay) : null;
setActiveReplayDraftState(restoredReplay, "reset");
}
return true;
}
return applyUndoActionToDraft(
action,
mainDraftRef,
commitMainDraft,
true
);
}, [
activeReplayId,
applyUndoActionToDraft,
commitMainDraft,
mainDraftRef,
setActiveReplayDraftState,
updateReplaysState,
]);
const applyReplayUndoAction = useCallback((action: UndoAction): boolean => {
if (action.type !== "replay_session") return false;
const restoredReplay = action.prevReplay ? deepClone(action.prevReplay) : null;
if (!restoredReplay) return false;
setActiveReplayDraftState(restoredReplay, "reset");
return true;
}, [setActiveReplayDraftState]);
const {
undoStack: mainUndoStack,
pushUndo: pushMainUndo,
undo: undoMain,
clearUndo: clearMainUndo,
} = useUndoStack({ applyUndoAction: applyMainUndoAction });
const {
undoStack: replayUndoStack,
pushUndo: pushReplayUndo,
undo: undoReplay,
clearUndo: clearReplayUndo,
} = useUndoStack({ applyUndoAction: applyReplayUndoAction });
useEffect(() => { useEffect(() => {
mainDraftState.resetDraft(deepClone(initialData)); resetMainDraft(deepClone(initialData));
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION); resetReplayDraft(EMPTY_FEATURE_COLLECTION);
setReplays(initialReplays || []); updateReplaysState(initialReplays || []);
setActiveReplayId(null); setActiveReplayId(null);
clearUndo(); setActiveReplayDraftState(null, "none");
activeReplayOriginRef.current = null;
activeReplaySeedRef.current = null;
clearMainUndo();
clearReplayUndo();
initialMapRef.current = buildInitialMap(initialData); initialMapRef.current = buildInitialMap(initialData);
setBaselineVersion((version) => version + 1); setBaselineVersion((version) => version + 1);
}, [clearUndo, initialData, initialReplays, mainDraftState.resetDraft, replayDraftState.resetDraft]); }, [
clearMainUndo,
clearReplayUndo,
initialData,
initialReplays,
resetMainDraft,
resetReplayDraft,
setActiveReplayDraftState,
updateReplaysState,
]);
const changes = useMemo(() => { const changes = useMemo(() => {
const baseline = initialMapRef.current; const baseline = initialMapRef.current;
return diffDraftToInitial(draft, baseline); return diffDraftToInitial(mainDraft, baseline);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, baselineVersion]); }, [mainDraft, baselineVersion]);
const changeCount = useMemo(() => changes.size, [changes]); const changeCount = useMemo(() => changes.size, [changes]);
const applyReplaySessionMutation = useCallback((
label: string,
mutator: (draftReplay: BattleReplay) => void
) => {
const currentReplay = activeReplayDraftRef.current;
if (!currentReplay) return false;
const prevReplay = deepClone(currentReplay);
const nextReplay = deepClone(currentReplay);
if (!nextReplay.replay_features) {
nextReplay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
}
mutator(nextReplay);
if (replayEquals(prevReplay, nextReplay)) {
return false;
}
pushReplayUndo({
type: "replay_session",
geometryId: nextReplay.geometry_id,
label,
prevReplay,
});
setActiveReplayDraftState(nextReplay, "reset");
return true;
}, [pushReplayUndo, setActiveReplayDraftState]);
const finalizeActiveReplaySession = useCallback((recordMainUndo = true) => {
if (activeReplayId == null) return;
const geometryId = String(activeReplayId);
const currentReplay = activeReplayDraftRef.current
? deepClone(activeReplayDraftRef.current)
: null;
const originReplay = activeReplayOriginRef.current
? deepClone(activeReplayOriginRef.current)
: null;
const seedReplay = activeReplaySeedRef.current
? deepClone(activeReplaySeedRef.current)
: null;
if (!currentReplay) return;
if (replayEquals(currentReplay, seedReplay)) return;
updateReplaysState((prev) =>
replaceReplayByGeometryId(prev, geometryId, currentReplay)
);
if (recordMainUndo && !replayEquals(currentReplay, originReplay)) {
pushMainUndo({
type: "replay",
geometryId,
label: `Replay #${geometryId}`,
prevReplay: originReplay ? deepClone(originReplay) : null,
});
}
}, [activeReplayId, pushMainUndo, updateReplaysState]);
const effectiveReplays = useMemo(() => {
if (activeReplayId == null || !activeReplayDraft) return replays;
const seedReplay = activeReplaySeedRef.current;
if (!seedReplay || replayEquals(activeReplayDraft, seedReplay)) {
return replays;
}
return replaceReplayByGeometryId(replays, String(activeReplayId), activeReplayDraft);
}, [activeReplayDraft, activeReplayId, replays]);
function createFeature(feature: Feature) { function createFeature(feature: Feature) {
const featureClone = deepClone(feature); const featureClone = deepClone(feature);
commitDraft({
...draftRef.current, if (mode === "replay") {
features: [...draftRef.current.features, featureClone], applyReplaySessionMutation(`Replay: thêm #${featureClone.properties.id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
featureDraft.features = [...featureDraft.features, featureClone];
});
return;
}
activeCommitDraft({
...activeDraftRef.current,
features: [...activeDraftRef.current.features, featureClone],
}); });
pushUndo({ type: "create", id: featureClone.properties.id }); pushMainUndo({ type: "create", id: featureClone.properties.id });
} }
function createFeatureWithSnapshotEntities( function createFeatureWithSnapshotEntities(
@@ -173,6 +383,11 @@ export function useEditorState(
nextEntities: SetStateAction<EntitySnapshot[]>, nextEntities: SetStateAction<EntitySnapshot[]>,
label = "Import geometry" label = "Import geometry"
) { ) {
if (mode === "replay") {
createFeature(feature);
return;
}
const featureClone = deepClone(feature); const featureClone = deepClone(feature);
const undoActions: UndoAction[] = []; const undoActions: UndoAction[] = [];
@@ -202,14 +417,14 @@ export function useEditorState(
} }
undoActions.push({ type: "create", id: featureClone.properties.id }); undoActions.push({ type: "create", id: featureClone.properties.id });
pushUndo( pushMainUndo(
undoActions.length === 1 undoActions.length === 1
? undoActions[0] ? undoActions[0]
: { type: "group", label, actions: undoActions } : { type: "group", label, actions: undoActions }
); );
commitDraft({ commitMainDraft({
...draftRef.current, ...mainDraftRef.current,
features: [...draftRef.current.features, featureClone], features: [...mainDraftRef.current.features, featureClone],
}); });
} }
@@ -217,10 +432,26 @@ export function useEditorState(
id: FeatureProperties["id"], id: FeatureProperties["id"],
patch: Partial<FeatureProperties> patch: Partial<FeatureProperties>
) { ) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id); if (mode === "replay") {
applyReplaySessionMutation(`Replay: cập nhật thuộc tính #${id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
const idx = featureDraft.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
featureDraft.features[idx] = {
...featureDraft.features[idx],
properties: {
...featureDraft.features[idx].properties,
...deepClone(patch),
},
};
});
return;
}
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return; if (idx === -1) return;
const nextFeatures = [...draftRef.current.features]; const nextFeatures = [...mainDraftRef.current.features];
const prevProperties = deepClone(nextFeatures[idx].properties); const prevProperties = deepClone(nextFeatures[idx].properties);
nextFeatures[idx] = { nextFeatures[idx] = {
...nextFeatures[idx], ...nextFeatures[idx],
@@ -234,14 +465,42 @@ export function useEditorState(
return; return;
} }
pushUndo({ type: "properties", id, prevProperties }); pushMainUndo({ type: "properties", id, prevProperties });
commitDraft({ ...draftRef.current, features: nextFeatures }); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
} }
function patchFeaturePropertiesBatch( function patchFeaturePropertiesBatch(
patches: FeaturePropertiesPatch[], patches: FeaturePropertiesPatch[],
label = "Cập nhật nhiều geometry" label = "Cập nhật nhiều geometry"
) { ) {
if (mode === "replay") {
applyReplaySessionMutation(label, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
for (const item of patches || []) {
if (!item) continue;
const prev = mergedPatches.get(item.id) || {};
mergedPatches.set(item.id, {
...prev,
...deepClone(item.patch),
});
}
featureDraft.features = featureDraft.features.map((feature) => {
const featurePatch = mergedPatches.get(feature.properties.id);
if (!featurePatch) return feature;
return {
...feature,
properties: {
...feature.properties,
...deepClone(featurePatch),
},
};
});
});
return;
}
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>(); const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
for (const item of patches || []) { for (const item of patches || []) {
if (!item) continue; if (!item) continue;
@@ -253,7 +512,7 @@ export function useEditorState(
} }
if (!mergedPatches.size) return; if (!mergedPatches.size) return;
const nextFeatures = [...draftRef.current.features]; const nextFeatures = [...mainDraftRef.current.features];
const undoActions: UndoAction[] = []; const undoActions: UndoAction[] = [];
for (const [id, patch] of mergedPatches.entries()) { for (const [id, patch] of mergedPatches.entries()) {
@@ -278,43 +537,64 @@ export function useEditorState(
if (!undoActions.length) return; if (!undoActions.length) return;
pushUndo( pushMainUndo(
undoActions.length === 1 undoActions.length === 1
? undoActions[0] ? undoActions[0]
: { type: "group", label, actions: undoActions } : { type: "group", label, actions: undoActions }
); );
commitDraft({ ...draftRef.current, features: nextFeatures }); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
} }
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) { function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id); if (mode === "replay") {
applyReplaySessionMutation(`Replay: chỉnh sửa #${id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
const idx = featureDraft.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return;
featureDraft.features[idx] = {
...featureDraft.features[idx],
geometry: deepClone(newGeometry),
};
});
return;
}
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return; if (idx === -1) return;
const prevFeature = draftRef.current.features[idx]; const prevFeature = mainDraftRef.current.features[idx];
const prevGeometry = deepClone(prevFeature.geometry); const prevGeometry = deepClone(prevFeature.geometry);
if (geometryEquals(prevGeometry, newGeometry)) { if (geometryEquals(prevGeometry, newGeometry)) {
return; return;
} }
const nextFeatures = [...draftRef.current.features]; const nextFeatures = [...mainDraftRef.current.features];
nextFeatures[idx] = { nextFeatures[idx] = {
...prevFeature, ...prevFeature,
geometry: deepClone(newGeometry), geometry: deepClone(newGeometry),
}; };
pushUndo({ type: "update", id, prevGeometry }); pushMainUndo({ type: "update", id, prevGeometry });
commitDraft({ ...draftRef.current, features: nextFeatures }); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
} }
function deleteFeature(id: FeatureProperties["id"]) { function deleteFeature(id: FeatureProperties["id"]) {
const idx = draftRef.current.features.findIndex((feature) => feature.properties.id === id); if (mode === "replay") {
applyReplaySessionMutation(`Replay: xóa #${id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
featureDraft.features = featureDraft.features.filter((feature) => feature.properties.id !== id);
});
return;
}
const idx = mainDraftRef.current.features.findIndex((feature) => feature.properties.id === id);
if (idx === -1) return; if (idx === -1) return;
const feature = draftRef.current.features[idx]; const feature = mainDraftRef.current.features[idx];
const nextFeatures = [...draftRef.current.features]; const nextFeatures = [...mainDraftRef.current.features];
nextFeatures.splice(idx, 1); nextFeatures.splice(idx, 1);
pushUndo({ type: "delete", feature: deepClone(feature) }); pushMainUndo({ type: "delete", feature: deepClone(feature) });
commitDraft({ ...draftRef.current, features: nextFeatures }); commitMainDraft({ ...mainDraftRef.current, features: nextFeatures });
} }
function buildPayload(): Change[] { function buildPayload(): Change[] {
@@ -322,8 +602,9 @@ export function useEditorState(
} }
function clearChanges() { function clearChanges() {
clearUndo(); clearMainUndo();
initialMapRef.current = buildInitialMap(mainDraftState.draftRef.current); clearReplayUndo();
initialMapRef.current = buildInitialMap(mainDraftRef.current);
setBaselineVersion((version) => version + 1); setBaselineVersion((version) => version + 1);
} }
@@ -332,75 +613,42 @@ export function useEditorState(
} }
const switchReplayContext = useCallback((featureId: string | number, selectedIds: (string | number)[] = []) => { const switchReplayContext = useCallback((featureId: string | number, selectedIds: (string | number)[] = []) => {
const id = String(featureId); const geometryId = String(featureId);
// Lưu draft replay cũ nếu có (defensive)
if (activeReplayId && mode === "replay") { if (activeReplayId != null && String(activeReplayId) === geometryId) {
const currentDraft = replayDraftState.draftRef.current; return;
setReplays(prev => prev.map(r =>
r.geometry_id === String(activeReplayId)
? { ...r, replay_features: deepClone(currentDraft) }
: r
));
} }
const existing = replays.find(r => r.geometry_id === id); if (activeReplayId != null) {
finalizeActiveReplaySession(true);
// Chuẩn bị data: bao gồm tất cả các geo đang chọn + binding của geo chính
const selectedIdsSet = new Set(selectedIds.map(String));
selectedIdsSet.add(id); // Luôn bao gồm geo chính
const triggerFeature = mainDraftState.draftRef.current.features.find(f => String(f.properties.id) === id);
const mainBoundIds = new Set(triggerFeature?.properties?.binding?.map(String) || []);
// Quy tắc: targetIds bao gồm các geo được chọn và binding CHỈ của geo chính.
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
const gatheredFeatures = mainDraftState.draftRef.current.features
.filter(f => targetIds.has(String(f.properties.id)))
.map(deepClone);
if (existing) {
// Đồng bộ lại danh sách geometry theo lựa chọn mới nhất (Sync với Main Draft)
// Giúp "reset" danh sách geo theo multi-select và binding mới nhất,
// nhưng vẫn giữ nguyên phần kịch bản (detail) đã dựng.
const nextFeatures: FeatureCollection = {
type: "FeatureCollection",
features: gatheredFeatures,
};
replayDraftState.resetDraft(deepClone(nextFeatures));
// Cập nhật lại list replays để đồng bộ
setReplays(prev => prev.map(r =>
r.geometry_id === id ? { ...r, replay_features: nextFeatures } : r
));
} else {
const initialFeatures: FeatureCollection = {
type: "FeatureCollection",
features: gatheredFeatures,
};
const newReplay: BattleReplay = {
geometry_id: id,
detail: [],
replay_features: initialFeatures,
};
setReplays(prev => [...prev, newReplay]);
replayDraftState.resetDraft(deepClone(initialFeatures));
} }
setActiveReplayId(id);
}, [activeReplayId, mode, replayDraftState, replays, mainDraftState.draftRef]); const existing = replaysRef.current.find((replay) => replay.geometry_id === geometryId) || null;
const seedReplay = existing
? normalizeReplaySessionSeed(existing, mainDraftRef.current, geometryId, selectedIds)
: createReplaySessionSeed(mainDraftRef.current, geometryId, selectedIds);
activeReplayOriginRef.current = existing ? deepClone(existing) : null;
activeReplaySeedRef.current = deepClone(seedReplay);
clearReplayUndo();
setActiveReplayDraftState(seedReplay, "reset");
setActiveReplayId(geometryId);
}, [
activeReplayId,
clearReplayUndo,
finalizeActiveReplaySession,
mainDraftRef,
setActiveReplayDraftState,
]);
const closeReplayContext = useCallback(() => { const closeReplayContext = useCallback(() => {
if (activeReplayId) { finalizeActiveReplaySession(true);
const currentDraft = replayDraftState.draftRef.current;
setReplays(prev => prev.map(r =>
r.geometry_id === String(activeReplayId)
? { ...r, replay_features: deepClone(currentDraft) }
: r
));
}
setActiveReplayId(null); setActiveReplayId(null);
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION); setActiveReplayDraftState(null, "reset");
}, [activeReplayId, replayDraftState]); activeReplayOriginRef.current = null;
activeReplaySeedRef.current = null;
clearReplayUndo();
}, [clearReplayUndo, finalizeActiveReplaySession, setActiveReplayDraftState]);
const setSnapshotEntitiesUndoable = useCallback(( const setSnapshotEntitiesUndoable = useCallback((
next: SetStateAction<EntitySnapshot[]>, next: SetStateAction<EntitySnapshot[]>,
@@ -419,10 +667,10 @@ export function useEditorState(
if (!changed) return; if (!changed) return;
const computedClone = deepClone(computed); const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_entities", label, prev: prevClone }); pushMainUndo({ type: "snapshot_entities", label, prev: prevClone });
snapshotUndo.snapshotEntitiesRef.current = computedClone; snapshotUndo.snapshotEntitiesRef.current = computedClone;
snapshotUndo.setSnapshotEntities(computedClone); snapshotUndo.setSnapshotEntities(computedClone);
}, [pushUndo, snapshotUndo]); }, [pushMainUndo, snapshotUndo]);
const setSnapshotWikisUndoable = useCallback(( const setSnapshotWikisUndoable = useCallback((
next: SetStateAction<WikiSnapshot[]>, next: SetStateAction<WikiSnapshot[]>,
@@ -441,10 +689,10 @@ export function useEditorState(
if (!changed) return; if (!changed) return;
const computedClone = deepClone(computed); const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_wikis", label, prev: prevClone }); pushMainUndo({ type: "snapshot_wikis", label, prev: prevClone });
snapshotUndo.snapshotWikisRef.current = computedClone; snapshotUndo.snapshotWikisRef.current = computedClone;
snapshotUndo.setSnapshotWikis(computedClone); snapshotUndo.setSnapshotWikis(computedClone);
}, [pushUndo, snapshotUndo]); }, [pushMainUndo, snapshotUndo]);
const setSnapshotEntityWikiLinksUndoable = useCallback(( const setSnapshotEntityWikiLinksUndoable = useCallback((
next: SetStateAction<EntityWikiLinkSnapshot[]>, next: SetStateAction<EntityWikiLinkSnapshot[]>,
@@ -465,24 +713,38 @@ export function useEditorState(
if (!changed) return; if (!changed) return;
const computedClone = deepClone(computed); const computedClone = deepClone(computed);
pushUndo({ type: "snapshot_entity_wiki", label, prev: prevClone }); pushMainUndo({ type: "snapshot_entity_wiki", label, prev: prevClone });
snapshotUndo.snapshotEntityWikiLinksRef.current = computedClone; snapshotUndo.snapshotEntityWikiLinksRef.current = computedClone;
snapshotUndo.setSnapshotEntityWikiLinks(computedClone); snapshotUndo.setSnapshotEntityWikiLinks(computedClone);
}, [pushUndo, snapshotUndo]); }, [pushMainUndo, snapshotUndo]);
const undo = useCallback(() => {
if (mode === "replay") {
undoReplay();
return;
}
undoMain();
}, [mode, undoMain, undoReplay]);
const undoStack = mode === "replay" ? replayUndoStack : mainUndoStack;
return { return {
draft, draft: activeDraft,
draftRef, draftRef: activeDraftRef,
mainDraft: mainDraftState.draft, mainDraft,
replayDraft: replayDraftState.draft, replayDraft,
replays, replays,
setReplays, activeReplayDraft,
effectiveReplays,
setReplays: updateReplaysState,
activeReplayId, activeReplayId,
switchReplayContext, switchReplayContext,
closeReplayContext, closeReplayContext,
changes, changes,
undoStack, undoStack,
replayUndoStack,
changeCount, changeCount,
canUndoReplay: replayUndoStack.length > 0,
createFeature, createFeature,
createFeatureWithSnapshotEntities, createFeatureWithSnapshotEntities,
patchFeatureProperties, patchFeatureProperties,
@@ -499,3 +761,99 @@ export function useEditorState(
setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable, setSnapshotEntityWikiLinks: setSnapshotEntityWikiLinksUndoable,
}; };
} }
function resolveStateAction<T>(next: SetStateAction<T>, prev: T): T {
return typeof next === "function" ? (next as (value: T) => T)(prev) : next;
}
function buildReplaySeedFeatures(
sourceDraft: FeatureCollection,
featureId: string,
selectedIds: (string | number)[]
): FeatureCollection {
const selectedIdsSet = new Set(selectedIds.map(String));
selectedIdsSet.add(featureId);
const triggerFeature = sourceDraft.features.find(
(feature) => String(feature.properties.id) === featureId
);
const mainBoundIds = new Set(
Array.isArray(triggerFeature?.properties?.binding)
? triggerFeature.properties.binding.map(String)
: []
);
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
return {
type: "FeatureCollection",
features: sourceDraft.features
.filter((feature) => targetIds.has(String(feature.properties.id)))
.map(deepClone),
};
}
function createReplaySessionSeed(
sourceDraft: FeatureCollection,
geometryId: string,
selectedIds: (string | number)[]
): BattleReplay {
return {
geometry_id: geometryId,
detail: [],
replay_features: buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds),
};
}
function normalizeReplaySessionSeed(
replay: BattleReplay,
sourceDraft: FeatureCollection,
geometryId: string,
selectedIds: (string | number)[]
): BattleReplay {
const nextReplay = deepClone(replay);
if (!nextReplay.replay_features) {
nextReplay.replay_features = buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds);
}
return nextReplay;
}
function ensureReplayFeatureCollection(replay: BattleReplay): FeatureCollection {
if (!replay.replay_features) {
replay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
}
return replay.replay_features;
}
function replaceReplayByGeometryId(
replays: BattleReplay[],
geometryId: string,
nextReplay: BattleReplay | null
) {
const next: BattleReplay[] = [];
let replaced = false;
for (const replay of replays || []) {
if (!replay || replay.geometry_id !== geometryId) {
next.push(replay);
continue;
}
if (nextReplay) {
next.push(deepClone(nextReplay));
}
replaced = true;
}
if (!replaced && nextReplay) {
next.push(deepClone(nextReplay));
}
return next;
}
function replayEquals(a: BattleReplay | null | undefined, b: BattleReplay | null | undefined) {
try {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
} catch {
return false;
}
}