complete replay editor v1
This commit is contained in:
+124
-54
@@ -1,13 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import Map from "@/uhm/components/Map";
|
import Map, { type MapHandle } from "@/uhm/components/Map";
|
||||||
import Editor from "@/uhm/components/Editor";
|
import Editor from "@/uhm/components/Editor";
|
||||||
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
import BackgroundLayersPanel from "@/uhm/components/editor/BackgroundLayersPanel";
|
||||||
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
import TimelineBar from "@/uhm/components/ui/TimelineBar";
|
||||||
import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel";
|
import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel";
|
||||||
|
import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar";
|
||||||
|
import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar";
|
||||||
import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
|
import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
|
||||||
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
|
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
|
||||||
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
|
import EntityWikiBindingsPanel from "@/uhm/components/editor/EntityWikiBindingsPanel";
|
||||||
@@ -82,6 +84,7 @@ function EditorPageContent() {
|
|||||||
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
const geoBindingStatusTimeoutRef = useRef<number | null>(null);
|
||||||
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
|
const localCreatedEntityIdsRef = useRef<Set<string>>(new Set());
|
||||||
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
const lastSelectedFeatureIdRef = useRef<string | null>(null);
|
||||||
|
const mapHandleRef = useRef<MapHandle | null>(null);
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
internalSetMode,
|
internalSetMode,
|
||||||
@@ -151,7 +154,6 @@ function EditorPageContent() {
|
|||||||
setTimelineFilterEnabled,
|
setTimelineFilterEnabled,
|
||||||
geometryBindingFilterEnabled,
|
geometryBindingFilterEnabled,
|
||||||
setGeoBindingStatus,
|
setGeoBindingStatus,
|
||||||
hoveredGeometryId,
|
|
||||||
geometryFocusRequest,
|
geometryFocusRequest,
|
||||||
setGeometryFocusRequest,
|
setGeometryFocusRequest,
|
||||||
replayFeatureId,
|
replayFeatureId,
|
||||||
@@ -228,7 +230,6 @@ function EditorPageContent() {
|
|||||||
setTimelineFilterEnabled: state.setTimelineFilterEnabled,
|
setTimelineFilterEnabled: state.setTimelineFilterEnabled,
|
||||||
geometryBindingFilterEnabled: state.geometryBindingFilterEnabled,
|
geometryBindingFilterEnabled: state.geometryBindingFilterEnabled,
|
||||||
setGeoBindingStatus: state.setGeoBindingStatus,
|
setGeoBindingStatus: state.setGeoBindingStatus,
|
||||||
hoveredGeometryId: state.hoveredGeometryId,
|
|
||||||
geometryFocusRequest: state.geometryFocusRequest,
|
geometryFocusRequest: state.geometryFocusRequest,
|
||||||
setGeometryFocusRequest: state.setGeometryFocusRequest,
|
setGeometryFocusRequest: state.setGeometryFocusRequest,
|
||||||
replayFeatureId: state.replayFeatureId,
|
replayFeatureId: state.replayFeatureId,
|
||||||
@@ -287,7 +288,6 @@ function EditorPageContent() {
|
|||||||
id: String(e.id || ""),
|
id: String(e.id || ""),
|
||||||
name: String(e.name || "").trim() || String(e.id || ""),
|
name: String(e.name || "").trim() || String(e.id || ""),
|
||||||
description: e.description ?? null,
|
description: e.description ?? null,
|
||||||
status: typeof e.status === "number" ? e.status : 1,
|
|
||||||
geometry_count: 0,
|
geometry_count: 0,
|
||||||
}))
|
}))
|
||||||
.filter((e) => e.id.length > 0 && e.name.length > 0);
|
.filter((e) => e.id.length > 0 && e.name.length > 0);
|
||||||
@@ -297,6 +297,13 @@ function EditorPageContent() {
|
|||||||
() => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
|
() => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
|
||||||
[entityCatalog, snapshotEntitiesAsEntities]
|
[entityCatalog, snapshotEntitiesAsEntities]
|
||||||
);
|
);
|
||||||
|
const [replaySelection, setReplaySelection] = useState<{
|
||||||
|
stageId: number | null;
|
||||||
|
stepIndex: number | null;
|
||||||
|
}>({
|
||||||
|
stageId: null,
|
||||||
|
stepIndex: null,
|
||||||
|
});
|
||||||
const entitiesRef = useRef(entities);
|
const entitiesRef = useRef(entities);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
entitiesRef.current = entities;
|
entitiesRef.current = entities;
|
||||||
@@ -382,17 +389,15 @@ function EditorPageContent() {
|
|||||||
return normalizeFeatureBindingIds(selectedFeature);
|
return normalizeFeatureBindingIds(selectedFeature);
|
||||||
}, [selectedFeature]);
|
}, [selectedFeature]);
|
||||||
|
|
||||||
const hoveredGeometryHighlight = useMemo(() => {
|
const wikiChoices = useMemo(() => {
|
||||||
if (!hoveredGeometryId) return null;
|
return (snapshotWikis || [])
|
||||||
const feature = editor.draft.features.find(
|
.filter((wiki) => wiki && wiki.operation !== "delete")
|
||||||
(item) => String(item.properties.id) === hoveredGeometryId
|
.map((wiki) => ({
|
||||||
);
|
id: String(wiki.id || ""),
|
||||||
if (!feature) return null;
|
label: `${(wiki.title || "").trim() || "Untitled wiki"} (${String(wiki.id || "")})`,
|
||||||
return {
|
}))
|
||||||
type: "FeatureCollection",
|
.filter((wiki) => wiki.id.length > 0);
|
||||||
features: [feature],
|
}, [snapshotWikis]);
|
||||||
} as FeatureCollection;
|
|
||||||
}, [editor.draft.features, hoveredGeometryId]);
|
|
||||||
|
|
||||||
const wikiDirty = useMemo(() => {
|
const wikiDirty = useMemo(() => {
|
||||||
const prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
|
const prev = normalizeWikisForCompare(baselineSnapshot?.wikis);
|
||||||
@@ -440,6 +445,10 @@ function EditorPageContent() {
|
|||||||
+ (entitiesDirty ? 1 : 0)
|
+ (entitiesDirty ? 1 : 0)
|
||||||
+ (entityWikiDirty ? 1 : 0)
|
+ (entityWikiDirty ? 1 : 0)
|
||||||
+ (replayDirty ? 1 : 0);
|
+ (replayDirty ? 1 : 0);
|
||||||
|
const activeReplayStages = useMemo(
|
||||||
|
() => editor.activeReplayDraft?.detail || [],
|
||||||
|
[editor.activeReplayDraft?.detail]
|
||||||
|
);
|
||||||
|
|
||||||
const sectionCommands = useProjectCommands({
|
const sectionCommands = useProjectCommands({
|
||||||
editor,
|
editor,
|
||||||
@@ -459,7 +468,9 @@ function EditorPageContent() {
|
|||||||
// QUY TẮC: Geo chọn đầu tiên là geo main.
|
// QUY TẮC: Geo chọn đầu tiên là geo main.
|
||||||
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
|
const triggerId = selectedFeatureIds.length > 0 ? selectedFeatureIds[0] : featureId;
|
||||||
setReplayFeatureId(triggerId);
|
setReplayFeatureId(triggerId);
|
||||||
|
setReplaySelection({ stageId: null, stepIndex: null });
|
||||||
editor.switchReplayContext(triggerId, selectedFeatureIds);
|
editor.switchReplayContext(triggerId, selectedFeatureIds);
|
||||||
|
setSelectedFeatureIds([]);
|
||||||
} else if (m !== "replay") {
|
} else if (m !== "replay") {
|
||||||
if (mode === "replay") {
|
if (mode === "replay") {
|
||||||
editor.closeReplayContext();
|
editor.closeReplayContext();
|
||||||
@@ -467,10 +478,49 @@ function EditorPageContent() {
|
|||||||
}
|
}
|
||||||
setReplayFeatureId(null);
|
setReplayFeatureId(null);
|
||||||
setHideOutside(false);
|
setHideOutside(false);
|
||||||
|
setReplaySelection({ stageId: null, stepIndex: null });
|
||||||
}
|
}
|
||||||
internalSetMode(m);
|
internalSetMode(m);
|
||||||
}, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]);
|
}, [internalSetMode, mode, editor, selectedFeatureIds, setHideOutside, setReplayFeatureId, setSelectedFeatureIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeReplayStages.length) {
|
||||||
|
if (replaySelection.stageId != null || replaySelection.stepIndex != null) {
|
||||||
|
setReplaySelection({ stageId: null, stepIndex: null });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStage =
|
||||||
|
activeReplayStages.find((stage) => stage.id === replaySelection.stageId) ||
|
||||||
|
activeReplayStages[0];
|
||||||
|
const nextStageId = targetStage.id;
|
||||||
|
let nextStepIndex: number | null = null;
|
||||||
|
|
||||||
|
if (targetStage.steps.length > 0) {
|
||||||
|
if (
|
||||||
|
replaySelection.stageId === targetStage.id &&
|
||||||
|
replaySelection.stepIndex != null &&
|
||||||
|
replaySelection.stepIndex >= 0 &&
|
||||||
|
replaySelection.stepIndex < targetStage.steps.length
|
||||||
|
) {
|
||||||
|
nextStepIndex = replaySelection.stepIndex;
|
||||||
|
} else {
|
||||||
|
nextStepIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextStageId !== replaySelection.stageId ||
|
||||||
|
nextStepIndex !== replaySelection.stepIndex
|
||||||
|
) {
|
||||||
|
setReplaySelection({
|
||||||
|
stageId: nextStageId,
|
||||||
|
stepIndex: nextStepIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeReplayStages, replaySelection.stageId, replaySelection.stepIndex]);
|
||||||
|
|
||||||
const effectiveGeometryVisibility = useMemo(() => {
|
const effectiveGeometryVisibility = useMemo(() => {
|
||||||
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
const visibility: Record<string, boolean> = { ...geometryVisibility };
|
||||||
|
|
||||||
@@ -1097,7 +1147,6 @@ function EditorPageContent() {
|
|||||||
operation: "reference",
|
operation: "reference",
|
||||||
title,
|
title,
|
||||||
doc: null,
|
doc: null,
|
||||||
updated_at: wiki.updated_at,
|
|
||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
];
|
];
|
||||||
@@ -1119,7 +1168,6 @@ function EditorPageContent() {
|
|||||||
id: entityItem.entity_id,
|
id: entityItem.entity_id,
|
||||||
name: (entityItem.name || "").trim() || entityItem.entity_id,
|
name: (entityItem.name || "").trim() || entityItem.entity_id,
|
||||||
description: (entityItem.description || "").trim() || null,
|
description: (entityItem.description || "").trim() || null,
|
||||||
status: 1,
|
|
||||||
geometry_count: 0,
|
geometry_count: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1226,7 +1274,6 @@ function EditorPageContent() {
|
|||||||
id: entityId,
|
id: entityId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
status: 1,
|
|
||||||
geometry_count: 0,
|
geometry_count: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1241,9 +1288,7 @@ function EditorPageContent() {
|
|||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "create",
|
operation: "create",
|
||||||
name,
|
name,
|
||||||
slug: null,
|
|
||||||
description,
|
description,
|
||||||
status: 1,
|
|
||||||
},
|
},
|
||||||
...prev,
|
...prev,
|
||||||
];
|
];
|
||||||
@@ -1317,7 +1362,19 @@ function EditorPageContent() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ width: leftPanelWidth, height: "100vh", background: "#0b1220", borderRight: "1px solid #1f2937", flex: "0 0 auto" }} />
|
<ReplayTimelineSidebar
|
||||||
|
width={leftPanelWidth}
|
||||||
|
replay={editor.activeReplayDraft}
|
||||||
|
selectedStageId={replaySelection.stageId}
|
||||||
|
selectedStepIndex={replaySelection.stepIndex}
|
||||||
|
pendingSaveCount={pendingSaveCount}
|
||||||
|
replayUndoStack={editor.replayUndoStack}
|
||||||
|
canUndoReplay={editor.canUndoReplay}
|
||||||
|
onSelectStep={(stageId, stepIndex) => setReplaySelection({ stageId, stepIndex })}
|
||||||
|
onMutateReplay={editor.mutateActiveReplay}
|
||||||
|
onUndoReplay={editor.undo}
|
||||||
|
onExitReplay={() => setMode("select")}
|
||||||
|
/>
|
||||||
<ResizeHandle
|
<ResizeHandle
|
||||||
title="Resize left panel"
|
title="Resize left panel"
|
||||||
onDrag={(deltaX) => {
|
onDrag={(deltaX) => {
|
||||||
@@ -1373,6 +1430,7 @@ function EditorPageContent() {
|
|||||||
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
<div style={{ flex: 1, position: "relative", minHeight: "100vh" }}>
|
||||||
{isBackgroundVisibilityReady ? (
|
{isBackgroundVisibilityReady ? (
|
||||||
<Map
|
<Map
|
||||||
|
ref={mapHandleRef}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onSetMode={setMode}
|
onSetMode={setMode}
|
||||||
draft={timelineVisibleDraft}
|
draft={timelineVisibleDraft}
|
||||||
@@ -1384,8 +1442,8 @@ function EditorPageContent() {
|
|||||||
onUpdateFeature={editor.updateFeature}
|
onUpdateFeature={editor.updateFeature}
|
||||||
backgroundVisibility={backgroundVisibility}
|
backgroundVisibility={backgroundVisibility}
|
||||||
geometryVisibility={effectiveGeometryVisibility}
|
geometryVisibility={effectiveGeometryVisibility}
|
||||||
respectBindingFilter={geometryBindingFilterEnabled}
|
respectBindingFilter={mode === "replay" ? false : geometryBindingFilterEnabled}
|
||||||
highlightFeatures={hoveredGeometryHighlight}
|
highlightFeatures={null}
|
||||||
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
focusFeatureCollection={geometryFocusRequest?.collection || null}
|
||||||
focusRequestKey={geometryFocusRequest?.key ?? null}
|
focusRequestKey={geometryFocusRequest?.key ?? null}
|
||||||
focusPadding={96}
|
focusPadding={96}
|
||||||
@@ -1397,17 +1455,15 @@ function EditorPageContent() {
|
|||||||
) : (
|
) : (
|
||||||
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
|
||||||
)}
|
)}
|
||||||
{mode !== "replay" && (
|
<TimelineBar
|
||||||
<TimelineBar
|
year={timelineDraftYear}
|
||||||
year={timelineDraftYear}
|
onYearChange={handleTimelineYearChange}
|
||||||
onYearChange={handleTimelineYearChange}
|
isLoading={false}
|
||||||
isLoading={false}
|
disabled={false}
|
||||||
disabled={false}
|
statusText={null}
|
||||||
statusText={null}
|
filterEnabled={timelineFilterEnabled}
|
||||||
filterEnabled={timelineFilterEnabled}
|
onFilterEnabledChange={setTimelineFilterEnabled}
|
||||||
onFilterEnabledChange={setTimelineFilterEnabled}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -1692,7 +1748,18 @@ function EditorPageContent() {
|
|||||||
setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720));
|
setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: rightPanelWidth, height: "100vh", background: "#111827", borderLeft: "1px solid #1f2937", flex: "0 0 auto" }} />
|
<ReplayEffectsSidebar
|
||||||
|
width={rightPanelWidth}
|
||||||
|
replay={editor.activeReplayDraft}
|
||||||
|
selectedStageId={replaySelection.stageId}
|
||||||
|
selectedStepIndex={replaySelection.stepIndex}
|
||||||
|
selectedFeatureIds={selectedFeatureIds.map((id) => String(id))}
|
||||||
|
currentTimelineYear={timelineDraftYear}
|
||||||
|
geometryChoices={geometryChoices}
|
||||||
|
wikiChoices={wikiChoices}
|
||||||
|
getCurrentMapViewState={() => mapHandleRef.current?.getViewState() ?? null}
|
||||||
|
onMutateReplay={editor.mutateActiveReplay}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1794,9 +1861,7 @@ function normalizeEntitiesForCompare(input: EntitySnapshot[] | null | undefined)
|
|||||||
id: String(e.id),
|
id: String(e.id),
|
||||||
source: e.source,
|
source: e.source,
|
||||||
name: typeof e.name === "string" ? e.name.trim() : "",
|
name: typeof e.name === "string" ? e.name.trim() : "",
|
||||||
slug: typeof e.slug === "string" ? e.slug : null,
|
|
||||||
description: e.description == null ? null : String(e.description),
|
description: e.description == null ? null : String(e.description),
|
||||||
status: typeof e.status === "number" ? e.status : null,
|
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.id.localeCompare(b.id));
|
.sort((a, b) => a.id.localeCompare(b.id));
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -1821,28 +1886,33 @@ function normalizeReplaysForCompare(input: BattleReplay[] | null | undefined) {
|
|||||||
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
|
.filter((replay) => replay && typeof replay.geometry_id === "string" && replay.geometry_id.trim().length > 0)
|
||||||
.map((replay) => ({
|
.map((replay) => ({
|
||||||
geometry_id: replay.geometry_id,
|
geometry_id: replay.geometry_id,
|
||||||
|
target_geometry_ids: normalizeReplayTargetGeometryIdsForCompare(
|
||||||
|
replay.target_geometry_ids,
|
||||||
|
replay.geometry_id
|
||||||
|
),
|
||||||
detail: Array.isArray(replay.detail) ? replay.detail : [],
|
detail: Array.isArray(replay.detail) ? replay.detail : [],
|
||||||
replay_features: normalizeReplayFeatureCollection(replay.replay_features),
|
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
|
.sort((a, b) => a.geometry_id.localeCompare(b.geometry_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeReplayFeatureCollection(input: FeatureCollection | null | undefined) {
|
function normalizeReplayTargetGeometryIdsForCompare(
|
||||||
const features = Array.isArray(input?.features) ? input.features : [];
|
input: string[] | null | undefined,
|
||||||
return {
|
geometryId: string
|
||||||
type: "FeatureCollection" as const,
|
) {
|
||||||
features: features
|
const orderedIds: string[] = [];
|
||||||
.filter((feature) => feature && feature.properties && (typeof feature.properties.id === "string" || typeof feature.properties.id === "number"))
|
const seen = new Set<string>();
|
||||||
.map((feature) => ({
|
|
||||||
type: "Feature" as const,
|
const pushId = (rawId: string | number | null | undefined) => {
|
||||||
properties: {
|
if (rawId == null) return;
|
||||||
...feature.properties,
|
const id = String(rawId).trim();
|
||||||
id: String(feature.properties.id),
|
if (!id || seen.has(id)) return;
|
||||||
},
|
seen.add(id);
|
||||||
geometry: feature.geometry,
|
orderedIds.push(id);
|
||||||
}))
|
|
||||||
.sort((a, b) => String(a.properties.id).localeCompare(String(b.properties.id))),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pushId(geometryId);
|
||||||
|
for (const rawId of input || []) pushId(rawId);
|
||||||
|
return orderedIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
|
function normalizeGeoSearchGeometry(value: unknown): Geometry | null {
|
||||||
|
|||||||
@@ -332,9 +332,6 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
|
|||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: "12px", fontWeight: 700, color: hideOutside ? "#fb7185" : "#94a3b8" }}>
|
|
||||||
Hide Outside
|
|
||||||
</span>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "32px",
|
width: "32px",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,8 @@ export function useMapInteraction({
|
|||||||
}, [mapRef, onUpdateRef]);
|
}, [mapRef, onUpdateRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
const allowsSelectionMode = mode === "select" || mode === "replay";
|
||||||
|
if (!allowsSelectionMode || !selectedFeatureIds || selectedFeatureIds.length === 0) {
|
||||||
editingEngineRef.current?.clearEditing();
|
editingEngineRef.current?.clearEditing();
|
||||||
// Clear the internal selection state of the select engine to stay in sync with React state
|
// Clear the internal selection state of the select engine to stay in sync with React state
|
||||||
engineBindingsRef.current.select?.clearSelection?.(false);
|
engineBindingsRef.current.select?.clearSelection?.(false);
|
||||||
|
|||||||
@@ -216,7 +216,6 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
|||||||
title: seedTitle,
|
title: seedTitle,
|
||||||
slug: slug ?? null,
|
slug: slug ?? null,
|
||||||
doc: "",
|
doc: "",
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
setWikis((prev) => [seed, ...prev]);
|
setWikis((prev) => [seed, ...prev]);
|
||||||
setActiveId(id);
|
setActiveId(id);
|
||||||
@@ -291,7 +290,6 @@ export default function WikiSidebarPanel({ projectId, setWikis }: Props) {
|
|||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
slug: nextSlug,
|
slug: nextSlug,
|
||||||
doc: payload,
|
doc: payload,
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
+315
-145
@@ -1,194 +1,364 @@
|
|||||||
/**
|
/**
|
||||||
* Tài liệu schema tham chiếu cho snapshot commit.
|
* Schema tham chiếu cho commit snapshot.
|
||||||
*
|
*
|
||||||
* Lưu ý:
|
* Đây là file doc tự chứa, không import runtime types.
|
||||||
* - Đây không phải "single source of truth" của runtime hiện tại; logic normalize/build thật nằm ở
|
* Mục tiêu là mô tả đúng shape dữ liệu hiện tại của editor/commit/replay
|
||||||
* `src/uhm/lib/editor/snapshot/editorSnapshot.ts`.
|
* mà không phụ thuộc trực tiếp vào source code runtime.
|
||||||
* - Các phần như `replays` và nhóm `UIFunctionName` / `MapFunctionName` mô tả schema dự phòng hoặc tương lai.
|
*
|
||||||
* Editor route `/editor/[id]` hiện có mode `replay`, nhưng chưa thực thi hệ thống scripted replay đầy đủ theo file này.
|
* Ghi chú:
|
||||||
* - Các field denormalized dùng cho UI như `entity_ids`, `entity_name`, `binding`, `time_start`, `time_end`
|
* - Payload tạo commit hiện là `{ snapshot_json, edit_summary }`.
|
||||||
* có thể xuất hiện trong editor runtime, nhưng frontend sẽ strip hoặc tái sinh chúng khi build snapshot API.
|
* - `CommitSnapshot` hiện tương đương `EditorSnapshot`.
|
||||||
|
* - Nhiều field root để optional vì frontend còn phải đọc snapshot cũ / partial.
|
||||||
|
* - Replay actions trong dữ liệu thật dùng `params: unknown[]` theo positional tuple.
|
||||||
|
* - Snapshot replay cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load.
|
||||||
|
* - Trước khi gửi API, frontend còn normalize thêm một số field, ví dụ `geometries[].type`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ---- Root request ----
|
// ---- Root request ----
|
||||||
|
|
||||||
export type CreateCommitRequest = {
|
export type CreateCommitRequest = {
|
||||||
snapshot_json: CommitSnapshot;
|
snapshot_json: CommitSnapshot;
|
||||||
edit_summary: string;
|
edit_summary: string;
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Snapshot root ----
|
|
||||||
|
|
||||||
export type CommitSnapshot = {
|
|
||||||
editor_feature_collection: FeatureCollection;
|
|
||||||
entities: EntitySnapshot[];
|
|
||||||
geometries: GeometrySnapshot[];
|
|
||||||
geometry_entity: GeometryEntitySnapshot[];
|
|
||||||
wikis: WikiSnapshot[];
|
|
||||||
entity_wiki: EntityWikiLinkSnapshot[];
|
|
||||||
replays?: BattleReplay[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Replay / Scripting System ----
|
|
||||||
|
|
||||||
export type UIFunctionName =
|
|
||||||
| "hide_timeline" // Ẩn thanh timeline
|
|
||||||
| "hide_layer_panel" // Ẩn panel lớp bản đồ
|
|
||||||
| "hide_wiki_panel" // Ẩn panel wiki (bên phải)
|
|
||||||
| "hide_zoom_panel" // Ẩn các nút điều khiển zoom
|
|
||||||
| "hide_all_UI" // Ẩn toàn bộ giao diện điều khiển (cinematic mode)
|
|
||||||
| "open_wiki" // Mở panel wiki
|
|
||||||
| "show_toast_message" // Hiển thị thông báo ngắn (toast)
|
|
||||||
| "focus_wiki_header" // Cuộn đến đề mục cụ thể trong Wiki
|
|
||||||
| "set_playback_speed"; // Thay đổi tốc độ phát replay
|
|
||||||
|
|
||||||
export type MapFunctionName =
|
|
||||||
| "zoom_to_lnglat" // Di chuyển camera đến tọa độ [lng, lat]
|
|
||||||
| "zoom_scale" // Thay đổi mức zoom của bản đồ
|
|
||||||
| "zoom_geometries" // Zoom bao quát danh sách các geometry
|
|
||||||
| "change_geometry_color" // Thay đổi màu của một geometry
|
|
||||||
| "change_geometries_color" // Thay đổi màu của danh sách geometry
|
|
||||||
| "change_geometry_texture" // Thay đổi texture của một geometry
|
|
||||||
| "change_geometries_texture"// Thay đổi texture của danh sách geometry
|
|
||||||
| "hide_geometries" // Ẩn danh sách các geometry
|
|
||||||
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing)
|
|
||||||
| "fly_to_geometry" // Di chuyển mượt mà đến một geometry
|
|
||||||
| "rotate_around_point" // Xoay camera quanh một điểm
|
|
||||||
| "pulse_geometry" // Hiệu ứng nhấp nháy cho geometry
|
|
||||||
| "set_time_filter" // Thay đổi bộ lọc thời gian trên bản đồ
|
|
||||||
| "toggle_labels"; // Bật/tắt hiển thị nhãn (labels) trên bản đồ
|
|
||||||
|
|
||||||
export type NarrativeFunctionName =
|
|
||||||
| "set_title" // Đặt tiêu đề cho bước replay
|
|
||||||
| "set_descriptions" // Đặt mô tả/nội dung diễn giải
|
|
||||||
| "show_dialog_box" // Hiển thị hộp thoại dẫn chuyện (có avatar)
|
|
||||||
| "display_historical_image" // Hiển thị hình ảnh tư liệu đè lên bản đồ
|
|
||||||
| "set_step_subtitle"; // Hiển thị phụ đề phía dưới màn hình
|
|
||||||
|
|
||||||
export type ReplayAction<T> = {
|
|
||||||
function_name: T;
|
|
||||||
params: any[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReplayStep = {
|
|
||||||
duration: number; // Trọng số thời gian của step trong 1 stage
|
|
||||||
use_UI_function: ReplayAction<UIFunctionName>[];
|
|
||||||
use_map_function: ReplayAction<MapFunctionName>[];
|
|
||||||
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ReplayStage = {
|
|
||||||
id: number; // số đếm thứ tự từ 0
|
|
||||||
title?: string;
|
|
||||||
detail_time_start: string;
|
|
||||||
detail_time_stop: string;
|
|
||||||
steps: ReplayStep[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BattleReplay = {
|
|
||||||
geometry_id: string; // geometry mà khi nhấn vào là có thể replay
|
|
||||||
detail: ReplayStage[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- GeoJSON / FeatureCollection ----
|
// ---- GeoJSON / FeatureCollection ----
|
||||||
|
|
||||||
|
export type GeometryPreset = "line" | "polygon" | "circle-area" | "point";
|
||||||
|
|
||||||
export type Geometry =
|
export type Geometry =
|
||||||
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
|
| ({ type: "Point"; coordinates: [number, number] } & CircleGeometryMetadata)
|
||||||
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
| ({ type: "MultiPoint"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
||||||
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
| ({ type: "LineString"; coordinates: [number, number][] } & CircleGeometryMetadata)
|
||||||
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
| ({ type: "MultiLineString"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
||||||
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
| ({ type: "Polygon"; coordinates: [number, number][][] } & CircleGeometryMetadata)
|
||||||
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
|
| ({ type: "MultiPolygon"; coordinates: [number, number][][][] } & CircleGeometryMetadata);
|
||||||
|
|
||||||
export type CircleGeometryMetadata = {
|
export type CircleGeometryMetadata = {
|
||||||
circle_center?: [number, number];
|
circle_center?: [number, number];
|
||||||
circle_radius?: number;
|
circle_radius?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeatureId = string | number;
|
export type FeatureId = string | number;
|
||||||
|
|
||||||
export type FeatureProperties = {
|
export type FeatureProperties = {
|
||||||
id: FeatureId;
|
id: FeatureId;
|
||||||
type?: string | null; //generate
|
type?: string | null;
|
||||||
geometry_preset?: string | null;
|
geometry_preset?: GeometryPreset | null;
|
||||||
time_start?: number | null; //generate
|
time_start?: number | null;
|
||||||
time_end?: number | null; //generate
|
time_end?: number | null;
|
||||||
binding?: string[]; //generate
|
binding?: string[];
|
||||||
|
|
||||||
// Legacy/UI-only fields should not be relied on by the backend.
|
// UI/editor-only denormalized fields.
|
||||||
// FE strips these when building snapshot_json, but we keep them optional here
|
entity_id?: string | null;
|
||||||
// because older snapshots may still contain them.
|
entity_ids?: string[];
|
||||||
entity_id?: string | null; //generate
|
entity_name?: string | null;
|
||||||
entity_ids?: string[]; //generate
|
entity_names?: string[];
|
||||||
entity_name?: string | null; //generate
|
entity_type_id?: string | null;
|
||||||
entity_names?: string[]; //generate
|
point_label?: string | null;
|
||||||
entity_type_id?: string | null; //generate
|
line_label?: string | null;
|
||||||
|
polygon_label?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Feature = {
|
export type Feature = {
|
||||||
type: "Feature";
|
type: "Feature";
|
||||||
properties: FeatureProperties;
|
properties: FeatureProperties;
|
||||||
geometry: Geometry;
|
geometry: Geometry;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeatureCollection = {
|
export type FeatureCollection = {
|
||||||
type: "FeatureCollection";
|
type: "FeatureCollection";
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Snapshot rows ----
|
// ---- Snapshot rows ----
|
||||||
|
|
||||||
export type SnapshotSource = "inline" | "ref";
|
export type SnapshotSource = "inline" | "ref";
|
||||||
|
|
||||||
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
export type EntitySnapshot = {
|
export type EntitySnapshotOperation = SnapshotOperation;
|
||||||
id: string;
|
export type GeometrySnapshotOperation = SnapshotOperation;
|
||||||
source: SnapshotSource;
|
export type WikiSnapshotOperation = SnapshotOperation;
|
||||||
operation?: SnapshotOperation;
|
|
||||||
|
|
||||||
name?: string;
|
export type EntitySnapshot = {
|
||||||
description?: string | null;
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: EntitySnapshotOperation;
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometrySnapshot = {
|
export type GeometrySnapshot = {
|
||||||
id: string;
|
id: string;
|
||||||
source: SnapshotSource;
|
source: SnapshotSource;
|
||||||
operation?: SnapshotOperation;
|
operation?: GeometrySnapshotOperation;
|
||||||
type?: string | null;
|
type?: string | null;
|
||||||
draw_geometry?: Geometry;
|
draw_geometry?: Geometry;
|
||||||
binding?: string[];
|
geometry?: Geometry;
|
||||||
time_start?: number | null;
|
binding?: string[];
|
||||||
time_end?: number | null;
|
time_start?: number | null;
|
||||||
bbox?: {
|
time_end?: number | null;
|
||||||
min_lng: number;
|
bbox?: {
|
||||||
min_lat: number;
|
min_lng: number;
|
||||||
max_lng: number;
|
min_lat: number;
|
||||||
max_lat: number;
|
max_lng: number;
|
||||||
} | null;
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometryEntitySnapshot = {
|
export type GeometryEntitySnapshot = {
|
||||||
geometry_id: string;
|
geometry_id: string;
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
operation?: "reference" | "delete" | "binding";
|
operation?: "reference" | "binding" | "delete";
|
||||||
};
|
};
|
||||||
|
|
||||||
// FE stores wiki doc as a string (often HTML) or null for ref-only rows.
|
|
||||||
export type WikiDoc = string | null;
|
export type WikiDoc = string | null;
|
||||||
|
|
||||||
export type WikiSnapshot = {
|
export type WikiSnapshot = {
|
||||||
id: string;
|
id: string;
|
||||||
source: SnapshotSource;
|
source: SnapshotSource;
|
||||||
operation?: SnapshotOperation;
|
operation?: WikiSnapshotOperation;
|
||||||
|
title: string;
|
||||||
title: string;
|
slug?: string | null;
|
||||||
slug?: string | null;
|
doc: WikiDoc;
|
||||||
doc: WikiDoc;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntityWikiLinkSnapshot = {
|
export type EntityWikiLinkSnapshot = {
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
wiki_id: string;
|
wiki_id: string;
|
||||||
operation?: "reference" | "delete" | "binding";
|
operation?: "reference" | "binding" | "delete";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Replay / Scripting System (runtime shape) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical UI action names trong snapshot hiện tại.
|
||||||
|
* Không còn wrapper `function_name: "UI"` trong shape mới.
|
||||||
|
*/
|
||||||
|
export type UIOptionName =
|
||||||
|
| "timeline"
|
||||||
|
| "layer_panel"
|
||||||
|
| "wiki_panel"
|
||||||
|
| "zoom_panel"
|
||||||
|
| "wiki"
|
||||||
|
| "toast"
|
||||||
|
| "wiki_header"
|
||||||
|
| "playback_speed";
|
||||||
|
|
||||||
|
export type MapFunctionName =
|
||||||
|
| "set_camera_view"
|
||||||
|
| "set_time_filter"
|
||||||
|
| "enable_timeline_filter"
|
||||||
|
| "disable_timeline_filter"
|
||||||
|
| "toggle_labels"
|
||||||
|
| "show_labels"
|
||||||
|
| "hide_labels"
|
||||||
|
| "reset_camera_north";
|
||||||
|
|
||||||
|
export type GeoFunctionName =
|
||||||
|
| "fly_to_geometry"
|
||||||
|
| "fly_to_geometries"
|
||||||
|
| "set_geometry_visibility"
|
||||||
|
| "show_geometries"
|
||||||
|
| "hide_geometries"
|
||||||
|
| "fit_to_geometries"
|
||||||
|
| "orbit_camera_around_geometry"
|
||||||
|
| "pulse_geometry"
|
||||||
|
| "animate_dashed_border"
|
||||||
|
| "set_geometry_style"
|
||||||
|
| "show_geometry_label"
|
||||||
|
| "follow_geometry_path"
|
||||||
|
| "follow_geometries_path"
|
||||||
|
| "dim_other_geometries";
|
||||||
|
|
||||||
|
export type NarrativeFunctionName =
|
||||||
|
| "set_title"
|
||||||
|
| "set_descriptions"
|
||||||
|
| "show_dialog_box"
|
||||||
|
| "display_historical_image"
|
||||||
|
| "set_step_subtitle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime thật hiện dùng positional array cho params.
|
||||||
|
* File doc này giữ đúng shape đó.
|
||||||
|
*/
|
||||||
|
export type ReplayAction<T> = {
|
||||||
|
function_name: T;
|
||||||
|
params: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayStep = {
|
||||||
|
duration: number;
|
||||||
|
use_UI_function: ReplayAction<UIOptionName>[];
|
||||||
|
use_map_function: ReplayAction<MapFunctionName>[];
|
||||||
|
use_geo_function: ReplayAction<GeoFunctionName>[];
|
||||||
|
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayStage = {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
detail_time_start: string;
|
||||||
|
detail_time_stop: string;
|
||||||
|
steps: ReplayStep[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BattleReplay = {
|
||||||
|
geometry_id: string;
|
||||||
|
target_geometry_ids: string[];
|
||||||
|
detail: ReplayStage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Replay tuple docs ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Doc-only helper để giải thích meaning của từng vị trí trong `params`.
|
||||||
|
* Runtime không ép các tuple này; chúng chỉ là tài liệu tham chiếu.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReplayCameraViewStateDoc = {
|
||||||
|
center?: [number, number] | { lng: number; lat: number };
|
||||||
|
zoom?: number;
|
||||||
|
pitch?: number;
|
||||||
|
bearing?: number;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayUiParamTupleDocs = {
|
||||||
|
timeline: [visible: boolean];
|
||||||
|
layer_panel: [visible: boolean];
|
||||||
|
wiki_panel: [visible: boolean];
|
||||||
|
zoom_panel: [visible: boolean];
|
||||||
|
wiki: [wiki_id: string];
|
||||||
|
toast: [message: string];
|
||||||
|
wiki_header: [header_id: string];
|
||||||
|
playback_speed: [speed: number];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshot cũ kiểu `function_name: "UI"` chỉ còn là legacy input.
|
||||||
|
* Frontend hiện normalize chúng sang `function_name: UIOptionName` khi load.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ReplayMapFunctionParamTupleDocs = {
|
||||||
|
set_camera_view: [state: ReplayCameraViewStateDoc];
|
||||||
|
set_time_filter: [year: number];
|
||||||
|
enable_timeline_filter: [];
|
||||||
|
disable_timeline_filter: [];
|
||||||
|
toggle_labels: [visible: boolean];
|
||||||
|
show_labels: [];
|
||||||
|
hide_labels: [];
|
||||||
|
reset_camera_north: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayGeoFunctionParamTupleDocs = {
|
||||||
|
fly_to_geometry: [
|
||||||
|
geometry_id: string,
|
||||||
|
zoom?: number,
|
||||||
|
padding?: number,
|
||||||
|
duration?: number,
|
||||||
|
];
|
||||||
|
fly_to_geometries: [geometry_ids: string[]];
|
||||||
|
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
|
||||||
|
show_geometries: [geometry_ids: string[]];
|
||||||
|
hide_geometries: [geometry_ids: string[]];
|
||||||
|
fit_to_geometries: [
|
||||||
|
geometry_ids: string[],
|
||||||
|
padding?: number,
|
||||||
|
duration?: number,
|
||||||
|
];
|
||||||
|
orbit_camera_around_geometry: [
|
||||||
|
geometry_id: string,
|
||||||
|
zoom?: number,
|
||||||
|
pitch?: number,
|
||||||
|
revolutions?: number,
|
||||||
|
duration?: number,
|
||||||
|
];
|
||||||
|
pulse_geometry: [
|
||||||
|
geometry_id: string,
|
||||||
|
color?: string,
|
||||||
|
repeat?: number,
|
||||||
|
duration?: number,
|
||||||
|
];
|
||||||
|
animate_dashed_border: [
|
||||||
|
geometry_id: string,
|
||||||
|
color?: string,
|
||||||
|
width?: number,
|
||||||
|
speed?: number,
|
||||||
|
duration?: number,
|
||||||
|
];
|
||||||
|
set_geometry_style: [
|
||||||
|
geometry_ids: string[],
|
||||||
|
fill_color?: string,
|
||||||
|
fill_opacity?: number,
|
||||||
|
line_color?: string,
|
||||||
|
line_width?: number,
|
||||||
|
];
|
||||||
|
show_geometry_label: [
|
||||||
|
geometry_id: string,
|
||||||
|
text?: string,
|
||||||
|
color?: string,
|
||||||
|
size?: number,
|
||||||
|
];
|
||||||
|
follow_geometry_path: [
|
||||||
|
geometry_id: string,
|
||||||
|
duration?: number,
|
||||||
|
zoom?: number,
|
||||||
|
pitch?: number,
|
||||||
|
];
|
||||||
|
follow_geometries_path: [
|
||||||
|
geometry_ids: string[],
|
||||||
|
duration?: number,
|
||||||
|
zoom?: number,
|
||||||
|
pitch?: number,
|
||||||
|
];
|
||||||
|
dim_other_geometries: [
|
||||||
|
geometry_ids: string[],
|
||||||
|
opacity?: number,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayNarrativeParamTupleDocs = {
|
||||||
|
set_title: [title: string];
|
||||||
|
set_descriptions: [text: string];
|
||||||
|
show_dialog_box: [
|
||||||
|
avatar: string,
|
||||||
|
text: string,
|
||||||
|
side?: "left" | "right",
|
||||||
|
speaker?: string,
|
||||||
|
];
|
||||||
|
display_historical_image: [
|
||||||
|
url: string,
|
||||||
|
caption?: string,
|
||||||
|
];
|
||||||
|
set_step_subtitle: [subtitle: string | null];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReplayParamTupleDocs =
|
||||||
|
& ReplayUiParamTupleDocs
|
||||||
|
& ReplayMapFunctionParamTupleDocs
|
||||||
|
& ReplayGeoFunctionParamTupleDocs
|
||||||
|
& ReplayNarrativeParamTupleDocs;
|
||||||
|
|
||||||
|
export type ReplayActionTupleDoc<T extends keyof ReplayParamTupleDocs> = {
|
||||||
|
function_name: T;
|
||||||
|
params: ReplayParamTupleDocs[T];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Snapshot root ----
|
||||||
|
|
||||||
|
export type EditorSnapshot = {
|
||||||
|
// Legacy snapshots có thể còn field project embedded.
|
||||||
|
project?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
editor_feature_collection?: FeatureCollection;
|
||||||
|
entities?: EntitySnapshot[];
|
||||||
|
geometries?: GeometrySnapshot[];
|
||||||
|
geometry_entity?: GeometryEntitySnapshot[];
|
||||||
|
wikis?: WikiSnapshot[];
|
||||||
|
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||||
|
replays?: BattleReplay[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommitSnapshot = EditorSnapshot;
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ Có thể do:
|
|||||||
- timeline filter
|
- timeline filter
|
||||||
- geometry visibility theo type
|
- geometry visibility theo type
|
||||||
- binding filter
|
- binding filter
|
||||||
- replay hide outside
|
|
||||||
|
|
||||||
Không phải lúc nào cũng là bug render layer.
|
Không phải lúc nào cũng là bug render layer.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
# UHM Editor - state replay hiện tại
|
||||||
|
|
||||||
|
Tài liệu này mô tả đúng flow replay mode hiện tại của `/editor/[id]`.
|
||||||
|
|
||||||
|
Nguồn thật:
|
||||||
|
|
||||||
|
- `src/app/editor/[id]/page.tsx`
|
||||||
|
- `src/uhm/lib/editor/state/useEditorState.ts`
|
||||||
|
- `src/uhm/lib/editor/project/useProjectCommands.ts`
|
||||||
|
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts`
|
||||||
|
|
||||||
|
## 1. Kết luận ngắn
|
||||||
|
|
||||||
|
Replay mode hiện tại có 2 lớp state:
|
||||||
|
|
||||||
|
- `activeReplayDraft`
|
||||||
|
- là `BattleReplay` đang chỉnh
|
||||||
|
- chỉ chứa `geometry_id`, `target_geometry_ids`, `detail`
|
||||||
|
- `replayDraft`
|
||||||
|
- là `FeatureCollection` local, được FE hydrate lại từ `mainDraft + target_geometry_ids`
|
||||||
|
- chỉ dùng để map/render/select trong replay mode
|
||||||
|
|
||||||
|
Điểm quan trọng:
|
||||||
|
|
||||||
|
- `replayDraft` không còn được persist vào commit/API
|
||||||
|
- commit chỉ lưu `replays[]` với `target_geometry_ids`
|
||||||
|
- snapshot cũ còn `replay_features` sẽ được FE migrate sang `target_geometry_ids` khi load
|
||||||
|
|
||||||
|
## 2. Shape replay hiện tại
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type BattleReplay = {
|
||||||
|
geometry_id: string;
|
||||||
|
target_geometry_ids: string[];
|
||||||
|
detail: ReplayStage[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Ý nghĩa:
|
||||||
|
|
||||||
|
- `geometry_id`
|
||||||
|
- MAIN geo của replay
|
||||||
|
- cũng là key để tìm replay tương ứng
|
||||||
|
- `target_geometry_ids`
|
||||||
|
- toàn bộ geo được đưa vào replay
|
||||||
|
- phần tử đầu nên luôn là MAIN geo
|
||||||
|
- `detail`
|
||||||
|
- stage/step/actions của kịch bản
|
||||||
|
|
||||||
|
## 3. Replay được mở như thế nào
|
||||||
|
|
||||||
|
Khi vào replay từ UI:
|
||||||
|
|
||||||
|
1. editor lấy `triggerId`
|
||||||
|
- ưu tiên `selectedFeatureIds[0]`
|
||||||
|
- nếu chưa có selection thì dùng `featureId` vừa click
|
||||||
|
2. gọi `editor.switchReplayContext(triggerId, selectedFeatureIds)`
|
||||||
|
3. `switchReplayContext()` sẽ:
|
||||||
|
- flush replay cũ nếu đang mở replay khác
|
||||||
|
- tìm replay đã tồn tại theo `geometry_id`
|
||||||
|
- nếu chưa có thì tạo seed mới
|
||||||
|
|
||||||
|
## 4. Seed replay được tạo ra sao
|
||||||
|
|
||||||
|
Replay seed mới có dạng:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
geometry_id: triggerId,
|
||||||
|
target_geometry_ids: [...],
|
||||||
|
detail: []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`target_geometry_ids` được build từ:
|
||||||
|
|
||||||
|
- MAIN geo
|
||||||
|
- toàn bộ bulk selection hiện tại
|
||||||
|
- toàn bộ `binding` của MAIN geo trong `mainDraft`
|
||||||
|
|
||||||
|
Rule hiện tại:
|
||||||
|
|
||||||
|
- MAIN geo luôn đứng đầu
|
||||||
|
- geo trùng sẽ được dedupe
|
||||||
|
- nếu replay đã tồn tại sẵn, FE giữ `detail` cũ và chỉ append thêm geo mới còn thiếu vào `target_geometry_ids`
|
||||||
|
|
||||||
|
## 5. `replayDraft` được hydrate thế nào
|
||||||
|
|
||||||
|
`replayDraft` không còn nằm trong snapshot.
|
||||||
|
|
||||||
|
Mỗi lần:
|
||||||
|
|
||||||
|
- mở replay
|
||||||
|
- undo replay session
|
||||||
|
- restore `activeReplayDraft`
|
||||||
|
|
||||||
|
FE sẽ hydrate lại:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
replayDraft = hydrate(mainDraft, activeReplayDraft.target_geometry_ids)
|
||||||
|
```
|
||||||
|
|
||||||
|
Hydrate hiện tại:
|
||||||
|
|
||||||
|
- lấy feature từ `mainDraft` theo đúng thứ tự `target_geometry_ids`
|
||||||
|
- clone ra `FeatureCollection` mới
|
||||||
|
- flatten `binding` thành `[]` để các geo trong replay bình đẳng với nhau
|
||||||
|
|
||||||
|
## 6. Trong replay mode map đang đọc gì
|
||||||
|
|
||||||
|
`useEditorState()` vẫn switch active draft như cũ:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const activeDraft = mode === "replay" ? replayDraft : mainDraft;
|
||||||
|
```
|
||||||
|
|
||||||
|
Nên khi `mode === "replay"`:
|
||||||
|
|
||||||
|
- `editor.draft` trỏ vào `replayDraft`
|
||||||
|
- `editor.draftRef` trỏ vào `replayDraftRef`
|
||||||
|
- map chỉ render tập geo đang nằm trong `target_geometry_ids`
|
||||||
|
|
||||||
|
## 7. Replay mode còn sửa geometry không
|
||||||
|
|
||||||
|
Không.
|
||||||
|
|
||||||
|
Hiện tại state layer đã chặn toàn bộ nhánh mutate geometry trong replay mode:
|
||||||
|
|
||||||
|
- `createFeature`
|
||||||
|
- `createFeatureWithSnapshotEntities`
|
||||||
|
- `patchFeatureProperties`
|
||||||
|
- `patchFeaturePropertiesBatch`
|
||||||
|
- `updateFeature`
|
||||||
|
- `deleteFeature`
|
||||||
|
|
||||||
|
Nghĩa là:
|
||||||
|
|
||||||
|
- replay mode chỉ còn là nơi viết script replay
|
||||||
|
- không còn persist hay commit geometry edit riêng của replay
|
||||||
|
|
||||||
|
## 8. Cái gì vẫn được sửa trong replay mode
|
||||||
|
|
||||||
|
Replay sidebar vẫn sửa:
|
||||||
|
|
||||||
|
- `detail[]`
|
||||||
|
- `stage`
|
||||||
|
- `step`
|
||||||
|
- các action `UI / map / geo / narrative`
|
||||||
|
|
||||||
|
Các thay đổi đó đi qua:
|
||||||
|
|
||||||
|
- `editor.mutateActiveReplay`
|
||||||
|
- `applyReplaySessionMutation()`
|
||||||
|
|
||||||
|
Undo replay vẫn riêng ở:
|
||||||
|
|
||||||
|
- `replayUndoStack`
|
||||||
|
|
||||||
|
## 9. Khi nào replay được flush về `replays[]`
|
||||||
|
|
||||||
|
`activeReplayDraft` chỉ là session đang mở.
|
||||||
|
|
||||||
|
Nó được flush về `replays[]` khi:
|
||||||
|
|
||||||
|
- thoát replay mode
|
||||||
|
- chuyển sang replay khác
|
||||||
|
|
||||||
|
Hàm chịu trách nhiệm là:
|
||||||
|
|
||||||
|
- `finalizeActiveReplaySession()`
|
||||||
|
|
||||||
|
## 10. Commit lấy replay từ đâu
|
||||||
|
|
||||||
|
Commit không lấy `activeReplayDraft` trực tiếp.
|
||||||
|
|
||||||
|
Nó lấy:
|
||||||
|
|
||||||
|
- `editor.effectiveReplays`
|
||||||
|
|
||||||
|
`effectiveReplays` là:
|
||||||
|
|
||||||
|
- `replays`
|
||||||
|
- cộng thêm overlay của `activeReplayDraft` nếu session hiện tại đã thay đổi nhưng chưa flush
|
||||||
|
|
||||||
|
Vì vậy:
|
||||||
|
|
||||||
|
- đang còn ở replay mode vẫn commit được replay mới nhất
|
||||||
|
- không cần thoát replay mode mới lưu được script
|
||||||
|
|
||||||
|
## 11. Replay đi qua API ra sao
|
||||||
|
|
||||||
|
Payload commit hiện tại chỉ gửi:
|
||||||
|
|
||||||
|
- `geometry_id`
|
||||||
|
- `target_geometry_ids`
|
||||||
|
- `detail`
|
||||||
|
|
||||||
|
Không gửi:
|
||||||
|
|
||||||
|
- `replayDraft`
|
||||||
|
- `replay_features`
|
||||||
|
- `FeatureCollection` local của replay mode
|
||||||
|
|
||||||
|
## 12. Migrate dữ liệu cũ
|
||||||
|
|
||||||
|
Snapshot cũ nếu còn:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
replay_features?: FeatureCollection
|
||||||
|
```
|
||||||
|
|
||||||
|
thì FE sẽ:
|
||||||
|
|
||||||
|
- đọc `replay_features.features[].properties.id`
|
||||||
|
- chuyển chúng thành `target_geometry_ids`
|
||||||
|
- bỏ `replay_features` khỏi runtime replay mới
|
||||||
|
|
||||||
|
Nên dữ liệu cũ vẫn mở được, nhưng commit mới sẽ ra schema mới.
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# Replay Export JSON
|
||||||
|
|
||||||
|
Tài liệu này mô tả đúng payload mà nút `Export JSON` của replay đang xuất ra hiện tại.
|
||||||
|
|
||||||
|
Nguồn thật:
|
||||||
|
|
||||||
|
- `src/uhm/components/editor/ReplayTimelineSidebar.tsx`
|
||||||
|
|
||||||
|
## 1. Kết luận ngắn
|
||||||
|
|
||||||
|
Export hiện tại có dạng:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exported_at": "2026-05-17T12:34:56.000Z",
|
||||||
|
"geometry_id": "geo-main-id",
|
||||||
|
"current_replay": { "...": "BattleReplay hiện tại" },
|
||||||
|
"snapshot_fragment": {
|
||||||
|
"replays": [
|
||||||
|
{ "...": "chính current_replay" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Trong đó:
|
||||||
|
|
||||||
|
- `current_replay` là replay đang edit
|
||||||
|
- `snapshot_fragment.replays[0]` là cùng replay đó, nhưng đặt vào đúng chỗ trong commit snapshot
|
||||||
|
|
||||||
|
## 2. Root payload
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ReplayExportPayload = {
|
||||||
|
exported_at: string;
|
||||||
|
geometry_id: string;
|
||||||
|
current_replay: BattleReplay;
|
||||||
|
snapshot_fragment: {
|
||||||
|
replays: BattleReplay[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Ý nghĩa:
|
||||||
|
|
||||||
|
- `exported_at`
|
||||||
|
- timestamp ISO lúc bấm export
|
||||||
|
- chỉ để debug
|
||||||
|
- `geometry_id`
|
||||||
|
- copy nhanh từ `current_replay.geometry_id`
|
||||||
|
- `current_replay`
|
||||||
|
- replay draft hiện tại
|
||||||
|
- `snapshot_fragment`
|
||||||
|
- fragment để test replay này nếu đặt vào commit snapshot thật
|
||||||
|
|
||||||
|
## 3. Shape của `current_replay`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type BattleReplay = {
|
||||||
|
geometry_id: string;
|
||||||
|
target_geometry_ids: string[];
|
||||||
|
detail: ReplayStage[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Ý nghĩa:
|
||||||
|
|
||||||
|
- `geometry_id`
|
||||||
|
- MAIN geo của replay
|
||||||
|
- `target_geometry_ids`
|
||||||
|
- toàn bộ geo thuộc replay
|
||||||
|
- phần tử đầu nên luôn là MAIN geo
|
||||||
|
- `detail`
|
||||||
|
- stage/step/actions của replay script
|
||||||
|
|
||||||
|
## 4. `target_geometry_ids` là gì
|
||||||
|
|
||||||
|
Đây là phần thay thế cho `replay_features` cũ.
|
||||||
|
|
||||||
|
FE không còn export/persist cả `FeatureCollection` riêng của replay nữa. Thay vào đó chỉ lưu:
|
||||||
|
|
||||||
|
- geo MAIN
|
||||||
|
- các geo được đưa vào replay từ bulk select
|
||||||
|
- binding của MAIN geo
|
||||||
|
|
||||||
|
Khi mở replay, FE sẽ hydrate lại `replayDraft` từ:
|
||||||
|
|
||||||
|
- `mainDraft`
|
||||||
|
- `target_geometry_ids`
|
||||||
|
|
||||||
|
## 5. Shape của `detail`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ReplayStage = {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
detail_time_start: string;
|
||||||
|
detail_time_stop: string;
|
||||||
|
steps: ReplayStep[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ReplayStep = {
|
||||||
|
duration: number;
|
||||||
|
use_UI_function: ReplayAction<UIOptionName>[];
|
||||||
|
use_map_function: ReplayAction<MapFunctionName>[];
|
||||||
|
use_geo_function: ReplayAction<GeoFunctionName>[];
|
||||||
|
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Ý nghĩa:
|
||||||
|
|
||||||
|
- `stage` là cụm lớn theo mốc thời gian hoặc nhịp kể chuyện
|
||||||
|
- `step` là đơn vị phát nhỏ hơn trong một stage
|
||||||
|
- `duration` là trọng số thời gian của step
|
||||||
|
- action hiện tách thành 4 nhóm
|
||||||
|
|
||||||
|
## 6. Ví dụ JSON gần thực tế
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exported_at": "2026-05-17T12:34:56.000Z",
|
||||||
|
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||||
|
"current_replay": {
|
||||||
|
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||||
|
"target_geometry_ids": [
|
||||||
|
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||||
|
"019e13ab-6063-713d-a28f-98a1556817a7",
|
||||||
|
"019e13ab-5896-713a-111111111111"
|
||||||
|
],
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"title": "Mở đầu chiến dịch",
|
||||||
|
"detail_time_start": "1939",
|
||||||
|
"detail_time_stop": "1940",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"duration": 1000,
|
||||||
|
"use_UI_function": [
|
||||||
|
{
|
||||||
|
"function_name": "timeline",
|
||||||
|
"params": [false]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"use_map_function": [
|
||||||
|
{
|
||||||
|
"function_name": "set_time_filter",
|
||||||
|
"params": [1939]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"use_geo_function": [
|
||||||
|
{
|
||||||
|
"function_name": "fly_to_geometries",
|
||||||
|
"params": [
|
||||||
|
[
|
||||||
|
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||||
|
"019e13ab-6063-713d-a28f-98a1556817a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"use_narrow_function": [
|
||||||
|
{
|
||||||
|
"function_name": "set_title",
|
||||||
|
"params": ["Chiến dịch bắt đầu"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"snapshot_fragment": {
|
||||||
|
"replays": [
|
||||||
|
{
|
||||||
|
"geometry_id": "019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||||
|
"target_geometry_ids": [
|
||||||
|
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||||
|
"019e13ab-6063-713d-a28f-98a1556817a7",
|
||||||
|
"019e13ab-5896-713a-111111111111"
|
||||||
|
],
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"title": "Mở đầu chiến dịch",
|
||||||
|
"detail_time_start": "1939",
|
||||||
|
"detail_time_stop": "1940",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"duration": 1000,
|
||||||
|
"use_UI_function": [
|
||||||
|
{
|
||||||
|
"function_name": "timeline",
|
||||||
|
"params": [false]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"use_map_function": [
|
||||||
|
{
|
||||||
|
"function_name": "set_time_filter",
|
||||||
|
"params": [1939]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"use_geo_function": [
|
||||||
|
{
|
||||||
|
"function_name": "fly_to_geometries",
|
||||||
|
"params": [
|
||||||
|
[
|
||||||
|
"019e13ab-4823-76c5-afde-2391c0cf311d",
|
||||||
|
"019e13ab-6063-713d-a28f-98a1556817a7"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"use_narrow_function": [
|
||||||
|
{
|
||||||
|
"function_name": "set_title",
|
||||||
|
"params": ["Chiến dịch bắt đầu"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Cách đọc file export
|
||||||
|
|
||||||
|
Khi nhìn file export:
|
||||||
|
|
||||||
|
- nếu cần biết replay bám vào geo nào, xem `geometry_id`
|
||||||
|
- nếu cần biết replay gồm những geo nào, xem `target_geometry_ids`
|
||||||
|
- nếu cần biết script sẽ làm gì, xem `detail[].steps[]`
|
||||||
|
- nếu cần so với commit snapshot, xem `snapshot_fragment.replays`
|
||||||
|
|
||||||
|
## 8. Ghi chú quan trọng
|
||||||
|
|
||||||
|
- Export hiện tại không còn chứa `replay_features`
|
||||||
|
- Nếu mở replay cũ từng dùng `replay_features`, FE sẽ migrate sang `target_geometry_ids` trước khi export
|
||||||
|
- `current_replay` và `snapshot_fragment.replays[0]` hiện vẫn là cùng một replay, chỉ khác góc nhìn
|
||||||
@@ -157,7 +157,6 @@ Binding hiện tại:
|
|||||||
Nó là mode hiển thị tập trung vào một geometry:
|
Nó là mode hiển thị tập trung vào một geometry:
|
||||||
|
|
||||||
- có nút thoát replay
|
- có nút thoát replay
|
||||||
- có toggle `Hide Outside`
|
|
||||||
- có thể ẩn geometry ngoài danh sách `binding`
|
- có thể ẩn geometry ngoài danh sách `binding`
|
||||||
|
|
||||||
## 8. Đồng bộ selection và feature state
|
## 8. Đồng bộ selection và feature state
|
||||||
|
|||||||
@@ -306,10 +306,11 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
|
|||||||
const id = String(e.id);
|
const id = String(e.id);
|
||||||
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
|
||||||
return {
|
return {
|
||||||
...e,
|
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
|
name: typeof e.name === "string" ? e.name : undefined,
|
||||||
|
description: typeof e.description === "string" ? e.description : e.description ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,10 +324,23 @@ function toEditorSessionGeometries(input: EditorSnapshot["geometries"]): Geometr
|
|||||||
const id = String(g.id);
|
const id = String(g.id);
|
||||||
const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
|
const source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
|
||||||
return {
|
return {
|
||||||
...g,
|
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
|
type: g.type ?? undefined,
|
||||||
|
draw_geometry: g.draw_geometry,
|
||||||
|
geometry: g.geometry,
|
||||||
|
binding: Array.isArray(g.binding) ? [...g.binding] : undefined,
|
||||||
|
time_start: typeof g.time_start === "number" ? g.time_start : g.time_start ?? undefined,
|
||||||
|
time_end: typeof g.time_end === "number" ? g.time_end : g.time_end ?? undefined,
|
||||||
|
bbox: g.bbox
|
||||||
|
? {
|
||||||
|
min_lng: g.bbox.min_lng,
|
||||||
|
min_lat: g.bbox.min_lat,
|
||||||
|
max_lng: g.bbox.max_lng,
|
||||||
|
max_lat: g.bbox.max_lat,
|
||||||
|
}
|
||||||
|
: g.bbox ?? undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -350,7 +364,6 @@ function toEditorSessionGeometryEntity(input: EditorSnapshot["geometry_entity"])
|
|||||||
geometry_id,
|
geometry_id,
|
||||||
entity_id,
|
entity_id,
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
base_links_hash: safeRow.base_links_hash,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Array.from(deduped.values()).sort((a, b) => {
|
return Array.from(deduped.values()).sort((a, b) => {
|
||||||
@@ -368,9 +381,12 @@ function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
|
|||||||
.map((w) => {
|
.map((w) => {
|
||||||
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
|
||||||
return {
|
return {
|
||||||
...w,
|
id: w.id,
|
||||||
source,
|
source,
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
|
title: typeof w.title === "string" ? w.title : "",
|
||||||
|
slug: w.slug ?? null,
|
||||||
|
doc: w.doc ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ 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, 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,
|
||||||
|
GeoFunctionName,
|
||||||
|
MapFunctionName,
|
||||||
|
NarrativeFunctionName,
|
||||||
|
Project,
|
||||||
|
ReplayAction,
|
||||||
|
UIOptionName,
|
||||||
|
} from "@/uhm/types/projects";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
|
||||||
|
|
||||||
@@ -22,7 +31,6 @@ interface RawEntityRow extends UnknownRecord {
|
|||||||
ref?: { id?: string };
|
ref?: { id?: string };
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawWikiRow extends UnknownRecord {
|
interface RawWikiRow extends UnknownRecord {
|
||||||
@@ -33,14 +41,27 @@ interface RawWikiRow extends UnknownRecord {
|
|||||||
title?: string;
|
title?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
doc?: string;
|
doc?: string;
|
||||||
updated_at?: string | number;
|
}
|
||||||
|
|
||||||
|
interface RawGeometryRow extends UnknownRecord {
|
||||||
|
id?: string | number;
|
||||||
|
operation?: string;
|
||||||
|
source?: string;
|
||||||
|
ref?: { id?: string };
|
||||||
|
type?: unknown;
|
||||||
|
geo_type?: unknown;
|
||||||
|
draw_geometry?: unknown;
|
||||||
|
geometry?: unknown;
|
||||||
|
binding?: unknown;
|
||||||
|
time_start?: unknown;
|
||||||
|
time_end?: unknown;
|
||||||
|
bbox?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawGeometryEntityRow extends UnknownRecord {
|
interface RawGeometryEntityRow extends UnknownRecord {
|
||||||
geometry_id?: string | number;
|
geometry_id?: string | number;
|
||||||
entity_id?: string | number;
|
entity_id?: string | number;
|
||||||
operation?: string;
|
operation?: string;
|
||||||
base_links_hash?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawEntityWikiRow extends UnknownRecord {
|
interface RawEntityWikiRow extends UnknownRecord {
|
||||||
@@ -96,14 +117,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
const refId = getRefId(e.ref);
|
const refId = getRefId(e.ref);
|
||||||
const source: "inline" | "ref" =
|
const source: "inline" | "ref" =
|
||||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||||
const rest: UnknownRecord = { ...e };
|
|
||||||
delete rest.ref;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
|
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
operation,
|
operation,
|
||||||
|
name: typeof e.name === "string" ? e.name : undefined,
|
||||||
|
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -113,25 +133,37 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
? geometriesRaw
|
? geometriesRaw
|
||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
const id = getStringId(g.id);
|
const row = g as RawGeometryRow;
|
||||||
const opRaw = typeof g.operation === "string" ? g.operation : undefined;
|
const id = getStringId(row.id);
|
||||||
|
const opRaw = typeof row.operation === "string" ? row.operation : undefined;
|
||||||
const operation: GeometrySnapshot["operation"] =
|
const operation: GeometrySnapshot["operation"] =
|
||||||
opRaw === "delete" ? "delete" : "reference";
|
opRaw === "delete" ? "delete" : "reference";
|
||||||
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
|
const existingSource = row.source === "inline" || row.source === "ref" ? row.source : undefined;
|
||||||
const refId = getRefId(g.ref);
|
const refId = getRefId(row.ref);
|
||||||
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
|
const hasInlineGeometry = "draw_geometry" in row || "geometry" in row;
|
||||||
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
|
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
|
||||||
const rest: UnknownRecord = { ...g };
|
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
|
||||||
delete rest.ref;
|
|
||||||
const typeKey = normalizeGeoTypeKey(rest.type) || normalizeGeoTypeKey(rest.geo_type);
|
|
||||||
delete rest.geo_type;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
|
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
operation,
|
operation,
|
||||||
type: typeKey,
|
type: typeKey,
|
||||||
|
draw_geometry: row.draw_geometry as GeometrySnapshot["draw_geometry"],
|
||||||
|
geometry: row.geometry as GeometrySnapshot["geometry"],
|
||||||
|
binding: Array.isArray(row.binding) ? row.binding as string[] : undefined,
|
||||||
|
time_start: typeof row.time_start === "number" ? row.time_start : row.time_start == null ? undefined : undefined,
|
||||||
|
time_end: typeof row.time_end === "number" ? row.time_end : row.time_end == null ? undefined : undefined,
|
||||||
|
bbox: isRecord(row.bbox)
|
||||||
|
? {
|
||||||
|
min_lng: Number(row.bbox.min_lng),
|
||||||
|
min_lat: Number(row.bbox.min_lat),
|
||||||
|
max_lng: Number(row.bbox.max_lng),
|
||||||
|
max_lat: Number(row.bbox.max_lat),
|
||||||
|
}
|
||||||
|
: row.bbox == null
|
||||||
|
? undefined
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -141,6 +173,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
? wikisRaw
|
? wikisRaw
|
||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.map((w) => {
|
.map((w) => {
|
||||||
|
const row = w as RawWikiRow;
|
||||||
const id = typeof w.id === "string" ? w.id : "";
|
const id = typeof w.id === "string" ? w.id : "";
|
||||||
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
|
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
|
||||||
const operation: WikiSnapshot["operation"] =
|
const operation: WikiSnapshot["operation"] =
|
||||||
@@ -149,14 +182,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
const refId = getRefId(w.ref);
|
const refId = getRefId(w.ref);
|
||||||
const source: "inline" | "ref" =
|
const source: "inline" | "ref" =
|
||||||
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
|
||||||
const rest: UnknownRecord = { ...w };
|
|
||||||
delete rest.ref;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
|
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
operation,
|
operation,
|
||||||
|
title: typeof row.title === "string" ? row.title : "",
|
||||||
|
slug: row.slug ?? null,
|
||||||
|
doc: typeof row.doc === "string" ? row.doc : null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -173,7 +205,6 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
const geometry_id = getStringId(row.geometry_id);
|
const geometry_id = getStringId(row.geometry_id);
|
||||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||||
return {
|
return {
|
||||||
...(row as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
|
|
||||||
geometry_id,
|
geometry_id,
|
||||||
entity_id,
|
entity_id,
|
||||||
};
|
};
|
||||||
@@ -298,7 +329,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
wikis,
|
wikis,
|
||||||
geometry_entity: geometryEntity || migratedGeometryEntity,
|
geometry_entity: geometryEntity || migratedGeometryEntity,
|
||||||
entity_wiki: entityWikis,
|
entity_wiki: entityWikis,
|
||||||
replays: Array.isArray(snapshot.replays) ? (snapshot.replays as BattleReplay[]) : undefined,
|
replays: normalizeReplaySnapshots(snapshot.replays),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,10 +383,11 @@ export function buildEditorSnapshot(options: {
|
|||||||
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
const cloned = JSON.parse(JSON.stringify(prev)) as EntitySnapshot;
|
||||||
delete cloned.operation;
|
delete cloned.operation;
|
||||||
entityRows.set(id, {
|
entityRows.set(id, {
|
||||||
...cloned,
|
|
||||||
id,
|
id,
|
||||||
source: "inline",
|
source: "inline",
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
|
name: typeof cloned.name === "string" ? cloned.name : undefined,
|
||||||
|
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const row of options.snapshotEntities || []) {
|
for (const row of options.snapshotEntities || []) {
|
||||||
@@ -374,11 +406,11 @@ export function buildEditorSnapshot(options: {
|
|||||||
if (opRaw === "delete") continue;
|
if (opRaw === "delete") continue;
|
||||||
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
|
||||||
entityRows.set(id, {
|
entityRows.set(id, {
|
||||||
...cloned,
|
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
name,
|
name,
|
||||||
operation,
|
operation,
|
||||||
|
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,9 +423,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
source: "ref",
|
source: "ref",
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
name: id,
|
name: id,
|
||||||
slug: null,
|
|
||||||
description: null,
|
description: null,
|
||||||
status: 1,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,9 +435,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
source: "ref",
|
source: "ref",
|
||||||
operation: "reference",
|
operation: "reference",
|
||||||
name: entityId,
|
name: entityId,
|
||||||
slug: null,
|
|
||||||
description: null,
|
description: null,
|
||||||
status: 1,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,7 +485,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const baselineGeometryEntity = new globalThis.Map<string, string | undefined>();
|
const baselineGeometryEntity = new Set<string>();
|
||||||
for (const r of options.previousSnapshot?.geometry_entity || []) {
|
for (const r of options.previousSnapshot?.geometry_entity || []) {
|
||||||
const row = r as RawGeometryEntityRow;
|
const row = r as RawGeometryEntityRow;
|
||||||
if (!row) continue;
|
if (!row) continue;
|
||||||
@@ -465,7 +493,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
|
const geometry_id = typeof row.geometry_id === "string" || typeof row.geometry_id === "number" ? String(row.geometry_id).trim() : "";
|
||||||
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
|
const entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
|
||||||
if (!geometry_id || !entity_id) continue;
|
if (!geometry_id || !entity_id) continue;
|
||||||
baselineGeometryEntity.set(`${geometry_id}::${entity_id}`, row.base_links_hash);
|
baselineGeometryEntity.add(`${geometry_id}::${entity_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
|
const currentGeometryEntityRows: GeometryEntitySnapshot[] = [];
|
||||||
@@ -481,18 +509,17 @@ export function buildEditorSnapshot(options: {
|
|||||||
geometry_id,
|
geometry_id,
|
||||||
entity_id,
|
entity_id,
|
||||||
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
|
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
|
||||||
base_links_hash: baselineGeometryEntity.get(key),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relations removed during this session are emitted as "delete" operations.
|
// Relations removed during this session are emitted as "delete" operations.
|
||||||
// NOTE: The editor state itself should remove the relation row; the commit payload is the delta.
|
// NOTE: The editor state itself should remove the relation row; the commit payload is the delta.
|
||||||
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) {
|
for (const key of baselineGeometryEntity.values()) {
|
||||||
if (currentGeometryEntityKeys.has(key)) continue;
|
if (currentGeometryEntityKeys.has(key)) continue;
|
||||||
const [geometry_id, entity_id] = key.split("::");
|
const [geometry_id, entity_id] = key.split("::");
|
||||||
if (!geometry_id || !entity_id) continue;
|
if (!geometry_id || !entity_id) continue;
|
||||||
currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete", base_links_hash });
|
currentGeometryEntityRows.push({ geometry_id, entity_id, operation: "delete" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows);
|
const geometryEntity = dedupeAndSortGeometryEntity(currentGeometryEntityRows);
|
||||||
@@ -587,7 +614,6 @@ export function buildEditorSnapshot(options: {
|
|||||||
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
|
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
|
||||||
slug: row.slug ?? null,
|
slug: row.slug ?? null,
|
||||||
doc: row.doc ?? null,
|
doc: row.doc ?? null,
|
||||||
updated_at: row.updated_at ?? undefined,
|
|
||||||
} as WikiSnapshot);
|
} as WikiSnapshot);
|
||||||
}
|
}
|
||||||
const wikis = [...wikisCurrent, ...deletedWikis];
|
const wikis = [...wikisCurrent, ...deletedWikis];
|
||||||
@@ -675,9 +701,237 @@ export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(cloned.replays)) {
|
||||||
|
cloned.replays = cloned.replays.map((replay) => {
|
||||||
|
const geometryId = typeof replay?.geometry_id === "string" ? replay.geometry_id : "";
|
||||||
|
return {
|
||||||
|
geometry_id: geometryId,
|
||||||
|
target_geometry_ids: normalizeReplayTargetGeometryIds(replay as unknown, geometryId),
|
||||||
|
detail: Array.isArray(replay?.detail) ? replay.detail : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return cloned;
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReplaySnapshots(value: unknown): BattleReplay[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
return value.map((replay) => normalizeReplaySnapshot(replay as BattleReplay));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplaySnapshot(replay: BattleReplay): BattleReplay {
|
||||||
|
const geometryId = typeof replay?.geometry_id === "string" ? replay.geometry_id : "";
|
||||||
|
return {
|
||||||
|
geometry_id: geometryId,
|
||||||
|
target_geometry_ids: normalizeReplayTargetGeometryIds(replay, geometryId),
|
||||||
|
detail: Array.isArray(replay.detail)
|
||||||
|
? replay.detail.map((stage) => ({
|
||||||
|
...stage,
|
||||||
|
steps: Array.isArray(stage.steps)
|
||||||
|
? stage.steps.map((step) => {
|
||||||
|
const { mapActions, geoActions } = normalizeReplayMapAndGeoActions(
|
||||||
|
isRecord(step) ? step.use_map_function : undefined,
|
||||||
|
isRecord(step) ? step.use_geo_function : undefined
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
use_UI_function: normalizeReplayUiActions(isRecord(step) ? step.use_UI_function : undefined),
|
||||||
|
use_map_function: mapActions,
|
||||||
|
use_geo_function: geoActions,
|
||||||
|
use_narrow_function: normalizeReplayNarrativeActions(
|
||||||
|
isRecord(step) ? step.use_narrow_function : undefined
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayTargetGeometryIds(replay: unknown, geometryId: string): string[] {
|
||||||
|
const orderedIds: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const pushId = (rawId: unknown) => {
|
||||||
|
if (typeof rawId !== "string" && typeof rawId !== "number") return;
|
||||||
|
const id = String(rawId).trim();
|
||||||
|
if (!id || seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
orderedIds.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
pushId(geometryId);
|
||||||
|
|
||||||
|
if (isRecord(replay) && Array.isArray(replay.target_geometry_ids)) {
|
||||||
|
for (const rawId of replay.target_geometry_ids) pushId(rawId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRecord(replay) && isRecord(replay.replay_features) && Array.isArray(replay.replay_features.features)) {
|
||||||
|
for (const feature of replay.replay_features.features) {
|
||||||
|
if (!isRecord(feature) || !isRecord(feature.properties)) continue;
|
||||||
|
pushId(feature.properties.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayUiActions(actions: unknown): ReplayAction<UIOptionName>[] {
|
||||||
|
if (!Array.isArray(actions)) return [];
|
||||||
|
|
||||||
|
return actions.flatMap((action) => {
|
||||||
|
if (!isRecord(action)) return [];
|
||||||
|
|
||||||
|
const functionName = action.function_name;
|
||||||
|
const params = Array.isArray(action.params) ? action.params : [];
|
||||||
|
|
||||||
|
if (functionName === "UI") {
|
||||||
|
const option = normalizeReplayUiOption(params[0]);
|
||||||
|
if (!option) return [];
|
||||||
|
return [{
|
||||||
|
function_name: option,
|
||||||
|
params: params.slice(1),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = normalizeReplayUiOption(functionName);
|
||||||
|
if (!option) return [];
|
||||||
|
return [{
|
||||||
|
function_name: option,
|
||||||
|
params,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayUiOption(value: unknown): UIOptionName | null {
|
||||||
|
switch (value) {
|
||||||
|
case "timeline":
|
||||||
|
case "layer_panel":
|
||||||
|
case "wiki_panel":
|
||||||
|
case "zoom_panel":
|
||||||
|
case "wiki":
|
||||||
|
case "toast":
|
||||||
|
case "wiki_header":
|
||||||
|
case "playback_speed":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayMapAndGeoActions(
|
||||||
|
mapActions: unknown,
|
||||||
|
geoActions: unknown
|
||||||
|
): {
|
||||||
|
mapActions: ReplayAction<MapFunctionName>[];
|
||||||
|
geoActions: ReplayAction<GeoFunctionName>[];
|
||||||
|
} {
|
||||||
|
const combinedActions = [
|
||||||
|
...(Array.isArray(mapActions) ? mapActions : []),
|
||||||
|
...(Array.isArray(geoActions) ? geoActions : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizedMapActions: ReplayAction<MapFunctionName>[] = [];
|
||||||
|
const normalizedGeoActions: ReplayAction<GeoFunctionName>[] = [];
|
||||||
|
|
||||||
|
for (const action of combinedActions) {
|
||||||
|
if (!isRecord(action)) continue;
|
||||||
|
|
||||||
|
const functionName = action.function_name;
|
||||||
|
const params = Array.isArray(action.params) ? action.params : [];
|
||||||
|
const mapFunctionName = normalizeReplayMapFunctionName(functionName);
|
||||||
|
if (mapFunctionName) {
|
||||||
|
normalizedMapActions.push({
|
||||||
|
function_name: mapFunctionName,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geoFunctionName = normalizeReplayGeoFunctionName(functionName);
|
||||||
|
if (geoFunctionName) {
|
||||||
|
normalizedGeoActions.push({
|
||||||
|
function_name: geoFunctionName,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapActions: normalizedMapActions,
|
||||||
|
geoActions: normalizedGeoActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayMapFunctionName(value: unknown): MapFunctionName | null {
|
||||||
|
switch (value) {
|
||||||
|
case "set_camera_view":
|
||||||
|
case "set_time_filter":
|
||||||
|
case "enable_timeline_filter":
|
||||||
|
case "disable_timeline_filter":
|
||||||
|
case "toggle_labels":
|
||||||
|
case "show_labels":
|
||||||
|
case "hide_labels":
|
||||||
|
case "reset_camera_north":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayGeoFunctionName(value: unknown): GeoFunctionName | null {
|
||||||
|
switch (value) {
|
||||||
|
case "fly_to_geometry":
|
||||||
|
case "fly_to_geometries":
|
||||||
|
case "set_geometry_visibility":
|
||||||
|
case "show_geometries":
|
||||||
|
case "hide_geometries":
|
||||||
|
case "fit_to_geometries":
|
||||||
|
case "orbit_camera_around_geometry":
|
||||||
|
case "pulse_geometry":
|
||||||
|
case "animate_dashed_border":
|
||||||
|
case "set_geometry_style":
|
||||||
|
case "show_geometry_label":
|
||||||
|
case "follow_geometry_path":
|
||||||
|
case "follow_geometries_path":
|
||||||
|
case "dim_other_geometries":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<NarrativeFunctionName>[] {
|
||||||
|
if (!Array.isArray(actions)) return [];
|
||||||
|
|
||||||
|
return actions.flatMap((action) => {
|
||||||
|
if (!isRecord(action)) return [];
|
||||||
|
|
||||||
|
const functionName = normalizeReplayNarrativeFunctionName(action.function_name);
|
||||||
|
if (!functionName) return [];
|
||||||
|
|
||||||
|
return [{
|
||||||
|
function_name: functionName,
|
||||||
|
params: Array.isArray(action.params) ? action.params : [],
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayNarrativeFunctionName(value: unknown): NarrativeFunctionName | null {
|
||||||
|
switch (value) {
|
||||||
|
case "set_title":
|
||||||
|
case "set_descriptions":
|
||||||
|
case "show_dialog_box":
|
||||||
|
case "display_historical_image":
|
||||||
|
case "set_step_subtitle":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
|
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const deduped: GeometryEntitySnapshot[] = [];
|
const deduped: GeometryEntitySnapshot[] = [];
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ type ReplayDraftSyncMode = "none" | "reset";
|
|||||||
|
|
||||||
// State trung tâm của editor:
|
// State trung tâm của editor:
|
||||||
// - main draft: dữ liệu section thông thường
|
// - main draft: dữ liệu section thông thường
|
||||||
// - active replay draft: bản sao đầy đủ của toàn bộ BattleReplay đang chỉnh
|
// - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids)
|
||||||
// - replay feature draft: FeatureCollection con để map/editor hiện tại thao tác
|
// - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids
|
||||||
export function useEditorState(
|
export function useEditorState(
|
||||||
initialData: FeatureCollection,
|
initialData: FeatureCollection,
|
||||||
options: {
|
options: {
|
||||||
@@ -86,9 +86,9 @@ export function useEditorState(
|
|||||||
return cloned;
|
return cloned;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const syncReplayFeatureDraft = useCallback((nextFeatures: FeatureCollection) => {
|
const syncReplayFeatureDraft = useCallback((nextReplay: BattleReplay | null) => {
|
||||||
resetReplayDraft(deepClone(nextFeatures));
|
resetReplayDraft(buildReplayFeatureDraft(mainDraftRef.current, nextReplay));
|
||||||
}, [resetReplayDraft]);
|
}, [mainDraftRef, resetReplayDraft]);
|
||||||
|
|
||||||
const setActiveReplayDraftState = useCallback((
|
const setActiveReplayDraftState = useCallback((
|
||||||
next: SetStateAction<BattleReplay | null>,
|
next: SetStateAction<BattleReplay | null>,
|
||||||
@@ -100,7 +100,7 @@ export function useEditorState(
|
|||||||
setActiveReplayDraft(cloned);
|
setActiveReplayDraft(cloned);
|
||||||
|
|
||||||
if (syncMode === "reset") {
|
if (syncMode === "reset") {
|
||||||
syncReplayFeatureDraft(cloned?.replay_features || EMPTY_FEATURE_COLLECTION);
|
syncReplayFeatureDraft(cloned);
|
||||||
}
|
}
|
||||||
|
|
||||||
return cloned;
|
return cloned;
|
||||||
@@ -302,9 +302,6 @@ export function useEditorState(
|
|||||||
|
|
||||||
const prevReplay = deepClone(currentReplay);
|
const prevReplay = deepClone(currentReplay);
|
||||||
const nextReplay = deepClone(currentReplay);
|
const nextReplay = deepClone(currentReplay);
|
||||||
if (!nextReplay.replay_features) {
|
|
||||||
nextReplay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
|
|
||||||
}
|
|
||||||
mutator(nextReplay);
|
mutator(nextReplay);
|
||||||
if (replayEquals(prevReplay, nextReplay)) {
|
if (replayEquals(prevReplay, nextReplay)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -364,10 +361,6 @@ export function useEditorState(
|
|||||||
const featureClone = deepClone(feature);
|
const featureClone = deepClone(feature);
|
||||||
|
|
||||||
if (mode === "replay") {
|
if (mode === "replay") {
|
||||||
applyReplaySessionMutation(`Replay: thêm #${featureClone.properties.id}`, (draftReplay) => {
|
|
||||||
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
|
||||||
featureDraft.features = [...featureDraft.features, featureClone];
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +377,6 @@ export function useEditorState(
|
|||||||
label = "Import geometry"
|
label = "Import geometry"
|
||||||
) {
|
) {
|
||||||
if (mode === "replay") {
|
if (mode === "replay") {
|
||||||
createFeature(feature);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,18 +425,6 @@ export function useEditorState(
|
|||||||
patch: Partial<FeatureProperties>
|
patch: Partial<FeatureProperties>
|
||||||
) {
|
) {
|
||||||
if (mode === "replay") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,30 +454,6 @@ export function useEditorState(
|
|||||||
label = "Cập nhật nhiều geometry"
|
label = "Cập nhật nhiều geometry"
|
||||||
) {
|
) {
|
||||||
if (mode === "replay") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,15 +503,6 @@ export function useEditorState(
|
|||||||
|
|
||||||
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
|
||||||
if (mode === "replay") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,10 +526,6 @@ export function useEditorState(
|
|||||||
|
|
||||||
function deleteFeature(id: FeatureProperties["id"]) {
|
function deleteFeature(id: FeatureProperties["id"]) {
|
||||||
if (mode === "replay") {
|
if (mode === "replay") {
|
||||||
applyReplaySessionMutation(`Replay: xóa #${id}`, (draftReplay) => {
|
|
||||||
const featureDraft = ensureReplayFeatureCollection(draftReplay);
|
|
||||||
featureDraft.features = featureDraft.features.filter((feature) => feature.properties.id !== id);
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,6 +680,7 @@ export function useEditorState(
|
|||||||
activeReplayDraft,
|
activeReplayDraft,
|
||||||
effectiveReplays,
|
effectiveReplays,
|
||||||
setReplays: updateReplaysState,
|
setReplays: updateReplaysState,
|
||||||
|
mutateActiveReplay: applyReplaySessionMutation,
|
||||||
activeReplayId,
|
activeReplayId,
|
||||||
switchReplayContext,
|
switchReplayContext,
|
||||||
closeReplayContext,
|
closeReplayContext,
|
||||||
@@ -766,32 +710,6 @@ function resolveStateAction<T>(next: SetStateAction<T>, prev: T): T {
|
|||||||
return typeof next === "function" ? (next as (value: T) => T)(prev) : next;
|
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(
|
function createReplaySessionSeed(
|
||||||
sourceDraft: FeatureCollection,
|
sourceDraft: FeatureCollection,
|
||||||
geometryId: string,
|
geometryId: string,
|
||||||
@@ -799,8 +717,12 @@ function createReplaySessionSeed(
|
|||||||
): BattleReplay {
|
): BattleReplay {
|
||||||
return {
|
return {
|
||||||
geometry_id: geometryId,
|
geometry_id: geometryId,
|
||||||
|
target_geometry_ids: buildReplaySeedTargetIds(
|
||||||
|
sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId),
|
||||||
|
geometryId,
|
||||||
|
selectedIds
|
||||||
|
),
|
||||||
detail: [],
|
detail: [],
|
||||||
replay_features: buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,17 +733,103 @@ function normalizeReplaySessionSeed(
|
|||||||
selectedIds: (string | number)[]
|
selectedIds: (string | number)[]
|
||||||
): BattleReplay {
|
): BattleReplay {
|
||||||
const nextReplay = deepClone(replay);
|
const nextReplay = deepClone(replay);
|
||||||
if (!nextReplay.replay_features) {
|
const triggerFeature = sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId);
|
||||||
nextReplay.replay_features = buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds);
|
const seedTargetIds = buildReplaySeedTargetIds(triggerFeature, geometryId, selectedIds);
|
||||||
}
|
nextReplay.target_geometry_ids = normalizeReplayTargetGeometryIds(
|
||||||
|
nextReplay.target_geometry_ids,
|
||||||
|
geometryId,
|
||||||
|
seedTargetIds
|
||||||
|
);
|
||||||
return nextReplay;
|
return nextReplay;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureReplayFeatureCollection(replay: BattleReplay): FeatureCollection {
|
function buildReplaySeedTargetIds(
|
||||||
if (!replay.replay_features) {
|
triggerFeature: Feature | undefined,
|
||||||
replay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
|
featureId: string,
|
||||||
|
selectedIds: (string | number)[]
|
||||||
|
) {
|
||||||
|
const orderedIds: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const pushId = (rawId: string | number | null | undefined) => {
|
||||||
|
if (rawId == null) return;
|
||||||
|
const id = String(rawId).trim();
|
||||||
|
if (!id || seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
orderedIds.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
pushId(featureId);
|
||||||
|
|
||||||
|
for (const rawId of selectedIds || []) {
|
||||||
|
pushId(rawId);
|
||||||
}
|
}
|
||||||
return replay.replay_features;
|
|
||||||
|
if (Array.isArray(triggerFeature?.properties?.binding)) {
|
||||||
|
for (const rawId of triggerFeature.properties.binding) {
|
||||||
|
pushId(rawId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReplayFeatureDraft(
|
||||||
|
sourceDraft: FeatureCollection,
|
||||||
|
replay: BattleReplay | null
|
||||||
|
): FeatureCollection {
|
||||||
|
if (!replay) return deepClone(EMPTY_FEATURE_COLLECTION);
|
||||||
|
return buildReplayFeatureDraftFromTargetIds(
|
||||||
|
sourceDraft,
|
||||||
|
normalizeReplayTargetGeometryIds(replay.target_geometry_ids, replay.geometry_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReplayFeatureDraftFromTargetIds(
|
||||||
|
sourceDraft: FeatureCollection,
|
||||||
|
targetGeometryIds: string[]
|
||||||
|
): FeatureCollection {
|
||||||
|
return {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: targetGeometryIds
|
||||||
|
.map((id) =>
|
||||||
|
sourceDraft.features.find((feature) => String(feature.properties.id) === id) || null
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((feature) => sanitizeReplayFeature(deepClone(feature!))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplayTargetGeometryIds(
|
||||||
|
targetGeometryIds: string[] | undefined,
|
||||||
|
geometryId: string,
|
||||||
|
extraIds: (string | number)[] = []
|
||||||
|
): string[] {
|
||||||
|
const orderedIds: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const pushId = (rawId: string | number | null | undefined) => {
|
||||||
|
if (rawId == null) return;
|
||||||
|
const id = String(rawId).trim();
|
||||||
|
if (!id || seen.has(id)) return;
|
||||||
|
seen.add(id);
|
||||||
|
orderedIds.push(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
pushId(geometryId);
|
||||||
|
for (const rawId of targetGeometryIds || []) pushId(rawId);
|
||||||
|
for (const rawId of extraIds || []) pushId(rawId);
|
||||||
|
return orderedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeReplayFeature(feature: Feature): Feature {
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
properties: {
|
||||||
|
...feature.properties,
|
||||||
|
binding: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceReplayByGeometryId(
|
function replaceReplayByGeometryId(
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function initSelect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const additive = !!e.originalEvent?.altKey;
|
const additive = !!e.originalEvent?.altKey;
|
||||||
selectFeature(features[0], additive);
|
selectFeature(pickPreferredFeature(features), additive);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
// Hiển thị menu ngữ cảnh (sửa/xóa) khi click chuột phải.
|
||||||
@@ -88,7 +88,7 @@ export function initSelect(
|
|||||||
|
|
||||||
if (!features.length) return;
|
if (!features.length) return;
|
||||||
|
|
||||||
const feature = features[0];
|
const feature = pickPreferredFeature(features);
|
||||||
const id = feature.id ?? feature.properties?.id;
|
const id = feature.id ?? feature.properties?.id;
|
||||||
if (id === undefined || id === null) return;
|
if (id === undefined || id === null) return;
|
||||||
|
|
||||||
@@ -136,6 +136,22 @@ export function initSelect(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickPreferredFeature(features: maplibregl.MapGeoJSONFeature[]) {
|
||||||
|
return [...features].sort((a, b) => featureSelectPriority(b) - featureSelectPriority(a))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureSelectPriority(feature: maplibregl.MapGeoJSONFeature) {
|
||||||
|
const layerId = typeof feature.layer?.id === "string" ? feature.layer.id : "";
|
||||||
|
const geometryType = feature.geometry?.type;
|
||||||
|
const source = typeof feature.source === "string" ? feature.source : "";
|
||||||
|
|
||||||
|
if (layerId.endsWith("-hit")) return 400;
|
||||||
|
if (source === "path-arrow-shapes") return 300;
|
||||||
|
if (geometryType === "LineString" || geometryType === "MultiLineString") return 200;
|
||||||
|
if (geometryType === "Point" || geometryType === "MultiPoint") return 100;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
map.on("click", onClick);
|
map.on("click", onClick);
|
||||||
map.on("mousemove", onMove);
|
map.on("mousemove", onMove);
|
||||||
if (hasContextActions) {
|
if (hasContextActions) {
|
||||||
@@ -223,7 +239,12 @@ export function initSelect(
|
|||||||
if (onReplayEdit) {
|
if (onReplayEdit) {
|
||||||
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId)));
|
menu.appendChild(
|
||||||
|
createItem(
|
||||||
|
selectedCount > 1 ? `Vào replay (${selectedCount} geo)` : "Vào replay",
|
||||||
|
() => onReplayEdit(featureId)
|
||||||
|
)
|
||||||
|
);
|
||||||
hasMenuItems = true;
|
hasMenuItems = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,14 +36,6 @@ import { LayerSpecification } from "maplibre-gl";
|
|||||||
|
|
||||||
export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
|
||||||
return [
|
return [
|
||||||
...getDefenseLineLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getAttackRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getRetreatRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getInvasionRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getMigrationRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getRefugeeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getTradeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getShippingRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
|
||||||
...getCountryLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getCountryLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
@@ -52,6 +44,14 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
|
|||||||
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getCivilizationLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
...getRebellionZoneLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getRebellionZoneLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getDefenseLineLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getAttackRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getRetreatRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getInvasionRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getMigrationRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getRefugeeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getTradeRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
...getShippingRouteLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
...getPersonDeathplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getPersonDeathplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId),
|
...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId),
|
||||||
|
|||||||
@@ -7,32 +7,44 @@ import type { FeatureCollection } from "@/uhm/types/geo";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const mapActions = {
|
export const mapActions = {
|
||||||
// Di chuyển camera đến tọa độ [lng, lat]
|
|
||||||
zoom_to_lnglat: (map: maplibregl.Map, lng: number, lat: number, zoom?: number) => {
|
|
||||||
map.easeTo({
|
|
||||||
center: [lng, lat],
|
|
||||||
zoom: zoom ?? map.getZoom(),
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Thay đổi mức zoom của bản đồ
|
|
||||||
zoom_scale: (map: maplibregl.Map, zoom: number) => {
|
|
||||||
map.easeTo({
|
|
||||||
zoom,
|
|
||||||
duration: 1500,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing)
|
// Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing)
|
||||||
set_camera_view: (map: maplibregl.Map, state: { center: { lng: number; lat: number }; zoom: number; pitch: number; bearing: number }) => {
|
set_camera_view: (
|
||||||
map.easeTo({
|
map: maplibregl.Map,
|
||||||
center: [state.center.lng, state.center.lat],
|
state: {
|
||||||
zoom: state.zoom,
|
center?: [number, number] | { lng: number; lat: number };
|
||||||
pitch: state.pitch,
|
zoom?: number;
|
||||||
bearing: state.bearing,
|
pitch?: number;
|
||||||
duration: 2500,
|
bearing?: number;
|
||||||
});
|
duration?: number;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const center = normalizeReplayCenter(state.center);
|
||||||
|
const nextView: maplibregl.EaseToOptions = {
|
||||||
|
duration: Number.isFinite(state.duration) ? state.duration : 2500,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (center) {
|
||||||
|
nextView.center = center;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(state.zoom)) {
|
||||||
|
nextView.zoom = state.zoom;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(state.pitch)) {
|
||||||
|
nextView.pitch = state.pitch;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(state.bearing)) {
|
||||||
|
nextView.bearing = state.bearing;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
nextView.center == null &&
|
||||||
|
nextView.zoom == null &&
|
||||||
|
nextView.pitch == null &&
|
||||||
|
nextView.bearing == null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
map.easeTo(nextView);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Di chuyển mượt mà đến một geometry dựa trên ID
|
// Di chuyển mượt mà đến một geometry dựa trên ID
|
||||||
@@ -54,31 +66,13 @@ export const mapActions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Xoay camera quanh một điểm
|
|
||||||
rotate_around_point: (map: maplibregl.Map, duration: number = 5000) => {
|
|
||||||
const startBearing = map.getBearing();
|
|
||||||
map.easeTo({
|
|
||||||
bearing: startBearing + 180,
|
|
||||||
duration,
|
|
||||||
easing: (t) => t,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Thay đổi màu của một geometry (thao tác trực tiếp trên layer map)
|
|
||||||
change_geometry_color: (map: maplibregl.Map, geometryId: string | number, color: string) => {
|
|
||||||
const layerId = `uhm-geo-${geometryId}`; // Giả định format ID layer
|
|
||||||
if (map.getLayer(layerId)) {
|
|
||||||
map.setPaintProperty(layerId, 'fill-color', color);
|
|
||||||
map.setPaintProperty(layerId, 'line-color', color);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Ẩn/hiện nhãn (labels) trên bản đồ
|
// Ẩn/hiện nhãn (labels) trên bản đồ
|
||||||
toggle_labels: (map: maplibregl.Map, visible: boolean) => {
|
toggle_labels: (map: maplibregl.Map, visible: boolean) => {
|
||||||
const style = map.getStyle();
|
const style = map.getStyle();
|
||||||
if (!style) return;
|
if (!style) return;
|
||||||
style.layers.forEach(layer => {
|
style.layers.forEach(layer => {
|
||||||
if (layer.type === 'symbol' && (layer as any).layout?.['text-field']) {
|
const layout = "layout" in layer ? layer.layout : undefined;
|
||||||
|
if (layer.type === 'symbol' && layout && typeof layout === "object" && "text-field" in layout) {
|
||||||
map.setLayoutProperty(layer.id, 'visibility', visible ? 'visible' : 'none');
|
map.setLayoutProperty(layer.id, 'visibility', visible ? 'visible' : 'none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -89,3 +83,24 @@ export const mapActions = {
|
|||||||
onYearChange(year);
|
onYearChange(year);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeReplayCenter(
|
||||||
|
center: [number, number] | { lng: number; lat: number } | undefined
|
||||||
|
): [number, number] | null {
|
||||||
|
if (Array.isArray(center) && center.length >= 2) {
|
||||||
|
const lng = Number(center[0]);
|
||||||
|
const lat = Number(center[1]);
|
||||||
|
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
center &&
|
||||||
|
typeof center === "object" &&
|
||||||
|
"lng" in center &&
|
||||||
|
"lat" in center
|
||||||
|
) {
|
||||||
|
const lng = Number(center.lng);
|
||||||
|
const lat = Number(center.lat);
|
||||||
|
return Number.isFinite(lng) && Number.isFinite(lat) ? [lng, lat] : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type maplibregl from "maplibre-gl";
|
import type maplibregl from "maplibre-gl";
|
||||||
import type { FeatureCollection } from "@/uhm/types/geo";
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
||||||
import type { ReplayAction, UIFunctionName, MapFunctionName, NarrativeFunctionName } from "@/uhm/types/projects";
|
import type {
|
||||||
|
GeoFunctionName,
|
||||||
|
MapFunctionName,
|
||||||
|
NarrativeFunctionName,
|
||||||
|
ReplayAction,
|
||||||
|
UIOptionName,
|
||||||
|
} from "@/uhm/types/projects";
|
||||||
import { mapActions } from "./mapActions";
|
import { mapActions } from "./mapActions";
|
||||||
import { uiActions } from "./uiActions";
|
import { uiActions } from "./uiActions";
|
||||||
import { narrativeActions } from "./narrativeActions";
|
import { narrativeActions } from "./narrativeActions";
|
||||||
@@ -15,7 +21,6 @@ export interface ReplayControllers {
|
|||||||
|
|
||||||
// UI Setters
|
// UI Setters
|
||||||
setTimelineVisible: (v: boolean) => void;
|
setTimelineVisible: (v: boolean) => void;
|
||||||
setUIVisible: (v: boolean) => void;
|
|
||||||
setSidebarOpen: (v: boolean) => void;
|
setSidebarOpen: (v: boolean) => void;
|
||||||
onSelectWiki: (id: string) => void;
|
onSelectWiki: (id: string) => void;
|
||||||
addToast: (msg: string) => void;
|
addToast: (msg: string) => void;
|
||||||
@@ -25,7 +30,7 @@ export interface ReplayControllers {
|
|||||||
// Narrative Setters
|
// Narrative Setters
|
||||||
setTitle: (t: string) => void;
|
setTitle: (t: string) => void;
|
||||||
setDescriptions: (d: string) => void;
|
setDescriptions: (d: string) => void;
|
||||||
setDialog: (data: any) => void;
|
setDialog: (data: { avatar: string; text: string; side: "left" | "right" }) => void;
|
||||||
setImage: (url: string | null) => void;
|
setImage: (url: string | null) => void;
|
||||||
setSubtitle: (s: string | null) => void;
|
setSubtitle: (s: string | null) => void;
|
||||||
}
|
}
|
||||||
@@ -34,72 +39,210 @@ export interface ReplayControllers {
|
|||||||
* Dispatcher trung tâm: Nhận một Action và thực thi logic tương ứng
|
* Dispatcher trung tâm: Nhận một Action và thực thi logic tương ứng
|
||||||
* bằng cách gọi đến các bộ Action con (map, ui, narrative).
|
* bằng cách gọi đến các bộ Action con (map, ui, narrative).
|
||||||
*/
|
*/
|
||||||
export const dispatchReplayAction = (controllers: ReplayControllers, action: ReplayAction<any>) => {
|
export const dispatchReplayAction = (
|
||||||
|
controllers: ReplayControllers,
|
||||||
|
action: ReplayAction<UIOptionName | MapFunctionName | GeoFunctionName | NarrativeFunctionName> | {
|
||||||
|
function_name: "UI";
|
||||||
|
params: unknown[];
|
||||||
|
}
|
||||||
|
) => {
|
||||||
const { function_name, params } = action;
|
const { function_name, params } = action;
|
||||||
|
|
||||||
// 1. Nhóm Map Actions
|
// 1. Nhóm Map Actions
|
||||||
if (controllers.map) {
|
if (controllers.map) {
|
||||||
const map = controllers.map;
|
const map = controllers.map;
|
||||||
switch (function_name as MapFunctionName) {
|
switch (function_name as MapFunctionName | GeoFunctionName) {
|
||||||
case "zoom_to_lnglat":
|
|
||||||
mapActions.zoom_to_lnglat(map, params[0], params[1], params[2]);
|
|
||||||
return;
|
|
||||||
case "zoom_scale":
|
|
||||||
mapActions.zoom_scale(map, params[0]);
|
|
||||||
return;
|
|
||||||
case "set_camera_view":
|
case "set_camera_view":
|
||||||
mapActions.set_camera_view(map, params[0]);
|
mapActions.set_camera_view(map, normalizeCameraViewState(params[0]));
|
||||||
return;
|
return;
|
||||||
case "fly_to_geometry":
|
case "fly_to_geometry":
|
||||||
mapActions.fly_to_geometry(map, params[0], controllers.draft);
|
mapActions.fly_to_geometry(
|
||||||
return;
|
map,
|
||||||
case "rotate_around_point":
|
asStringValue(params[0]),
|
||||||
mapActions.rotate_around_point(map, params[0]);
|
controllers.draft,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
case "toggle_labels":
|
case "toggle_labels":
|
||||||
mapActions.toggle_labels(map, params[0]);
|
mapActions.toggle_labels(map, asBooleanValue(params[0], true));
|
||||||
|
return;
|
||||||
|
case "show_labels":
|
||||||
|
mapActions.toggle_labels(map, true);
|
||||||
|
return;
|
||||||
|
case "hide_labels":
|
||||||
|
mapActions.toggle_labels(map, false);
|
||||||
return;
|
return;
|
||||||
case "set_time_filter":
|
case "set_time_filter":
|
||||||
mapActions.set_time_filter(controllers.onYearChange, params[0]);
|
mapActions.set_time_filter(controllers.onYearChange, asNumberValue(params[0], 0));
|
||||||
|
return;
|
||||||
|
case "reset_camera_north":
|
||||||
|
mapActions.set_camera_view(map, { bearing: 0 });
|
||||||
|
return;
|
||||||
|
case "fly_to_geometries":
|
||||||
|
case "enable_timeline_filter":
|
||||||
|
case "disable_timeline_filter":
|
||||||
|
case "show_geometries":
|
||||||
|
case "hide_geometries":
|
||||||
|
case "set_geometry_visibility":
|
||||||
|
case "fit_to_geometries":
|
||||||
|
case "orbit_camera_around_geometry":
|
||||||
|
case "pulse_geometry":
|
||||||
|
case "animate_dashed_border":
|
||||||
|
case "set_geometry_style":
|
||||||
|
case "show_geometry_label":
|
||||||
|
case "follow_geometry_path":
|
||||||
|
case "follow_geometries_path":
|
||||||
|
case "dim_other_geometries":
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Nhóm UI Actions
|
// 2. Nhóm UI Actions
|
||||||
switch (function_name as UIFunctionName) {
|
const uiDescriptor = getUiActionDescriptor(function_name, params);
|
||||||
case "hide_timeline":
|
if (uiDescriptor) {
|
||||||
uiActions.hide_timeline(controllers.setTimelineVisible);
|
const { option, payload } = uiDescriptor;
|
||||||
return;
|
switch (option) {
|
||||||
case "hide_all_UI":
|
case "timeline":
|
||||||
uiActions.hide_all_UI(controllers.setUIVisible);
|
uiActions.timeline(controllers.setTimelineVisible, Boolean(payload[0] ?? false));
|
||||||
return;
|
return;
|
||||||
case "open_wiki":
|
case "layer_panel":
|
||||||
uiActions.open_wiki(controllers.setSidebarOpen, controllers.onSelectWiki, params[0]);
|
uiActions.layer_panel(Boolean(payload[0] ?? false));
|
||||||
return;
|
return;
|
||||||
case "show_toast_message":
|
case "wiki_panel":
|
||||||
uiActions.show_toast_message(controllers.addToast, params[0]);
|
uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false));
|
||||||
return;
|
return;
|
||||||
case "set_playback_speed":
|
case "zoom_panel":
|
||||||
uiActions.set_playback_speed(controllers.setPlaybackSpeed, params[0]);
|
uiActions.zoom_panel(Boolean(payload[0] ?? false));
|
||||||
return;
|
return;
|
||||||
|
case "wiki":
|
||||||
|
uiActions.wiki(
|
||||||
|
controllers.setSidebarOpen,
|
||||||
|
controllers.onSelectWiki,
|
||||||
|
typeof payload[0] === "string" ? payload[0] : ""
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "toast":
|
||||||
|
uiActions.toast(
|
||||||
|
controllers.addToast,
|
||||||
|
typeof payload[0] === "string" ? payload[0] : ""
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "wiki_header":
|
||||||
|
uiActions.wiki_header(typeof payload[0] === "string" ? payload[0] : "");
|
||||||
|
return;
|
||||||
|
case "playback_speed":
|
||||||
|
uiActions.playback_speed(
|
||||||
|
controllers.setPlaybackSpeed,
|
||||||
|
typeof payload[0] === "number" ? payload[0] : 1
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Nhóm Narrative Actions
|
// 3. Nhóm Narrative Actions
|
||||||
switch (function_name as NarrativeFunctionName) {
|
switch (function_name as NarrativeFunctionName) {
|
||||||
case "set_title":
|
case "set_title":
|
||||||
narrativeActions.set_title(controllers.setTitle, params[0]);
|
narrativeActions.set_title(controllers.setTitle, asStringValue(params[0]));
|
||||||
return;
|
return;
|
||||||
case "set_descriptions":
|
case "set_descriptions":
|
||||||
narrativeActions.set_descriptions(controllers.setDescriptions, params[0]);
|
narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0]));
|
||||||
return;
|
return;
|
||||||
case "show_dialog_box":
|
case "show_dialog_box":
|
||||||
narrativeActions.show_dialog_box(controllers.setDialog, params[0], params[1]);
|
narrativeActions.show_dialog_box(
|
||||||
|
controllers.setDialog,
|
||||||
|
asStringValue(params[0]),
|
||||||
|
asStringValue(params[1])
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
case "display_historical_image":
|
case "display_historical_image":
|
||||||
narrativeActions.display_historical_image(controllers.setImage, params[0]);
|
narrativeActions.display_historical_image(controllers.setImage, asStringValue(params[0]));
|
||||||
return;
|
return;
|
||||||
case "set_step_subtitle":
|
case "set_step_subtitle":
|
||||||
narrativeActions.set_step_subtitle(controllers.setSubtitle, params[0]);
|
narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeUiOption(value: unknown): UIOptionName | null {
|
||||||
|
switch (value) {
|
||||||
|
case "timeline":
|
||||||
|
case "layer_panel":
|
||||||
|
case "wiki_panel":
|
||||||
|
case "zoom_panel":
|
||||||
|
case "wiki":
|
||||||
|
case "toast":
|
||||||
|
case "wiki_header":
|
||||||
|
case "playback_speed":
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUiActionDescriptor(function_name: unknown, params: unknown[]) {
|
||||||
|
if (function_name === "UI") {
|
||||||
|
const option = normalizeUiOption(params[0]);
|
||||||
|
if (!option) return null;
|
||||||
|
return {
|
||||||
|
option,
|
||||||
|
payload: params.slice(1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = normalizeUiOption(function_name);
|
||||||
|
if (!option) return null;
|
||||||
|
return {
|
||||||
|
option,
|
||||||
|
payload: params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCameraViewState(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const nextState: {
|
||||||
|
center?: [number, number] | { lng: number; lat: number };
|
||||||
|
zoom?: number;
|
||||||
|
pitch?: number;
|
||||||
|
bearing?: number;
|
||||||
|
duration?: number;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
const center = record.center;
|
||||||
|
if (Array.isArray(center) && center.length >= 2) {
|
||||||
|
const lng = Number(center[0]);
|
||||||
|
const lat = Number(center[1]);
|
||||||
|
if (Number.isFinite(lng) && Number.isFinite(lat)) {
|
||||||
|
nextState.center = [lng, lat];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = asOptionalNumberValue(record.zoom);
|
||||||
|
const pitch = asOptionalNumberValue(record.pitch);
|
||||||
|
const bearing = asOptionalNumberValue(record.bearing);
|
||||||
|
const duration = asOptionalNumberValue(record.duration);
|
||||||
|
if (zoom != null) nextState.zoom = zoom;
|
||||||
|
if (pitch != null) nextState.pitch = pitch;
|
||||||
|
if (bearing != null) nextState.bearing = bearing;
|
||||||
|
if (duration != null) nextState.duration = duration;
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBooleanValue(value: unknown, fallback: boolean) {
|
||||||
|
return typeof value === "boolean" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asOptionalNumberValue(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumberValue(value: unknown, fallback: number) {
|
||||||
|
return asOptionalNumberValue(value) ?? fallback;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,29 +3,47 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const uiActions = {
|
export const uiActions = {
|
||||||
// Ẩn thanh Timeline
|
// Ẩn/hiện thanh Timeline
|
||||||
hide_timeline: (setTimelineVisible: (v: boolean) => void) => {
|
timeline: (setTimelineVisible: (v: boolean) => void, visible: boolean) => {
|
||||||
setTimelineVisible(false);
|
setTimelineVisible(visible);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Ẩn toàn bộ UI để có trải nghiệm điện ảnh (Cinematic)
|
// Ẩn/hiện panel layer. Runtime hiện chưa có controller riêng nên tạm no-op.
|
||||||
hide_all_UI: (setUIVisible: (v: boolean) => void) => {
|
layer_panel: (visible: boolean) => {
|
||||||
setUIVisible(false);
|
void visible;
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ẩn/hiện panel wiki.
|
||||||
|
wiki_panel: (setSidebarOpen: (v: boolean) => void, visible: boolean) => {
|
||||||
|
setSidebarOpen(visible);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ẩn/hiện panel zoom. Runtime hiện chưa có controller riêng nên tạm no-op.
|
||||||
|
zoom_panel: (visible: boolean) => {
|
||||||
|
void visible;
|
||||||
|
return;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mở Wiki và tìm đến một ID cụ thể
|
// Mở Wiki và tìm đến một ID cụ thể
|
||||||
open_wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string) => {
|
wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string) => {
|
||||||
setSidebarOpen(true);
|
setSidebarOpen(true);
|
||||||
onSelectWiki(wikiId);
|
onSelectWiki(wikiId);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Hiển thị thông báo (toast)
|
// Hiển thị thông báo (toast)
|
||||||
show_toast_message: (addToast: (msg: string) => void, message: string) => {
|
toast: (addToast: (msg: string) => void, message: string) => {
|
||||||
addToast(message);
|
addToast(message);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Focus header trong wiki. Runtime hiện chưa có controller riêng nên tạm no-op.
|
||||||
|
wiki_header: (headerId: string) => {
|
||||||
|
void headerId;
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
|
||||||
// Thay đổi tốc độ phát Replay
|
// Thay đổi tốc độ phát Replay
|
||||||
set_playback_speed: (setSpeed: (s: number) => void, speed: number) => {
|
playback_speed: (setSpeed: (s: number) => void, speed: number) => {
|
||||||
setSpeed(speed);
|
setSpeed(speed);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type Entity = {
|
|||||||
// API cũ / snapshot editor (giữ optional để không phá flow editor snapshot)
|
// API cũ / snapshot editor (giữ optional để không phá flow editor snapshot)
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
type_id?: string | null;
|
type_id?: string | null;
|
||||||
status?: number | null;
|
|
||||||
geometry_count?: number;
|
geometry_count?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,9 +28,5 @@ export type EntitySnapshot = {
|
|||||||
// join tables (geometry_entity / entity_wiki), not here.
|
// join tables (geometry_entity / entity_wiki), not here.
|
||||||
operation?: EntitySnapshotOperation;
|
operation?: EntitySnapshotOperation;
|
||||||
name?: string;
|
name?: string;
|
||||||
slug?: string | null;
|
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
status?: number | null;
|
|
||||||
base_updated_at?: string;
|
|
||||||
base_hash?: string;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,8 +61,6 @@ export type GeometrySnapshot = {
|
|||||||
max_lng: number;
|
max_lng: number;
|
||||||
max_lat: number;
|
max_lat: number;
|
||||||
} | null;
|
} | null;
|
||||||
base_updated_at?: string;
|
|
||||||
base_hash?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Snapshot join table (geometry ↔ entity).
|
// Snapshot join table (geometry ↔ entity).
|
||||||
@@ -73,7 +71,6 @@ export type GeometryEntitySnapshot = {
|
|||||||
// - reference/binding: the link exists (assigned)
|
// - reference/binding: the link exists (assigned)
|
||||||
// - delete: the link is removed
|
// - delete: the link is removed
|
||||||
operation?: "reference" | "binding" | "delete";
|
operation?: "reference" | "binding" | "delete";
|
||||||
base_links_hash?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeometryChange =
|
export type GeometryChange =
|
||||||
|
|||||||
+35
-27
@@ -89,32 +89,41 @@ export type EditorSnapshot = {
|
|||||||
|
|
||||||
// ---- Replay / Scripting System ----
|
// ---- Replay / Scripting System ----
|
||||||
|
|
||||||
export type UIFunctionName =
|
export type UIOptionName =
|
||||||
| "hide_timeline" // Ẩn thanh timeline
|
| "timeline" // Ẩn/hiện timeline
|
||||||
| "hide_layer_panel" // Ẩn panel lớp bản đồ
|
| "layer_panel" // Ẩn/hiện panel layer
|
||||||
| "hide_wiki_panel" // Ẩn panel wiki (bên phải)
|
| "wiki_panel" // Ẩn/hiện panel wiki
|
||||||
| "hide_zoom_panel" // Ẩn các nút điều khiển zoom
|
| "zoom_panel" // Ẩn/hiện nút zoom
|
||||||
| "hide_all_UI" // Ẩn toàn bộ giao diện điều khiển (cinematic mode)
|
| "wiki" // Mở/chọn wiki
|
||||||
| "open_wiki" // Mở panel wiki
|
| "toast" // Hiển thị toast
|
||||||
| "show_toast_message" // Hiển thị thông báo ngắn (toast)
|
| "wiki_header" // Focus header trong wiki
|
||||||
| "focus_wiki_header" // Cuộn đến đề mục cụ thể trong Wiki
|
| "playback_speed"; // Thay đổi tốc độ phát replay
|
||||||
| "set_playback_speed"; // Thay đổi tốc độ phát replay
|
|
||||||
|
|
||||||
export type MapFunctionName =
|
export type MapFunctionName =
|
||||||
| "zoom_to_lnglat" // Di chuyển camera đến tọa độ [lng, lat]
|
|
||||||
| "zoom_scale" // Thay đổi mức zoom của bản đồ
|
|
||||||
| "zoom_geometries" // Zoom bao quát danh sách các geometry
|
|
||||||
| "change_geometry_color" // Thay đổi màu của một geometry
|
|
||||||
| "change_geometries_color" // Thay đổi màu của danh sách geometry
|
|
||||||
| "change_geometry_texture" // Thay đổi texture của một geometry
|
|
||||||
| "change_geometries_texture"// Thay đổi texture của danh sách geometry
|
|
||||||
| "hide_geometries" // Ẩn danh sách các geometry
|
|
||||||
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing)
|
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing)
|
||||||
| "fly_to_geometry" // Di chuyển mượt mà đến một geometry
|
|
||||||
| "rotate_around_point" // Xoay camera quanh một điểm
|
|
||||||
| "pulse_geometry" // Hiệu ứng nhấp nháy cho geometry
|
|
||||||
| "set_time_filter" // Thay đổi bộ lọc thời gian trên bản đồ
|
| "set_time_filter" // Thay đổi bộ lọc thời gian trên bản đồ
|
||||||
| "toggle_labels"; // Bật/tắt hiển thị nhãn (labels) trên bản đồ
|
| "enable_timeline_filter" // Bật timeline filter
|
||||||
|
| "disable_timeline_filter" // Tắt timeline filter
|
||||||
|
| "toggle_labels" // Legacy: bật/tắt hiển thị nhãn (labels) trên bản đồ
|
||||||
|
| "show_labels" // Hiện labels
|
||||||
|
| "hide_labels" // Ẩn labels
|
||||||
|
| "reset_camera_north"; // Đưa camera về hướng bắc
|
||||||
|
|
||||||
|
export type GeoFunctionName =
|
||||||
|
| "fly_to_geometry" // Legacy: di chuyển mượt mà đến một geometry
|
||||||
|
| "fly_to_geometries" // Di chuyển mượt mà đến một hoặc nhiều geometry
|
||||||
|
| "set_geometry_visibility" // Legacy: ẩn/hiện một hoặc nhiều geometry
|
||||||
|
| "show_geometries" // Hiện một hoặc nhiều geometry
|
||||||
|
| "hide_geometries" // Ẩn một hoặc nhiều geometry
|
||||||
|
| "fit_to_geometries" // Legacy: fit camera theo nhiều geometry
|
||||||
|
| "orbit_camera_around_geometry" // Quay camera quanh một geometry
|
||||||
|
| "pulse_geometry" // Hiệu ứng pulse/emphasis cho geometry
|
||||||
|
| "animate_dashed_border" // Hiệu ứng border nét đứt chuyển động
|
||||||
|
| "set_geometry_style" // Đổi style trực tiếp của geometry
|
||||||
|
| "show_geometry_label" // Hiện label riêng cho geometry
|
||||||
|
| "follow_geometry_path" // Legacy: cho camera bám theo một path geometry
|
||||||
|
| "follow_geometries_path" // Cho camera bám theo chuỗi path geometry
|
||||||
|
| "dim_other_geometries"; // Làm mờ các geometry ngoài target set
|
||||||
|
|
||||||
export type NarrativeFunctionName =
|
export type NarrativeFunctionName =
|
||||||
| "set_title" // Đặt tiêu đề cho bước replay
|
| "set_title" // Đặt tiêu đề cho bước replay
|
||||||
@@ -125,13 +134,14 @@ export type NarrativeFunctionName =
|
|||||||
|
|
||||||
export type ReplayAction<T> = {
|
export type ReplayAction<T> = {
|
||||||
function_name: T;
|
function_name: T;
|
||||||
params: any[];
|
params: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReplayStep = {
|
export type ReplayStep = {
|
||||||
duration: number; // Trọng số thời gian của step trong 1 stage
|
duration: number; // Trọng số thời gian của step trong 1 stage
|
||||||
use_UI_function: ReplayAction<UIFunctionName>[];
|
use_UI_function: ReplayAction<UIOptionName>[];
|
||||||
use_map_function: ReplayAction<MapFunctionName>[];
|
use_map_function: ReplayAction<MapFunctionName>[];
|
||||||
|
use_geo_function: ReplayAction<GeoFunctionName>[];
|
||||||
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
use_narrow_function: ReplayAction<NarrativeFunctionName>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,9 +155,8 @@ export type ReplayStage = {
|
|||||||
|
|
||||||
export type BattleReplay = {
|
export type BattleReplay = {
|
||||||
geometry_id: string; // geometry mà khi nhấn vào là có thể replay
|
geometry_id: string; // geometry mà khi nhấn vào là có thể replay
|
||||||
|
target_geometry_ids: string[]; // tập geometry được đưa vào replay, phần tử đầu nên là MAIN geo
|
||||||
detail: ReplayStage[];
|
detail: ReplayStage[];
|
||||||
// Local-only: separate draft for this specific replay
|
|
||||||
replay_features?: FeatureCollection;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -175,4 +184,3 @@ export type CreateCommitInput = {
|
|||||||
export type RestoreCommitInput = {
|
export type RestoreCommitInput = {
|
||||||
commit_id: string;
|
commit_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,6 @@
|
|||||||
// FE wiki runtime now stores HTML or plain text in this string field.
|
// FE wiki runtime now stores HTML or plain text in this string field.
|
||||||
export type WikiDoc = string | null;
|
export type WikiDoc = string | null;
|
||||||
|
|
||||||
export type WikiContentSample = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
export type WikiSnapshot = {
|
export type WikiSnapshot = {
|
||||||
@@ -17,6 +11,4 @@ export type WikiSnapshot = {
|
|||||||
title: string;
|
title: string;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
doc: WikiDoc;
|
doc: WikiDoc;
|
||||||
content_sample?: WikiContentSample[];
|
|
||||||
updated_at?: string;
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user