refactor: decouple map effects from dispatcher and consolidate replay actions into a unified catalog
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
NarrativeFunctionName,
|
||||
ReplayAction,
|
||||
UIOptionName,
|
||||
DialogState,
|
||||
} from "@/uhm/types/projects";
|
||||
import { mapActions } from "./mapActions";
|
||||
import { uiActions } from "./uiActions";
|
||||
@@ -18,10 +19,13 @@ import { narrativeActions } from "./narrativeActions";
|
||||
export interface ReplayControllers {
|
||||
map: maplibregl.Map | null;
|
||||
draft: FeatureCollection;
|
||||
effects: any; // Type helper for ReplayMapEffects to avoid circular dependency
|
||||
|
||||
// UI Setters
|
||||
setTimelineVisible: (v: boolean) => void;
|
||||
setTimelineFilterEnabled: (v: boolean) => void;
|
||||
setLayerPanelVisible: (v: boolean) => void;
|
||||
setZoomPanelVisible: (v: boolean) => void;
|
||||
setSidebarOpen: (v: boolean) => void;
|
||||
onSelectWiki: (id: string) => void;
|
||||
addToast: (msg: string) => void;
|
||||
@@ -33,16 +37,8 @@ export interface ReplayControllers {
|
||||
showAllGeometries: () => void;
|
||||
|
||||
// Narrative Setters
|
||||
setTitle: (t: string) => void;
|
||||
setDescriptions: (d: string) => void;
|
||||
setDialog: (data: {
|
||||
avatar: string;
|
||||
text: string;
|
||||
side: "left" | "right";
|
||||
speaker?: string | null;
|
||||
} | null) => void;
|
||||
setImage: (image: { url: string; caption?: string | null } | null) => void;
|
||||
setSubtitle: (s: string | null) => void;
|
||||
setDialog: (dialog: DialogState | null) => void;
|
||||
getDialog?: () => DialogState | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,26 +47,22 @@ export interface ReplayControllers {
|
||||
*/
|
||||
export const dispatchReplayAction = (
|
||||
controllers: ReplayControllers,
|
||||
action: ReplayAction<UIOptionName | MapFunctionName | GeoFunctionName | NarrativeFunctionName> | {
|
||||
function_name: "UI";
|
||||
params: unknown[];
|
||||
}
|
||||
rawAction: ReplayAction<any> | { function_name: string; params: unknown[] }
|
||||
) => {
|
||||
const action = normalizeSingleAction(rawAction);
|
||||
if (!action) return;
|
||||
|
||||
const { function_name, params } = action;
|
||||
|
||||
// 1. Nhóm Map Actions
|
||||
// 1. Nhóm Map/Geo Actions
|
||||
if (controllers.map) {
|
||||
const map = controllers.map;
|
||||
switch (function_name as MapFunctionName | GeoFunctionName) {
|
||||
switch (function_name) {
|
||||
case "set_camera_view":
|
||||
mapActions.set_camera_view(map, normalizeCameraViewState(params[0]));
|
||||
return;
|
||||
case "fly_to_geometry":
|
||||
mapActions.fly_to_geometry(
|
||||
map,
|
||||
asStringValue(params[0]),
|
||||
controllers.draft,
|
||||
);
|
||||
case "set_labels_visible":
|
||||
mapActions.set_labels_visible(map, asBooleanValue(params[0], true));
|
||||
return;
|
||||
case "fly_to_geometries":
|
||||
mapActions.fly_to_geometries(
|
||||
@@ -80,33 +72,6 @@ export const dispatchReplayAction = (
|
||||
asNumberValue(params[1], 2200)
|
||||
);
|
||||
return;
|
||||
case "toggle_labels":
|
||||
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 "show_all_geometries":
|
||||
controllers.showAllGeometries();
|
||||
return;
|
||||
case "set_time_filter":
|
||||
mapActions.set_time_filter(controllers.onYearChange, asNumberValue(params[0], 0));
|
||||
return;
|
||||
case "enable_timeline_filter":
|
||||
controllers.setTimelineFilterEnabled(true);
|
||||
return;
|
||||
case "disable_timeline_filter":
|
||||
controllers.setTimelineFilterEnabled(false);
|
||||
return;
|
||||
case "show_geometries":
|
||||
controllers.showGeometries(toStringValues(params[0]));
|
||||
return;
|
||||
case "hide_geometries":
|
||||
controllers.hideGeometries(toStringValues(params[0]));
|
||||
return;
|
||||
case "set_geometry_visibility": {
|
||||
const geometryIds = toStringValues(params[0]);
|
||||
const visible = asBooleanValue(params[1], true);
|
||||
@@ -117,12 +82,49 @@ export const dispatchReplayAction = (
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "fit_to_geometries":
|
||||
mapActions.fly_to_geometries(
|
||||
case "follow_geometries_path":
|
||||
controllers.effects.followGeometriesPath(
|
||||
map,
|
||||
toStringValues(params[0]),
|
||||
controllers.draft,
|
||||
asNumberValue(params[1], 1800)
|
||||
toStringValues(params[0]),
|
||||
asNumberValue(params[1], 5000),
|
||||
asNumberValue(params[2], 8),
|
||||
asNumberValue(params[3], 50)
|
||||
);
|
||||
return;
|
||||
case "hide_others_geometries":
|
||||
controllers.showOnlyGeometries(toStringValues(params[0]));
|
||||
return;
|
||||
case "pulse_geometry":
|
||||
controllers.effects.pulseGeometry(
|
||||
map,
|
||||
controllers.draft,
|
||||
asStringValue(params[0]),
|
||||
asStringValue(params[1]) || "#f59e0b",
|
||||
asNumberValue(params[2], 2),
|
||||
asNumberValue(params[3], 1800)
|
||||
);
|
||||
return;
|
||||
case "animate_dashed_border":
|
||||
controllers.effects.animateDashedBorder(
|
||||
map,
|
||||
controllers.draft,
|
||||
asStringValue(params[0]),
|
||||
asStringValue(params[1]) || "#38bdf8",
|
||||
asNumberValue(params[2], 2),
|
||||
asNumberValue(params[3], 2),
|
||||
asNumberValue(params[4], 3000)
|
||||
);
|
||||
return;
|
||||
case "set_geometry_style":
|
||||
controllers.effects.setGeometryStyle(
|
||||
map,
|
||||
controllers.draft,
|
||||
toStringValues(params[0]),
|
||||
asStringValue(params[1]) || "#f97316",
|
||||
asNumberValue(params[2], 0.35),
|
||||
asStringValue(params[3]) || "#fdba74",
|
||||
asNumberValue(params[4], 2)
|
||||
);
|
||||
return;
|
||||
case "orbit_camera_around_geometry":
|
||||
@@ -136,161 +138,158 @@ export const dispatchReplayAction = (
|
||||
asNumberValue(params[4], 5000)
|
||||
);
|
||||
return;
|
||||
case "follow_geometry_path":
|
||||
mapActions.fly_to_geometries(
|
||||
map,
|
||||
[asStringValue(params[0])],
|
||||
controllers.draft,
|
||||
asNumberValue(params[1], 5000)
|
||||
);
|
||||
return;
|
||||
case "follow_geometries_path":
|
||||
mapActions.fly_to_geometries(
|
||||
map,
|
||||
toStringValues(params[0]),
|
||||
controllers.draft,
|
||||
asNumberValue(params[1], 5000)
|
||||
);
|
||||
return;
|
||||
case "reset_camera_north":
|
||||
mapActions.set_camera_view(map, { bearing: 0 });
|
||||
return;
|
||||
case "pulse_geometry":
|
||||
case "animate_dashed_border":
|
||||
case "set_geometry_style":
|
||||
case "show_geometry_label":
|
||||
return;
|
||||
case "dim_other_geometries":
|
||||
controllers.showOnlyGeometries(toStringValues(params[0]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Nhóm UI Actions
|
||||
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 "close_wiki_panel":
|
||||
uiActions.close_wiki_panel(controllers.setSidebarOpen, controllers.onSelectWiki);
|
||||
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;
|
||||
}
|
||||
switch (function_name) {
|
||||
case "timeline":
|
||||
uiActions.timeline(controllers.setTimelineVisible, asBooleanValue(params[0], true));
|
||||
return;
|
||||
case "layer_panel":
|
||||
uiActions.layer_panel(controllers.setLayerPanelVisible, asBooleanValue(params[0], true));
|
||||
return;
|
||||
case "zoom_panel":
|
||||
uiActions.zoom_panel(controllers.setZoomPanelVisible, asBooleanValue(params[0], true));
|
||||
return;
|
||||
case "wiki":
|
||||
uiActions.wiki(
|
||||
controllers.setSidebarOpen,
|
||||
controllers.onSelectWiki,
|
||||
params[0] as string | null
|
||||
);
|
||||
return;
|
||||
case "toast":
|
||||
uiActions.toast(
|
||||
controllers.addToast,
|
||||
typeof params[0] === "string" ? params[0] : ""
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Nhóm Narrative Actions
|
||||
switch (function_name as NarrativeFunctionName) {
|
||||
case "set_title":
|
||||
narrativeActions.set_title(controllers.setTitle, asStringValue(params[0]));
|
||||
return;
|
||||
case "clear_title":
|
||||
narrativeActions.clear_title(controllers.setTitle);
|
||||
return;
|
||||
case "set_descriptions":
|
||||
narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0]));
|
||||
return;
|
||||
case "clear_descriptions":
|
||||
narrativeActions.clear_descriptions(controllers.setDescriptions);
|
||||
return;
|
||||
case "show_dialog_box":
|
||||
narrativeActions.show_dialog_box(
|
||||
controllers.setDialog,
|
||||
asStringValue(params[0]),
|
||||
asStringValue(params[1]),
|
||||
normalizeDialogSide(params[2]),
|
||||
nullableStringValue(params[3])
|
||||
);
|
||||
return;
|
||||
case "clear_dialog_box":
|
||||
narrativeActions.clear_dialog_box(controllers.setDialog);
|
||||
return;
|
||||
case "display_historical_image":
|
||||
narrativeActions.display_historical_image(
|
||||
controllers.setImage,
|
||||
asStringValue(params[0]),
|
||||
nullableStringValue(params[1])
|
||||
);
|
||||
return;
|
||||
case "clear_historical_image":
|
||||
narrativeActions.clear_historical_image(controllers.setImage);
|
||||
return;
|
||||
case "set_step_subtitle":
|
||||
narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
|
||||
return;
|
||||
case "clear_step_subtitle":
|
||||
narrativeActions.clear_step_subtitle(controllers.setSubtitle);
|
||||
return;
|
||||
if (function_name === "set_dialog") {
|
||||
const nextDialog = params[0] as DialogState | null;
|
||||
if (nextDialog === null) {
|
||||
narrativeActions.set_dialog(controllers.setDialog, null);
|
||||
} else {
|
||||
// merge with existing dialog state if available
|
||||
const existing = controllers.getDialog ? controllers.getDialog() : null;
|
||||
narrativeActions.set_dialog(controllers.setDialog, {
|
||||
avatar: nextDialog.avatar ?? existing?.avatar ?? "",
|
||||
text: nextDialog.text ?? existing?.text ?? "",
|
||||
image_url: nextDialog.image_url ?? existing?.image_url,
|
||||
image_caption: nextDialog.image_caption ?? existing?.image_caption,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
function normalizeUiOption(value: unknown): UIOptionName | null {
|
||||
switch (value) {
|
||||
/**
|
||||
* Lớp tương thích ngược (Backward Compatibility)
|
||||
* Chuẩn hóa các action cũ thành 16 action chính thức.
|
||||
*/
|
||||
function normalizeSingleAction(action: any): ReplayAction<any> | null {
|
||||
if (!action || typeof action !== "object") return null;
|
||||
|
||||
let { function_name, params } = action;
|
||||
if (!Array.isArray(params)) {
|
||||
params = [];
|
||||
}
|
||||
|
||||
if (function_name === "UI") {
|
||||
function_name = params[0];
|
||||
params = params.slice(1);
|
||||
}
|
||||
|
||||
switch (function_name) {
|
||||
// UI Options
|
||||
case "timeline":
|
||||
case "layer_panel":
|
||||
case "wiki_panel":
|
||||
case "close_wiki_panel":
|
||||
case "zoom_panel":
|
||||
case "wiki":
|
||||
case "toast":
|
||||
case "wiki_header":
|
||||
return { function_name, params: [params[0]] };
|
||||
case "wiki":
|
||||
return { function_name: "wiki", params: [params[0] || null] };
|
||||
case "close_wiki_panel":
|
||||
return { function_name: "wiki", params: [null] };
|
||||
case "wiki_panel":
|
||||
return { function_name: "wiki", params: [params[0] ? "" : null] };
|
||||
case "playback_speed":
|
||||
return value;
|
||||
return null;
|
||||
|
||||
// Map Functions
|
||||
case "set_camera_view":
|
||||
return { function_name, params };
|
||||
case "set_timeline_filter":
|
||||
return { function_name, params: [Boolean(params[0])] };
|
||||
case "enable_timeline_filter":
|
||||
case "disable_timeline_filter":
|
||||
return { function_name: "set_timeline_filter", params: [function_name === "enable_timeline_filter"] };
|
||||
case "set_labels_visible":
|
||||
case "toggle_labels":
|
||||
return { function_name: "set_labels_visible", params: [Boolean(params[0])] };
|
||||
case "show_labels":
|
||||
case "hide_labels":
|
||||
return { function_name: "set_labels_visible", params: [function_name === "show_labels"] };
|
||||
case "reset_camera_north":
|
||||
return { function_name: "set_camera_view", params: [{ bearing: 0 }] };
|
||||
case "set_time_filter":
|
||||
case "show_all_geometries":
|
||||
return null;
|
||||
|
||||
// Geo Functions
|
||||
case "fly_to_geometries":
|
||||
return { function_name, params };
|
||||
case "fly_to_geometry":
|
||||
return { function_name: "fly_to_geometries", params: [[params[0]], params[3]] };
|
||||
case "fit_to_geometries":
|
||||
return { function_name: "fly_to_geometries", params: [params[0], params[1]] };
|
||||
case "set_geometry_visibility":
|
||||
return { function_name, params: [params[0], params[1] !== undefined ? Boolean(params[1]) : true] };
|
||||
case "show_geometries":
|
||||
return { function_name: "set_geometry_visibility", params: [params[0], true] };
|
||||
case "hide_geometries":
|
||||
return { function_name: "set_geometry_visibility", params: [params[0], false] };
|
||||
case "follow_geometries_path":
|
||||
return { function_name, params };
|
||||
case "follow_geometry_path":
|
||||
return { function_name: "follow_geometries_path", params: [[params[0]], params[1], params[2], params[3]] };
|
||||
case "dim_other_geometries":
|
||||
case "hide_others_geometries":
|
||||
return { function_name: "hide_others_geometries", params: [params[0]] };
|
||||
case "pulse_geometry":
|
||||
case "animate_dashed_border":
|
||||
case "set_geometry_style":
|
||||
case "orbit_camera_around_geometry":
|
||||
return { function_name, params };
|
||||
case "show_geometry_label":
|
||||
return null;
|
||||
|
||||
// Narrative Functions
|
||||
case "set_dialog":
|
||||
return { function_name, params };
|
||||
case "show_dialog_box":
|
||||
return { function_name: "set_dialog", params: [{ avatar: params[0], text: params[1] }] };
|
||||
case "set_title":
|
||||
case "set_descriptions":
|
||||
case "set_step_subtitle":
|
||||
return { function_name: "set_dialog", params: [{ text: params[0] }] };
|
||||
case "display_historical_image":
|
||||
return { function_name: "set_dialog", params: [{ image_url: params[0], image_caption: params[1] }] };
|
||||
case "clear_dialog_box":
|
||||
case "clear_title":
|
||||
case "clear_descriptions":
|
||||
case "clear_historical_image":
|
||||
case "clear_step_subtitle":
|
||||
return { function_name: "set_dialog", params: [null] };
|
||||
|
||||
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 {};
|
||||
@@ -330,21 +329,17 @@ function asStringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function nullableStringValue(value: unknown) {
|
||||
const next = asStringValue(value).trim();
|
||||
return next.length > 0 ? next : null;
|
||||
}
|
||||
|
||||
function asBooleanValue(value: unknown, fallback: boolean) {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
function normalizeDialogSide(value: unknown): "left" | "right" {
|
||||
return value === "right" ? "right" : "left";
|
||||
}
|
||||
|
||||
function asOptionalNumberValue(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asNumberValue(value: unknown, fallback: number) {
|
||||
@@ -353,7 +348,8 @@ function asNumberValue(value: unknown, fallback: number) {
|
||||
|
||||
function toStringValues(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
const single = asStringValue(value).trim();
|
||||
return single.length > 0 ? [single] : [];
|
||||
}
|
||||
return value
|
||||
.map((item) => asStringValue(item).trim())
|
||||
|
||||
Reference in New Issue
Block a user