complete replay editor v1
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user