371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
import type maplibregl from "maplibre-gl";
|
|
import type { FeatureCollection } from "@/uhm/types/geo";
|
|
import type {
|
|
ReplayAction,
|
|
DialogState,
|
|
GeoFunctionName,
|
|
MapFunctionName,
|
|
NarrativeFunctionName,
|
|
UIOptionName,
|
|
} from "@/uhm/types/projects";
|
|
import { mapActions } from "./mapActions";
|
|
import { uiActions } from "./uiActions";
|
|
import { narrativeActions } from "./narrativeActions";
|
|
import type { ReplayMapEffects } from "./replayMapEffects";
|
|
|
|
/**
|
|
* Interface định nghĩa các controller cần thiết để thực thi Replay.
|
|
* Các thành phần UI sẽ cung cấp các hàm setter này cho Dispatcher.
|
|
*/
|
|
export interface ReplayControllers {
|
|
map: maplibregl.Map | null;
|
|
draft: FeatureCollection;
|
|
effects: ReplayMapEffects;
|
|
|
|
// 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;
|
|
setPlaybackSpeed: (s: number) => void;
|
|
onYearChange: (y: number) => void;
|
|
showGeometries: (ids: string[]) => void;
|
|
hideGeometries: (ids: string[]) => void;
|
|
showOnlyGeometries: (ids: string[]) => void;
|
|
showAllGeometries: () => void;
|
|
setAsBackgroundGeometries: (ids: string[]) => void;
|
|
removeFromBackgroundGeometries: (ids: string[]) => void;
|
|
|
|
// Narrative Setters
|
|
setDialog: (dialog: DialogState | null) => void;
|
|
getDialog?: () => DialogState | null;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
rawAction: ReplayAction<ReplayFunctionName> | { function_name: string; params: unknown[] }
|
|
) => {
|
|
const action = normalizeSingleAction(rawAction);
|
|
if (!action) return;
|
|
|
|
const { function_name, params } = action;
|
|
|
|
// 1. Nhóm Map/Geo Actions
|
|
if (controllers.map) {
|
|
const map = controllers.map;
|
|
switch (function_name) {
|
|
case "set_camera_view":
|
|
mapActions.set_camera_view(map, normalizeCameraViewState(params[0]));
|
|
return;
|
|
case "set_labels_visible":
|
|
mapActions.set_labels_visible(map, asBooleanValue(params[0], true));
|
|
return;
|
|
case "fly_to_geometries":
|
|
mapActions.fly_to_geometries(
|
|
map,
|
|
toStringValues(params[0]),
|
|
controllers.draft,
|
|
asNumberValue(params[1], 2200)
|
|
);
|
|
return;
|
|
case "set_geometry_visibility": {
|
|
const geometryIds = toStringValues(params[0]);
|
|
const visible = asBooleanValue(params[1], true);
|
|
if (visible) {
|
|
controllers.showGeometries(geometryIds);
|
|
} else {
|
|
controllers.hideGeometries(geometryIds);
|
|
}
|
|
return;
|
|
}
|
|
case "follow_geometries_path":
|
|
controllers.effects.followGeometriesPath(
|
|
map,
|
|
controllers.draft,
|
|
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":
|
|
mapActions.orbit_camera_around_geometry(
|
|
map,
|
|
asStringValue(params[0]),
|
|
controllers.draft,
|
|
asNumberValue(params[1], 8),
|
|
asNumberValue(params[2], 45),
|
|
asNumberValue(params[3], 1),
|
|
asNumberValue(params[4], 5000)
|
|
);
|
|
return;
|
|
case "set_as_background_geometries":
|
|
controllers.setAsBackgroundGeometries(toStringValues(params[0]));
|
|
return;
|
|
case "remove_from_background_geometries":
|
|
controllers.removeFromBackgroundGeometries(toStringValues(params[0]));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 2. Nhóm UI Actions
|
|
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
|
|
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, {
|
|
text: nextDialog.text ?? existing?.text ?? "",
|
|
image_url: nextDialog.image_url ?? existing?.image_url,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
type ReplayFunctionName = UIOptionName | MapFunctionName | GeoFunctionName | NarrativeFunctionName;
|
|
type ReplayActionLike = {
|
|
function_name?: unknown;
|
|
params?: unknown;
|
|
};
|
|
|
|
function normalizeSingleAction(action: unknown): ReplayAction<ReplayFunctionName> | null {
|
|
if (!action || typeof action !== "object") return null;
|
|
|
|
let { function_name } = action as ReplayActionLike;
|
|
let params: unknown[] = Array.isArray((action as ReplayActionLike).params)
|
|
? (action as { params: unknown[] }).params
|
|
: [];
|
|
if (typeof function_name !== "string") return null;
|
|
|
|
if (function_name === "UI") {
|
|
const legacyFunctionName = params[0];
|
|
if (typeof legacyFunctionName !== "string") return null;
|
|
function_name = legacyFunctionName;
|
|
params = params.slice(1);
|
|
}
|
|
|
|
switch (function_name) {
|
|
// UI Options
|
|
case "timeline":
|
|
case "layer_panel":
|
|
case "zoom_panel":
|
|
case "toast":
|
|
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 null;
|
|
|
|
// Map Functions
|
|
case "set_camera_view":
|
|
return { function_name, params };
|
|
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":
|
|
case "set_as_background_geometries":
|
|
case "remove_from_background_geometries":
|
|
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: [{ 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] }] };
|
|
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 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) {
|
|
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) {
|
|
return asOptionalNumberValue(value) ?? fallback;
|
|
}
|
|
|
|
function toStringValues(value: unknown) {
|
|
if (!Array.isArray(value)) {
|
|
const single = asStringValue(value).trim();
|
|
return single.length > 0 ? [single] : [];
|
|
}
|
|
return value
|
|
.map((item) => asStringValue(item).trim())
|
|
.filter((item) => item.length > 0);
|
|
}
|