complete replay editor v1

This commit is contained in:
taDuc
2026-05-17 21:45:33 +07:00
parent 3808086529
commit 047f662736
23 changed files with 4658 additions and 490 deletions
@@ -306,10 +306,11 @@ function toEditorSessionEntities(input: EditorSnapshot["entities"]): EntitySnaps
const id = String(e.id);
const source: EntitySnapshot["source"] = e.source === "inline" ? "inline" : "ref";
return {
...e,
id,
source,
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 source: GeometrySnapshot["source"] = g.source === "inline" ? "inline" : "ref";
return {
...g,
id,
source,
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,
entity_id,
operation: "reference",
base_links_hash: safeRow.base_links_hash,
});
}
return Array.from(deduped.values()).sort((a, b) => {
@@ -368,9 +381,12 @@ function toEditorSessionWikis(input: EditorSnapshot["wikis"]): WikiSnapshot[] {
.map((w) => {
const source: WikiSnapshot["source"] = w.source === "inline" ? "inline" : "ref";
return {
...w,
id: w.id,
source,
operation: "reference",
title: typeof w.title === "string" ? w.title : "",
slug: w.slug ?? null,
doc: w.doc ?? null,
};
});
}
+289 -35
View File
@@ -5,7 +5,16 @@ import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
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 { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
@@ -22,7 +31,6 @@ interface RawEntityRow extends UnknownRecord {
ref?: { id?: string };
name?: string;
description?: string;
status?: number;
}
interface RawWikiRow extends UnknownRecord {
@@ -33,14 +41,27 @@ interface RawWikiRow extends UnknownRecord {
title?: string;
slug?: 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 {
geometry_id?: string | number;
entity_id?: string | number;
operation?: string;
base_links_hash?: string;
}
interface RawEntityWikiRow extends UnknownRecord {
@@ -96,14 +117,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const refId = getRefId(e.ref);
const source: "inline" | "ref" =
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
const rest: UnknownRecord = { ...e };
delete rest.ref;
return {
...(rest as unknown as Omit<EntitySnapshot, "id" | "source" | "operation">),
id,
source,
operation,
name: typeof e.name === "string" ? e.name : undefined,
description: typeof e.description === "string" ? e.description : e.description == null ? undefined : undefined,
};
})
: undefined;
@@ -113,25 +133,37 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
? geometriesRaw
.filter(isRecord)
.map((g) => {
const id = getStringId(g.id);
const opRaw = typeof g.operation === "string" ? g.operation : undefined;
const row = g as RawGeometryRow;
const id = getStringId(row.id);
const opRaw = typeof row.operation === "string" ? row.operation : undefined;
const operation: GeometrySnapshot["operation"] =
opRaw === "delete" ? "delete" : "reference";
const existingSource = g.source === "inline" || g.source === "ref" ? g.source : undefined;
const refId = getRefId(g.ref);
const hasInlineGeometry = "draw_geometry" in g || "geometry" in g;
const existingSource = row.source === "inline" || row.source === "ref" ? row.source : undefined;
const refId = getRefId(row.ref);
const hasInlineGeometry = "draw_geometry" in row || "geometry" in row;
const source: "inline" | "ref" = existingSource || (refId || !hasInlineGeometry ? "ref" : "inline");
const rest: UnknownRecord = { ...g };
delete rest.ref;
const typeKey = normalizeGeoTypeKey(rest.type) || normalizeGeoTypeKey(rest.geo_type);
delete rest.geo_type;
const typeKey = normalizeGeoTypeKey(row.type) || normalizeGeoTypeKey(row.geo_type);
return {
...(rest as unknown as Omit<GeometrySnapshot, "id" | "source" | "operation">),
id,
source,
operation,
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;
@@ -141,6 +173,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
? wikisRaw
.filter(isRecord)
.map((w) => {
const row = w as RawWikiRow;
const id = typeof w.id === "string" ? w.id : "";
const opRaw = typeof w.operation === "string" ? w.operation : undefined;
const operation: WikiSnapshot["operation"] =
@@ -149,14 +182,13 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const refId = getRefId(w.ref);
const source: "inline" | "ref" =
existingSource || (refId || opRaw === "reference" ? "ref" : "inline");
const rest: UnknownRecord = { ...w };
delete rest.ref;
return {
...(rest as unknown as Omit<WikiSnapshot, "id" | "source" | "operation">),
id,
source,
operation,
title: typeof row.title === "string" ? row.title : "",
slug: row.slug ?? null,
doc: typeof row.doc === "string" ? row.doc : null,
};
})
: undefined;
@@ -173,7 +205,6 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
const geometry_id = getStringId(row.geometry_id);
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
return {
...(row as unknown as Omit<GeometryEntitySnapshot, "geometry_id" | "entity_id">),
geometry_id,
entity_id,
};
@@ -298,7 +329,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
wikis,
geometry_entity: geometryEntity || migratedGeometryEntity,
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;
delete cloned.operation;
entityRows.set(id, {
...cloned,
id,
source: "inline",
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 || []) {
@@ -374,11 +406,11 @@ export function buildEditorSnapshot(options: {
if (opRaw === "delete") continue;
const operation: EntitySnapshot["operation"] = source === "ref" ? "reference" : opRaw;
entityRows.set(id, {
...cloned,
id,
source,
name,
operation,
description: typeof cloned.description === "string" ? cloned.description : cloned.description ?? null,
});
}
@@ -391,9 +423,7 @@ export function buildEditorSnapshot(options: {
source: "ref",
operation: "reference",
name: id,
slug: null,
description: null,
status: 1,
});
}
@@ -405,9 +435,7 @@ export function buildEditorSnapshot(options: {
source: "ref",
operation: "reference",
name: entityId,
slug: 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 || []) {
const row = r as RawGeometryEntityRow;
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 entity_id = typeof row.entity_id === "string" || typeof row.entity_id === "number" ? String(row.entity_id).trim() : "";
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[] = [];
@@ -481,18 +509,17 @@ export function buildEditorSnapshot(options: {
geometry_id,
entity_id,
operation: baselineGeometryEntity.has(key) ? "reference" : "binding",
base_links_hash: baselineGeometryEntity.get(key),
});
}
}
// 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.
for (const [key, base_links_hash] of baselineGeometryEntity.entries()) {
for (const key of baselineGeometryEntity.values()) {
if (currentGeometryEntityKeys.has(key)) continue;
const [geometry_id, entity_id] = key.split("::");
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);
@@ -587,7 +614,6 @@ export function buildEditorSnapshot(options: {
title: typeof prev.title === "string" ? prev.title : "Untitled wiki",
slug: row.slug ?? null,
doc: row.doc ?? null,
updated_at: row.updated_at ?? undefined,
} as WikiSnapshot);
}
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;
}
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[] {
const seen = new Set<string>();
const deduped: GeometryEntitySnapshot[] = [];
+105 -97
View File
@@ -38,8 +38,8 @@ type ReplayDraftSyncMode = "none" | "reset";
// State trung tâm của editor:
// - 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
// - replay feature draft: FeatureCollection con để map/editor hiện tại thao tác
// - active replay draft: bản sao BattleReplay đang chỉnh (script + target ids)
// - replay feature draft: FeatureCollection local được hydrate từ mainDraft + target ids
export function useEditorState(
initialData: FeatureCollection,
options: {
@@ -86,9 +86,9 @@ export function useEditorState(
return cloned;
}, []);
const syncReplayFeatureDraft = useCallback((nextFeatures: FeatureCollection) => {
resetReplayDraft(deepClone(nextFeatures));
}, [resetReplayDraft]);
const syncReplayFeatureDraft = useCallback((nextReplay: BattleReplay | null) => {
resetReplayDraft(buildReplayFeatureDraft(mainDraftRef.current, nextReplay));
}, [mainDraftRef, resetReplayDraft]);
const setActiveReplayDraftState = useCallback((
next: SetStateAction<BattleReplay | null>,
@@ -100,7 +100,7 @@ export function useEditorState(
setActiveReplayDraft(cloned);
if (syncMode === "reset") {
syncReplayFeatureDraft(cloned?.replay_features || EMPTY_FEATURE_COLLECTION);
syncReplayFeatureDraft(cloned);
}
return cloned;
@@ -302,9 +302,6 @@ export function useEditorState(
const prevReplay = deepClone(currentReplay);
const nextReplay = deepClone(currentReplay);
if (!nextReplay.replay_features) {
nextReplay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
}
mutator(nextReplay);
if (replayEquals(prevReplay, nextReplay)) {
return false;
@@ -364,10 +361,6 @@ export function useEditorState(
const featureClone = deepClone(feature);
if (mode === "replay") {
applyReplaySessionMutation(`Replay: thêm #${featureClone.properties.id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
featureDraft.features = [...featureDraft.features, featureClone];
});
return;
}
@@ -384,7 +377,6 @@ export function useEditorState(
label = "Import geometry"
) {
if (mode === "replay") {
createFeature(feature);
return;
}
@@ -433,18 +425,6 @@ export function useEditorState(
patch: Partial<FeatureProperties>
) {
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;
}
@@ -474,30 +454,6 @@ export function useEditorState(
label = "Cập nhật nhiều geometry"
) {
if (mode === "replay") {
applyReplaySessionMutation(label, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
const mergedPatches = new Map<FeatureProperties["id"], Partial<FeatureProperties>>();
for (const item of patches || []) {
if (!item) continue;
const prev = mergedPatches.get(item.id) || {};
mergedPatches.set(item.id, {
...prev,
...deepClone(item.patch),
});
}
featureDraft.features = featureDraft.features.map((feature) => {
const featurePatch = mergedPatches.get(feature.properties.id);
if (!featurePatch) return feature;
return {
...feature,
properties: {
...feature.properties,
...deepClone(featurePatch),
},
};
});
});
return;
}
@@ -547,15 +503,6 @@ export function useEditorState(
function updateFeature(id: FeatureProperties["id"], newGeometry: Geometry) {
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;
}
@@ -579,10 +526,6 @@ export function useEditorState(
function deleteFeature(id: FeatureProperties["id"]) {
if (mode === "replay") {
applyReplaySessionMutation(`Replay: xóa #${id}`, (draftReplay) => {
const featureDraft = ensureReplayFeatureCollection(draftReplay);
featureDraft.features = featureDraft.features.filter((feature) => feature.properties.id !== id);
});
return;
}
@@ -737,6 +680,7 @@ export function useEditorState(
activeReplayDraft,
effectiveReplays,
setReplays: updateReplaysState,
mutateActiveReplay: applyReplaySessionMutation,
activeReplayId,
switchReplayContext,
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;
}
function buildReplaySeedFeatures(
sourceDraft: FeatureCollection,
featureId: string,
selectedIds: (string | number)[]
): FeatureCollection {
const selectedIdsSet = new Set(selectedIds.map(String));
selectedIdsSet.add(featureId);
const triggerFeature = sourceDraft.features.find(
(feature) => String(feature.properties.id) === featureId
);
const mainBoundIds = new Set(
Array.isArray(triggerFeature?.properties?.binding)
? triggerFeature.properties.binding.map(String)
: []
);
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
return {
type: "FeatureCollection",
features: sourceDraft.features
.filter((feature) => targetIds.has(String(feature.properties.id)))
.map(deepClone),
};
}
function createReplaySessionSeed(
sourceDraft: FeatureCollection,
geometryId: string,
@@ -799,8 +717,12 @@ function createReplaySessionSeed(
): BattleReplay {
return {
geometry_id: geometryId,
target_geometry_ids: buildReplaySeedTargetIds(
sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId),
geometryId,
selectedIds
),
detail: [],
replay_features: buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds),
};
}
@@ -811,17 +733,103 @@ function normalizeReplaySessionSeed(
selectedIds: (string | number)[]
): BattleReplay {
const nextReplay = deepClone(replay);
if (!nextReplay.replay_features) {
nextReplay.replay_features = buildReplaySeedFeatures(sourceDraft, geometryId, selectedIds);
}
const triggerFeature = sourceDraft.features.find((feature) => String(feature.properties.id) === geometryId);
const seedTargetIds = buildReplaySeedTargetIds(triggerFeature, geometryId, selectedIds);
nextReplay.target_geometry_ids = normalizeReplayTargetGeometryIds(
nextReplay.target_geometry_ids,
geometryId,
seedTargetIds
);
return nextReplay;
}
function ensureReplayFeatureCollection(replay: BattleReplay): FeatureCollection {
if (!replay.replay_features) {
replay.replay_features = deepClone(EMPTY_FEATURE_COLLECTION);
function buildReplaySeedTargetIds(
triggerFeature: Feature | undefined,
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(
+24 -3
View File
@@ -69,7 +69,7 @@ export function initSelect(
}
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.
@@ -88,7 +88,7 @@ export function initSelect(
if (!features.length) return;
const feature = features[0];
const feature = pickPreferredFeature(features);
const id = feature.id ?? feature.properties?.id;
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("mousemove", onMove);
if (hasContextActions) {
@@ -223,7 +239,12 @@ export function initSelect(
if (onReplayEdit) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
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;
}
}
+8 -8
View File
@@ -36,14 +36,6 @@ import { LayerSpecification } from "maplibre-gl";
export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string, pointSourceId?: string): LayerSpecification[] {
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),
...getStateLayers(sourceId, pathArrowSourceId, pointSourceId),
...getEmpireLayers(sourceId, pathArrowSourceId, pointSourceId),
@@ -52,6 +44,14 @@ export function getAllGeotypeLayers(sourceId: string, pathArrowSourceId?: string
...getBattleLayers(sourceId, pathArrowSourceId, pointSourceId),
...getCivilizationLayers(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),
...getPersonBirthplaceLayers(sourceId, pathArrowSourceId, pointSourceId),
...getPersonActivityLayers(sourceId, pathArrowSourceId, pointSourceId),
+60 -45
View File
@@ -7,32 +7,44 @@ import type { FeatureCollection } from "@/uhm/types/geo";
*/
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)
set_camera_view: (map: maplibregl.Map, state: { center: { lng: number; lat: number }; zoom: number; pitch: number; bearing: number }) => {
map.easeTo({
center: [state.center.lng, state.center.lat],
zoom: state.zoom,
pitch: state.pitch,
bearing: state.bearing,
duration: 2500,
});
set_camera_view: (
map: maplibregl.Map,
state: {
center?: [number, number] | { lng: number; lat: number };
zoom?: number;
pitch?: number;
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
@@ -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 đồ
toggle_labels: (map: maplibregl.Map, visible: boolean) => {
const style = map.getStyle();
if (!style) return;
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');
}
});
@@ -89,3 +83,24 @@ export const mapActions = {
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;
}
+182 -39
View File
@@ -1,6 +1,12 @@
import type maplibregl from "maplibre-gl";
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 { uiActions } from "./uiActions";
import { narrativeActions } from "./narrativeActions";
@@ -15,7 +21,6 @@ export interface ReplayControllers {
// UI Setters
setTimelineVisible: (v: boolean) => void;
setUIVisible: (v: boolean) => void;
setSidebarOpen: (v: boolean) => void;
onSelectWiki: (id: string) => void;
addToast: (msg: string) => void;
@@ -25,7 +30,7 @@ export interface ReplayControllers {
// Narrative Setters
setTitle: (t: 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;
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
* 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;
// 1. Nhóm Map Actions
if (controllers.map) {
const map = controllers.map;
switch (function_name as MapFunctionName) {
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;
switch (function_name as MapFunctionName | GeoFunctionName) {
case "set_camera_view":
mapActions.set_camera_view(map, params[0]);
mapActions.set_camera_view(map, normalizeCameraViewState(params[0]));
return;
case "fly_to_geometry":
mapActions.fly_to_geometry(map, params[0], controllers.draft);
return;
case "rotate_around_point":
mapActions.rotate_around_point(map, params[0]);
mapActions.fly_to_geometry(
map,
asStringValue(params[0]),
controllers.draft,
);
return;
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;
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;
}
}
// 2. Nhóm UI Actions
switch (function_name as UIFunctionName) {
case "hide_timeline":
uiActions.hide_timeline(controllers.setTimelineVisible);
return;
case "hide_all_UI":
uiActions.hide_all_UI(controllers.setUIVisible);
return;
case "open_wiki":
uiActions.open_wiki(controllers.setSidebarOpen, controllers.onSelectWiki, params[0]);
return;
case "show_toast_message":
uiActions.show_toast_message(controllers.addToast, params[0]);
return;
case "set_playback_speed":
uiActions.set_playback_speed(controllers.setPlaybackSpeed, params[0]);
return;
const uiDescriptor = getUiActionDescriptor(function_name, params);
if (uiDescriptor) {
const { option, payload } = uiDescriptor;
switch (option) {
case "timeline":
uiActions.timeline(controllers.setTimelineVisible, Boolean(payload[0] ?? false));
return;
case "layer_panel":
uiActions.layer_panel(Boolean(payload[0] ?? false));
return;
case "wiki_panel":
uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false));
return;
case "zoom_panel":
uiActions.zoom_panel(Boolean(payload[0] ?? false));
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
switch (function_name as NarrativeFunctionName) {
case "set_title":
narrativeActions.set_title(controllers.setTitle, params[0]);
narrativeActions.set_title(controllers.setTitle, asStringValue(params[0]));
return;
case "set_descriptions":
narrativeActions.set_descriptions(controllers.setDescriptions, params[0]);
narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0]));
return;
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;
case "display_historical_image":
narrativeActions.display_historical_image(controllers.setImage, params[0]);
narrativeActions.display_historical_image(controllers.setImage, asStringValue(params[0]));
return;
case "set_step_subtitle":
narrativeActions.set_step_subtitle(controllers.setSubtitle, params[0]);
narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
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;
}
+27 -9
View File
@@ -3,29 +3,47 @@
*/
export const uiActions = {
// Ẩn thanh Timeline
hide_timeline: (setTimelineVisible: (v: boolean) => void) => {
setTimelineVisible(false);
// Ẩn/hiện thanh Timeline
timeline: (setTimelineVisible: (v: boolean) => void, visible: boolean) => {
setTimelineVisible(visible);
},
// Ẩn toàn bộ UI để có trải nghiệm điện ảnh (Cinematic)
hide_all_UI: (setUIVisible: (v: boolean) => void) => {
setUIVisible(false);
// Ẩn/hiện panel layer. Runtime hiện chưa có controller riêng nên tạm no-op.
layer_panel: (visible: boolean) => {
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ể
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);
onSelectWiki(wikiId);
},
// 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);
},
// 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
set_playback_speed: (setSpeed: (s: number) => void, speed: number) => {
playback_speed: (setSpeed: (s: number) => void, speed: number) => {
setSpeed(speed);
}
};