complete replay editor v1
This commit is contained in:
@@ -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