refactor: decouple map effects from dispatcher and consolidate replay actions into a unified catalog

This commit is contained in:
taDuc
2026-05-25 02:36:18 +07:00
parent 395eb3de47
commit 9aa61dce27
18 changed files with 2527 additions and 1344 deletions
+37 -4
View File
@@ -11,6 +11,7 @@ import SelectedGeometryPanel from "@/uhm/components/editor/SelectedGeometryPanel
import ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar";
import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar";
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel";
@@ -46,7 +47,10 @@ import { buildFeatureEntityPatch } from "@/uhm/lib/editor/entity/entityBinding";
import { newId } from "@/uhm/lib/utils/id";
import {
loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import { BACKGROUND_LAYER_OPTIONS } from "@/uhm/lib/map/styles/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
@@ -2231,6 +2235,7 @@ function EditorPageContent() {
imageOverlay={imageOverlay}
onImageOverlayChange={setImageOverlay}
onBindGeometries={handleBindGeometries}
showViewportControls={!isReplayPreviewMode || replayPreview.zoomPanelVisible}
/>
) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
@@ -2239,11 +2244,7 @@ function EditorPageContent() {
<ReplayPreviewOverlay
isPreviewMode={true}
isPlaying={replayPreview.isPlaying}
title={replayPreview.title}
descriptions={replayPreview.descriptions}
subtitle={replayPreview.subtitle}
dialog={replayPreview.dialog}
image={replayPreview.image}
toasts={replayPreview.toasts}
sidebarOpen={replayPreview.sidebarOpen}
playbackSpeed={replayPreview.playbackSpeed}
@@ -2281,6 +2282,36 @@ function EditorPageContent() {
/>
</aside>
) : null}
{isReplayPreviewMode ? (
<aside
style={{
position: "absolute",
top: "50%",
left: 18,
transform: "translateY(-50%)",
zIndex: 16,
pointerEvents: "auto",
}}
>
<ReplayPreviewLayerPanel
backgroundVisibility={backgroundVisibility}
geometryVisibility={effectiveGeometryVisibility}
onToggleBackground={(id) =>
setBackgroundVisibility((prev) => {
const next = { ...prev, [id]: !prev[id] };
persistBackgroundLayerVisibility(next);
return next;
})
}
onToggleGeometry={(typeKey) =>
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}))
}
/>
</aside>
) : null}
{!isReplayPreviewMode || replayPreview.timelineVisible ? (
<TimelineBar
year={activeTimelineYear}
@@ -2432,6 +2463,8 @@ function readImageAspectRatio(url: string): Promise<number> {
});
}
// ReplayPreviewLayerPanel is imported from "@/uhm/components/editor/ReplayPreviewLayerPanel"
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
+4
View File
@@ -61,6 +61,7 @@ type MapProps = {
imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
showViewportControls?: boolean;
};
const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -90,6 +91,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
imageOverlay = null,
onImageOverlayChange,
onBindGeometries,
showViewportControls = true,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
@@ -273,6 +275,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
</div>
) : null}
{showViewportControls ? (
<div
style={{
position: "absolute",
@@ -428,6 +431,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
</div>
</div>
</div>
) : null}
</div>
);
});
+102 -297
View File
@@ -62,12 +62,12 @@ type ActionDefinition<T extends string> = {
};
type NarrativeActionDefinitionMap = Record<NarrativeFunctionName, ActionDefinition<NarrativeFunctionName>>;
type UiVisibleOptionName = "timeline" | "layer_panel" | "zoom_panel";
type UiEffectsDraftState = {
selected: Record<UIOptionName, boolean>;
visible: Record<UiVisibleOptionName, boolean>;
wiki_id: string;
message: string;
header_id: string;
speed: string;
};
type MapCameraOptionName = "center" | "zoom" | "bearing" | "pitch";
type MapCameraDraftState = {
@@ -84,28 +84,20 @@ type CurrentMapViewState = {
const uiOptionChoices: Array<{ label: string; value: UIOptionName }> = [
{ label: "Timeline", value: "timeline" },
{ label: "Layer Panel", value: "layer_panel" },
{ label: "Wiki Panel", value: "wiki_panel" },
{ label: "Close Wiki Panel", value: "close_wiki_panel" },
{ label: "Zoom Panel", value: "zoom_panel" },
{ label: "Wiki", value: "wiki" },
{ label: "Toast", value: "toast" },
{ label: "Wiki Header", value: "wiki_header" },
{ label: "Playback Speed", value: "playback_speed" },
];
const uiSimpleOptionValues: UIOptionName[] = [
"timeline",
"layer_panel",
"wiki_panel",
"close_wiki_panel",
"zoom_panel",
];
const uiInputOptionValues: UIOptionName[] = [
"wiki",
"toast",
"wiki_header",
"playback_speed",
];
const mapCameraOptionChoices: Array<{ label: string; value: MapCameraOptionName }> = [
@@ -148,107 +140,51 @@ const buttonStyle = {
};
const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
set_title: {
label: "Tiêu đề step",
fields: [{ name: "title", label: "Title", kind: "text", placeholder: "Tiêu đề" }],
create: () => ({ function_name: "set_title", params: [""] }),
deserialize: (params) => ({ title: asString(params[0]) }),
serialize: (values) => [asString(values.title)],
},
clear_title: {
label: "Xóa tiêu đề",
fields: [],
create: () => ({ function_name: "clear_title", params: [] }),
deserialize: () => ({}),
serialize: () => [],
},
set_descriptions: {
label: "Mô tả",
fields: [{ name: "text", label: "Text", kind: "textarea", placeholder: "Nội dung diễn giải" }],
create: () => ({ function_name: "set_descriptions", params: [""] }),
deserialize: (params) => ({ text: asString(params[0]) }),
serialize: (values) => [asString(values.text)],
},
clear_descriptions: {
label: "Xóa mô tả",
fields: [],
create: () => ({ function_name: "clear_descriptions", params: [] }),
deserialize: () => ({}),
serialize: () => [],
},
show_dialog_box: {
set_dialog: {
label: "Dialog box",
fields: [
{ name: "avatar", label: "Avatar", kind: "text", placeholder: "avatar url" },
{ name: "text", label: "Text", kind: "textarea", placeholder: "Lời thoại" },
{
name: "side",
label: "Side",
kind: "select",
options: [
{ label: "Left", value: "left" },
{ label: "Right", value: "right" },
{ name: "clear", label: "Ẩn dialog (Clear)", kind: "boolean" },
{ name: "avatar", label: "Avatar URL", kind: "text", placeholder: "https://... (avatar)" },
{ name: "text", label: "Nội dung", kind: "textarea", placeholder: "Lời thoại / Dẫn chuyện" },
{ name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (ảnh đè)" },
{ name: "image_caption", label: "Chú thích ảnh", kind: "text", placeholder: "Chú thích ảnh" },
],
create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }),
deserialize: (params) => {
const data: any = params[0];
if (data === null) {
return {
clear: true,
avatar: "",
text: "",
image_url: "",
image_caption: "",
};
}
return {
clear: false,
avatar: asString(data?.avatar),
text: asString(data?.text),
image_url: asString(data?.image_url),
image_caption: asString(data?.image_caption),
};
},
{ name: "speaker", label: "Speaker", kind: "text", placeholder: "Tên nhân vật" },
],
create: () => ({ function_name: "show_dialog_box", params: ["", "", "left", ""] }),
deserialize: (params) => ({
avatar: asString(params[0]),
text: asString(params[1]),
side: normalizeSelectValue(asString(params[2]), "left"),
speaker: asString(params[3]),
}),
serialize: (values) => [
asString(values.avatar),
asString(values.text),
normalizeSelectValue(asString(values.side), "left"),
asString(values.speaker),
],
serialize: (values) => {
if (values.clear) {
return [null];
}
const data: any = {
avatar: asString(values.avatar),
text: asString(values.text),
};
if (values.image_url) {
data.image_url = asString(values.image_url);
}
if (values.image_caption) {
data.image_caption = asString(values.image_caption);
}
return [data];
},
clear_dialog_box: {
label: "Đóng dialog box",
fields: [],
create: () => ({ function_name: "clear_dialog_box", params: [] }),
deserialize: () => ({}),
serialize: () => [],
},
display_historical_image: {
label: "Ảnh lịch sử",
fields: [
{ name: "url", label: "URL", kind: "text", placeholder: "https://..." },
{ name: "caption", label: "Caption", kind: "textarea", placeholder: "Chú thích" },
],
create: () => ({ function_name: "display_historical_image", params: ["", ""] }),
deserialize: (params) => ({
url: asString(params[0]),
caption: asString(params[1]),
}),
serialize: (values) => compactTrailingUndefined([
asString(values.url),
emptyToUndefined(asString(values.caption)),
]),
},
clear_historical_image: {
label: "Xóa ảnh lịch sử",
fields: [],
create: () => ({ function_name: "clear_historical_image", params: [] }),
deserialize: () => ({}),
serialize: () => [],
},
set_step_subtitle: {
label: "Phụ đề",
fields: [{ name: "subtitle", label: "Subtitle", kind: "textarea", placeholder: "Để trống để ẩn subtitle" }],
create: () => ({ function_name: "set_step_subtitle", params: [""] }),
deserialize: (params) => ({ subtitle: params[0] == null ? "" : asString(params[0]) }),
serialize: (values) => [emptyToNull(asString(values.subtitle))],
},
clear_step_subtitle: {
label: "Xóa phụ đề",
fields: [],
create: () => ({ function_name: "clear_step_subtitle", params: [] }),
deserialize: () => ({}),
serialize: () => [],
},
};
@@ -306,11 +242,6 @@ export default function ReplayEffectsSidebar({
})
.map((id) => byId.get(id) || { id, label: id });
}, [geometryChoices, selectedFeatureIds]);
const selectedGeometryIds = useMemo(
() => selectedGeometryItems.map((item) => item.id),
[selectedGeometryItems]
);
const updateStep = (label: string, updater: (step: ReplayStep) => void) => {
if (!selectedStage || selectedStepIndex == null) return;
onMutateReplay(label, (draftReplay) => {
@@ -362,7 +293,6 @@ export default function ReplayEffectsSidebar({
<>
<ActionGroupEditor
title="Narrative"
groupKey="use_narrow_function"
groupLabel={`Replay: cập nhật narrative step ${selectedStepIndex + 1} của stage #${selectedStage.id}`}
actions={selectedStep.use_narrow_function}
definitions={narrativeActionDefinitions}
@@ -433,7 +363,7 @@ function MapFunctionShortcutPanel({
tone="blue"
onClick={() =>
onAppendActions(
[{ function_name: "show_labels", params: [] }],
[{ function_name: "set_labels_visible", params: [true] }],
"Map: show labels"
)
}
@@ -443,7 +373,7 @@ function MapFunctionShortcutPanel({
tone="slate"
onClick={() =>
onAppendActions(
[{ function_name: "hide_labels", params: [] }],
[{ function_name: "set_labels_visible", params: [false] }],
"Map: hide labels"
)
}
@@ -453,7 +383,7 @@ function MapFunctionShortcutPanel({
tone="green"
onClick={() =>
onAppendActions(
[{ function_name: "enable_timeline_filter", params: [] }],
[{ function_name: "set_timeline_filter", params: [true] }],
"Map: enable timeline filter"
)
}
@@ -463,41 +393,11 @@ function MapFunctionShortcutPanel({
tone="slate"
onClick={() =>
onAppendActions(
[{ function_name: "disable_timeline_filter", params: [] }],
[{ function_name: "set_timeline_filter", params: [false] }],
"Map: disable timeline filter"
)
}
/>
<ShortcutButton
label="Lấy Timeline"
tone="teal"
onClick={() =>
onAppendActions(
[{ function_name: "set_time_filter", params: [safeYear] }],
`Map: set timeline ${safeYear}`
)
}
/>
<ShortcutButton
label="North Up"
tone="amber"
onClick={() =>
onAppendActions(
[{ function_name: "reset_camera_north", params: [] }],
"Map: reset camera north"
)
}
/>
<ShortcutButton
label="Show All Geo"
tone="green"
onClick={() =>
onAppendActions(
[{ function_name: "show_all_geometries", params: [] }],
"Map: show all geometries"
)
}
/>
</div>
</div>
</Panel>
@@ -553,7 +453,7 @@ function GeoFunctionShortcutPanel({
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "show_geometries", params: [selectedIds] }],
[{ function_name: "set_geometry_visibility", params: [selectedIds, true] }],
`Geo: show ${selectedCount} geo`
)
}
@@ -564,89 +464,22 @@ function GeoFunctionShortcutPanel({
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "hide_geometries", params: [selectedIds] }],
[{ function_name: "set_geometry_visibility", params: [selectedIds, false] }],
`Geo: hide ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Pulse"
tone="amber"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
selectedIds.map((id) => ({
function_name: "pulse_geometry",
params: [id, "#f59e0b", 2, 1800],
})),
`Geo: pulse ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Dash Border"
tone="blue"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
selectedIds.map((id) => ({
function_name: "animate_dashed_border",
params: [id, "#38bdf8", 2, 1, 3000],
})),
`Geo: dashed border ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Orbit"
tone="teal"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "orbit_camera_around_geometry", params: [firstId, 8, 45, 1, 5000] }],
`Geo: orbit ${firstId || "main"}`
)
}
/>
<ShortcutButton
label="Label Geo"
tone="green"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
selectedIds.map((id) => ({
function_name: "show_geometry_label",
params: [id, "", "#ffffff", 14],
})),
`Geo: label ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Hide Others"
tone="slate"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "dim_other_geometries", params: [selectedIds] }],
[{ function_name: "hide_others_geometries", params: [selectedIds] }],
`Geo: hide others ngoài ${selectedCount} geo`
)
}
/>
<ShortcutButton
label="Style Geo"
tone="amber"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{
function_name: "set_geometry_style",
params: [selectedIds, "#f97316", 0.35, "#fdba74", 2],
}],
`Geo: style ${selectedCount} geo`
)
}
/>
</div>
</div>
</Panel>
@@ -849,36 +682,6 @@ function UiInputEffectsPanel({
/>
) : null}
{draft.selected.wiki_header ? (
<FieldInput
field={{
name: "header_id",
label: "Header ID",
kind: "text",
placeholder: "heading-id",
}}
value={draft.header_id}
geometryChoices={[]}
wikiChoices={wikiChoices}
onChange={(nextValue) => onChangeDraft({ header_id: asString(nextValue) })}
/>
) : null}
{draft.selected.playback_speed ? (
<FieldInput
field={{
name: "speed",
label: "Speed",
kind: "number",
placeholder: "1",
}}
value={draft.speed}
geometryChoices={[]}
wikiChoices={wikiChoices}
onChange={(nextValue) => onChangeDraft({ speed: asString(nextValue) })}
/>
) : null}
<button
type="button"
onClick={onApply}
@@ -916,6 +719,8 @@ function UiOptionToggleRow({
);
}
// UiVisibilityOptions removed since toggles are evaluated directly
function SimpleOptionToggleRow<T extends string>({
options,
onToggleOption,
@@ -1022,7 +827,6 @@ function UiEffectsEditor({
function ActionGroupEditor<T extends string>({
title,
groupKey,
groupLabel,
actions,
definitions,
@@ -1033,7 +837,6 @@ function ActionGroupEditor<T extends string>({
onUpdateActions,
}: {
title: string;
groupKey: ActionGroupKey;
groupLabel: string;
actions: ReplayAction<T>[];
definitions: Record<T, ActionDefinition<T>>;
@@ -1045,10 +848,13 @@ function ActionGroupEditor<T extends string>({
}) {
const functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
const [composerFunctionName, setComposerFunctionName] = useState<T | "">(
createOnSelect ? "" : (functionNames[0] as T)
createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T)
);
const [composerDraftValues, setComposerDraftValues] = useState<ActionFormValues>(() =>
buildActionComposerDraft(definitions, createOnSelect ? "" : (functionNames[0] as T))
buildActionComposerDraft(
definitions,
createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T)
)
);
const composerDefinition = composerFunctionName
@@ -1076,7 +882,7 @@ function ActionGroupEditor<T extends string>({
`${groupLabel}: thêm ${definition.label}`
);
if (createOnSelect) {
if (createOnSelect && functionNames.length > 1) {
setComposerFunctionName("");
setComposerDraftValues(buildActionComposerDraft(definitions, ""));
return;
@@ -1088,6 +894,7 @@ function ActionGroupEditor<T extends string>({
return (
<Panel title={title} badge={`${actions.length}`} defaultOpen>
<div style={{ display: "grid", gap: 10 }}>
{functionNames.length > 1 ? (
<div
style={{
display: "grid",
@@ -1114,6 +921,7 @@ function ActionGroupEditor<T extends string>({
))}
</select>
</div>
) : null}
{composerDefinition ? (
<div
@@ -1396,40 +1204,35 @@ function compactTrailingUndefined(values: unknown[]) {
return next;
}
function normalizeColorValue(value: unknown, fallback: string) {
const raw = asString(value).trim();
return raw.length > 0 ? raw : fallback;
}
function normalizeSelectValue(value: string, fallback: string) {
return value.trim().length ? value : fallback;
}
function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffectsDraftState {
const selected = buildEmptyUiOptionSelection();
const visible = buildDefaultUiVisibilityState();
let wiki_id = "";
let message = "";
let header_id = "";
let speed = "1";
for (const action of actions) {
const descriptor = getUiActionDescriptor(action);
if (!descriptor) continue;
selected[descriptor.option] = true;
switch (descriptor.option) {
case "timeline":
case "layer_panel":
case "zoom_panel":
selected[descriptor.option] = Boolean(descriptor.payload[0] ?? false);
visible[descriptor.option] = Boolean(descriptor.payload[0] ?? false);
break;
case "wiki":
selected[descriptor.option] = true;
wiki_id = asString(descriptor.payload[0]);
break;
case "toast":
selected[descriptor.option] = true;
message = asString(descriptor.payload[0]);
break;
case "wiki_header":
header_id = asString(descriptor.payload[0]);
break;
case "playback_speed":
speed = toInputNumber(descriptor.payload[0], "1");
break;
default:
break;
}
@@ -1437,10 +1240,9 @@ function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffe
return {
selected,
visible,
wiki_id,
message,
header_id,
speed,
};
}
@@ -1448,13 +1250,17 @@ function buildEmptyUiOptionSelection(): Record<UIOptionName, boolean> {
return {
timeline: false,
layer_panel: false,
wiki_panel: false,
close_wiki_panel: false,
zoom_panel: false,
wiki: false,
toast: false,
wiki_header: false,
playback_speed: false,
};
}
function buildDefaultUiVisibilityState(): Record<UiVisibleOptionName, boolean> {
return {
timeline: false,
layer_panel: false,
zoom_panel: false,
};
}
@@ -1538,7 +1344,12 @@ function replaceUiActionsByGroup(
});
const nextGroupActions = groupOptions
.filter((option) => draft.selected[option])
.filter((option) => {
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
return true;
}
return draft.selected[option];
})
.map((option) => buildUiOptionAction(option, draft));
return [...preserved, ...nextGroupActions];
@@ -1550,11 +1361,22 @@ function buildUiEffectsApplyLabel(
groupOptions: UIOptionName[]
) {
const activeLabels = groupOptions
.filter((option) => draft.selected[option])
.map((option) => uiOptionChoices.find((choice) => choice.value === option)?.label || option);
.filter((option) => {
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
return true;
}
return draft.selected[option];
})
.map((option) => {
const label = uiOptionChoices.find((choice) => choice.value === option)?.label || option;
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
return draft.selected[option] ? `Show ${label}` : `Hide ${label}`;
}
return label;
});
return activeLabels.length > 0
? `${prefix}: apply ${activeLabels.join(", ")}`
? `${prefix}: ${activeLabels.join(", ")}`
: `${prefix}: clear`;
}
@@ -1565,37 +1387,21 @@ function buildUiOptionAction(
switch (option) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "zoom_panel":
return {
function_name: option,
params: [false],
};
case "close_wiki_panel":
return {
function_name: option,
params: [],
params: [draft.selected[option]],
};
case "wiki":
return {
function_name: option,
params: [draft.wiki_id],
params: [draft.wiki_id || null],
};
case "toast":
return {
function_name: option,
params: [draft.message],
};
case "wiki_header":
return {
function_name: option,
params: [draft.header_id],
};
case "playback_speed":
return {
function_name: option,
params: [toNumberOr(draft.speed, 1)],
};
}
}
@@ -1626,14 +1432,13 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
switch (value) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
case "wiki_header":
case "playback_speed":
return value;
case "wiki_panel":
case "close_wiki_panel":
return "wiki";
default:
return null;
}
@@ -0,0 +1,365 @@
"use client";
import type { BackgroundLayerId } from "@/uhm/lib/map/styles/backgroundLayers";
import { BACKGROUND_LAYER_OPTIONS } from "@/uhm/lib/map/styles/backgroundLayers";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
type Props = {
backgroundVisibility: Record<string, boolean>;
geometryVisibility: Record<string, boolean>;
onToggleBackground: (id: BackgroundLayerId) => void;
onToggleGeometry: (typeKey: string) => void;
};
// Map each layer ID/geometry type to a premium inline SVG icon
const LAYER_ICONS: Record<string, React.ReactNode> = {
// Background layers
"raster-base-layer": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
),
"bg-country-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12h18M3 6h18M3 18h18" strokeDasharray="2 2" />
<rect x="2" y="2" width="20" height="20" rx="4" />
</svg>
),
"bg-province-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" strokeDasharray="3 3" />
<rect x="2" y="2" width="20" height="20" rx="3" />
</svg>
),
"bg-district-borders-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3v18M12 3v18M18 3v18M3 6h18M3 12h18M3 18h18" strokeDasharray="1 3" />
<rect x="2" y="2" width="20" height="20" rx="2" />
</svg>
),
"country-labels": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 7V4h16v3M9 20h6M12 4v16" />
</svg>
),
"rivers-line": (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12c3-3 6-3 9 0s6 3 9 0" />
<path d="M3 16c3-3 6-3 9 0s6 3 9 0" opacity="0.6" />
</svg>
),
// Polygon Geometries
country: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5" />
</svg>
),
state: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 4 20 9 20 15 12 20 4 15 4 9" />
</svg>
),
faction: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1zM4 22v-7" />
</svg>
),
rebellion_zone: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z" />
</svg>
),
// Line Geometries
defense_line: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="10" rx="2" />
<path d="M12 2v9M8 5v3M16 5v3" />
</svg>
),
military_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M12 12l9 9M18 15h3v3" />
</svg>
),
retreat_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
),
migration_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
),
trade_route: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8M12 6v12" />
</svg>
),
// Point Geometries
battle: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 17.5L3 6M17.5 14.5L6 3" />
<path d="M8.5 19.5L19.5 8.5" />
</svg>
),
person_event: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
),
temple: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7h20L12 2zM4 7v10h16V7H4zm2 10v4h2v-4H6zm10 0v4h2v-4h-2z" />
</svg>
),
capital: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polygon points="12 7 13.9 10.8 18.1 11.4 15.1 14.4 15.8 18.6 12 16.6 8.2 18.6 8.9 14.4 5.9 11.4 10.1 10.8" fill="currentColor" />
</svg>
),
city: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<line x1="9" y1="22" x2="9" y2="16" />
<line x1="15" y1="22" x2="15" y2="16" />
<line x1="9" y1="16" x2="15" y2="16" />
<path d="M8 6h2M14 6h2M8 10h2M14 10h2" strokeWidth="1.5" />
</svg>
),
fortification: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 22V8l3-3h10l3 3v14H4z" />
<path d="M9 22v-6h6v6H9z" />
<path d="M8 8h8M12 5v3" />
</svg>
),
ruin: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="6" y1="20" x2="6" y2="4" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="3" y1="4" x2="9" y2="4" />
<line x1="15" y1="4" x2="21" y2="4" />
<line x1="3" y1="20" x2="21" y2="20" />
<line x1="6" y1="12" x2="18" y2="12" strokeDasharray="3 3" />
</svg>
),
port: (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="3" />
<line x1="12" y1="22" x2="12" y2="8" />
<path d="M5 12h14M12 12c-4 0-6 4-6 6a6 6 0 0 0 12 0c0-2-2-6-6-6z" />
</svg>
),
};
// Class name helper for tooltips using CSS
const buttonClassName = "preview-layer-btn";
export default function ReplayPreviewLayerPanel({
backgroundVisibility,
geometryVisibility,
onToggleBackground,
onToggleGeometry,
}: Props) {
// Categorize geometry types for logical grouping
const polygonKeys = ["country", "state", "faction", "rebellion_zone"];
const lineKeys = ["defense_line", "military_route", "retreat_route", "migration_route", "trade_route"];
const pointKeys = ["battle", "person_event", "temple", "capital", "city", "fortification", "ruin", "port"];
const getButtonStyles = (isActive: boolean, activeColor: string): React.CSSProperties => ({
border: "none",
background: isActive ? `rgba(${activeColor}, 0.18)` : "rgba(30, 41, 59, 0.4)",
color: isActive ? `rgb(${activeColor})` : "#64748b",
width: 36,
height: 36,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
boxShadow: isActive ? `inset 0 0 0 1px rgba(${activeColor}, 0.3), 0 0 12px rgba(${activeColor}, 0.2)` : "inset 0 0 0 1px rgba(148, 163, 184, 0.1)",
outline: "none",
});
const renderTooltipStyles = () => (
<style dangerouslySetInnerHTML={{ __html: `
.${buttonClassName} {
position: relative;
}
.${buttonClassName}::after {
content: attr(data-tooltip);
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%) scale(0.9);
margin-left: 10px;
padding: 6px 10px;
background: rgba(15, 23, 42, 0.95);
border: 1px solid rgba(148, 163, 184, 0.25);
color: #f8fafc;
font-size: 11px;
font-weight: 800;
white-space: nowrap;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(2, 6, 23, 0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease, transform 0.15s ease;
z-index: 999;
}
.${buttonClassName}:hover::after {
opacity: 1;
transform: translateY(-50%) scale(1);
}
`}} />
);
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.8))",
border: "1px solid rgba(148, 163, 184, 0.22)",
borderRadius: 20,
padding: "14px 10px",
width: 100,
alignItems: "center",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)",
maxHeight: "calc(100vh - 180px)",
overflowY: "auto",
}}
>
{renderTooltipStyles()}
{/* Background layers */}
<div style={groupHeaderStyle}>Map</div>
<div style={gridStyle}>
{BACKGROUND_LAYER_OPTIONS.map((layer) => {
const active = Boolean(backgroundVisibility[layer.id]);
return (
<button
key={layer.id}
type="button"
className={buttonClassName}
data-tooltip={layer.label}
onClick={() => onToggleBackground(layer.id)}
style={getButtonStyles(active, "56, 189, 248")} // sky-400
>
{LAYER_ICONS[layer.id] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Territories / Polygons */}
<div style={groupHeaderStyle}>Areas</div>
<div style={gridStyle}>
{polygonKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "249, 115, 22")} // orange-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Routes / Lines */}
<div style={groupHeaderStyle}>Routes</div>
<div style={gridStyle}>
{lineKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "192, 132, 252")} // purple-400
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
<div style={dividerStyle} />
{/* Places & Events / Points */}
<div style={groupHeaderStyle}>Points</div>
<div style={gridStyle}>
{pointKeys.map((typeKey) => {
const active = geometryVisibility[typeKey] !== false;
const label = typeKey.replace("_", " ").toUpperCase();
return (
<button
key={typeKey}
type="button"
className={buttonClassName}
data-tooltip={label}
onClick={() => onToggleGeometry(typeKey)}
style={getButtonStyles(active, "245, 158, 11")} // amber-500
>
{LAYER_ICONS[typeKey] || "?"}
</button>
);
})}
</div>
</div>
);
}
const groupHeaderStyle: React.CSSProperties = {
fontSize: 9,
fontWeight: 900,
color: "#94a3b8",
letterSpacing: 1,
textTransform: "uppercase",
width: "100%",
textAlign: "center",
marginBottom: 4,
};
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 8,
width: "100%",
};
const dividerStyle: React.CSSProperties = {
height: 1,
background: "rgba(148, 163, 184, 0.15)",
width: "80%",
margin: "6px 0",
};
@@ -1,20 +1,13 @@
"use client";
import type { CSSProperties } from "react";
import type {
ReplayPreviewDialog,
ReplayPreviewImage,
ReplayPreviewToast,
} from "@/uhm/lib/replay/useReplayPreview";
import type { DialogState } from "@/uhm/types/projects";
import type { ReplayPreviewToast } from "@/uhm/lib/replay/useReplayPreview";
type Props = {
isPreviewMode: boolean;
isPlaying: boolean;
title: string;
descriptions: string;
subtitle: string | null;
dialog: ReplayPreviewDialog | null;
image: ReplayPreviewImage | null;
dialog: DialogState | null;
toasts: ReplayPreviewToast[];
sidebarOpen: boolean;
playbackSpeed: number;
@@ -30,11 +23,7 @@ type Props = {
export default function ReplayPreviewOverlay({
isPreviewMode,
isPlaying,
title,
descriptions,
subtitle,
dialog,
image,
toasts,
sidebarOpen,
playbackSpeed,
@@ -46,15 +35,11 @@ export default function ReplayPreviewOverlay({
onResetPreview,
onExitPreview,
}: Props) {
const hasNarrativeCard = title.trim().length > 0 || descriptions.trim().length > 0;
const hasWikiPreview = sidebarOpen;
const shouldRender =
isPreviewMode ||
isPlaying ||
hasNarrativeCard ||
Boolean(subtitle) ||
Boolean(dialog) ||
Boolean(image) ||
Boolean(toasts.length);
if (!shouldRender) {
@@ -70,49 +55,6 @@ export default function ReplayPreviewOverlay({
pointerEvents: "none",
}}
>
{hasNarrativeCard ? (
<div
style={{
position: "absolute",
top: 72,
left: 18,
maxWidth: 460,
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.26)",
background: "linear-gradient(145deg, rgba(15, 23, 42, 0.94), rgba(30, 41, 59, 0.88))",
boxShadow: "0 14px 42px rgba(2, 6, 23, 0.42)",
padding: "18px 20px",
}}
>
{title.trim().length ? (
<div
style={{
fontSize: 26,
lineHeight: 1.1,
fontWeight: 900,
color: "#f8fafc",
overflowWrap: "anywhere",
}}
>
{title}
</div>
) : null}
{descriptions.trim().length ? (
<div
style={{
marginTop: title.trim().length ? 12 : 0,
fontSize: 14,
lineHeight: 1.55,
color: "#dbeafe",
whiteSpace: "pre-wrap",
}}
>
{descriptions}
</div>
) : null}
</div>
) : null}
{toasts.length ? (
<div
style={{
@@ -144,7 +86,7 @@ export default function ReplayPreviewOverlay({
</div>
) : null}
{image ? (
{dialog?.image_url ? (
<div
style={{
position: "absolute",
@@ -159,8 +101,8 @@ export default function ReplayPreviewOverlay({
}}
>
<img
src={image.url}
alt={image.caption || "Historical image"}
src={dialog.image_url}
alt={dialog.image_caption || "Historical image"}
style={{
width: "100%",
display: "block",
@@ -169,7 +111,7 @@ export default function ReplayPreviewOverlay({
background: "#020617",
}}
/>
{image.caption?.trim() ? (
{dialog.image_caption?.trim() ? (
<div
style={{
padding: "10px 12px",
@@ -178,30 +120,29 @@ export default function ReplayPreviewOverlay({
color: "#cbd5e1",
}}
>
{image.caption}
{dialog.image_caption}
</div>
) : null}
</div>
) : null}
{dialog ? (
{dialog && dialog.text?.trim() ? (
dialog.avatar?.trim() ? (
<div
style={{
position: "absolute",
left: dialog.side === "right" ? "auto" : 18,
right: dialog.side === "right" ? 18 : "auto",
bottom: subtitle ? 138 : 96,
left: 18,
bottom: 96,
maxWidth: 420,
display: "grid",
gap: 10,
gridTemplateColumns: dialog.avatar.trim().length ? "56px 1fr" : "1fr",
gridTemplateColumns: "56px 1fr",
alignItems: "start",
}}
>
{dialog.avatar.trim().length ? (
<img
src={dialog.avatar}
alt={dialog.speaker || "speaker"}
alt="speaker"
style={{
width: 56,
height: 56,
@@ -211,7 +152,6 @@ export default function ReplayPreviewOverlay({
background: "#0f172a",
}}
/>
) : null}
<div
style={{
borderRadius: 18,
@@ -222,19 +162,6 @@ export default function ReplayPreviewOverlay({
boxShadow: "0 14px 36px rgba(2, 6, 23, 0.38)",
}}
>
{dialog.speaker?.trim() ? (
<div
style={{
marginBottom: 6,
fontSize: 11,
color: "#7dd3fc",
fontWeight: 900,
letterSpacing: 0.4,
}}
>
{dialog.speaker}
</div>
) : null}
<div
style={{
fontSize: 15,
@@ -246,9 +173,7 @@ export default function ReplayPreviewOverlay({
</div>
</div>
</div>
) : null}
{subtitle?.trim() ? (
) : (
<div
style={{
position: "absolute",
@@ -256,7 +181,7 @@ export default function ReplayPreviewOverlay({
bottom: 90,
transform: "translateX(-50%)",
maxWidth: 720,
borderRadius: 999,
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(2, 6, 23, 0.84)",
color: "#f8fafc",
@@ -268,8 +193,9 @@ export default function ReplayPreviewOverlay({
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)",
}}
>
{subtitle}
{dialog.text}
</div>
)
) : null}
{isPreviewMode ? (
@@ -10,6 +10,7 @@ import type {
ReplayStage,
ReplayStep,
UIOptionName,
DialogState,
} from "@/uhm/types/projects";
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import { Panel } from "./Panel";
@@ -36,6 +37,51 @@ type Props = {
};
type ActionGroupKey = "use_UI_function" | "use_map_function" | "use_geo_function" | "use_narrow_function";
type AnyStepAction =
| ReplayAction<UIOptionName>
| ReplayAction<MapFunctionName>
| ReplayAction<GeoFunctionName>
| ReplayAction<NarrativeFunctionName>;
function validateReplayTimeFormat(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
// 1. Check DD/MM/YYYY
const dmyRegex = /^(\d{2})\/(\d{2})\/(-?\d{4})$/;
const dmyMatch = trimmed.match(dmyRegex);
if (dmyMatch) {
const day = parseInt(dmyMatch[1], 10);
const month = parseInt(dmyMatch[2], 10);
const year = parseInt(dmyMatch[3], 10);
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
if (month === 2) {
const isLeap = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
if (day > (isLeap ? 29 : 28)) return false;
} else if ([4, 6, 9, 11].includes(month)) {
if (day > 30) return false;
}
return true;
}
// 2. Check MM/YYYY
const myRegex = /^(\d{2})\/(-?\d{4})$/;
const myMatch = trimmed.match(myRegex);
if (myMatch) {
const month = parseInt(myMatch[1], 10);
if (month < 1 || month > 12) return false;
return true;
}
// 3. Check YYYY
const yRegex = /^(-?\d{4})$/;
if (yRegex.test(trimmed)) {
return true;
}
return false;
}
type StageFormState = {
title: string;
@@ -97,6 +143,13 @@ export default function ReplayTimelineSidebar({
detail_time_stop: "",
});
const [createStagePanelKey, setCreateStagePanelKey] = useState(0);
const isStartValid = validateReplayTimeFormat(createStageForm.detail_time_start);
const isStopValid = validateReplayTimeFormat(createStageForm.detail_time_stop);
const isCreateFormValid = createStageForm.title.trim() !== "" && isStartValid && isStopValid;
const showStartError = createStageForm.detail_time_start.length > 0 && !isStartValid;
const showStopError = createStageForm.detail_time_stop.length > 0 && !isStopValid;
const [openWeightEditorKey, setOpenWeightEditorKey] = useState<string | null>(null);
const [openActionDetailKey, setOpenActionDetailKey] = useState<string | null>(null);
@@ -153,6 +206,10 @@ export default function ReplayTimelineSidebar({
const handleCreateStage = () => {
if (!replay) return;
if (!validateReplayTimeFormat(createStageForm.detail_time_start) ||
!validateReplayTimeFormat(createStageForm.detail_time_stop)) {
return;
}
const nextId =
stages.length > 0
? Math.max(...stages.map((stage) => stage.id)) + 1
@@ -164,7 +221,7 @@ export default function ReplayTimelineSidebar({
detail_time_stop: createStageForm.detail_time_stop.trim(),
steps: [
{
duration: 1000,
duration: 5000,
use_UI_function: [],
use_map_function: [],
use_geo_function: [],
@@ -211,6 +268,26 @@ export default function ReplayTimelineSidebar({
}
};
const handleDuplicateStage = (stageId: number) => {
let nextStageId: number | null = null;
const changed = onMutateReplay(`Replay: nhân bản stage #${stageId}`, (draftReplay) => {
const index = draftReplay.detail.findIndex((item) => item.id === stageId);
if (index === -1) return;
nextStageId = draftReplay.detail.length > 0
? Math.max(...draftReplay.detail.map((stage) => stage.id)) + 1
: 0;
const source = draftReplay.detail[index];
draftReplay.detail.splice(index + 1, 0, {
...source,
id: nextStageId,
title: source.title ? `${source.title} copy` : undefined,
steps: source.steps.map(cloneReplayStep),
});
});
if (!changed || nextStageId == null) return;
onSelectStep(nextStageId, 0);
};
const handleAddStep = (stageId: number) => {
let nextStepIndex: number | null = null;
const changed = onMutateReplay(`Replay: tạo step cho stage #${stageId}`, (draftReplay) => {
@@ -220,7 +297,7 @@ export default function ReplayTimelineSidebar({
stage.steps = [
...stage.steps,
{
duration: 1000,
duration: 5000,
use_UI_function: [],
use_map_function: [],
use_geo_function: [],
@@ -272,6 +349,21 @@ export default function ReplayTimelineSidebar({
}
};
const handleDuplicateStep = (stageId: number, stepIndex: number) => {
let nextSelectedIndex = stepIndex + 1;
const changed = onMutateReplay(
`Replay: nhân bản step ${stepIndex + 1} của stage #${stageId}`,
(draftReplay) => {
const stage = draftReplay.detail.find((item) => item.id === stageId);
if (!stage || stepIndex < 0 || stepIndex >= stage.steps.length) return;
stage.steps.splice(stepIndex + 1, 0, cloneReplayStep(stage.steps[stepIndex]));
nextSelectedIndex = stepIndex + 1;
}
);
if (!changed) return;
onSelectStep(stageId, nextSelectedIndex);
};
const handleDeleteAction = (
stageId: number,
stepIndex: number,
@@ -309,6 +401,52 @@ export default function ReplayTimelineSidebar({
);
};
const handleDuplicateAction = (
stageId: number,
stepIndex: number,
groupKey: ActionGroupKey,
actionIndex: number,
actionTitle: string
) => {
onMutateReplay(
`Replay: nhân bản ${actionTitle} ở step ${stepIndex + 1} của stage #${stageId}`,
(draftReplay) => {
const stage = draftReplay.detail.find((item) => item.id === stageId);
if (!stage || stepIndex < 0 || stepIndex >= stage.steps.length) return;
const step = stage.steps[stepIndex];
const actions = [...getStepActionGroup(step, groupKey)];
if (actionIndex < 0 || actionIndex >= actions.length) return;
actions.splice(actionIndex + 1, 0, cloneReplayAction(actions[actionIndex]) as AnyStepAction);
setStepActionGroup(step, groupKey, actions);
}
);
};
const handleUpdateActionParams = (
stageId: number,
stepIndex: number,
groupKey: ActionGroupKey,
actionIndex: number,
actionTitle: string,
nextParams: unknown[]
) => {
onMutateReplay(
`Replay: cập nhật params ${actionTitle} ở step ${stepIndex + 1} của stage #${stageId}`,
(draftReplay) => {
const stage = draftReplay.detail.find((item) => item.id === stageId);
if (!stage || stepIndex < 0 || stepIndex >= stage.steps.length) return;
const step = stage.steps[stepIndex];
const actions = [...getStepActionGroup(step, groupKey)];
if (actionIndex < 0 || actionIndex >= actions.length) return;
actions[actionIndex] = {
...actions[actionIndex],
params: nextParams.map(cloneReplayParam),
} as AnyStepAction;
setStepActionGroup(step, groupKey, actions);
}
);
};
return (
<aside
style={{
@@ -452,7 +590,7 @@ export default function ReplayTimelineSidebar({
) : null}
</div>
<div style={{ fontSize: 11, color: "#94a3b8" }}>
Preview sẽ mở trong mode riêng với snapshot replay tại thời điểm bấm play.
Preview sẽ mở trong mode riêng với snapshot replay tại thời điểm bấm play. Speed {previewPlaybackSpeed}x.
</div>
</div>
</Panel>
@@ -465,9 +603,10 @@ export default function ReplayTimelineSidebar({
onChange={(event) =>
setCreateStageForm((prev) => ({ ...prev, title: event.target.value }))
}
placeholder="Title"
placeholder="Title (bắt buộc)"
style={inputStyle}
/>
<div>
<input
value={createStageForm.detail_time_start}
onChange={(event) =>
@@ -476,9 +615,19 @@ export default function ReplayTimelineSidebar({
detail_time_start: event.target.value,
}))
}
placeholder="detail_time_start"
style={inputStyle}
placeholder="Thời gian bắt đầu (detail_time_start)"
style={{
...inputStyle,
borderColor: showStartError ? "#ef4444" : "#334155",
}}
/>
{showStartError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div>
<input
value={createStageForm.detail_time_stop}
onChange={(event) =>
@@ -487,15 +636,30 @@ export default function ReplayTimelineSidebar({
detail_time_stop: event.target.value,
}))
}
placeholder="detail_time_stop"
style={inputStyle}
placeholder="Thời gian kết thúc (detail_time_stop)"
style={{
...inputStyle,
borderColor: showStopError ? "#ef4444" : "#334155",
}}
/>
{showStopError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div style={{ fontSize: 10, color: "#94a3b8", lineHeight: "1.4" }}>
Cấu trúc bắt buộc: <strong>DD/MM/YYYY</strong>, <strong>MM/YYYY</strong>, hoặc <strong>YYYY</strong>.
</div>
<button
type="button"
disabled={!isCreateFormValid}
onClick={handleCreateStage}
style={{
...buttonStyle,
background: "#1d4ed8",
background: isCreateFormValid ? "#1d4ed8" : "#1e293b",
color: isCreateFormValid ? "white" : "#64748b",
cursor: isCreateFormValid ? "pointer" : "not-allowed",
border: "none",
}}
>
@@ -555,7 +719,7 @@ export default function ReplayTimelineSidebar({
{stage.detail_time_start || "?"} {stage.detail_time_stop || "?"}
</div>
</button>
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 5 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(5, minmax(0, 1fr))", gap: 5 }}>
<button
type="button"
onClick={() => handleMoveStage(stage.id, -1)}
@@ -583,6 +747,17 @@ export default function ReplayTimelineSidebar({
>
Thêm step
</button>
<button
type="button"
onClick={() => handleDuplicateStage(stage.id)}
style={{
...smallButtonStyle(false),
background: "#334155",
border: "none",
}}
>
Copy
</button>
<button
type="button"
onClick={() => handleDeleteStage(stage.id)}
@@ -753,6 +928,22 @@ export default function ReplayTimelineSidebar({
</span>
</div>
</button>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, auto)", gap: 4 }}>
<button
type="button"
onClick={() =>
handleDuplicateAction(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title
)
}
style={actionButtonStyle(false, "#0f766e")}
>
Copy
</button>
<button
type="button"
onClick={() =>
@@ -764,22 +955,12 @@ export default function ReplayTimelineSidebar({
entry.title
)
}
style={{
padding: "3px 6px",
borderRadius: 6,
border: "none",
background: "#7f1d1d",
color: "white",
cursor: "pointer",
fontSize: 10,
fontWeight: 800,
flex: "0 0 auto",
alignSelf: "start",
}}
style={actionButtonStyle(false, "#7f1d1d")}
>
Xóa
</button>
</div>
</div>
{isActionOpen ? (
<div
style={{
@@ -790,6 +971,20 @@ export default function ReplayTimelineSidebar({
}}
>
{entry.summary}
<InlineActionParamsEditor
key={actionKey}
action={entry.action}
onApply={(nextParams) =>
handleUpdateActionParams(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title,
nextParams
)
}
/>
</div>
) : null}
</div>
@@ -801,8 +996,8 @@ export default function ReplayTimelineSidebar({
style={{
display: "grid",
gridTemplateColumns: isSelectedStep
? "repeat(4, minmax(0, 1fr))"
: "repeat(3, minmax(0, 1fr))",
? "repeat(5, minmax(0, 1fr))"
: "repeat(4, minmax(0, 1fr))",
gap: 5,
}}
>
@@ -841,6 +1036,17 @@ export default function ReplayTimelineSidebar({
Weight
</button>
) : null}
<button
type="button"
onClick={() => handleDuplicateStep(stage.id, stepIndex)}
style={{
...smallButtonStyle(false),
background: "#334155",
border: "none",
}}
>
Copy
</button>
<button
type="button"
onClick={() => handleDeleteStep(stage.id, stepIndex)}
@@ -922,7 +1128,15 @@ function StageMetadataEditor({
});
}, [stage]);
const isStartValid = validateReplayTimeFormat(form.detail_time_start);
const isStopValid = validateReplayTimeFormat(form.detail_time_stop);
const isEditFormValid = form.title.trim() !== "" && isStartValid && isStopValid;
const showStartError = form.detail_time_start.length > 0 && !isStartValid;
const showStopError = form.detail_time_stop.length > 0 && !isStopValid;
const handleApplyStageMetadata = () => {
if (!isEditFormValid) return;
onMutateReplay(`Replay: cập nhật stage #${stage.id}`, (draftReplay) => {
const targetStage = draftReplay.detail.find((item) => item.id === stage.id);
if (!targetStage) return;
@@ -940,9 +1154,10 @@ function StageMetadataEditor({
onChange={(event) =>
setForm((prev) => ({ ...prev, title: event.target.value }))
}
placeholder="Title"
placeholder="Title (bắt buộc)"
style={inputStyle}
/>
<div>
<input
value={form.detail_time_start}
onChange={(event) =>
@@ -951,9 +1166,19 @@ function StageMetadataEditor({
detail_time_start: event.target.value,
}))
}
placeholder="detail_time_start"
style={inputStyle}
placeholder="Thời gian bắt đầu (detail_time_start)"
style={{
...inputStyle,
borderColor: showStartError ? "#ef4444" : "#334155",
}}
/>
{showStartError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div>
<input
value={form.detail_time_stop}
onChange={(event) =>
@@ -962,15 +1187,30 @@ function StageMetadataEditor({
detail_time_stop: event.target.value,
}))
}
placeholder="detail_time_stop"
style={inputStyle}
placeholder="Thời gian kết thúc (detail_time_stop)"
style={{
...inputStyle,
borderColor: showStopError ? "#ef4444" : "#334155",
}}
/>
{showStopError ? (
<div style={{ color: "#ef4444", fontSize: 10, marginTop: 3 }}>
Đnh dạng không hợp lệ (DD/MM/YYYY, MM/YYYY, hoặc YYYY)
</div>
) : null}
</div>
<div style={{ fontSize: 10, color: "#94a3b8", lineHeight: "1.4" }}>
Cấu trúc bắt buộc: <strong>DD/MM/YYYY</strong>, <strong>MM/YYYY</strong>, hoặc <strong>YYYY</strong>.
</div>
<button
type="button"
disabled={!isEditFormValid}
onClick={handleApplyStageMetadata}
style={{
...buttonStyle,
background: "#0f766e",
background: isEditFormValid ? "#0f766e" : "#1e293b",
color: isEditFormValid ? "white" : "#64748b",
cursor: isEditFormValid ? "pointer" : "not-allowed",
border: "none",
}}
>
@@ -1017,6 +1257,22 @@ function smallButtonStyle(disabled: boolean) {
} as const;
}
function actionButtonStyle(disabled: boolean, background: string) {
return {
padding: "3px 6px",
borderRadius: 6,
border: "none",
background: disabled ? "#1e293b" : background,
color: "white",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.55 : 1,
fontSize: 10,
fontWeight: 800,
flex: "0 0 auto",
alignSelf: "start",
} as const;
}
function InlineStepDurationEditor({
stageId,
stepIndex,
@@ -1070,10 +1326,76 @@ function InlineStepDurationEditor({
);
}
function InlineActionParamsEditor({
action,
onApply,
}: {
action: AnyStepAction;
onApply: (params: unknown[]) => void;
}) {
const [paramsText, setParamsText] = useState(() => JSON.stringify(action.params, null, 2));
const [error, setError] = useState<string | null>(null);
const handleApply = () => {
try {
const parsed = JSON.parse(paramsText);
if (!Array.isArray(parsed)) {
setError("Params phải là JSON array.");
return;
}
setError(null);
onApply(parsed);
} catch (err) {
setError(err instanceof Error ? err.message : "JSON không hợp lệ.");
}
};
return (
<div style={{ display: "grid", gap: 5, marginTop: 6 }}>
<textarea
value={paramsText}
onChange={(event) => {
setParamsText(event.target.value);
setError(null);
}}
rows={Math.min(8, Math.max(3, paramsText.split("\n").length))}
spellCheck={false}
style={{
...inputStyle,
minHeight: 76,
resize: "vertical",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontSize: 11,
lineHeight: 1.35,
}}
/>
{error ? (
<div style={{ color: "#fca5a5", fontSize: 10, lineHeight: 1.3 }}>
{error}
</div>
) : null}
<button
type="button"
onClick={handleApply}
style={{
...smallButtonStyle(false),
background: "#0f766e",
border: "none",
justifySelf: "start",
padding: "5px 10px",
}}
>
Apply params
</button>
</div>
);
}
type StepActionEntry = {
group: "Narrative" | "Map" | "Geo" | "UI";
groupKey: ActionGroupKey;
actionIndex: number;
action: AnyStepAction;
functionName: string;
title: string;
summary: string;
@@ -1084,55 +1406,30 @@ type StepActionEntry = {
const uiOptionLabels: Record<UIOptionName, string> = {
timeline: "Timeline",
layer_panel: "Layer Panel",
wiki_panel: "Wiki Panel",
close_wiki_panel: "Đóng Wiki Panel",
zoom_panel: "Zoom Panel",
wiki: "Wiki",
toast: "Toast",
wiki_header: "Wiki Header",
playback_speed: "Playback Speed",
};
const narrativeFunctionLabels: Record<NarrativeFunctionName, string> = {
set_title: "Tiêu đề step",
clear_title: "Xóa tiêu đề",
set_descriptions: "Mô tả",
clear_descriptions: "Xóa mô tả",
show_dialog_box: "Dialog box",
clear_dialog_box: "Đóng dialog box",
display_historical_image: "Ảnh lịch sử",
clear_historical_image: "Xóa ảnh lịch sử",
set_step_subtitle: "Phụ đề",
clear_step_subtitle: "Xóa phụ đề",
set_dialog: "Dialog box",
};
const mapFunctionLabels: Record<MapFunctionName, string> = {
set_camera_view: "Camera view",
set_time_filter: "Lọc năm",
enable_timeline_filter: "Bật timeline filter",
disable_timeline_filter: "Tắt timeline filter",
toggle_labels: "Bật/tắt labels",
show_labels: "Hiện labels",
hide_labels: "Ẩn labels",
show_all_geometries: "Hiện tất cả geo",
reset_camera_north: "North up",
set_timeline_filter: "Lọc timeline",
set_labels_visible: "Hiện nhãn map",
};
const geoFunctionLabels: Record<GeoFunctionName, string> = {
fly_to_geometry: "Fly tới geo",
fly_to_geometries: "Fly tới geo",
set_geometry_visibility: "Ẩn/hiện geometry",
show_geometries: "Hiện geo",
hide_geometries: "Ẩn geo",
fit_to_geometries: "Fit nhiều geo",
orbit_camera_around_geometry: "Orbit quanh geo",
follow_geometries_path: "Follow path",
hide_others_geometries: "Ẩn geo khác",
pulse_geometry: "Pulse geometry",
animate_dashed_border: "Border nét đứt",
set_geometry_style: "Style geometry",
show_geometry_label: "Label geometry",
follow_geometry_path: "Follow path",
follow_geometries_path: "Follow path",
dim_other_geometries: "Ẩn geo khác",
orbit_camera_around_geometry: "Orbit quanh geo",
};
function buildStepActionEntries(step: ReplayStep): StepActionEntry[] {
@@ -1159,50 +1456,30 @@ function buildNarrativeActionEntry(
const params = Array.isArray(action.params) ? action.params : [];
let summary = "Không có tham số.";
switch (action.function_name) {
case "set_title":
summary = summarizeValue(params[0], "Tiêu đề trống");
break;
case "clear_title":
summary = "title=null";
break;
case "set_descriptions":
summary = summarizeValue(params[0], "Mô tả trống");
break;
case "clear_descriptions":
summary = "descriptions=null";
break;
case "show_dialog_box":
summary = [
`speaker=${summarizeValue(params[3], "ẩn danh")}`,
`side=${summarizeValue(params[2], "left")}`,
`text=${summarizeValue(params[1], "trống")}`,
].join(" | ");
break;
case "clear_dialog_box":
if (action.function_name === "set_dialog") {
const dialog = params[0] as DialogState | null;
if (dialog === null) {
summary = "dialog=null";
break;
case "display_historical_image":
summary = [
`url=${summarizeValue(params[0], "trống")}`,
`caption=${summarizeValue(params[1], "trống")}`,
].join(" | ");
break;
case "clear_historical_image":
summary = "image=null";
break;
case "set_step_subtitle":
summary = summarizeValue(params[0], "Ẩn subtitle");
break;
case "clear_step_subtitle":
summary = "subtitle=null";
break;
} else {
const parts: string[] = [];
if (dialog.text) {
parts.push(`text=${summarizeValue(dialog.text, "")}`);
}
if (dialog.avatar) {
parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`);
}
if (dialog.image_url) {
parts.push(`image=${summarizeValue(dialog.image_url, "")}`);
}
summary = parts.join(" | ") || "trống";
}
}
return {
group: "Narrative",
groupKey: "use_narrow_function",
actionIndex,
action,
functionName: action.function_name,
title: narrativeFunctionLabels[action.function_name],
summary,
@@ -1219,39 +1496,22 @@ function buildMapActionEntry(
let summary = "Không có tham số.";
switch (action.function_name) {
case "set_time_filter":
summary = `year=${summarizeValue(params[0], "trống")}`;
case "set_timeline_filter":
summary = `enabled=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break;
case "enable_timeline_filter":
summary = "enabled=true";
break;
case "disable_timeline_filter":
summary = "enabled=false";
break;
case "toggle_labels":
case "set_labels_visible":
summary = `visible=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break;
case "show_labels":
summary = "visible=true";
break;
case "hide_labels":
summary = "visible=false";
break;
case "show_all_geometries":
summary = "hidden_ids=[]";
break;
case "set_camera_view":
summary = summarizeCameraViewValue(params[0]);
break;
case "reset_camera_north":
summary = "bearing=0";
break;
}
return {
group: "Map",
groupKey: "use_map_function",
actionIndex,
action,
functionName: action.function_name,
title: mapFunctionLabels[action.function_name],
summary,
@@ -1268,14 +1528,6 @@ function buildGeoActionEntry(
let summary = "Không có tham số.";
switch (action.function_name) {
case "fly_to_geometry":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
`zoom=${summarizeValue(params[1], "mặc định")}`,
`padding=${summarizeValue(params[2], "mặc định")}`,
`duration=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "fly_to_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])}`;
break;
@@ -1285,19 +1537,6 @@ function buildGeoActionEntry(
`visible=${Boolean(params[1] ?? true) ? "true" : "false"}`,
].join(" | ");
break;
case "show_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])} | visible=true`;
break;
case "hide_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])} | visible=false`;
break;
case "fit_to_geometries":
summary = [
`geometry=${summarizeGeometryIdsValue(params[0])}`,
`padding=${summarizeValue(params[1], "mặc định")}`,
`duration=${summarizeValue(params[2], "mặc định")}`,
].join(" | ");
break;
case "orbit_camera_around_geometry":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
@@ -1333,22 +1572,6 @@ function buildGeoActionEntry(
`line_width=${summarizeValue(params[4], "mặc định")}`,
].join(" | ");
break;
case "show_geometry_label":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
`text=${summarizeValue(params[1], "trống")}`,
`color=${summarizeValue(params[2], "#ffffff")}`,
`size=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "follow_geometry_path":
summary = [
`geometry=${summarizeValue(params[0], "trống")}`,
`duration=${summarizeValue(params[1], "mặc định")}`,
`zoom=${summarizeValue(params[2], "mặc định")}`,
`pitch=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "follow_geometries_path":
summary = [
`geometry=${summarizeGeometryIdsValue(params[0])}`,
@@ -1357,7 +1580,7 @@ function buildGeoActionEntry(
`pitch=${summarizeValue(params[3], "mặc định")}`,
].join(" | ");
break;
case "dim_other_geometries":
case "hide_others_geometries":
summary = [
`keep=${summarizeGeometryIdsValue(params[0])}`,
].join(" | ");
@@ -1368,6 +1591,7 @@ function buildGeoActionEntry(
group: "Geo",
groupKey: "use_geo_function",
actionIndex,
action,
functionName: action.function_name,
title: geoFunctionLabels[action.function_name],
summary,
@@ -1386,18 +1610,12 @@ function buildUiActionEntry(
const optionLabel = option ? uiOptionLabels[option] : summarizeValue(action.function_name, "Unknown option");
let summary = "Không có tham số.";
if (option === "timeline" || option === "layer_panel" || option === "wiki_panel" || option === "zoom_panel") {
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
summary = `visible=${Boolean(params[0]) ? "true" : "false"}`;
} else if (option === "close_wiki_panel") {
summary = "visible=false | active_wiki=null";
} else if (option === "wiki") {
summary = `wiki_id=${summarizeValue(params[0], "trống")}`;
} else if (option === "toast") {
summary = `message=${summarizeValue(params[0], "trống")}`;
} else if (option === "wiki_header") {
summary = `header_id=${summarizeValue(params[0], "trống")}`;
} else if (option === "playback_speed") {
summary = `speed=${summarizeValue(params[0], "1")}`;
} else if (params.length > 0) {
summary = summarizeValue(params, "Không có tham số");
}
@@ -1406,6 +1624,7 @@ function buildUiActionEntry(
group: "UI",
groupKey: "use_UI_function",
actionIndex,
action,
functionName: action.function_name,
title: optionLabel,
summary,
@@ -1453,14 +1672,13 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
switch (value) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
case "wiki_header":
case "playback_speed":
return value;
case "wiki_panel":
case "close_wiki_panel":
return "wiki";
default:
return null;
}
@@ -1489,6 +1707,66 @@ function getUiActionDescriptor(action: {
};
}
function getStepActionGroup(step: ReplayStep, groupKey: ActionGroupKey): AnyStepAction[] {
switch (groupKey) {
case "use_UI_function":
return step.use_UI_function;
case "use_map_function":
return step.use_map_function;
case "use_geo_function":
return step.use_geo_function;
case "use_narrow_function":
return step.use_narrow_function;
}
}
function setStepActionGroup(
step: ReplayStep,
groupKey: ActionGroupKey,
actions: AnyStepAction[]
) {
switch (groupKey) {
case "use_UI_function":
step.use_UI_function = actions as ReplayStep["use_UI_function"];
return;
case "use_map_function":
step.use_map_function = actions as ReplayStep["use_map_function"];
return;
case "use_geo_function":
step.use_geo_function = actions as ReplayStep["use_geo_function"];
return;
case "use_narrow_function":
step.use_narrow_function = actions as ReplayStep["use_narrow_function"];
return;
}
}
function cloneReplayStep(step: ReplayStep): ReplayStep {
return {
duration: step.duration,
use_UI_function: step.use_UI_function.map(cloneReplayAction) as ReplayStep["use_UI_function"],
use_map_function: step.use_map_function.map(cloneReplayAction) as ReplayStep["use_map_function"],
use_geo_function: step.use_geo_function.map(cloneReplayAction) as ReplayStep["use_geo_function"],
use_narrow_function: step.use_narrow_function.map(cloneReplayAction) as ReplayStep["use_narrow_function"],
};
}
function cloneReplayAction<T>(action: ReplayAction<T>): ReplayAction<T> {
return {
function_name: action.function_name,
params: action.params.map(cloneReplayParam),
};
}
function cloneReplayParam(value: unknown): unknown {
if (value == null || typeof value !== "object") return value;
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
function summarizeValue(value: unknown, fallback = "trống") {
if (value == null) return fallback;
if (typeof value === "string") {
+26 -92
View File
@@ -143,55 +143,37 @@ export type EntityWikiLinkSnapshot = {
* Canonical UI action names trong snapshot hiện tại.
* Không còn wrapper `function_name: "UI"` trong shape mới.
*/
export type DialogState = {
avatar: string; // Avatar image URL
text: string; // Subtitle / spoken narrative text
image_url?: string; // Optional image URL
image_caption?: string;// Optional caption
};
export type UIOptionName =
| "timeline"
| "layer_panel"
| "wiki_panel"
| "close_wiki_panel"
| "zoom_panel"
| "wiki"
| "toast"
| "wiki_header"
| "playback_speed";
| "toast";
export type MapFunctionName =
| "set_camera_view"
| "set_time_filter"
| "enable_timeline_filter"
| "disable_timeline_filter"
| "toggle_labels"
| "show_labels"
| "hide_labels"
| "show_all_geometries"
| "reset_camera_north";
| "set_timeline_filter"
| "set_labels_visible";
export type GeoFunctionName =
| "fly_to_geometry"
| "fly_to_geometries"
| "set_geometry_visibility"
| "show_geometries"
| "hide_geometries"
| "fit_to_geometries"
| "orbit_camera_around_geometry"
| "follow_geometries_path"
| "hide_others_geometries"
| "pulse_geometry"
| "animate_dashed_border"
| "set_geometry_style"
| "show_geometry_label"
| "follow_geometry_path"
| "follow_geometries_path"
| "dim_other_geometries";
| "orbit_camera_around_geometry";
export type NarrativeFunctionName =
| "set_title"
| "clear_title"
| "set_descriptions"
| "clear_descriptions"
| "show_dialog_box"
| "clear_dialog_box"
| "display_historical_image"
| "clear_historical_image"
| "set_step_subtitle"
| "clear_step_subtitle";
| "set_dialog";
/**
* Runtime thật hiện dùng positional array cho params.
@@ -243,13 +225,9 @@ export type ReplayCameraViewStateDoc = {
export type ReplayUiParamTupleDocs = {
timeline: [visible: boolean];
layer_panel: [visible: boolean];
wiki_panel: [visible: boolean];
close_wiki_panel: [];
zoom_panel: [visible: boolean];
wiki: [wiki_id: string];
wiki: [wiki_id: string | null];
toast: [message: string];
wiki_header: [header_id: string];
playback_speed: [speed: number];
};
/**
@@ -259,34 +237,21 @@ export type ReplayUiParamTupleDocs = {
export type ReplayMapFunctionParamTupleDocs = {
set_camera_view: [state: ReplayCameraViewStateDoc];
set_time_filter: [year: number];
enable_timeline_filter: [];
disable_timeline_filter: [];
toggle_labels: [visible: boolean];
show_labels: [];
hide_labels: [];
show_all_geometries: [];
reset_camera_north: [];
set_timeline_filter: [enabled: boolean];
set_labels_visible: [visible: boolean];
};
export type ReplayGeoFunctionParamTupleDocs = {
fly_to_geometry: [
geometry_id: string,
zoom?: number,
padding?: number,
duration?: number,
];
fly_to_geometries: [geometry_ids: string[], duration?: number];
set_geometry_visibility: [geometry_ids: string[], visible: boolean];
show_geometries: [geometry_ids: string[]];
hide_geometries: [geometry_ids: string[]];
fit_to_geometries: [geometry_ids: string[], duration?: number];
orbit_camera_around_geometry: [
geometry_id: string,
follow_geometries_path: [
geometry_ids: string[],
duration?: number,
zoom?: number,
pitch?: number,
revolutions?: number,
duration?: number,
];
hide_others_geometries: [
geometry_ids: string[],
];
pulse_geometry: [
geometry_id: string,
@@ -308,48 +273,17 @@ export type ReplayGeoFunctionParamTupleDocs = {
line_color?: string,
line_width?: number,
];
show_geometry_label: [
orbit_camera_around_geometry: [
geometry_id: string,
text?: string,
color?: string,
size?: number,
];
follow_geometry_path: [
geometry_id: string,
duration?: number,
zoom?: number,
pitch?: number,
];
follow_geometries_path: [
geometry_ids: string[],
revolutions?: number,
duration?: number,
zoom?: number,
pitch?: number,
];
dim_other_geometries: [
geometry_ids: string[],
];
};
export type ReplayNarrativeParamTupleDocs = {
set_title: [title: string];
clear_title: [];
set_descriptions: [text: string];
clear_descriptions: [];
show_dialog_box: [
avatar: string,
text: string,
side?: "left" | "right",
speaker?: string,
];
clear_dialog_box: [];
display_historical_image: [
url: string,
caption?: string,
];
clear_historical_image: [];
set_step_subtitle: [subtitle: string | null];
clear_step_subtitle: [];
set_dialog: [dialog: DialogState | null];
};
export type ReplayParamTupleDocs =
+15 -12
View File
@@ -1,6 +1,6 @@
# UHM Editor - replay actions catalog
Cập nhật: 2026-05-22.
Cập nhật: 2026-05-25.
Tài liệu này mô tả action catalog của replay editor/preview hiện tại. Shape chuẩn nằm ở `src/uhm/types/projects.ts`; dispatcher runtime nằm ở `src/uhm/lib/replay/replayDispatcher.ts`.
@@ -54,22 +54,23 @@ Trong mỗi step, dispatcher chạy các group action từ step hiện tại. Du
- hidden geometry ids
- title/descriptions/subtitle/dialog/image/toast
- wiki sidebar/open wiki
- preview layer panel / zoom controls
- temporary geometry effects
- playback speed
Stop/reset preview khôi phục presentation state và một phần map/timeline baseline.
Stop/reset preview khôi phục presentation state, map/timeline baseline, label visibility và dọn toàn bộ temporary geometry effects.
## 3. UI actions
| Action | Params | Runtime hiện tại |
| --- | --- | --- |
| `timeline` | `[visible: boolean]` | Ẩn/hiện TimelineBar trong preview |
| `layer_panel` | `[visible: boolean]` | No-op hiện tại |
| `layer_panel` | `[visible: boolean]` | Ẩn/hiện panel layer trong preview |
| `wiki_panel` | `[visible: boolean]` | Mở/đóng wiki sidebar preview |
| `close_wiki_panel` | `[]` | Đóng wiki sidebar và clear active wiki |
| `zoom_panel` | `[visible: boolean]` | No-op hiện tại |
| `zoom_panel` | `[visible: boolean]` | Ẩn/hiện cụm zoom/projection control trên map preview |
| `wiki` | `[wikiId: string]` | Mở wiki sidebar và active wiki id |
| `toast` | `[message: string]` | Hiện toast tạm thời |
| `wiki_header` | `[headerId: string]` | No-op hiện tại |
| `playback_speed` | `[speed: number]` | Đổi tốc độ phát preview |
Legacy shape vẫn được dispatcher đọc:
@@ -111,15 +112,15 @@ Shape mới nên dùng trực tiếp:
| `hide_geometries` | `[geometryIds]` | Thêm ids vào hidden set |
| `fit_to_geometries` | `[geometryIds, duration?]` | Legacy: dùng fly/fit tới geometry |
| `orbit_camera_around_geometry` | `[geometryId, zoom?, pitch?, turns?, duration?]` | Ease camera quanh bbox geometry |
| `pulse_geometry` | `[geometryId, color?, repeat?, duration?]` | No-op trong dispatcher hiện tại |
| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | No-op trong dispatcher hiện tại |
| `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | No-op trong dispatcher hiện tại |
| `show_geometry_label` | `[geometryId, text?, color?, size?]` | No-op trong dispatcher hiện tại |
| `follow_geometry_path` | `[geometryId, duration?]` | Legacy: fly theo một path bằng fit/fly |
| `follow_geometries_path` | `[geometryIds, duration?, zoom?, padding?]` | Hiện dùng fly/fit tới nhiều geometry |
| `pulse_geometry` | `[geometryId, color?, repeat?, duration?]` | Pulse overlay tạm thời, tự cleanup |
| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | Dashed border overlay tạm thời, tự cleanup |
| `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | Style overlay trong preview tới khi stop/reset |
| `show_geometry_label` | `[geometryId, text?, color?, size?]` | Hiện label riêng trong preview tới khi stop/reset |
| `follow_geometry_path` | `[geometryId, duration?, zoom?, pitch?]` | Camera chạy theo tọa độ path geometry |
| `follow_geometries_path` | `[geometryIds, duration?, zoom?, pitch?]` | Camera chạy theo chuỗi path geometry |
| `dim_other_geometries` | `[geometryIds]` | Chỉ hiện target ids, ẩn các geometry khác |
Các action visual effect no-op vẫn có trong composer để giữ schema và chuẩn bị cho runtime effect sau này.
Các visual effect dùng overlay source/layer riêng và không mutate geometry draft.
## 6. Narrative actions
@@ -163,6 +164,8 @@ Geo shortcuts:
Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6.
Timeline action list hỗ trợ reorder, duplicate, delete và edit `params` trực tiếp bằng JSON array có validate nhẹ. Composer bên phải vẫn là đường chính để tạo action mới.
## 8. Normalization và migration
Khi load snapshot:
@@ -0,0 +1,83 @@
# Đề xuất tối giản hóa các hàm kịch bản trong Replay (Đã cập nhật theo phản hồi)
Dưới đây là phương án tối giản hóa tối đa các hàm kịch bản Replay (Replay Actions) sau khi thống nhất theo các ý kiến phản hồi của bạn.
Tổng số hàm ban đầu: **41 hàm**
Tổng số hàm sau tối giản: **16 hàm** (Giảm **61%** độ phức tạp của API)
---
## 1. Nhóm UI Actions (Còn 5 hàm)
* **Quyết định**: Loại bỏ `playback_speed` khỏi kịch bản (tốc độ phát sẽ do người dùng tự điều khiển hoàn toàn trên giao diện Player). Loại bỏ `close_wiki_panel``wiki_panel`, hợp nhất vào `wiki`.
| Hàm đề xuất | Tham số đề xuất (`params`) | Mô tả chi tiết |
| :--- | :--- | :--- |
| **`timeline`** | `[visible: boolean]` | Ẩn hoặc hiển thị TimelineBar. |
| **`layer_panel`** | `[visible: boolean]` | Ẩn hoặc hiển thị Panel quản lý Layer. |
| **`zoom_panel`** | `[visible: boolean]` | Ẩn hoặc hiển thị các cụm zoom/projection trên bản đồ. |
| **`wiki`** | `[wiki_id: string \| null]` | Nhận `wiki_id` để mở wiki panel. Nhận `null` hoặc `""` để đóng panel và xóa wiki đang chọn. |
| **`toast`** | `[message: string]` | Hiện thông báo nhanh (toast) trên màn hình. |
---
## 2. Nhóm Map Actions (Còn 3 hàm)
* **Quyết định**: Loại bỏ `set_time_filter` (hệ thống sẽ tự động cập nhật bộ lọc thời gian dựa trên `detail_time_start``detail_time_stop` khai báo ở mỗi Stage/Step). Loại bỏ `reset_camera_north` (dùng `set_camera_view` với `bearing: 0`).
| Hàm đề xuất | Tham số đề xuất (`params`) | Mô tả chi tiết |
| :--- | :--- | :--- |
| **`set_camera_view`** | `[state: ReplayCameraViewStateDoc]` | Cập nhật vị trí camera (center, zoom, pitch, bearing). Để reset hướng Bắc, chỉ cần truyền `{ bearing: 0 }`. |
| **`set_timeline_filter`** | `[enabled: boolean]` | Bật hoặc tắt bộ lọc dữ liệu theo dòng thời gian (thay cho `enable_timeline_filter` / `disable_timeline_filter`). |
| **`set_labels_visible`** | `[visible: boolean]` | Ẩn hoặc hiện toàn bộ text label mặc định trên bản đồ (thành phố, quốc gia...). |
---
## 3. Nhóm Geo Actions (Còn 7 hàm)
* **Quyết định**:
- Gộp `fly_to_geometry`, `fly_to_geometries``fit_to_geometries` thành `fly_to_geometries`.
- Gộp `show_geometries``hide_geometries` thành `set_geometry_visibility`.
- Gộp `follow_geometry_path` vào `follow_geometries_path`.
- Loại bỏ hoàn toàn `show_geometry_label` (đã có thuộc tính `point_label/line_label` hiển thị tự động trên bản đồ dựa theo cấu hình của geometry).
- Tạm khóa các visual effects phức tạp (`pulse_geometry`, `animate_dashed_border`, `set_geometry_style`) trên giao diện UI soạn thảo để giảm độ phức tạp ở giai đoạn đầu, nhưng vẫn giữ khai báo trong schema để mở rộng sau này.
- Tên gọi `dim_other_geometries` được giữ nguyên hoặc đổi tên thành `hide_others_geometries` tùy ý (hiện tại trong code runtime đang là `dim_other_geometries`, nếu cần ta sẽ map lại).
| Hàm đề xuất | Tham số đề xuất (`params`) | Trạng thái phát triển |
| :--- | :--- | :--- |
| **`fly_to_geometries`** | `[geometry_ids: string[], duration?: number]` | Hoạt động |
| **`set_geometry_visibility`** | `[geometry_ids: string[], visible: boolean]` | Hoạt động |
| **`follow_geometries_path`** | `[geometry_ids: string[], duration?, zoom?, pitch?]` | Hoạt động |
| **`dim_other_geometries`** | `[geometry_ids: string[]]` | Hoạt động |
| **`pulse_geometry`** | `[geometry_id: string, color?, repeat?, duration?]` | *Khóa tạm thời trên UI* |
| **`animate_dashed_border`** | `[geometry_id: string, color?, width?, speed?, duration?]` | *Khóa tạm thời trên UI* |
| **`set_geometry_style`** | `[geometry_ids: string[], fill?, opacity?, stroke?, width?]` | *Khóa tạm thời trên UI* |
| **`orbit_camera_around_geometry`** | `[geometry_id: string, zoom?, pitch?, turns?, duration?]` | *Khóa tạm thời trên UI* |
---
## 4. Nhóm Narrative Actions (Còn đúng 1 hàm duy nhất!)
* **Quyết định**:
- Loại bỏ hoàn toàn `set_title`, `set_descriptions`, `set_step_subtitle` (và các hàm `clear_*` tương ứng) vì thông tin Stage/Step đã được hiển thị qua tiêu đề Stage có sẵn. Mô tả chi tiết giờ đây được dồn hoàn toàn vào hộp thoại dẫn chuyện (`dialog`).
- Loại bỏ `display_historical_image` và đưa trường `image_url`, `image_caption` làm tham số tùy chọn bên trong `dialog` để hiển thị ảnh đi kèm cuộc hội thoại một cách nhất quán nhất.
- Loại bỏ tham số `side` (mặc định hiển thị cố định ở phía dưới cùng màn hình) và `speaker` (dùng chung avatar/tên trong thiết kế hội thoại tinh gọn).
### Hàm duy nhất được giữ lại:
| Hàm đề xuất | Tham số đề xuất (`params`) |
| :--- | :--- |
| **`set_dialog`** | `[dialog: DialogState \| null]` |
Trong đó đối tượng `DialogState` được định nghĩa tinh giản gồm:
```typescript
export type DialogState = {
avatar: string; // URL ảnh đại diện nhân vật dẫn chuyện
text: string; // Nội dung lời dẫn/hội thoại
image_url?: string; // (Tùy chọn) Ảnh lịch sử đi kèm hiển thị trong dialog
image_caption?: string;// (Tùy chọn) Chú thích cho ảnh
};
```
Khi muốn ẩn dialog, chỉ cần truyền `null` (ví dụ: `{ function_name: "set_dialog", params: [null] }`).
---
## Ý kiến chốt phương án của bạn:
> *Hãy phản hồi ở đây nếu bạn muốn tiến hành refactor code theo thiết kế này.*
tôi đồng ý làm theo thiết kế này, tuy nhiên tôi muốn đổi dim_other_geometries thanh hide_others_geometries
>
+293 -103
View File
@@ -922,45 +922,50 @@ function normalizeReplayTargetGeometryIds(replay: unknown, geometryId: string):
function normalizeReplayUiActions(actions: unknown): ReplayAction<UIOptionName>[] {
if (!Array.isArray(actions)) return [];
return actions.flatMap((action) => {
if (!isRecord(action)) return [];
const normalized: ReplayAction<UIOptionName>[] = [];
const functionName = action.function_name;
const params = Array.isArray(action.params) ? action.params : [];
for (const action of actions) {
if (!isRecord(action)) continue;
let functionName = action.function_name;
let 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),
}];
functionName = params[0];
params = params.slice(1);
}
const option = normalizeReplayUiOption(functionName);
if (!option) return [];
return [{
function_name: option,
params,
}];
});
}
function normalizeReplayUiOption(value: unknown): UIOptionName | null {
switch (value) {
switch (functionName) {
case "timeline":
case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel":
case "wiki":
case "toast":
case "wiki_header":
case "playback_speed":
return value;
default:
return null;
normalized.push({
function_name: functionName,
params: [params[0]],
});
break;
case "wiki":
normalized.push({
function_name: "wiki",
params: [params[0] || null],
});
break;
case "close_wiki_panel":
case "wiki_panel":
if (functionName === "close_wiki_panel" || (functionName === "wiki_panel" && !params[0])) {
normalized.push({
function_name: "wiki",
params: [null],
});
}
break;
default:
break;
}
}
return normalized;
}
function normalizeReplayMapAndGeoActions(
@@ -983,21 +988,180 @@ function normalizeReplayMapAndGeoActions(
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) {
switch (functionName) {
// --- Map Functions ---
case "set_camera_view":
normalizedMapActions.push({
function_name: "set_camera_view",
params: [params[0]],
});
break;
case "set_timeline_filter":
normalizedMapActions.push({
function_name: "set_timeline_filter",
params: [Boolean(params[0])],
});
break;
case "enable_timeline_filter":
case "disable_timeline_filter":
normalizedMapActions.push({
function_name: "set_timeline_filter",
params: [functionName === "enable_timeline_filter"],
});
break;
case "set_labels_visible":
normalizedMapActions.push({
function_name: "set_labels_visible",
params: [Boolean(params[0])],
});
break;
case "toggle_labels":
normalizedMapActions.push({
function_name: "set_labels_visible",
params: [Boolean(params[0])],
});
break;
case "show_labels":
case "hide_labels":
normalizedMapActions.push({
function_name: "set_labels_visible",
params: [functionName === "show_labels"],
});
break;
case "reset_camera_north":
normalizedMapActions.push({
function_name: "set_camera_view",
params: [{ bearing: 0 }],
});
break;
// --- Geo Functions ---
case "fly_to_geometry": {
const id = String(params[0] || "");
if (id) {
const duration = typeof params[3] === "number" ? params[3] : undefined;
normalizedGeoActions.push({
function_name: geoFunctionName,
function_name: "fly_to_geometries",
params: [[id], duration],
});
}
break;
}
case "fly_to_geometries": {
const ids = Array.isArray(params[0]) ? params[0].map(String) : [];
if (ids.length > 0) {
const duration = typeof params[1] === "number" ? params[1] : undefined;
normalizedGeoActions.push({
function_name: "fly_to_geometries",
params: [ids, duration],
});
}
break;
}
case "fit_to_geometries": {
const ids = Array.isArray(params[0]) ? params[0].map(String) : [];
if (ids.length > 0) {
const duration = typeof params[1] === "number" ? params[1] : undefined;
normalizedGeoActions.push({
function_name: "fly_to_geometries",
params: [ids, duration],
});
}
break;
}
case "set_geometry_visibility": {
const ids = Array.isArray(params[0]) ? params[0].map(String) : [];
const visible = params[1] !== undefined ? Boolean(params[1]) : true;
if (ids.length > 0) {
normalizedGeoActions.push({
function_name: "set_geometry_visibility",
params: [ids, visible],
});
}
break;
}
case "show_geometries": {
const ids = Array.isArray(params[0]) ? params[0].map(String) : [];
if (ids.length > 0) {
normalizedGeoActions.push({
function_name: "set_geometry_visibility",
params: [ids, true],
});
}
break;
}
case "hide_geometries": {
const ids = Array.isArray(params[0]) ? params[0].map(String) : [];
if (ids.length > 0) {
normalizedGeoActions.push({
function_name: "set_geometry_visibility",
params: [ids, false],
});
}
break;
}
case "follow_geometry_path": {
const id = String(params[0] || "");
if (id) {
const duration = typeof params[1] === "number" ? params[1] : undefined;
const zoom = typeof params[2] === "number" ? params[2] : undefined;
const pitch = typeof params[3] === "number" ? params[3] : undefined;
normalizedGeoActions.push({
function_name: "follow_geometries_path",
params: [[id], duration, zoom, pitch],
});
}
break;
}
case "follow_geometries_path": {
const ids = Array.isArray(params[0]) ? params[0].map(String) : [];
if (ids.length > 0) {
const duration = typeof params[1] === "number" ? params[1] : undefined;
const zoom = typeof params[2] === "number" ? params[2] : undefined;
const pitch = typeof params[3] === "number" ? params[3] : undefined;
normalizedGeoActions.push({
function_name: "follow_geometries_path",
params: [ids, duration, zoom, pitch],
});
}
break;
}
case "dim_other_geometries":
case "hide_others_geometries": {
const ids = Array.isArray(params[0]) ? params[0].map(String) : [];
normalizedGeoActions.push({
function_name: "hide_others_geometries",
params: [ids],
});
break;
}
case "pulse_geometry":
normalizedGeoActions.push({
function_name: "pulse_geometry",
params,
});
break;
case "animate_dashed_border":
normalizedGeoActions.push({
function_name: "animate_dashed_border",
params,
});
break;
case "set_geometry_style":
normalizedGeoActions.push({
function_name: "set_geometry_style",
params,
});
break;
case "orbit_camera_around_geometry":
normalizedGeoActions.push({
function_name: "orbit_camera_around_geometry",
params,
});
break;
default:
break;
}
}
@@ -1007,77 +1171,103 @@ function normalizeReplayMapAndGeoActions(
};
}
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 "show_all_geometries":
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 [];
let avatar = "";
let text = "";
let image_url = "";
let image_caption = "";
let hasDialog = false;
let isCleared = false;
const functionName = normalizeReplayNarrativeFunctionName(action.function_name);
if (!functionName) return [];
for (const action of actions) {
if (!isRecord(action)) continue;
return [{
function_name: functionName,
params: Array.isArray(action.params) ? action.params : [],
}];
});
const functionName = action.function_name;
const params = Array.isArray(action.params) ? action.params : [];
switch (functionName) {
case "set_dialog": {
const data = params[0];
if (data && typeof data === "object") {
hasDialog = true;
avatar = String((data as any).avatar || avatar);
text = String((data as any).text || text);
image_url = String((data as any).image_url || image_url);
image_caption = String((data as any).image_caption || image_caption);
} else if (data === null) {
isCleared = true;
}
break;
}
function normalizeReplayNarrativeFunctionName(value: unknown): NarrativeFunctionName | null {
switch (value) {
case "set_title":
case "clear_title":
case "set_descriptions":
case "clear_descriptions":
case "show_dialog_box":
case "clear_dialog_box":
case "display_historical_image":
case "clear_historical_image":
case "set_step_subtitle":
case "clear_step_subtitle":
return value;
default:
return null;
hasDialog = true;
avatar = String(params[0] || avatar);
text = String(params[1] || text);
break;
case "set_title":
hasDialog = true;
if (!text) {
text = String(params[0] || "");
}
break;
case "set_descriptions":
hasDialog = true;
if (!text) {
text = String(params[0] || "");
} else if (params[0]) {
text = text + "\n" + String(params[0]);
}
break;
case "set_step_subtitle":
hasDialog = true;
if (!text) {
text = String(params[0] || "");
}
break;
case "display_historical_image":
hasDialog = true;
image_url = String(params[0] || image_url);
image_caption = String(params[1] || image_caption);
break;
case "clear_dialog_box":
case "clear_title":
case "clear_descriptions":
case "clear_historical_image":
case "clear_step_subtitle":
isCleared = true;
break;
default:
break;
}
}
if (hasDialog) {
const dialogData: any = {
avatar,
text,
};
if (image_url) {
dialogData.image_url = image_url;
}
if (image_caption) {
dialogData.image_caption = image_caption;
}
return [{
function_name: "set_dialog",
params: [dialogData],
}];
}
if (isCleared) {
return [{
function_name: "set_dialog",
params: [null],
}];
}
return [];
}
function dedupeAndSortGeometryEntity(rows: GeometryEntitySnapshot[]): GeometryEntitySnapshot[] {
+15
View File
@@ -0,0 +1,15 @@
import type { UIOptionName } from "@/uhm/types/projects";
export const REPLAY_UI_OPTIONS = [
"timeline",
"layer_panel",
"zoom_panel",
"wiki",
"toast",
] as const satisfies UIOptionName[];
export function normalizeReplayUiOption(value: unknown): UIOptionName | null {
return REPLAY_UI_OPTIONS.includes(value as UIOptionName)
? value as UIOptionName
: null;
}
+20 -10
View File
@@ -6,7 +6,6 @@ import { fitMapToFeatureCollection, getFeatureCollectionBBox } from "@/uhm/compo
* Các hàm xử lý tương tác bản đồ cho hệ thống Replay.
* Hầu hết các hàm yêu cầu instance của MapLibre GL.
*/
export const mapActions = {
// Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing)
set_camera_view: (
@@ -48,11 +47,6 @@ export const mapActions = {
map.easeTo(nextView);
},
// Di chuyển mượt mà đến một geometry dựa trên ID
fly_to_geometry: (map: maplibregl.Map, geometryId: string | number, draft: FeatureCollection) => {
mapActions.fly_to_geometries(map, [geometryId], draft);
},
// Di chuyển mượt mà đến một hoặc nhiều geometry dựa trên ID.
fly_to_geometries: (
map: maplibregl.Map,
@@ -120,7 +114,7 @@ export const mapActions = {
},
// Ẩn/hiện nhãn (labels) trên bản đồ
toggle_labels: (map: maplibregl.Map, visible: boolean) => {
set_labels_visible: (map: maplibregl.Map, visible: boolean) => {
const style = map.getStyle();
if (!style) return;
style.layers.forEach(layer => {
@@ -131,10 +125,26 @@ export const mapActions = {
});
},
// Thay đổi bộ lọc thời gian trên bản đồ
set_time_filter: (onYearChange: (year: number) => void, year: number) => {
onYearChange(year);
get_label_visibility: (map: maplibregl.Map): Record<string, "visible" | "none"> => {
const style = map.getStyle();
const state: Record<string, "visible" | "none"> = {};
if (!style) return state;
style.layers.forEach((layer) => {
const layout = "layout" in layer ? layer.layout : undefined;
if (layer.type !== "symbol" || !layout || typeof layout !== "object" || !("text-field" in layout)) {
return;
}
state[layer.id] = layout.visibility === "none" ? "none" : "visible";
});
return state;
},
restore_label_visibility: (map: maplibregl.Map, state: Record<string, "visible" | "none">) => {
for (const [layerId, visibility] of Object.entries(state)) {
if (!map.getLayer(layerId)) continue;
map.setLayoutProperty(layerId, "visibility", visibility);
}
},
};
function normalizeReplayCenter(
+7 -71
View File
@@ -1,78 +1,14 @@
import type { DialogState } from "@/uhm/types/projects";
/**
* Các hàm điều khiển nội dung dẫn chuyện và thuyết minh trong Replay.
*/
export const narrativeActions = {
// Đặt tiêu đề cho cảnh hiện tại
set_title: (setTitle: (t: string) => void, title: string) => {
setTitle(title);
},
clear_title: (setTitle: (t: string) => void) => {
setTitle("");
},
// Đặt nội dung mô tả chi tiết
set_descriptions: (setDesc: (d: string) => void, descriptions: string) => {
setDesc(descriptions);
},
clear_descriptions: (setDesc: (d: string) => void) => {
setDesc("");
},
// Hiển thị hộp thoại hội thoại (Dialogue)
show_dialog_box: (
setDialog: (data: {
avatar: string;
text: string;
side: "left" | "right";
speaker?: string | null;
}) => void,
avatar: string,
text: string,
side: "left" | "right",
speaker: string | null
// Đặt kịch bản đối thoại/hình ảnh dẫn chuyện mới (hoặc null để xóa)
set_dialog: (
setDialog: (data: DialogState | null) => void,
dialog: DialogState | null
) => {
setDialog({ avatar, text, side, speaker });
},
clear_dialog_box: (
setDialog: (data: {
avatar: string;
text: string;
side: "left" | "right";
speaker?: string | null;
} | null) => void
) => {
setDialog(null);
},
// Hiển thị hình ảnh lịch sử đè lên bản đồ
display_historical_image: (
setImage: (image: { url: string; caption?: string | null } | null) => void,
imageUrl: string,
caption: string | null
) => {
if (!imageUrl.trim().length) {
setImage(null);
return;
setDialog(dialog);
}
setImage({ url: imageUrl, caption });
},
clear_historical_image: (
setImage: (image: { url: string; caption?: string | null } | null) => void,
) => {
setImage(null);
},
// Hiển thị phụ đề (Subtitle)
set_step_subtitle: (setSubtitle: (s: string | null) => void, subtitle: string) => {
setSubtitle(subtitle);
},
clear_step_subtitle: (setSubtitle: (s: string | null) => void) => {
setSubtitle(null);
},
};
+177 -181
View File
@@ -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) {
switch (function_name) {
case "timeline":
uiActions.timeline(controllers.setTimelineVisible, Boolean(payload[0] ?? false));
uiActions.timeline(controllers.setTimelineVisible, asBooleanValue(params[0], true));
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);
uiActions.layer_panel(controllers.setLayerPanelVisible, asBooleanValue(params[0], true));
return;
case "zoom_panel":
uiActions.zoom_panel(Boolean(payload[0] ?? false));
uiActions.zoom_panel(controllers.setZoomPanelVisible, asBooleanValue(params[0], true));
return;
case "wiki":
uiActions.wiki(
controllers.setSidebarOpen,
controllers.onSelectWiki,
typeof payload[0] === "string" ? payload[0] : ""
params[0] as string | null
);
return;
case "toast":
uiActions.toast(
controllers.addToast,
typeof payload[0] === "string" ? payload[0] : ""
typeof params[0] === "string" ? params[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, 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);
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())
+638
View File
@@ -0,0 +1,638 @@
import type maplibregl from "maplibre-gl";
import type { Feature, FeatureCollection, Geometry } from "@/uhm/types/geo";
import { getFeatureCollectionBBox } from "@/uhm/components/map/mapUtils";
import { MAP_EMPHASIS_TEXT_FONT_STACK } from "@/uhm/lib/map/styles/shared/textFonts";
const EMPTY_EFFECT_COLLECTION = {
type: "FeatureCollection",
features: [],
} as Parameters<maplibregl.GeoJSONSource["setData"]>[0];
const STYLE_SOURCE_ID = "replay-effect-style-source";
const STYLE_FILL_LAYER_ID = "replay-effect-style-fill";
const STYLE_LINE_LAYER_ID = "replay-effect-style-line";
const STYLE_POINT_LAYER_ID = "replay-effect-style-point";
const LABEL_SOURCE_ID = "replay-effect-label-source";
const LABEL_LAYER_ID = "replay-effect-label";
const PULSE_SOURCE_ID = "replay-effect-pulse-source";
const PULSE_FILL_LAYER_ID = "replay-effect-pulse-fill";
const PULSE_LINE_LAYER_ID = "replay-effect-pulse-line";
const PULSE_POINT_LAYER_ID = "replay-effect-pulse-point";
const DASH_SOURCE_ID = "replay-effect-dash-source";
const DASH_LAYER_ID = "replay-effect-dash-line";
type EffectFeature = Feature & {
properties: Feature["properties"] & Record<string, unknown>;
};
type LabelFeature = {
type: "Feature";
properties: Record<string, unknown>;
geometry: {
type: "Point";
coordinates: [number, number];
};
};
type Cleanup = () => void;
export type ReplayMapEffects = ReturnType<typeof createReplayMapEffects>;
export function createReplayMapEffects() {
let activeMap: maplibregl.Map | null = null;
const cleanups = new Set<Cleanup>();
const styledFeatures = new Map<string, EffectFeature>();
const labelFeatures = new Map<string, LabelFeature>();
const pulseFeatures = new Map<string, EffectFeature>();
const dashFeatures = new Map<string, EffectFeature>();
const setActiveMap = (map: maplibregl.Map) => {
activeMap = map;
ensureReplayEffectLayers(map);
};
const clear = (map: maplibregl.Map | null = activeMap) => {
for (const cleanup of cleanups) {
cleanup();
}
cleanups.clear();
styledFeatures.clear();
labelFeatures.clear();
pulseFeatures.clear();
dashFeatures.clear();
if (!map) return;
updateSource(map, STYLE_SOURCE_ID, []);
updateSource(map, LABEL_SOURCE_ID, []);
updateSource(map, PULSE_SOURCE_ID, []);
updateSource(map, DASH_SOURCE_ID, []);
};
const registerCleanup = (cleanup: Cleanup) => {
cleanups.add(cleanup);
return () => {
cleanups.delete(cleanup);
};
};
return {
clear,
setGeometryStyle(
map: maplibregl.Map,
draft: FeatureCollection,
geometryIds: string[],
fillColor: string,
fillOpacity: number,
lineColor: string,
lineWidth: number
) {
setActiveMap(map);
const features = findFeaturesById(draft, geometryIds);
for (const feature of features) {
const id = String(feature.properties.id);
styledFeatures.set(id, cloneFeatureWithProps(feature, {
replay_fill_color: normalizeColor(fillColor, "#f97316"),
replay_fill_opacity: clampNumber(fillOpacity, 0, 1, 0.35),
replay_line_color: normalizeColor(lineColor, "#fdba74"),
replay_line_width: clampNumber(lineWidth, 0.5, 12, 2),
replay_circle_radius: 9,
}));
}
updateSource(map, STYLE_SOURCE_ID, Array.from(styledFeatures.values()));
},
showGeometryLabel(
map: maplibregl.Map,
draft: FeatureCollection,
geometryId: string,
text: string,
color: string,
size: number
) {
setActiveMap(map);
const feature = findFeatureById(draft, geometryId);
if (!feature) return;
const center = getFeatureCenter(feature);
if (!center) return;
const label = text.trim() || getDefaultFeatureLabel(feature);
if (!label.trim()) return;
labelFeatures.set(String(feature.properties.id), {
type: "Feature",
properties: {
id: `replay-label-${String(feature.properties.id)}`,
label,
color: normalizeColor(color, "#ffffff"),
size: clampNumber(size, 9, 28, 14),
},
geometry: {
type: "Point",
coordinates: center,
},
});
updateSource(map, LABEL_SOURCE_ID, Array.from(labelFeatures.values()));
},
pulseGeometry(
map: maplibregl.Map,
draft: FeatureCollection,
geometryId: string,
color: string,
repeat: number,
duration: number
) {
setActiveMap(map);
const feature = findFeatureById(draft, geometryId);
if (!feature) return;
const effectId = `pulse-${String(feature.properties.id)}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const totalDuration = clampNumber(duration, 250, 30000, 1800);
const repeatCount = Math.max(1, Math.trunc(clampNumber(repeat, 1, 20, 2)));
const effectColor = normalizeColor(color, "#f59e0b");
const startedAt = performance.now();
let rafId = 0;
let unregister: Cleanup | null = null;
const removeEffect = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
pulseFeatures.delete(effectId);
updateSource(map, PULSE_SOURCE_ID, Array.from(pulseFeatures.values()));
unregister?.();
unregister = null;
};
const tick = (now: number) => {
const elapsed = now - startedAt;
const progress = Math.min(1, elapsed / totalDuration);
const cycle = (progress * repeatCount) % 1;
const wave = 1 - Math.abs(cycle * 2 - 1);
pulseFeatures.set(effectId, cloneFeatureWithProps(feature, {
id: effectId,
replay_pulse_color: effectColor,
replay_pulse_fill_opacity: 0.06 + wave * 0.22,
replay_pulse_line_opacity: 0.28 + wave * 0.68,
replay_pulse_line_width: 2 + wave * 5,
replay_pulse_circle_radius: 8 + wave * 12,
replay_pulse_circle_opacity: 0.25 + wave * 0.55,
}));
updateSource(map, PULSE_SOURCE_ID, Array.from(pulseFeatures.values()));
if (progress >= 1) {
removeEffect();
return;
}
rafId = requestAnimationFrame(tick);
};
unregister = registerCleanup(removeEffect);
rafId = requestAnimationFrame(tick);
},
animateDashedBorder(
map: maplibregl.Map,
draft: FeatureCollection,
geometryId: string,
color: string,
width: number,
speed: number,
duration: number
) {
setActiveMap(map);
const feature = findFeatureById(draft, geometryId);
if (!feature || isPointGeometry(feature.geometry)) return;
const effectId = `dash-${String(feature.properties.id)}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const totalDuration = clampNumber(duration, 250, 60000, 3000);
const safeSpeed = clampNumber(speed, 0.25, 8, 1);
const startedAt = performance.now();
let rafId = 0;
let unregister: Cleanup | null = null;
const removeEffect = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
dashFeatures.delete(effectId);
updateSource(map, DASH_SOURCE_ID, Array.from(dashFeatures.values()));
unregister?.();
unregister = null;
};
dashFeatures.set(effectId, cloneFeatureWithProps(feature, {
id: effectId,
replay_dash_color: normalizeColor(color, "#38bdf8"),
replay_dash_width: clampNumber(width, 0.5, 12, 2),
replay_dash_opacity: 0.96,
}));
updateSource(map, DASH_SOURCE_ID, Array.from(dashFeatures.values()));
const tick = (now: number) => {
const elapsed = now - startedAt;
const phase = Math.floor((elapsed / 140) * safeSpeed) % 4;
const dashArray = phase % 2 === 0 ? [1.2, 0.8] : [0.35, 1.15, 1.2, 0.8];
if (map.getLayer(DASH_LAYER_ID)) {
map.setPaintProperty(DASH_LAYER_ID, "line-dasharray", dashArray);
}
if (elapsed >= totalDuration) {
removeEffect();
return;
}
rafId = requestAnimationFrame(tick);
};
unregister = registerCleanup(removeEffect);
rafId = requestAnimationFrame(tick);
},
followGeometriesPath(
map: maplibregl.Map,
draft: FeatureCollection,
geometryIds: string[],
duration: number,
zoom: number,
pitch: number
) {
const features = findFeaturesById(draft, geometryIds);
const coordinates = features.flatMap((feature) => getPathCoordinates(feature.geometry));
const path = removeDuplicateCoordinates(coordinates);
if (path.length === 0) return;
if (path.length === 1) {
map.easeTo({
center: path[0],
zoom: clampNumber(zoom, 1, 18, 8),
pitch: clampNumber(pitch, 0, 85, 50),
duration: clampNumber(duration, 250, 60000, 5000),
});
return;
}
setActiveMap(map);
const measured = buildMeasuredLngLatPath(path);
const totalDistance = measured[measured.length - 1]?.distance || 0;
if (totalDistance <= 0) return;
const totalDuration = clampNumber(duration, 250, 60000, 5000);
const safeZoom = clampNumber(zoom, 1, 18, 8);
const safePitch = clampNumber(pitch, 0, 85, 50);
const startedAt = performance.now();
let rafId = 0;
let unregister: Cleanup | null = null;
const stop = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
unregister?.();
unregister = null;
};
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / totalDuration);
const targetDistance = totalDistance * progress;
const center = interpolateMeasuredPath(measured, targetDistance);
const next = interpolateMeasuredPath(measured, Math.min(totalDistance, targetDistance + totalDistance * 0.02));
map.jumpTo({
center,
zoom: safeZoom,
pitch: safePitch,
bearing: getBearing(center, next),
});
if (progress >= 1) {
stop();
return;
}
rafId = requestAnimationFrame(tick);
};
unregister = registerCleanup(stop);
rafId = requestAnimationFrame(tick);
},
};
}
function ensureReplayEffectLayers(map: maplibregl.Map) {
if (!map.isStyleLoaded()) return;
ensureSource(map, STYLE_SOURCE_ID);
ensureSource(map, LABEL_SOURCE_ID);
ensureSource(map, PULSE_SOURCE_ID);
ensureSource(map, DASH_SOURCE_ID);
if (!map.getLayer(STYLE_FILL_LAYER_ID)) {
map.addLayer({
id: STYLE_FILL_LAYER_ID,
type: "fill",
source: STYLE_SOURCE_ID,
filter: polygonFilter(),
paint: {
"fill-color": ["coalesce", ["get", "replay_fill_color"], "#f97316"],
"fill-opacity": ["coalesce", ["to-number", ["get", "replay_fill_opacity"]], 0.35],
},
});
}
if (!map.getLayer(STYLE_LINE_LAYER_ID)) {
map.addLayer({
id: STYLE_LINE_LAYER_ID,
type: "line",
source: STYLE_SOURCE_ID,
filter: nonPointFilter(),
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": ["coalesce", ["get", "replay_line_color"], "#fdba74"],
"line-width": ["coalesce", ["to-number", ["get", "replay_line_width"]], 2],
"line-opacity": 0.98,
},
});
}
if (!map.getLayer(STYLE_POINT_LAYER_ID)) {
map.addLayer({
id: STYLE_POINT_LAYER_ID,
type: "circle",
source: STYLE_SOURCE_ID,
filter: pointFilter(),
paint: {
"circle-color": ["coalesce", ["get", "replay_fill_color"], "#f97316"],
"circle-radius": ["coalesce", ["to-number", ["get", "replay_circle_radius"]], 9],
"circle-opacity": ["coalesce", ["to-number", ["get", "replay_fill_opacity"]], 0.85],
"circle-stroke-color": ["coalesce", ["get", "replay_line_color"], "#fdba74"],
"circle-stroke-width": ["coalesce", ["to-number", ["get", "replay_line_width"]], 2],
},
});
}
if (!map.getLayer(PULSE_FILL_LAYER_ID)) {
map.addLayer({
id: PULSE_FILL_LAYER_ID,
type: "fill",
source: PULSE_SOURCE_ID,
filter: polygonFilter(),
paint: {
"fill-color": ["coalesce", ["get", "replay_pulse_color"], "#f59e0b"],
"fill-opacity": ["coalesce", ["to-number", ["get", "replay_pulse_fill_opacity"]], 0.18],
},
});
}
if (!map.getLayer(PULSE_LINE_LAYER_ID)) {
map.addLayer({
id: PULSE_LINE_LAYER_ID,
type: "line",
source: PULSE_SOURCE_ID,
filter: nonPointFilter(),
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": ["coalesce", ["get", "replay_pulse_color"], "#f59e0b"],
"line-width": ["coalesce", ["to-number", ["get", "replay_pulse_line_width"]], 4],
"line-opacity": ["coalesce", ["to-number", ["get", "replay_pulse_line_opacity"]], 0.75],
},
});
}
if (!map.getLayer(PULSE_POINT_LAYER_ID)) {
map.addLayer({
id: PULSE_POINT_LAYER_ID,
type: "circle",
source: PULSE_SOURCE_ID,
filter: pointFilter(),
paint: {
"circle-color": ["coalesce", ["get", "replay_pulse_color"], "#f59e0b"],
"circle-radius": ["coalesce", ["to-number", ["get", "replay_pulse_circle_radius"]], 12],
"circle-opacity": ["coalesce", ["to-number", ["get", "replay_pulse_circle_opacity"]], 0.7],
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1,
},
});
}
if (!map.getLayer(DASH_LAYER_ID)) {
map.addLayer({
id: DASH_LAYER_ID,
type: "line",
source: DASH_SOURCE_ID,
filter: nonPointFilter(),
layout: {
"line-cap": "round",
"line-join": "round",
},
paint: {
"line-color": ["coalesce", ["get", "replay_dash_color"], "#38bdf8"],
"line-width": ["coalesce", ["to-number", ["get", "replay_dash_width"]], 2],
"line-opacity": ["coalesce", ["to-number", ["get", "replay_dash_opacity"]], 0.96],
"line-dasharray": [1.2, 0.8],
},
});
}
if (!map.getLayer(LABEL_LAYER_ID)) {
map.addLayer({
id: LABEL_LAYER_ID,
type: "symbol",
source: LABEL_SOURCE_ID,
layout: {
"text-field": ["to-string", ["get", "label"]],
"text-size": ["coalesce", ["to-number", ["get", "size"]], 14],
"text-font": [...MAP_EMPHASIS_TEXT_FONT_STACK],
"text-anchor": "bottom",
"text-offset": [0, -0.8],
"text-allow-overlap": true,
"text-ignore-placement": true,
},
paint: {
"text-color": ["coalesce", ["get", "color"], "#ffffff"],
"text-halo-color": "#020617",
"text-halo-width": 1.6,
},
});
}
}
function ensureSource(map: maplibregl.Map, sourceId: string) {
if (map.getSource(sourceId)) return;
map.addSource(sourceId, {
type: "geojson",
data: EMPTY_EFFECT_COLLECTION,
});
}
function updateSource(
map: maplibregl.Map,
sourceId: string,
features: Array<EffectFeature | LabelFeature>
) {
const source = map.getSource(sourceId) as maplibregl.GeoJSONSource | undefined;
if (!source) return;
source.setData({
type: "FeatureCollection",
features,
} as Parameters<maplibregl.GeoJSONSource["setData"]>[0]);
}
function findFeatureById(draft: FeatureCollection, geometryId: string) {
const id = String(geometryId || "").trim();
if (!id) return null;
return draft.features.find((feature) => String(feature.properties.id) === id) || null;
}
function findFeaturesById(draft: FeatureCollection, geometryIds: string[]) {
const idSet = new Set(geometryIds.map((id) => String(id || "").trim()).filter(Boolean));
if (!idSet.size) return [];
return draft.features.filter((feature) => idSet.has(String(feature.properties.id)));
}
function cloneFeatureWithProps(feature: Feature, props: Record<string, unknown>): EffectFeature {
return {
...feature,
properties: {
...feature.properties,
...props,
},
};
}
function getFeatureCenter(feature: Feature): [number, number] | null {
if (feature.geometry.type === "Point") return feature.geometry.coordinates;
if (feature.geometry.type === "MultiPoint") return feature.geometry.coordinates[0] || null;
const bbox = getFeatureCollectionBBox({
type: "FeatureCollection",
features: [feature],
});
if (!bbox) return null;
return [
(bbox.minLng + bbox.maxLng) / 2,
(bbox.minLat + bbox.maxLat) / 2,
];
}
function getDefaultFeatureLabel(feature: Feature) {
return String(
feature.properties.point_label ||
feature.properties.line_label ||
feature.properties.polygon_label ||
feature.properties.entity_name ||
feature.properties.entity_names?.[0] ||
feature.properties.id ||
""
);
}
function getPathCoordinates(geometry: Geometry): [number, number][] {
switch (geometry.type) {
case "Point":
return [geometry.coordinates];
case "MultiPoint":
case "LineString":
return geometry.coordinates;
case "MultiLineString":
return geometry.coordinates.flat();
case "Polygon":
return geometry.coordinates[0] || [];
case "MultiPolygon":
return geometry.coordinates.flatMap((polygon) => polygon[0] || []);
}
}
function removeDuplicateCoordinates(coordinates: [number, number][]) {
const result: [number, number][] = [];
for (const coord of coordinates) {
const last = result[result.length - 1];
if (last && last[0] === coord[0] && last[1] === coord[1]) continue;
if (!Number.isFinite(coord[0]) || !Number.isFinite(coord[1])) continue;
result.push(coord);
}
return result;
}
type MeasuredLngLat = {
coordinate: [number, number];
distance: number;
};
function buildMeasuredLngLatPath(path: [number, number][]): MeasuredLngLat[] {
let distance = 0;
return path.map((coordinate, index) => {
if (index > 0) {
distance += distanceLngLat(path[index - 1], coordinate);
}
return { coordinate, distance };
});
}
function interpolateMeasuredPath(path: MeasuredLngLat[], targetDistance: number): [number, number] {
if (targetDistance <= 0) return path[0].coordinate;
for (let index = 1; index < path.length; index += 1) {
const previous = path[index - 1];
const next = path[index];
if (targetDistance > next.distance) continue;
const segmentDistance = next.distance - previous.distance;
const ratio = segmentDistance > 0 ? (targetDistance - previous.distance) / segmentDistance : 0;
return [
previous.coordinate[0] + (next.coordinate[0] - previous.coordinate[0]) * ratio,
previous.coordinate[1] + (next.coordinate[1] - previous.coordinate[1]) * ratio,
];
}
return path[path.length - 1].coordinate;
}
function distanceLngLat(left: [number, number], right: [number, number]) {
const lngDistance = (right[0] - left[0]) * Math.cos(((left[1] + right[1]) / 2) * Math.PI / 180);
const latDistance = right[1] - left[1];
return Math.hypot(lngDistance, latDistance);
}
function getBearing(left: [number, number], right: [number, number]) {
const lng1 = left[0] * Math.PI / 180;
const lat1 = left[1] * Math.PI / 180;
const lng2 = right[0] * Math.PI / 180;
const lat2 = right[1] * Math.PI / 180;
const y = Math.sin(lng2 - lng1) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(lng2 - lng1);
return Math.atan2(y, x) * 180 / Math.PI;
}
function isPointGeometry(geometry: Geometry) {
return geometry.type === "Point" || geometry.type === "MultiPoint";
}
function polygonFilter(): maplibregl.ExpressionSpecification {
return [
"any",
["==", ["geometry-type"], "Polygon"],
["==", ["geometry-type"], "MultiPolygon"],
];
}
function pointFilter(): maplibregl.ExpressionSpecification {
return [
"any",
["==", ["geometry-type"], "Point"],
["==", ["geometry-type"], "MultiPoint"],
];
}
function nonPointFilter(): maplibregl.ExpressionSpecification {
return [
"any",
["==", ["geometry-type"], "LineString"],
["==", ["geometry-type"], "MultiLineString"],
["==", ["geometry-type"], "Polygon"],
["==", ["geometry-type"], "MultiPolygon"],
];
}
function normalizeColor(value: string, fallback: string) {
const raw = String(value || "").trim();
return raw.length > 0 ? raw : fallback;
}
function clampNumber(value: number, min: number, max: number, fallback: number) {
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
+11 -32
View File
@@ -8,50 +8,29 @@ export const uiActions = {
setTimelineVisible(visible);
},
// Ẩ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 layer trong preview.
layer_panel: (setLayerPanelVisible: (v: boolean) => void, visible: boolean) => {
setLayerPanelVisible(visible);
},
// Ẩn/hiện panel wiki.
wiki_panel: (setSidebarOpen: (v: boolean) => void, visible: boolean) => {
setSidebarOpen(visible);
// Ẩn/hiện cụm control zoom/projection trên map preview.
zoom_panel: (setZoomPanelVisible: (v: boolean) => void, visible: boolean) => {
setZoomPanelVisible(visible);
},
close_wiki_panel: (
setSidebarOpen: (v: boolean) => void,
onSelectWiki: (id: string) => void,
) => {
// Mở Wiki và tìm đến một ID cụ thể. Nếu wikiId là null/rỗng thì đóng panel wiki.
wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string | null) => {
if (!wikiId) {
setSidebarOpen(false);
onSelectWiki("");
},
// Ẩ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ể
wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string) => {
} else {
setSidebarOpen(true);
onSelectWiki(wikiId);
}
},
// Hiển thị thông báo (toast)
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
playback_speed: (setSpeed: (s: number) => void, speed: number) => {
setSpeed(speed);
}
};
+47 -39
View File
@@ -2,21 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { BattleReplay, ReplayStage, ReplayStep } from "@/uhm/types/projects";
import type { BattleReplay, ReplayStage, ReplayStep, DialogState } from "@/uhm/types/projects";
import { dispatchReplayAction } from "./replayDispatcher";
import { mapActions } from "./mapActions";
export type ReplayPreviewDialog = {
avatar: string;
text: string;
side: "left" | "right";
speaker?: string | null;
};
export type ReplayPreviewImage = {
url: string;
caption?: string | null;
};
import { createReplayMapEffects } from "./replayMapEffects";
export type ReplayPreviewToast = {
id: number;
@@ -27,6 +16,9 @@ type PreviewBaseline = {
timelineYear: number;
timelineFilterEnabled: boolean;
timelineVisible: boolean;
layerPanelVisible: boolean;
zoomPanelVisible: boolean;
labelVisibility: Record<string, "visible" | "none">;
mapViewState: {
center: { lng: number; lat: number };
zoom: number;
@@ -67,13 +59,16 @@ export function useReplayPreview({
onSelectStep,
}: UseReplayPreviewOptions) {
const [isPlaying, setIsPlaying] = useState(false);
const [title, setTitle] = useState("");
const [descriptions, setDescriptions] = useState("");
const [subtitle, setSubtitle] = useState<string | null>(null);
const [dialog, setDialog] = useState<ReplayPreviewDialog | null>(null);
const [image, setImage] = useState<ReplayPreviewImage | null>(null);
const [dialog, setDialog] = useState<DialogState | null>(null);
const dialogRef = useRef<DialogState | null>(null);
const setDialogWithRef = useCallback((d: DialogState | null) => {
dialogRef.current = d;
setDialog(d);
}, []);
const [toasts, setToasts] = useState<ReplayPreviewToast[]>([]);
const [timelineVisible, setTimelineVisible] = useState(true);
const [layerPanelVisible, setLayerPanelVisible] = useState(false);
const [zoomPanelVisible, setZoomPanelVisible] = useState(true);
const [timelineYear, setTimelineYear] = useState(initialTimelineYear);
const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(initialTimelineFilterEnabled);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -94,6 +89,7 @@ export function useReplayPreview({
const toastIdRef = useRef(0);
const toastTimeoutsRef = useRef<number[]>([]);
const baselineRef = useRef<PreviewBaseline | null>(null);
const effects = useMemo(() => createReplayMapEffects(), []);
const flatSteps = useMemo(() => flattenReplaySteps(replay), [replay]);
@@ -102,24 +98,26 @@ export function useReplayPreview({
}, [playbackSpeed]);
useEffect(() => {
setTimelineYear(initialTimelineYear);
setTimelineFilterEnabled(initialTimelineFilterEnabled);
setTimelineVisible(true);
const map = getMapInstance();
baselineRef.current = {
timelineYear: initialTimelineYear,
timelineFilterEnabled: initialTimelineFilterEnabled,
timelineVisible: true,
layerPanelVisible: false,
zoomPanelVisible: true,
labelVisibility: map ? mapActions.get_label_visibility(map) : {},
mapViewState: initialMapViewState,
};
}, [initialMapViewState, initialTimelineFilterEnabled, initialTimelineYear, replay?.id]);
}, [getMapInstance, initialMapViewState, initialTimelineFilterEnabled, initialTimelineYear, replay?.id]);
useEffect(() => {
return () => {
runIdRef.current += 1;
effects.clear(getMapInstance());
toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId));
toastTimeoutsRef.current = [];
};
}, []);
}, [effects, getMapInstance]);
const clearToasts = useCallback(() => {
toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId));
@@ -128,18 +126,17 @@ export function useReplayPreview({
}, []);
const resetPresentation = useCallback(() => {
setTitle("");
setDescriptions("");
setSubtitle(null);
setDialog(null);
setImage(null);
setDialogWithRef(null);
setSidebarOpen(false);
setActiveWikiId(null);
setLayerPanelVisible(false);
setZoomPanelVisible(true);
playbackSpeedRef.current = 1;
setPlaybackSpeed(1);
setHiddenGeometryIds([]);
effects.clear(getMapInstance());
clearToasts();
}, [clearToasts]);
}, [clearToasts, effects, getMapInstance, setDialogWithRef]);
const addToast = useCallback((message: string) => {
const text = String(message || "").trim();
@@ -167,12 +164,17 @@ export function useReplayPreview({
}
setTimelineVisible(baseline.timelineVisible);
setLayerPanelVisible(baseline.layerPanelVisible);
setZoomPanelVisible(baseline.zoomPanelVisible);
setTimelineYear(baseline.timelineYear);
setTimelineFilterEnabled(baseline.timelineFilterEnabled);
const map = getMapInstance();
if (map) {
mapActions.toggle_labels(map, true);
mapActions.restore_label_visibility(map, baseline.labelVisibility);
if (baseline.mapViewState) {
map.setProjection({
type: baseline.mapViewState.projection === "globe" ? "globe" : "mercator",
});
mapActions.set_camera_view(map, {
center: baseline.mapViewState.center,
zoom: baseline.mapViewState.zoom,
@@ -195,11 +197,15 @@ export function useReplayPreview({
}, [restorePreviewState]);
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
useEffect(() => {
controllersRef.current = {
map: getMapInstance(),
draft,
effects,
setTimelineVisible,
setTimelineFilterEnabled,
setLayerPanelVisible,
setZoomPanelVisible,
setSidebarOpen,
onSelectWiki: (id) => {
const nextId = String(id || "").trim();
@@ -240,17 +246,16 @@ export function useReplayPreview({
showAllGeometries: () => {
setHiddenGeometryIds([]);
},
setTitle,
setDescriptions,
setDialog,
setImage,
setSubtitle,
setDialog: setDialogWithRef,
getDialog: () => dialogRef.current,
};
}, [addToast, draft, effects, getMapInstance]);
const playFromIndex = useCallback(async (startIndex: number) => {
if (!flatSteps.length) return;
const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex));
resetPresentation();
effects.clear(getMapInstance());
setTimelineVisible(true);
setTimelineYear(initialTimelineYear);
setTimelineFilterEnabled(initialTimelineFilterEnabled);
@@ -272,6 +277,8 @@ export function useReplayPreview({
const controllers = controllersRef.current;
if (!controllers) return;
controllers.map = getMapInstance();
controllers.draft = draft;
const actions = [
...current.step.use_narrow_function,
@@ -294,6 +301,9 @@ export function useReplayPreview({
restorePreviewState();
}, [
flatSteps,
draft,
effects,
getMapInstance,
initialTimelineFilterEnabled,
initialTimelineYear,
onSelectStep,
@@ -312,13 +322,11 @@ export function useReplayPreview({
return {
isPlaying,
title,
descriptions,
subtitle,
dialog,
image,
toasts,
timelineVisible,
layerPanelVisible,
zoomPanelVisible,
timelineYear,
timelineFilterEnabled,
sidebarOpen,
+15 -35
View File
@@ -87,57 +87,37 @@ export type EditorSnapshot = {
replays?: BattleReplay[];
};
// ---- Replay / Scripting System ----
export type DialogState = {
avatar: string; // Avatar image URL
text: string; // Subtitle / spoken narrative text
image_url?: string; // Optional image URL
image_caption?: string;// Optional caption
};
export type UIOptionName =
| "timeline" // Ẩn/hiện timeline
| "layer_panel" // Ẩn/hiện panel layer
| "wiki_panel" // Ẩn/hiện panel wiki
| "close_wiki_panel" // Đóng panel wiki và xóa wiki đang active
| "zoom_panel" // Ẩn/hiện nút zoom
| "wiki" // Mở/chọn wiki
| "toast" // Hiển thị toast
| "wiki_header" // Focus header trong wiki
| "playback_speed"; // Thay đổi tốc độ phát replay
| "wiki" // Mở/chọn wiki (null/rỗng để đóng)
| "toast"; // Hiển thị toast
export type MapFunctionName =
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing)
| "set_time_filter" // Thay đổi bộ lọc thời gian trên bản đồ
| "enable_timeline_filter" // Bật timeline filter
| "disable_timeline_filter" // Tắt timeline filter
| "toggle_labels" // Legacy: bật/tắt hiển thị nhãn (labels) trên bản đồ
| "show_labels" // Hiện labels
| "hide_labels" // Ẩn labels
| "show_all_geometries" // Hiện lại toàn bộ geometry đang có trong replay draft
| "reset_camera_north"; // Đưa camera về hướng bắc
| "set_timeline_filter" // Bật/tắt lọc timeline
| "set_labels_visible"; // Ẩn/hiện nhãn (labels) trên bản đồ
export type GeoFunctionName =
| "fly_to_geometry" // Legacy: di chuyển mượt mà đến một geometry
| "fly_to_geometries" // Di chuyển mượt mà đến một hoặc nhiều geometry
| "set_geometry_visibility" // Legacy: ẩn/hiện một hoặc nhiều geometry
| "show_geometries" // Hiện một hoặc nhiều geometry
| "hide_geometries" // Ẩn một hoặc nhiều geometry
| "fit_to_geometries" // Legacy: fit camera theo nhiều geometry
| "orbit_camera_around_geometry" // Quay camera quanh một geometry
| "set_geometry_visibility" // n/hiện một hoặc nhiều geometry
| "follow_geometries_path" // Cho camera bám theo chuỗi path geometry
| "hide_others_geometries" // Ẩn các geometry ngoài target set, chỉ giữ geo focus
| "pulse_geometry" // Hiệu ứng pulse/emphasis cho geometry
| "animate_dashed_border" // Hiệu ứng border nét đứt chuyển động
| "set_geometry_style" // Đổi style trực tiếp của geometry
| "show_geometry_label" // Hiện label riêng cho geometry
| "follow_geometry_path" // Legacy: cho camera bám theo một path geometry
| "follow_geometries_path" // Cho camera bám theo chuỗi path geometry
| "dim_other_geometries"; // Ẩn các geometry ngoài target set, chỉ giữ geo focus
| "orbit_camera_around_geometry"; // Quay camera quanh một geometry
export type NarrativeFunctionName =
| "set_title" // Đặt tiêu đề cho bước replay
| "clear_title" // Xóa tiêu đề hiện tại
| "set_descriptions" // Đặt mô tả/nội dung diễn giải
| "clear_descriptions" // Xóa mô tả hiện tại
| "show_dialog_box" // Hiển thị hộp thoại dẫn chuyện (có avatar)
| "clear_dialog_box" // Đóng/xóa dialog hiện tại
| "display_historical_image" // Hiển thị hình ảnh tư liệu đè lên bản đồ
| "clear_historical_image" // Xóa ảnh lịch sử hiện tại
| "set_step_subtitle" // Hiển thị phụ đề phía dưới màn hình
| "clear_step_subtitle"; // Xóa phụ đề hiện tại
| "set_dialog"; // Đặt kịch bản đối thoại/hình ảnh dẫn chuyện mới (hoặc null để xóa)
export type ReplayAction<T> = {
function_name: T;