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 ReplayTimelineSidebar from "@/uhm/components/editor/ReplayTimelineSidebar";
import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar"; import ReplayEffectsSidebar from "@/uhm/components/editor/ReplayEffectsSidebar";
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel"; import WikiSidebarPanel from "@/uhm/components/wiki/WikiSidebarPanel";
import ProjectEntityRefsPanel from "@/uhm/components/editor/ProjectEntityRefsPanel"; 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 { newId } from "@/uhm/lib/utils/id";
import { import {
loadBackgroundLayerVisibilityFromStorage, loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage"; } 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 { deepClone } from "@/uhm/lib/editor/draft/draftDiff";
import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands"; import { useProjectCommands } from "@/uhm/lib/editor/project/useProjectCommands";
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview"; import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
@@ -2231,6 +2235,7 @@ function EditorPageContent() {
imageOverlay={imageOverlay} imageOverlay={imageOverlay}
onImageOverlayChange={setImageOverlay} onImageOverlayChange={setImageOverlay}
onBindGeometries={handleBindGeometries} onBindGeometries={handleBindGeometries}
showViewportControls={!isReplayPreviewMode || replayPreview.zoomPanelVisible}
/> />
) : ( ) : (
<div style={{ width: "100%", height: "100%", background: "#0b1220" }} /> <div style={{ width: "100%", height: "100%", background: "#0b1220" }} />
@@ -2239,11 +2244,7 @@ function EditorPageContent() {
<ReplayPreviewOverlay <ReplayPreviewOverlay
isPreviewMode={true} isPreviewMode={true}
isPlaying={replayPreview.isPlaying} isPlaying={replayPreview.isPlaying}
title={replayPreview.title}
descriptions={replayPreview.descriptions}
subtitle={replayPreview.subtitle}
dialog={replayPreview.dialog} dialog={replayPreview.dialog}
image={replayPreview.image}
toasts={replayPreview.toasts} toasts={replayPreview.toasts}
sidebarOpen={replayPreview.sidebarOpen} sidebarOpen={replayPreview.sidebarOpen}
playbackSpeed={replayPreview.playbackSpeed} playbackSpeed={replayPreview.playbackSpeed}
@@ -2281,6 +2282,36 @@ function EditorPageContent() {
/> />
</aside> </aside>
) : null} ) : 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 ? ( {!isReplayPreviewMode || replayPreview.timelineVisible ? (
<TimelineBar <TimelineBar
year={activeTimelineYear} 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 { function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase(); const tagName = target.tagName.toLowerCase();
+15 -11
View File
@@ -61,6 +61,7 @@ type MapProps = {
imageOverlay?: MapImageOverlay | null; imageOverlay?: MapImageOverlay | null;
onImageOverlayChange?: (overlay: MapImageOverlay) => void; onImageOverlayChange?: (overlay: MapImageOverlay) => void;
onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void; onBindGeometries?: (targetId: string | number, sourceIds: (string | number)[]) => void;
showViewportControls?: boolean;
}; };
const Map = forwardRef<MapHandle, MapProps>(function Map({ const Map = forwardRef<MapHandle, MapProps>(function Map({
@@ -90,6 +91,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
imageOverlay = null, imageOverlay = null,
onImageOverlayChange, onImageOverlayChange,
onBindGeometries, onBindGeometries,
showViewportControls = true,
}, ref) { }, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần. // Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode); const modeRef = useRef<MapProps["mode"]>(mode);
@@ -273,16 +275,17 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
</div> </div>
) : null} ) : null}
<div {showViewportControls ? (
style={{ <div
position: "absolute", style={{
top: "10px", position: "absolute",
left: "16px", top: "10px",
right: "16px", left: "16px",
zIndex: 12, right: "16px",
pointerEvents: "none", zIndex: 12,
}} pointerEvents: "none",
> }}
>
<div <div
style={{ style={{
maxWidth: "650px", maxWidth: "650px",
@@ -427,7 +430,8 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
{zoomLevel.toFixed(1)}x {zoomLevel.toFixed(1)}x
</div> </div>
</div> </div>
</div> </div>
) : null}
</div> </div>
); );
}); });
+128 -323
View File
@@ -62,12 +62,12 @@ type ActionDefinition<T extends string> = {
}; };
type NarrativeActionDefinitionMap = Record<NarrativeFunctionName, ActionDefinition<NarrativeFunctionName>>; type NarrativeActionDefinitionMap = Record<NarrativeFunctionName, ActionDefinition<NarrativeFunctionName>>;
type UiVisibleOptionName = "timeline" | "layer_panel" | "zoom_panel";
type UiEffectsDraftState = { type UiEffectsDraftState = {
selected: Record<UIOptionName, boolean>; selected: Record<UIOptionName, boolean>;
visible: Record<UiVisibleOptionName, boolean>;
wiki_id: string; wiki_id: string;
message: string; message: string;
header_id: string;
speed: string;
}; };
type MapCameraOptionName = "center" | "zoom" | "bearing" | "pitch"; type MapCameraOptionName = "center" | "zoom" | "bearing" | "pitch";
type MapCameraDraftState = { type MapCameraDraftState = {
@@ -84,28 +84,20 @@ type CurrentMapViewState = {
const uiOptionChoices: Array<{ label: string; value: UIOptionName }> = [ const uiOptionChoices: Array<{ label: string; value: UIOptionName }> = [
{ label: "Timeline", value: "timeline" }, { label: "Timeline", value: "timeline" },
{ label: "Layer Panel", value: "layer_panel" }, { 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: "Zoom Panel", value: "zoom_panel" },
{ label: "Wiki", value: "wiki" }, { label: "Wiki", value: "wiki" },
{ label: "Toast", value: "toast" }, { label: "Toast", value: "toast" },
{ label: "Wiki Header", value: "wiki_header" },
{ label: "Playback Speed", value: "playback_speed" },
]; ];
const uiSimpleOptionValues: UIOptionName[] = [ const uiSimpleOptionValues: UIOptionName[] = [
"timeline", "timeline",
"layer_panel", "layer_panel",
"wiki_panel",
"close_wiki_panel",
"zoom_panel", "zoom_panel",
]; ];
const uiInputOptionValues: UIOptionName[] = [ const uiInputOptionValues: UIOptionName[] = [
"wiki", "wiki",
"toast", "toast",
"wiki_header",
"playback_speed",
]; ];
const mapCameraOptionChoices: Array<{ label: string; value: MapCameraOptionName }> = [ const mapCameraOptionChoices: Array<{ label: string; value: MapCameraOptionName }> = [
@@ -148,107 +140,51 @@ const buttonStyle = {
}; };
const narrativeActionDefinitions: NarrativeActionDefinitionMap = { const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
set_title: { set_dialog: {
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: {
label: "Dialog box", label: "Dialog box",
fields: [ fields: [
{ name: "avatar", label: "Avatar", kind: "text", placeholder: "avatar url" }, { name: "clear", label: "Ẩn dialog (Clear)", kind: "boolean" },
{ name: "text", label: "Text", kind: "textarea", placeholder: "Lời thoại" }, { 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: "side", { name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (ảnh đè)" },
label: "Side", { name: "image_caption", label: "Chú thích ảnh", kind: "text", placeholder: "Chú thích ảnh" },
kind: "select",
options: [
{ label: "Left", value: "left" },
{ label: "Right", value: "right" },
],
},
{ name: "speaker", label: "Speaker", kind: "text", placeholder: "Tên nhân vật" },
], ],
create: () => ({ function_name: "show_dialog_box", params: ["", "", "left", ""] }), create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }),
deserialize: (params) => ({ deserialize: (params) => {
avatar: asString(params[0]), const data: any = params[0];
text: asString(params[1]), if (data === null) {
side: normalizeSelectValue(asString(params[2]), "left"), return {
speaker: asString(params[3]), clear: true,
}), avatar: "",
serialize: (values) => [ text: "",
asString(values.avatar), image_url: "",
asString(values.text), image_caption: "",
normalizeSelectValue(asString(values.side), "left"), };
asString(values.speaker), }
], return {
}, clear: false,
clear_dialog_box: { avatar: asString(data?.avatar),
label: "Đóng dialog box", text: asString(data?.text),
fields: [], image_url: asString(data?.image_url),
create: () => ({ function_name: "clear_dialog_box", params: [] }), image_caption: asString(data?.image_caption),
deserialize: () => ({}), };
serialize: () => [], },
}, serialize: (values) => {
display_historical_image: { if (values.clear) {
label: "Ảnh lịch sử", return [null];
fields: [ }
{ name: "url", label: "URL", kind: "text", placeholder: "https://..." }, const data: any = {
{ name: "caption", label: "Caption", kind: "textarea", placeholder: "Chú thích" }, avatar: asString(values.avatar),
], text: asString(values.text),
create: () => ({ function_name: "display_historical_image", params: ["", ""] }), };
deserialize: (params) => ({ if (values.image_url) {
url: asString(params[0]), data.image_url = asString(values.image_url);
caption: asString(params[1]), }
}), if (values.image_caption) {
serialize: (values) => compactTrailingUndefined([ data.image_caption = asString(values.image_caption);
asString(values.url), }
emptyToUndefined(asString(values.caption)), return [data];
]), },
},
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 }); .map((id) => byId.get(id) || { id, label: id });
}, [geometryChoices, selectedFeatureIds]); }, [geometryChoices, selectedFeatureIds]);
const selectedGeometryIds = useMemo(
() => selectedGeometryItems.map((item) => item.id),
[selectedGeometryItems]
);
const updateStep = (label: string, updater: (step: ReplayStep) => void) => { const updateStep = (label: string, updater: (step: ReplayStep) => void) => {
if (!selectedStage || selectedStepIndex == null) return; if (!selectedStage || selectedStepIndex == null) return;
onMutateReplay(label, (draftReplay) => { onMutateReplay(label, (draftReplay) => {
@@ -362,7 +293,6 @@ export default function ReplayEffectsSidebar({
<> <>
<ActionGroupEditor <ActionGroupEditor
title="Narrative" title="Narrative"
groupKey="use_narrow_function"
groupLabel={`Replay: cập nhật narrative step ${selectedStepIndex + 1} của stage #${selectedStage.id}`} groupLabel={`Replay: cập nhật narrative step ${selectedStepIndex + 1} của stage #${selectedStage.id}`}
actions={selectedStep.use_narrow_function} actions={selectedStep.use_narrow_function}
definitions={narrativeActionDefinitions} definitions={narrativeActionDefinitions}
@@ -433,7 +363,7 @@ function MapFunctionShortcutPanel({
tone="blue" tone="blue"
onClick={() => onClick={() =>
onAppendActions( onAppendActions(
[{ function_name: "show_labels", params: [] }], [{ function_name: "set_labels_visible", params: [true] }],
"Map: show labels" "Map: show labels"
) )
} }
@@ -443,7 +373,7 @@ function MapFunctionShortcutPanel({
tone="slate" tone="slate"
onClick={() => onClick={() =>
onAppendActions( onAppendActions(
[{ function_name: "hide_labels", params: [] }], [{ function_name: "set_labels_visible", params: [false] }],
"Map: hide labels" "Map: hide labels"
) )
} }
@@ -453,7 +383,7 @@ function MapFunctionShortcutPanel({
tone="green" tone="green"
onClick={() => onClick={() =>
onAppendActions( onAppendActions(
[{ function_name: "enable_timeline_filter", params: [] }], [{ function_name: "set_timeline_filter", params: [true] }],
"Map: enable timeline filter" "Map: enable timeline filter"
) )
} }
@@ -463,41 +393,11 @@ function MapFunctionShortcutPanel({
tone="slate" tone="slate"
onClick={() => onClick={() =>
onAppendActions( onAppendActions(
[{ function_name: "disable_timeline_filter", params: [] }], [{ function_name: "set_timeline_filter", params: [false] }],
"Map: disable timeline filter" "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>
</div> </div>
</Panel> </Panel>
@@ -553,7 +453,7 @@ function GeoFunctionShortcutPanel({
disabled={!hasSelection} disabled={!hasSelection}
onClick={() => onClick={() =>
onAppendActions( onAppendActions(
[{ function_name: "show_geometries", params: [selectedIds] }], [{ function_name: "set_geometry_visibility", params: [selectedIds, true] }],
`Geo: show ${selectedCount} geo` `Geo: show ${selectedCount} geo`
) )
} }
@@ -564,89 +464,22 @@ function GeoFunctionShortcutPanel({
disabled={!hasSelection} disabled={!hasSelection}
onClick={() => onClick={() =>
onAppendActions( onAppendActions(
[{ function_name: "hide_geometries", params: [selectedIds] }], [{ function_name: "set_geometry_visibility", params: [selectedIds, false] }],
`Geo: hide ${selectedCount} geo` `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 <ShortcutButton
label="Hide Others" label="Hide Others"
tone="slate" tone="slate"
disabled={!hasSelection} disabled={!hasSelection}
onClick={() => onClick={() =>
onAppendActions( onAppendActions(
[{ function_name: "dim_other_geometries", params: [selectedIds] }], [{ function_name: "hide_others_geometries", params: [selectedIds] }],
`Geo: hide others ngoài ${selectedCount} geo` `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>
</div> </div>
</Panel> </Panel>
@@ -849,36 +682,6 @@ function UiInputEffectsPanel({
/> />
) : null} ) : 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 <button
type="button" type="button"
onClick={onApply} onClick={onApply}
@@ -916,6 +719,8 @@ function UiOptionToggleRow({
); );
} }
// UiVisibilityOptions removed since toggles are evaluated directly
function SimpleOptionToggleRow<T extends string>({ function SimpleOptionToggleRow<T extends string>({
options, options,
onToggleOption, onToggleOption,
@@ -1022,7 +827,6 @@ function UiEffectsEditor({
function ActionGroupEditor<T extends string>({ function ActionGroupEditor<T extends string>({
title, title,
groupKey,
groupLabel, groupLabel,
actions, actions,
definitions, definitions,
@@ -1033,7 +837,6 @@ function ActionGroupEditor<T extends string>({
onUpdateActions, onUpdateActions,
}: { }: {
title: string; title: string;
groupKey: ActionGroupKey;
groupLabel: string; groupLabel: string;
actions: ReplayAction<T>[]; actions: ReplayAction<T>[];
definitions: Record<T, ActionDefinition<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 functionNames = useMemo(() => Object.keys(definitions) as T[], [definitions]);
const [composerFunctionName, setComposerFunctionName] = useState<T | "">( const [composerFunctionName, setComposerFunctionName] = useState<T | "">(
createOnSelect ? "" : (functionNames[0] as T) createOnSelect && functionNames.length > 1 ? "" : (functionNames[0] as T)
); );
const [composerDraftValues, setComposerDraftValues] = useState<ActionFormValues>(() => 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 const composerDefinition = composerFunctionName
@@ -1076,7 +882,7 @@ function ActionGroupEditor<T extends string>({
`${groupLabel}: thêm ${definition.label}` `${groupLabel}: thêm ${definition.label}`
); );
if (createOnSelect) { if (createOnSelect && functionNames.length > 1) {
setComposerFunctionName(""); setComposerFunctionName("");
setComposerDraftValues(buildActionComposerDraft(definitions, "")); setComposerDraftValues(buildActionComposerDraft(definitions, ""));
return; return;
@@ -1088,32 +894,34 @@ function ActionGroupEditor<T extends string>({
return ( return (
<Panel title={title} badge={`${actions.length}`} defaultOpen> <Panel title={title} badge={`${actions.length}`} defaultOpen>
<div style={{ display: "grid", gap: 10 }}> <div style={{ display: "grid", gap: 10 }}>
<div {functionNames.length > 1 ? (
style={{ <div
display: "grid", style={{
gridTemplateColumns: "1fr", display: "grid",
gap: 8, gridTemplateColumns: "1fr",
alignItems: "center", gap: 8,
}} alignItems: "center",
>
<select
value={composerFunctionName}
onChange={(event) => {
const nextValue = event.target.value as T | "";
handleComposerFunctionChange(nextValue);
}} }}
style={inputStyle}
> >
{createOnSelect ? ( <select
<option value="">{emptyOptionLabel || "Chọn option"}</option> value={composerFunctionName}
) : null} onChange={(event) => {
{functionNames.map((functionName) => ( const nextValue = event.target.value as T | "";
<option key={functionName} value={functionName}> handleComposerFunctionChange(nextValue);
{definitions[functionName].label} }}
</option> style={inputStyle}
))} >
</select> {createOnSelect ? (
</div> <option value="">{emptyOptionLabel || "Chọn option"}</option>
) : null}
{functionNames.map((functionName) => (
<option key={functionName} value={functionName}>
{definitions[functionName].label}
</option>
))}
</select>
</div>
) : null}
{composerDefinition ? ( {composerDefinition ? (
<div <div
@@ -1396,40 +1204,35 @@ function compactTrailingUndefined(values: unknown[]) {
return next; 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) { function normalizeSelectValue(value: string, fallback: string) {
return value.trim().length ? value : fallback; return value.trim().length ? value : fallback;
} }
function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffectsDraftState { function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffectsDraftState {
const selected = buildEmptyUiOptionSelection(); const selected = buildEmptyUiOptionSelection();
const visible = buildDefaultUiVisibilityState();
let wiki_id = ""; let wiki_id = "";
let message = ""; let message = "";
let header_id = "";
let speed = "1";
for (const action of actions) { for (const action of actions) {
const descriptor = getUiActionDescriptor(action); const descriptor = getUiActionDescriptor(action);
if (!descriptor) continue; if (!descriptor) continue;
selected[descriptor.option] = true;
switch (descriptor.option) { 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": case "wiki":
selected[descriptor.option] = true;
wiki_id = asString(descriptor.payload[0]); wiki_id = asString(descriptor.payload[0]);
break; break;
case "toast": case "toast":
selected[descriptor.option] = true;
message = asString(descriptor.payload[0]); message = asString(descriptor.payload[0]);
break; break;
case "wiki_header":
header_id = asString(descriptor.payload[0]);
break;
case "playback_speed":
speed = toInputNumber(descriptor.payload[0], "1");
break;
default: default:
break; break;
} }
@@ -1437,10 +1240,9 @@ function buildUiEffectsDraftState(actions: ReplayAction<UIOptionName>[]): UiEffe
return { return {
selected, selected,
visible,
wiki_id, wiki_id,
message, message,
header_id,
speed,
}; };
} }
@@ -1448,13 +1250,17 @@ function buildEmptyUiOptionSelection(): Record<UIOptionName, boolean> {
return { return {
timeline: false, timeline: false,
layer_panel: false, layer_panel: false,
wiki_panel: false,
close_wiki_panel: false,
zoom_panel: false, zoom_panel: false,
wiki: false, wiki: false,
toast: 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 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)); .map((option) => buildUiOptionAction(option, draft));
return [...preserved, ...nextGroupActions]; return [...preserved, ...nextGroupActions];
@@ -1550,11 +1361,22 @@ function buildUiEffectsApplyLabel(
groupOptions: UIOptionName[] groupOptions: UIOptionName[]
) { ) {
const activeLabels = groupOptions const activeLabels = groupOptions
.filter((option) => draft.selected[option]) .filter((option) => {
.map((option) => uiOptionChoices.find((choice) => choice.value === option)?.label || 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 return activeLabels.length > 0
? `${prefix}: apply ${activeLabels.join(", ")}` ? `${prefix}: ${activeLabels.join(", ")}`
: `${prefix}: clear`; : `${prefix}: clear`;
} }
@@ -1565,37 +1387,21 @@ function buildUiOptionAction(
switch (option) { switch (option) {
case "timeline": case "timeline":
case "layer_panel": case "layer_panel":
case "wiki_panel":
case "zoom_panel": case "zoom_panel":
return { return {
function_name: option, function_name: option,
params: [false], params: [draft.selected[option]],
};
case "close_wiki_panel":
return {
function_name: option,
params: [],
}; };
case "wiki": case "wiki":
return { return {
function_name: option, function_name: option,
params: [draft.wiki_id], params: [draft.wiki_id || null],
}; };
case "toast": case "toast":
return { return {
function_name: option, function_name: option,
params: [draft.message], 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) { switch (value) {
case "timeline": case "timeline":
case "layer_panel": case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel": case "zoom_panel":
case "wiki": case "wiki":
case "toast": case "toast":
case "wiki_header":
case "playback_speed":
return value; return value;
case "wiki_panel":
case "close_wiki_panel":
return "wiki";
default: default:
return null; 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"; "use client";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import type { import type { DialogState } from "@/uhm/types/projects";
ReplayPreviewDialog, import type { ReplayPreviewToast } from "@/uhm/lib/replay/useReplayPreview";
ReplayPreviewImage,
ReplayPreviewToast,
} from "@/uhm/lib/replay/useReplayPreview";
type Props = { type Props = {
isPreviewMode: boolean; isPreviewMode: boolean;
isPlaying: boolean; isPlaying: boolean;
title: string; dialog: DialogState | null;
descriptions: string;
subtitle: string | null;
dialog: ReplayPreviewDialog | null;
image: ReplayPreviewImage | null;
toasts: ReplayPreviewToast[]; toasts: ReplayPreviewToast[];
sidebarOpen: boolean; sidebarOpen: boolean;
playbackSpeed: number; playbackSpeed: number;
@@ -30,11 +23,7 @@ type Props = {
export default function ReplayPreviewOverlay({ export default function ReplayPreviewOverlay({
isPreviewMode, isPreviewMode,
isPlaying, isPlaying,
title,
descriptions,
subtitle,
dialog, dialog,
image,
toasts, toasts,
sidebarOpen, sidebarOpen,
playbackSpeed, playbackSpeed,
@@ -46,15 +35,11 @@ export default function ReplayPreviewOverlay({
onResetPreview, onResetPreview,
onExitPreview, onExitPreview,
}: Props) { }: Props) {
const hasNarrativeCard = title.trim().length > 0 || descriptions.trim().length > 0;
const hasWikiPreview = sidebarOpen; const hasWikiPreview = sidebarOpen;
const shouldRender = const shouldRender =
isPreviewMode || isPreviewMode ||
isPlaying || isPlaying ||
hasNarrativeCard ||
Boolean(subtitle) ||
Boolean(dialog) || Boolean(dialog) ||
Boolean(image) ||
Boolean(toasts.length); Boolean(toasts.length);
if (!shouldRender) { if (!shouldRender) {
@@ -70,49 +55,6 @@ export default function ReplayPreviewOverlay({
pointerEvents: "none", 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 ? ( {toasts.length ? (
<div <div
style={{ style={{
@@ -144,7 +86,7 @@ export default function ReplayPreviewOverlay({
</div> </div>
) : null} ) : null}
{image ? ( {dialog?.image_url ? (
<div <div
style={{ style={{
position: "absolute", position: "absolute",
@@ -159,8 +101,8 @@ export default function ReplayPreviewOverlay({
}} }}
> >
<img <img
src={image.url} src={dialog.image_url}
alt={image.caption || "Historical image"} alt={dialog.image_caption || "Historical image"}
style={{ style={{
width: "100%", width: "100%",
display: "block", display: "block",
@@ -169,7 +111,7 @@ export default function ReplayPreviewOverlay({
background: "#020617", background: "#020617",
}} }}
/> />
{image.caption?.trim() ? ( {dialog.image_caption?.trim() ? (
<div <div
style={{ style={{
padding: "10px 12px", padding: "10px 12px",
@@ -178,30 +120,29 @@ export default function ReplayPreviewOverlay({
color: "#cbd5e1", color: "#cbd5e1",
}} }}
> >
{image.caption} {dialog.image_caption}
</div> </div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
{dialog ? ( {dialog && dialog.text?.trim() ? (
<div dialog.avatar?.trim() ? (
style={{ <div
position: "absolute", style={{
left: dialog.side === "right" ? "auto" : 18, position: "absolute",
right: dialog.side === "right" ? 18 : "auto", left: 18,
bottom: subtitle ? 138 : 96, bottom: 96,
maxWidth: 420, maxWidth: 420,
display: "grid", display: "grid",
gap: 10, gap: 10,
gridTemplateColumns: dialog.avatar.trim().length ? "56px 1fr" : "1fr", gridTemplateColumns: "56px 1fr",
alignItems: "start", alignItems: "start",
}} }}
> >
{dialog.avatar.trim().length ? (
<img <img
src={dialog.avatar} src={dialog.avatar}
alt={dialog.speaker || "speaker"} alt="speaker"
style={{ style={{
width: 56, width: 56,
height: 56, height: 56,
@@ -211,65 +152,50 @@ export default function ReplayPreviewOverlay({
background: "#0f172a", background: "#0f172a",
}} }}
/> />
) : null}
<div
style={{
borderRadius: 18,
border: "1px solid rgba(148, 163, 184, 0.24)",
background: "rgba(15, 23, 42, 0.92)",
padding: "14px 16px",
color: "#f8fafc",
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 <div
style={{ style={{
fontSize: 15, borderRadius: 18,
lineHeight: 1.5, border: "1px solid rgba(148, 163, 184, 0.24)",
whiteSpace: "pre-wrap", background: "rgba(15, 23, 42, 0.92)",
padding: "14px 16px",
color: "#f8fafc",
boxShadow: "0 14px 36px rgba(2, 6, 23, 0.38)",
}} }}
> >
{dialog.text} <div
style={{
fontSize: 15,
lineHeight: 1.5,
whiteSpace: "pre-wrap",
}}
>
{dialog.text}
</div>
</div> </div>
</div> </div>
</div> ) : (
) : null} <div
style={{
{subtitle?.trim() ? ( position: "absolute",
<div left: "50%",
style={{ bottom: 90,
position: "absolute", transform: "translateX(-50%)",
left: "50%", maxWidth: 720,
bottom: 90, borderRadius: 18,
transform: "translateX(-50%)", border: "1px solid rgba(148, 163, 184, 0.24)",
maxWidth: 720, background: "rgba(2, 6, 23, 0.84)",
borderRadius: 999, color: "#f8fafc",
border: "1px solid rgba(148, 163, 184, 0.24)", padding: "10px 18px",
background: "rgba(2, 6, 23, 0.84)", fontSize: 14,
color: "#f8fafc", fontWeight: 700,
padding: "10px 18px", lineHeight: 1.45,
fontSize: 14, textAlign: "center",
fontWeight: 700, boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)",
lineHeight: 1.45, }}
textAlign: "center", >
boxShadow: "0 12px 32px rgba(2, 6, 23, 0.28)", {dialog.text}
}} </div>
> )
{subtitle}
</div>
) : null} ) : null}
{isPreviewMode ? ( {isPreviewMode ? (
@@ -10,6 +10,7 @@ import type {
ReplayStage, ReplayStage,
ReplayStep, ReplayStep,
UIOptionName, UIOptionName,
DialogState,
} from "@/uhm/types/projects"; } from "@/uhm/types/projects";
import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState"; import type { UndoAction } from "@/uhm/lib/editor/state/useEditorState";
import { Panel } from "./Panel"; 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 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 = { type StageFormState = {
title: string; title: string;
@@ -97,6 +143,13 @@ export default function ReplayTimelineSidebar({
detail_time_stop: "", detail_time_stop: "",
}); });
const [createStagePanelKey, setCreateStagePanelKey] = useState(0); 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 [openWeightEditorKey, setOpenWeightEditorKey] = useState<string | null>(null);
const [openActionDetailKey, setOpenActionDetailKey] = useState<string | null>(null); const [openActionDetailKey, setOpenActionDetailKey] = useState<string | null>(null);
@@ -153,6 +206,10 @@ export default function ReplayTimelineSidebar({
const handleCreateStage = () => { const handleCreateStage = () => {
if (!replay) return; if (!replay) return;
if (!validateReplayTimeFormat(createStageForm.detail_time_start) ||
!validateReplayTimeFormat(createStageForm.detail_time_stop)) {
return;
}
const nextId = const nextId =
stages.length > 0 stages.length > 0
? Math.max(...stages.map((stage) => stage.id)) + 1 ? Math.max(...stages.map((stage) => stage.id)) + 1
@@ -164,7 +221,7 @@ export default function ReplayTimelineSidebar({
detail_time_stop: createStageForm.detail_time_stop.trim(), detail_time_stop: createStageForm.detail_time_stop.trim(),
steps: [ steps: [
{ {
duration: 1000, duration: 5000,
use_UI_function: [], use_UI_function: [],
use_map_function: [], use_map_function: [],
use_geo_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) => { const handleAddStep = (stageId: number) => {
let nextStepIndex: number | null = null; let nextStepIndex: number | null = null;
const changed = onMutateReplay(`Replay: tạo step cho stage #${stageId}`, (draftReplay) => { const changed = onMutateReplay(`Replay: tạo step cho stage #${stageId}`, (draftReplay) => {
@@ -220,7 +297,7 @@ export default function ReplayTimelineSidebar({
stage.steps = [ stage.steps = [
...stage.steps, ...stage.steps,
{ {
duration: 1000, duration: 5000,
use_UI_function: [], use_UI_function: [],
use_map_function: [], use_map_function: [],
use_geo_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 = ( const handleDeleteAction = (
stageId: number, stageId: number,
stepIndex: 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 ( return (
<aside <aside
style={{ style={{
@@ -452,7 +590,7 @@ export default function ReplayTimelineSidebar({
) : null} ) : null}
</div> </div>
<div style={{ fontSize: 11, color: "#94a3b8" }}> <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>
</div> </div>
</Panel> </Panel>
@@ -465,37 +603,63 @@ export default function ReplayTimelineSidebar({
onChange={(event) => onChange={(event) =>
setCreateStageForm((prev) => ({ ...prev, title: event.target.value })) setCreateStageForm((prev) => ({ ...prev, title: event.target.value }))
} }
placeholder="Title" placeholder="Title (bắt buộc)"
style={inputStyle}
/>
<input
value={createStageForm.detail_time_start}
onChange={(event) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
placeholder="detail_time_start"
style={inputStyle}
/>
<input
value={createStageForm.detail_time_stop}
onChange={(event) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
placeholder="detail_time_stop"
style={inputStyle} style={inputStyle}
/> />
<div>
<input
value={createStageForm.detail_time_start}
onChange={(event) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
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) =>
setCreateStageForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
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 <button
type="button" type="button"
disabled={!isCreateFormValid}
onClick={handleCreateStage} onClick={handleCreateStage}
style={{ style={{
...buttonStyle, ...buttonStyle,
background: "#1d4ed8", background: isCreateFormValid ? "#1d4ed8" : "#1e293b",
color: isCreateFormValid ? "white" : "#64748b",
cursor: isCreateFormValid ? "pointer" : "not-allowed",
border: "none", border: "none",
}} }}
> >
@@ -555,7 +719,7 @@ export default function ReplayTimelineSidebar({
{stage.detail_time_start || "?"} {stage.detail_time_stop || "?"} {stage.detail_time_start || "?"} {stage.detail_time_stop || "?"}
</div> </div>
</button> </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 <button
type="button" type="button"
onClick={() => handleMoveStage(stage.id, -1)} onClick={() => handleMoveStage(stage.id, -1)}
@@ -583,6 +747,17 @@ export default function ReplayTimelineSidebar({
> >
Thêm step Thêm step
</button> </button>
<button
type="button"
onClick={() => handleDuplicateStage(stage.id)}
style={{
...smallButtonStyle(false),
background: "#334155",
border: "none",
}}
>
Copy
</button>
<button <button
type="button" type="button"
onClick={() => handleDeleteStage(stage.id)} onClick={() => handleDeleteStage(stage.id)}
@@ -753,32 +928,38 @@ export default function ReplayTimelineSidebar({
</span> </span>
</div> </div>
</button> </button>
<button <div style={{ display: "grid", gridTemplateColumns: "repeat(2, auto)", gap: 4 }}>
type="button" <button
onClick={() => type="button"
handleDeleteAction( onClick={() =>
stage.id, handleDuplicateAction(
stepIndex, stage.id,
entry.groupKey, stepIndex,
entry.actionIndex, entry.groupKey,
entry.title entry.actionIndex,
) entry.title
} )
style={{ }
padding: "3px 6px", style={actionButtonStyle(false, "#0f766e")}
borderRadius: 6, >
border: "none", Copy
background: "#7f1d1d", </button>
color: "white", <button
cursor: "pointer", type="button"
fontSize: 10, onClick={() =>
fontWeight: 800, handleDeleteAction(
flex: "0 0 auto", stage.id,
alignSelf: "start", stepIndex,
}} entry.groupKey,
> entry.actionIndex,
Xóa entry.title
</button> )
}
style={actionButtonStyle(false, "#7f1d1d")}
>
Xóa
</button>
</div>
</div> </div>
{isActionOpen ? ( {isActionOpen ? (
<div <div
@@ -790,6 +971,20 @@ export default function ReplayTimelineSidebar({
}} }}
> >
{entry.summary} {entry.summary}
<InlineActionParamsEditor
key={actionKey}
action={entry.action}
onApply={(nextParams) =>
handleUpdateActionParams(
stage.id,
stepIndex,
entry.groupKey,
entry.actionIndex,
entry.title,
nextParams
)
}
/>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -801,8 +996,8 @@ export default function ReplayTimelineSidebar({
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: isSelectedStep gridTemplateColumns: isSelectedStep
? "repeat(4, minmax(0, 1fr))" ? "repeat(5, minmax(0, 1fr))"
: "repeat(3, minmax(0, 1fr))", : "repeat(4, minmax(0, 1fr))",
gap: 5, gap: 5,
}} }}
> >
@@ -841,6 +1036,17 @@ export default function ReplayTimelineSidebar({
Weight Weight
</button> </button>
) : null} ) : null}
<button
type="button"
onClick={() => handleDuplicateStep(stage.id, stepIndex)}
style={{
...smallButtonStyle(false),
background: "#334155",
border: "none",
}}
>
Copy
</button>
<button <button
type="button" type="button"
onClick={() => handleDeleteStep(stage.id, stepIndex)} onClick={() => handleDeleteStep(stage.id, stepIndex)}
@@ -922,7 +1128,15 @@ function StageMetadataEditor({
}); });
}, [stage]); }, [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 = () => { const handleApplyStageMetadata = () => {
if (!isEditFormValid) return;
onMutateReplay(`Replay: cập nhật stage #${stage.id}`, (draftReplay) => { onMutateReplay(`Replay: cập nhật stage #${stage.id}`, (draftReplay) => {
const targetStage = draftReplay.detail.find((item) => item.id === stage.id); const targetStage = draftReplay.detail.find((item) => item.id === stage.id);
if (!targetStage) return; if (!targetStage) return;
@@ -940,37 +1154,63 @@ function StageMetadataEditor({
onChange={(event) => onChange={(event) =>
setForm((prev) => ({ ...prev, title: event.target.value })) setForm((prev) => ({ ...prev, title: event.target.value }))
} }
placeholder="Title" placeholder="Title (bắt buộc)"
style={inputStyle}
/>
<input
value={form.detail_time_start}
onChange={(event) =>
setForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
placeholder="detail_time_start"
style={inputStyle}
/>
<input
value={form.detail_time_stop}
onChange={(event) =>
setForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
placeholder="detail_time_stop"
style={inputStyle} style={inputStyle}
/> />
<div>
<input
value={form.detail_time_start}
onChange={(event) =>
setForm((prev) => ({
...prev,
detail_time_start: event.target.value,
}))
}
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) =>
setForm((prev) => ({
...prev,
detail_time_stop: event.target.value,
}))
}
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 <button
type="button" type="button"
disabled={!isEditFormValid}
onClick={handleApplyStageMetadata} onClick={handleApplyStageMetadata}
style={{ style={{
...buttonStyle, ...buttonStyle,
background: "#0f766e", background: isEditFormValid ? "#0f766e" : "#1e293b",
color: isEditFormValid ? "white" : "#64748b",
cursor: isEditFormValid ? "pointer" : "not-allowed",
border: "none", border: "none",
}} }}
> >
@@ -1017,6 +1257,22 @@ function smallButtonStyle(disabled: boolean) {
} as const; } 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({ function InlineStepDurationEditor({
stageId, stageId,
stepIndex, 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 = { type StepActionEntry = {
group: "Narrative" | "Map" | "Geo" | "UI"; group: "Narrative" | "Map" | "Geo" | "UI";
groupKey: ActionGroupKey; groupKey: ActionGroupKey;
actionIndex: number; actionIndex: number;
action: AnyStepAction;
functionName: string; functionName: string;
title: string; title: string;
summary: string; summary: string;
@@ -1084,55 +1406,30 @@ type StepActionEntry = {
const uiOptionLabels: Record<UIOptionName, string> = { const uiOptionLabels: Record<UIOptionName, string> = {
timeline: "Timeline", timeline: "Timeline",
layer_panel: "Layer Panel", layer_panel: "Layer Panel",
wiki_panel: "Wiki Panel",
close_wiki_panel: "Đóng Wiki Panel",
zoom_panel: "Zoom Panel", zoom_panel: "Zoom Panel",
wiki: "Wiki", wiki: "Wiki",
toast: "Toast", toast: "Toast",
wiki_header: "Wiki Header",
playback_speed: "Playback Speed",
}; };
const narrativeFunctionLabels: Record<NarrativeFunctionName, string> = { const narrativeFunctionLabels: Record<NarrativeFunctionName, string> = {
set_title: "Tiêu đề step", set_dialog: "Dialog box",
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ụ đề",
}; };
const mapFunctionLabels: Record<MapFunctionName, string> = { const mapFunctionLabels: Record<MapFunctionName, string> = {
set_camera_view: "Camera view", set_camera_view: "Camera view",
set_time_filter: "Lọc năm", set_timeline_filter: "Lọc timeline",
enable_timeline_filter: "Bật timeline filter", set_labels_visible: "Hiện nhãn map",
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",
}; };
const geoFunctionLabels: Record<GeoFunctionName, string> = { const geoFunctionLabels: Record<GeoFunctionName, string> = {
fly_to_geometry: "Fly tới geo",
fly_to_geometries: "Fly tới geo", fly_to_geometries: "Fly tới geo",
set_geometry_visibility: "Ẩn/hiện geometry", set_geometry_visibility: "Ẩn/hiện geometry",
show_geometries: "Hiện geo", follow_geometries_path: "Follow path",
hide_geometries: "Ẩn geo", hide_others_geometries: "Ẩn geo khác",
fit_to_geometries: "Fit nhiều geo",
orbit_camera_around_geometry: "Orbit quanh geo",
pulse_geometry: "Pulse geometry", pulse_geometry: "Pulse geometry",
animate_dashed_border: "Border nét đứt", animate_dashed_border: "Border nét đứt",
set_geometry_style: "Style geometry", set_geometry_style: "Style geometry",
show_geometry_label: "Label geometry", orbit_camera_around_geometry: "Orbit quanh geo",
follow_geometry_path: "Follow path",
follow_geometries_path: "Follow path",
dim_other_geometries: "Ẩn geo khác",
}; };
function buildStepActionEntries(step: ReplayStep): StepActionEntry[] { function buildStepActionEntries(step: ReplayStep): StepActionEntry[] {
@@ -1159,50 +1456,30 @@ function buildNarrativeActionEntry(
const params = Array.isArray(action.params) ? action.params : []; const params = Array.isArray(action.params) ? action.params : [];
let summary = "Không có tham số."; let summary = "Không có tham số.";
switch (action.function_name) { if (action.function_name === "set_dialog") {
case "set_title": const dialog = params[0] as DialogState | null;
summary = summarizeValue(params[0], "Tiêu đề trống"); if (dialog === null) {
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":
summary = "dialog=null"; summary = "dialog=null";
break; } else {
case "display_historical_image": const parts: string[] = [];
summary = [ if (dialog.text) {
`url=${summarizeValue(params[0], "trống")}`, parts.push(`text=${summarizeValue(dialog.text, "")}`);
`caption=${summarizeValue(params[1], "trống")}`, }
].join(" | "); if (dialog.avatar) {
break; parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`);
case "clear_historical_image": }
summary = "image=null"; if (dialog.image_url) {
break; parts.push(`image=${summarizeValue(dialog.image_url, "")}`);
case "set_step_subtitle": }
summary = summarizeValue(params[0], "Ẩn subtitle"); summary = parts.join(" | ") || "trống";
break; }
case "clear_step_subtitle":
summary = "subtitle=null";
break;
} }
return { return {
group: "Narrative", group: "Narrative",
groupKey: "use_narrow_function", groupKey: "use_narrow_function",
actionIndex, actionIndex,
action,
functionName: action.function_name, functionName: action.function_name,
title: narrativeFunctionLabels[action.function_name], title: narrativeFunctionLabels[action.function_name],
summary, summary,
@@ -1219,39 +1496,22 @@ function buildMapActionEntry(
let summary = "Không có tham số."; let summary = "Không có tham số.";
switch (action.function_name) { switch (action.function_name) {
case "set_time_filter": case "set_timeline_filter":
summary = `year=${summarizeValue(params[0], "trống")}`; summary = `enabled=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break; break;
case "enable_timeline_filter": case "set_labels_visible":
summary = "enabled=true";
break;
case "disable_timeline_filter":
summary = "enabled=false";
break;
case "toggle_labels":
summary = `visible=${Boolean(params[0] ?? true) ? "true" : "false"}`; summary = `visible=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break; 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": case "set_camera_view":
summary = summarizeCameraViewValue(params[0]); summary = summarizeCameraViewValue(params[0]);
break; break;
case "reset_camera_north":
summary = "bearing=0";
break;
} }
return { return {
group: "Map", group: "Map",
groupKey: "use_map_function", groupKey: "use_map_function",
actionIndex, actionIndex,
action,
functionName: action.function_name, functionName: action.function_name,
title: mapFunctionLabels[action.function_name], title: mapFunctionLabels[action.function_name],
summary, summary,
@@ -1268,14 +1528,6 @@ function buildGeoActionEntry(
let summary = "Không có tham số."; let summary = "Không có tham số.";
switch (action.function_name) { 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": case "fly_to_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])}`; summary = `geometry=${summarizeGeometryIdsValue(params[0])}`;
break; break;
@@ -1285,19 +1537,6 @@ function buildGeoActionEntry(
`visible=${Boolean(params[1] ?? true) ? "true" : "false"}`, `visible=${Boolean(params[1] ?? true) ? "true" : "false"}`,
].join(" | "); ].join(" | ");
break; 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": case "orbit_camera_around_geometry":
summary = [ summary = [
`geometry=${summarizeValue(params[0], "trống")}`, `geometry=${summarizeValue(params[0], "trống")}`,
@@ -1333,22 +1572,6 @@ function buildGeoActionEntry(
`line_width=${summarizeValue(params[4], "mặc định")}`, `line_width=${summarizeValue(params[4], "mặc định")}`,
].join(" | "); ].join(" | ");
break; 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": case "follow_geometries_path":
summary = [ summary = [
`geometry=${summarizeGeometryIdsValue(params[0])}`, `geometry=${summarizeGeometryIdsValue(params[0])}`,
@@ -1357,7 +1580,7 @@ function buildGeoActionEntry(
`pitch=${summarizeValue(params[3], "mặc định")}`, `pitch=${summarizeValue(params[3], "mặc định")}`,
].join(" | "); ].join(" | ");
break; break;
case "dim_other_geometries": case "hide_others_geometries":
summary = [ summary = [
`keep=${summarizeGeometryIdsValue(params[0])}`, `keep=${summarizeGeometryIdsValue(params[0])}`,
].join(" | "); ].join(" | ");
@@ -1368,6 +1591,7 @@ function buildGeoActionEntry(
group: "Geo", group: "Geo",
groupKey: "use_geo_function", groupKey: "use_geo_function",
actionIndex, actionIndex,
action,
functionName: action.function_name, functionName: action.function_name,
title: geoFunctionLabels[action.function_name], title: geoFunctionLabels[action.function_name],
summary, summary,
@@ -1386,18 +1610,12 @@ function buildUiActionEntry(
const optionLabel = option ? uiOptionLabels[option] : summarizeValue(action.function_name, "Unknown option"); const optionLabel = option ? uiOptionLabels[option] : summarizeValue(action.function_name, "Unknown option");
let summary = "Không có tham số."; 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"}`; summary = `visible=${Boolean(params[0]) ? "true" : "false"}`;
} else if (option === "close_wiki_panel") {
summary = "visible=false | active_wiki=null";
} else if (option === "wiki") { } else if (option === "wiki") {
summary = `wiki_id=${summarizeValue(params[0], "trống")}`; summary = `wiki_id=${summarizeValue(params[0], "trống")}`;
} else if (option === "toast") { } else if (option === "toast") {
summary = `message=${summarizeValue(params[0], "trống")}`; 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) { } else if (params.length > 0) {
summary = summarizeValue(params, "Không có tham số"); summary = summarizeValue(params, "Không có tham số");
} }
@@ -1406,6 +1624,7 @@ function buildUiActionEntry(
group: "UI", group: "UI",
groupKey: "use_UI_function", groupKey: "use_UI_function",
actionIndex, actionIndex,
action,
functionName: action.function_name, functionName: action.function_name,
title: optionLabel, title: optionLabel,
summary, summary,
@@ -1453,14 +1672,13 @@ function normalizeUiOptionValue(value: unknown): UIOptionName | null {
switch (value) { switch (value) {
case "timeline": case "timeline":
case "layer_panel": case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel": case "zoom_panel":
case "wiki": case "wiki":
case "toast": case "toast":
case "wiki_header":
case "playback_speed":
return value; return value;
case "wiki_panel":
case "close_wiki_panel":
return "wiki";
default: default:
return null; 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") { function summarizeValue(value: unknown, fallback = "trống") {
if (value == null) return fallback; if (value == null) return fallback;
if (typeof value === "string") { 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. * Canonical UI action names trong snapshot hiện tại.
* Không còn wrapper `function_name: "UI"` trong shape mớ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 = export type UIOptionName =
| "timeline" | "timeline"
| "layer_panel" | "layer_panel"
| "wiki_panel"
| "close_wiki_panel"
| "zoom_panel" | "zoom_panel"
| "wiki" | "wiki"
| "toast" | "toast";
| "wiki_header"
| "playback_speed";
export type MapFunctionName = export type MapFunctionName =
| "set_camera_view" | "set_camera_view"
| "set_time_filter" | "set_timeline_filter"
| "enable_timeline_filter" | "set_labels_visible";
| "disable_timeline_filter"
| "toggle_labels"
| "show_labels"
| "hide_labels"
| "show_all_geometries"
| "reset_camera_north";
export type GeoFunctionName = export type GeoFunctionName =
| "fly_to_geometry"
| "fly_to_geometries" | "fly_to_geometries"
| "set_geometry_visibility" | "set_geometry_visibility"
| "show_geometries" | "follow_geometries_path"
| "hide_geometries" | "hide_others_geometries"
| "fit_to_geometries"
| "orbit_camera_around_geometry"
| "pulse_geometry" | "pulse_geometry"
| "animate_dashed_border" | "animate_dashed_border"
| "set_geometry_style" | "set_geometry_style"
| "show_geometry_label" | "orbit_camera_around_geometry";
| "follow_geometry_path"
| "follow_geometries_path"
| "dim_other_geometries";
export type NarrativeFunctionName = export type NarrativeFunctionName =
| "set_title" | "set_dialog";
| "clear_title"
| "set_descriptions"
| "clear_descriptions"
| "show_dialog_box"
| "clear_dialog_box"
| "display_historical_image"
| "clear_historical_image"
| "set_step_subtitle"
| "clear_step_subtitle";
/** /**
* Runtime thật hiện dùng positional array cho params. * Runtime thật hiện dùng positional array cho params.
@@ -243,13 +225,9 @@ export type ReplayCameraViewStateDoc = {
export type ReplayUiParamTupleDocs = { export type ReplayUiParamTupleDocs = {
timeline: [visible: boolean]; timeline: [visible: boolean];
layer_panel: [visible: boolean]; layer_panel: [visible: boolean];
wiki_panel: [visible: boolean];
close_wiki_panel: [];
zoom_panel: [visible: boolean]; zoom_panel: [visible: boolean];
wiki: [wiki_id: string]; wiki: [wiki_id: string | null];
toast: [message: string]; toast: [message: string];
wiki_header: [header_id: string];
playback_speed: [speed: number];
}; };
/** /**
@@ -259,34 +237,21 @@ export type ReplayUiParamTupleDocs = {
export type ReplayMapFunctionParamTupleDocs = { export type ReplayMapFunctionParamTupleDocs = {
set_camera_view: [state: ReplayCameraViewStateDoc]; set_camera_view: [state: ReplayCameraViewStateDoc];
set_time_filter: [year: number]; set_timeline_filter: [enabled: boolean];
enable_timeline_filter: []; set_labels_visible: [visible: boolean];
disable_timeline_filter: [];
toggle_labels: [visible: boolean];
show_labels: [];
hide_labels: [];
show_all_geometries: [];
reset_camera_north: [];
}; };
export type ReplayGeoFunctionParamTupleDocs = { export type ReplayGeoFunctionParamTupleDocs = {
fly_to_geometry: [
geometry_id: string,
zoom?: number,
padding?: number,
duration?: number,
];
fly_to_geometries: [geometry_ids: string[], duration?: number]; fly_to_geometries: [geometry_ids: string[], duration?: number];
set_geometry_visibility: [geometry_ids: string[], visible: boolean]; set_geometry_visibility: [geometry_ids: string[], visible: boolean];
show_geometries: [geometry_ids: string[]]; follow_geometries_path: [
hide_geometries: [geometry_ids: string[]]; geometry_ids: string[],
fit_to_geometries: [geometry_ids: string[], duration?: number]; duration?: number,
orbit_camera_around_geometry: [
geometry_id: string,
zoom?: number, zoom?: number,
pitch?: number, pitch?: number,
revolutions?: number, ];
duration?: number, hide_others_geometries: [
geometry_ids: string[],
]; ];
pulse_geometry: [ pulse_geometry: [
geometry_id: string, geometry_id: string,
@@ -308,48 +273,17 @@ export type ReplayGeoFunctionParamTupleDocs = {
line_color?: string, line_color?: string,
line_width?: number, line_width?: number,
]; ];
show_geometry_label: [ orbit_camera_around_geometry: [
geometry_id: string, geometry_id: string,
text?: string,
color?: string,
size?: number,
];
follow_geometry_path: [
geometry_id: string,
duration?: number,
zoom?: number, zoom?: number,
pitch?: number, pitch?: number,
]; revolutions?: number,
follow_geometries_path: [
geometry_ids: string[],
duration?: number, duration?: number,
zoom?: number,
pitch?: number,
];
dim_other_geometries: [
geometry_ids: string[],
]; ];
}; };
export type ReplayNarrativeParamTupleDocs = { export type ReplayNarrativeParamTupleDocs = {
set_title: [title: string]; set_dialog: [dialog: DialogState | null];
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: [];
}; };
export type ReplayParamTupleDocs = export type ReplayParamTupleDocs =
+15 -12
View File
@@ -1,6 +1,6 @@
# UHM Editor - replay actions catalog # 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`. 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 - hidden geometry ids
- title/descriptions/subtitle/dialog/image/toast - title/descriptions/subtitle/dialog/image/toast
- wiki sidebar/open wiki - wiki sidebar/open wiki
- preview layer panel / zoom controls
- temporary geometry effects
- playback speed - 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 ## 3. UI actions
| Action | Params | Runtime hiện tại | | Action | Params | Runtime hiện tại |
| --- | --- | --- | | --- | --- | --- |
| `timeline` | `[visible: boolean]` | Ẩn/hiện TimelineBar trong preview | | `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 | | `wiki_panel` | `[visible: boolean]` | Mở/đóng wiki sidebar preview |
| `close_wiki_panel` | `[]` | Đóng wiki sidebar và clear active wiki | | `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 | | `wiki` | `[wikiId: string]` | Mở wiki sidebar và active wiki id |
| `toast` | `[message: string]` | Hiện toast tạm thời | | `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 | | `playback_speed` | `[speed: number]` | Đổi tốc độ phát preview |
Legacy shape vẫn được dispatcher đọc: 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 | | `hide_geometries` | `[geometryIds]` | Thêm ids vào hidden set |
| `fit_to_geometries` | `[geometryIds, duration?]` | Legacy: dùng fly/fit tới geometry | | `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 | | `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 | | `pulse_geometry` | `[geometryId, color?, repeat?, duration?]` | Pulse overlay tạm thời, tự cleanup |
| `animate_dashed_border` | `[geometryId, color?, width?, speed?, duration?]` | No-op trong dispatcher hiện tại | | `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?]` | No-op trong dispatcher hiện tại | | `set_geometry_style` | `[geometryIds, fill?, opacity?, stroke?, width?]` | Style overlay trong preview tới khi stop/reset |
| `show_geometry_label` | `[geometryId, text?, color?, size?]` | No-op trong dispatcher hiện tại | | `show_geometry_label` | `[geometryId, text?, color?, size?]` | Hiện label riêng trong preview tới khi stop/reset |
| `follow_geometry_path` | `[geometryId, duration?]` | Legacy: fly theo một path bằng fit/fly | | `follow_geometry_path` | `[geometryId, duration?, zoom?, pitch?]` | Camera chạy theo tọa độ path geometry |
| `follow_geometries_path` | `[geometryIds, duration?, zoom?, padding?]` | Hiện dùng fly/fit tới nhiều 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 | | `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 ## 6. Narrative actions
@@ -163,6 +164,8 @@ Geo shortcuts:
Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6. 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 ## 8. Normalization và migration
Khi load snapshot: 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
>
+301 -111
View File
@@ -922,45 +922,50 @@ function normalizeReplayTargetGeometryIds(replay: unknown, geometryId: string):
function normalizeReplayUiActions(actions: unknown): ReplayAction<UIOptionName>[] { function normalizeReplayUiActions(actions: unknown): ReplayAction<UIOptionName>[] {
if (!Array.isArray(actions)) return []; if (!Array.isArray(actions)) return [];
return actions.flatMap((action) => { const normalized: ReplayAction<UIOptionName>[] = [];
if (!isRecord(action)) return [];
const functionName = action.function_name; for (const action of actions) {
const params = Array.isArray(action.params) ? action.params : []; if (!isRecord(action)) continue;
let functionName = action.function_name;
let params = Array.isArray(action.params) ? action.params : [];
if (functionName === "UI") { if (functionName === "UI") {
const option = normalizeReplayUiOption(params[0]); functionName = params[0];
if (!option) return []; params = params.slice(1);
return [{
function_name: option,
params: params.slice(1),
}];
} }
const option = normalizeReplayUiOption(functionName); switch (functionName) {
if (!option) return []; case "timeline":
return [{ case "layer_panel":
function_name: option, case "zoom_panel":
params, case "toast":
}]; normalized.push({
}); function_name: functionName,
} params: [params[0]],
});
function normalizeReplayUiOption(value: unknown): UIOptionName | null { break;
switch (value) { case "wiki":
case "timeline": normalized.push({
case "layer_panel": function_name: "wiki",
case "wiki_panel": params: [params[0] || null],
case "close_wiki_panel": });
case "zoom_panel": break;
case "wiki": case "close_wiki_panel":
case "toast": case "wiki_panel":
case "wiki_header": if (functionName === "close_wiki_panel" || (functionName === "wiki_panel" && !params[0])) {
case "playback_speed": normalized.push({
return value; function_name: "wiki",
default: params: [null],
return null; });
}
break;
default:
break;
}
} }
return normalized;
} }
function normalizeReplayMapAndGeoActions( function normalizeReplayMapAndGeoActions(
@@ -983,21 +988,180 @@ function normalizeReplayMapAndGeoActions(
const functionName = action.function_name; const functionName = action.function_name;
const params = Array.isArray(action.params) ? action.params : []; 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); switch (functionName) {
if (geoFunctionName) { // --- Map Functions ---
normalizedGeoActions.push({ case "set_camera_view":
function_name: geoFunctionName, normalizedMapActions.push({
params, 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: "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>[] { function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<NarrativeFunctionName>[] {
if (!Array.isArray(actions)) return []; if (!Array.isArray(actions)) return [];
return actions.flatMap((action) => { let avatar = "";
if (!isRecord(action)) return []; let text = "";
let image_url = "";
let image_caption = "";
let hasDialog = false;
let isCleared = false;
const functionName = normalizeReplayNarrativeFunctionName(action.function_name); for (const action of actions) {
if (!functionName) return []; if (!isRecord(action)) continue;
return [{ const functionName = action.function_name;
function_name: functionName, const params = Array.isArray(action.params) ? action.params : [];
params: Array.isArray(action.params) ? action.params : [],
}];
});
}
function normalizeReplayNarrativeFunctionName(value: unknown): NarrativeFunctionName | null { switch (functionName) {
switch (value) { case "set_dialog": {
case "set_title": const data = params[0];
case "clear_title": if (data && typeof data === "object") {
case "set_descriptions": hasDialog = true;
case "clear_descriptions": avatar = String((data as any).avatar || avatar);
case "show_dialog_box": text = String((data as any).text || text);
case "clear_dialog_box": image_url = String((data as any).image_url || image_url);
case "display_historical_image": image_caption = String((data as any).image_caption || image_caption);
case "clear_historical_image": } else if (data === null) {
case "set_step_subtitle": isCleared = true;
case "clear_step_subtitle": }
return value; break;
default: }
return null; case "show_dialog_box":
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[] { 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;
}
+21 -11
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. * 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. * Hầu hết các hàm yêu cầu instance của MapLibre GL.
*/ */
export const mapActions = { export const mapActions = {
// Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing) // Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing)
set_camera_view: ( set_camera_view: (
@@ -48,11 +47,6 @@ export const mapActions = {
map.easeTo(nextView); 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. // Di chuyển mượt mà đến một hoặc nhiều geometry dựa trên ID.
fly_to_geometries: ( fly_to_geometries: (
map: maplibregl.Map, map: maplibregl.Map,
@@ -120,7 +114,7 @@ export const mapActions = {
}, },
// Ẩn/hiện nhãn (labels) trên bản đồ // Ẩ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(); const style = map.getStyle();
if (!style) return; if (!style) return;
style.layers.forEach(layer => { style.layers.forEach(layer => {
@@ -131,10 +125,26 @@ export const mapActions = {
}); });
}, },
// Thay đổi bộ lọc thời gian trên bản đồ get_label_visibility: (map: maplibregl.Map): Record<string, "visible" | "none"> => {
set_time_filter: (onYearChange: (year: number) => void, year: number) => { const style = map.getStyle();
onYearChange(year); 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( function normalizeReplayCenter(
+8 -72
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. * Các hàm điều khiển nội dung dẫn chuyện và thuyết minh trong Replay.
*/ */
export const narrativeActions = { export const narrativeActions = {
// Đặt tiêu đề cho cảnh hiện tại // Đặt kịch bản đối thoại/hình ảnh dẫn chuyện mới (hoặc null để xóa)
set_title: (setTitle: (t: string) => void, title: string) => { set_dialog: (
setTitle(title); setDialog: (data: DialogState | null) => void,
}, dialog: DialogState | null
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
) => { ) => {
setDialog({ avatar, text, side, speaker }); setDialog(dialog);
}, }
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;
}
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);
},
}; };
+195 -199
View File
@@ -6,6 +6,7 @@ import type {
NarrativeFunctionName, NarrativeFunctionName,
ReplayAction, ReplayAction,
UIOptionName, UIOptionName,
DialogState,
} from "@/uhm/types/projects"; } from "@/uhm/types/projects";
import { mapActions } from "./mapActions"; import { mapActions } from "./mapActions";
import { uiActions } from "./uiActions"; import { uiActions } from "./uiActions";
@@ -18,10 +19,13 @@ import { narrativeActions } from "./narrativeActions";
export interface ReplayControllers { export interface ReplayControllers {
map: maplibregl.Map | null; map: maplibregl.Map | null;
draft: FeatureCollection; draft: FeatureCollection;
effects: any; // Type helper for ReplayMapEffects to avoid circular dependency
// UI Setters // UI Setters
setTimelineVisible: (v: boolean) => void; setTimelineVisible: (v: boolean) => void;
setTimelineFilterEnabled: (v: boolean) => void; setTimelineFilterEnabled: (v: boolean) => void;
setLayerPanelVisible: (v: boolean) => void;
setZoomPanelVisible: (v: boolean) => void;
setSidebarOpen: (v: boolean) => void; setSidebarOpen: (v: boolean) => void;
onSelectWiki: (id: string) => void; onSelectWiki: (id: string) => void;
addToast: (msg: string) => void; addToast: (msg: string) => void;
@@ -33,16 +37,8 @@ export interface ReplayControllers {
showAllGeometries: () => void; showAllGeometries: () => void;
// Narrative Setters // Narrative Setters
setTitle: (t: string) => void; setDialog: (dialog: DialogState | null) => void;
setDescriptions: (d: string) => void; getDialog?: () => DialogState | null;
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;
} }
/** /**
@@ -51,26 +47,22 @@ export interface ReplayControllers {
*/ */
export const dispatchReplayAction = ( export const dispatchReplayAction = (
controllers: ReplayControllers, controllers: ReplayControllers,
action: ReplayAction<UIOptionName | MapFunctionName | GeoFunctionName | NarrativeFunctionName> | { rawAction: ReplayAction<any> | { function_name: string; params: unknown[] }
function_name: "UI";
params: unknown[];
}
) => { ) => {
const action = normalizeSingleAction(rawAction);
if (!action) return;
const { function_name, params } = action; const { function_name, params } = action;
// 1. Nhóm Map Actions // 1. Nhóm Map/Geo Actions
if (controllers.map) { if (controllers.map) {
const map = controllers.map; const map = controllers.map;
switch (function_name as MapFunctionName | GeoFunctionName) { switch (function_name) {
case "set_camera_view": case "set_camera_view":
mapActions.set_camera_view(map, normalizeCameraViewState(params[0])); mapActions.set_camera_view(map, normalizeCameraViewState(params[0]));
return; return;
case "fly_to_geometry": case "set_labels_visible":
mapActions.fly_to_geometry( mapActions.set_labels_visible(map, asBooleanValue(params[0], true));
map,
asStringValue(params[0]),
controllers.draft,
);
return; return;
case "fly_to_geometries": case "fly_to_geometries":
mapActions.fly_to_geometries( mapActions.fly_to_geometries(
@@ -80,33 +72,6 @@ export const dispatchReplayAction = (
asNumberValue(params[1], 2200) asNumberValue(params[1], 2200)
); );
return; 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": { case "set_geometry_visibility": {
const geometryIds = toStringValues(params[0]); const geometryIds = toStringValues(params[0]);
const visible = asBooleanValue(params[1], true); const visible = asBooleanValue(params[1], true);
@@ -117,12 +82,49 @@ export const dispatchReplayAction = (
} }
return; return;
} }
case "fit_to_geometries": case "follow_geometries_path":
mapActions.fly_to_geometries( controllers.effects.followGeometriesPath(
map, map,
toStringValues(params[0]),
controllers.draft, 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; return;
case "orbit_camera_around_geometry": case "orbit_camera_around_geometry":
@@ -136,161 +138,158 @@ export const dispatchReplayAction = (
asNumberValue(params[4], 5000) asNumberValue(params[4], 5000)
); );
return; 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 // 2. Nhóm UI Actions
const uiDescriptor = getUiActionDescriptor(function_name, params); switch (function_name) {
if (uiDescriptor) { case "timeline":
const { option, payload } = uiDescriptor; uiActions.timeline(controllers.setTimelineVisible, asBooleanValue(params[0], true));
switch (option) { return;
case "timeline": case "layer_panel":
uiActions.timeline(controllers.setTimelineVisible, Boolean(payload[0] ?? false)); uiActions.layer_panel(controllers.setLayerPanelVisible, asBooleanValue(params[0], true));
return; return;
case "layer_panel": case "zoom_panel":
uiActions.layer_panel(Boolean(payload[0] ?? false)); uiActions.zoom_panel(controllers.setZoomPanelVisible, asBooleanValue(params[0], true));
return; return;
case "wiki_panel": case "wiki":
uiActions.wiki_panel(controllers.setSidebarOpen, Boolean(payload[0] ?? false)); uiActions.wiki(
return; controllers.setSidebarOpen,
case "close_wiki_panel": controllers.onSelectWiki,
uiActions.close_wiki_panel(controllers.setSidebarOpen, controllers.onSelectWiki); params[0] as string | null
return; );
case "zoom_panel": return;
uiActions.zoom_panel(Boolean(payload[0] ?? false)); case "toast":
return; uiActions.toast(
case "wiki": controllers.addToast,
uiActions.wiki( typeof params[0] === "string" ? params[0] : ""
controllers.setSidebarOpen, );
controllers.onSelectWiki, return;
typeof payload[0] === "string" ? payload[0] : ""
);
return;
case "toast":
uiActions.toast(
controllers.addToast,
typeof payload[0] === "string" ? payload[0] : ""
);
return;
case "wiki_header":
uiActions.wiki_header(typeof payload[0] === "string" ? payload[0] : "");
return;
case "playback_speed":
uiActions.playback_speed(
controllers.setPlaybackSpeed,
typeof payload[0] === "number" ? payload[0] : 1
);
return;
}
} }
// 3. Nhóm Narrative Actions // 3. Nhóm Narrative Actions
switch (function_name as NarrativeFunctionName) { if (function_name === "set_dialog") {
case "set_title": const nextDialog = params[0] as DialogState | null;
narrativeActions.set_title(controllers.setTitle, asStringValue(params[0])); if (nextDialog === null) {
return; narrativeActions.set_dialog(controllers.setDialog, null);
case "clear_title": } else {
narrativeActions.clear_title(controllers.setTitle); // merge with existing dialog state if available
return; const existing = controllers.getDialog ? controllers.getDialog() : null;
case "set_descriptions": narrativeActions.set_dialog(controllers.setDialog, {
narrativeActions.set_descriptions(controllers.setDescriptions, asStringValue(params[0])); avatar: nextDialog.avatar ?? existing?.avatar ?? "",
return; text: nextDialog.text ?? existing?.text ?? "",
case "clear_descriptions": image_url: nextDialog.image_url ?? existing?.image_url,
narrativeActions.clear_descriptions(controllers.setDescriptions); image_caption: nextDialog.image_caption ?? existing?.image_caption,
return; });
case "show_dialog_box": }
narrativeActions.show_dialog_box( return;
controllers.setDialog,
asStringValue(params[0]),
asStringValue(params[1]),
normalizeDialogSide(params[2]),
nullableStringValue(params[3])
);
return;
case "clear_dialog_box":
narrativeActions.clear_dialog_box(controllers.setDialog);
return;
case "display_historical_image":
narrativeActions.display_historical_image(
controllers.setImage,
asStringValue(params[0]),
nullableStringValue(params[1])
);
return;
case "clear_historical_image":
narrativeActions.clear_historical_image(controllers.setImage);
return;
case "set_step_subtitle":
narrativeActions.set_step_subtitle(controllers.setSubtitle, asStringValue(params[0]));
return;
case "clear_step_subtitle":
narrativeActions.clear_step_subtitle(controllers.setSubtitle);
return;
} }
}; };
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 "timeline":
case "layer_panel": case "layer_panel":
case "wiki_panel":
case "close_wiki_panel":
case "zoom_panel": case "zoom_panel":
case "wiki":
case "toast": 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": 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: default:
return null; 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) { function normalizeCameraViewState(value: unknown) {
if (!value || typeof value !== "object") { if (!value || typeof value !== "object") {
return {}; return {};
@@ -330,21 +329,17 @@ function asStringValue(value: unknown) {
return typeof value === "string" ? value : value == null ? "" : String(value); 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) { function asBooleanValue(value: unknown, fallback: boolean) {
return typeof value === "boolean" ? value : fallback; return typeof value === "boolean" ? value : fallback;
} }
function normalizeDialogSide(value: unknown): "left" | "right" {
return value === "right" ? "right" : "left";
}
function asOptionalNumberValue(value: unknown) { 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) { function asNumberValue(value: unknown, fallback: number) {
@@ -353,7 +348,8 @@ function asNumberValue(value: unknown, fallback: number) {
function toStringValues(value: unknown) { function toStringValues(value: unknown) {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return []; const single = asStringValue(value).trim();
return single.length > 0 ? [single] : [];
} }
return value return value
.map((item) => asStringValue(item).trim()) .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));
}
+15 -36
View File
@@ -8,50 +8,29 @@ export const uiActions = {
setTimelineVisible(visible); setTimelineVisible(visible);
}, },
// Ẩn/hiện panel layer. Runtime hiện chưa có controller riêng nên tạm no-op. // Ẩn/hiện panel layer trong preview.
layer_panel: (visible: boolean) => { layer_panel: (setLayerPanelVisible: (v: boolean) => void, visible: boolean) => {
void visible; setLayerPanelVisible(visible);
return;
}, },
// Ẩn/hiện panel wiki. // Ẩn/hiện cụm control zoom/projection trên map preview.
wiki_panel: (setSidebarOpen: (v: boolean) => void, visible: boolean) => { zoom_panel: (setZoomPanelVisible: (v: boolean) => void, visible: boolean) => {
setSidebarOpen(visible); setZoomPanelVisible(visible);
}, },
close_wiki_panel: ( // Mở Wiki và tìm đến một ID cụ thể. Nếu wikiId là null/rỗng thì đóng panel wiki.
setSidebarOpen: (v: boolean) => void, wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string | null) => {
onSelectWiki: (id: string) => void, if (!wikiId) {
) => { setSidebarOpen(false);
setSidebarOpen(false); onSelectWiki("");
onSelectWiki(""); } else {
}, setSidebarOpen(true);
onSelectWiki(wikiId);
// Ẩ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) => {
setSidebarOpen(true);
onSelectWiki(wikiId);
}, },
// Hiển thị thông báo (toast) // Hiển thị thông báo (toast)
toast: (addToast: (msg: string) => void, message: string) => { toast: (addToast: (msg: string) => void, message: string) => {
addToast(message); 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);
}
}; };
+93 -85
View File
@@ -2,21 +2,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FeatureCollection } from "@/uhm/types/geo"; 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 { dispatchReplayAction } from "./replayDispatcher";
import { mapActions } from "./mapActions"; import { mapActions } from "./mapActions";
import { createReplayMapEffects } from "./replayMapEffects";
export type ReplayPreviewDialog = {
avatar: string;
text: string;
side: "left" | "right";
speaker?: string | null;
};
export type ReplayPreviewImage = {
url: string;
caption?: string | null;
};
export type ReplayPreviewToast = { export type ReplayPreviewToast = {
id: number; id: number;
@@ -27,6 +16,9 @@ type PreviewBaseline = {
timelineYear: number; timelineYear: number;
timelineFilterEnabled: boolean; timelineFilterEnabled: boolean;
timelineVisible: boolean; timelineVisible: boolean;
layerPanelVisible: boolean;
zoomPanelVisible: boolean;
labelVisibility: Record<string, "visible" | "none">;
mapViewState: { mapViewState: {
center: { lng: number; lat: number }; center: { lng: number; lat: number };
zoom: number; zoom: number;
@@ -67,13 +59,16 @@ export function useReplayPreview({
onSelectStep, onSelectStep,
}: UseReplayPreviewOptions) { }: UseReplayPreviewOptions) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [title, setTitle] = useState(""); const [dialog, setDialog] = useState<DialogState | null>(null);
const [descriptions, setDescriptions] = useState(""); const dialogRef = useRef<DialogState | null>(null);
const [subtitle, setSubtitle] = useState<string | null>(null); const setDialogWithRef = useCallback((d: DialogState | null) => {
const [dialog, setDialog] = useState<ReplayPreviewDialog | null>(null); dialogRef.current = d;
const [image, setImage] = useState<ReplayPreviewImage | null>(null); setDialog(d);
}, []);
const [toasts, setToasts] = useState<ReplayPreviewToast[]>([]); const [toasts, setToasts] = useState<ReplayPreviewToast[]>([]);
const [timelineVisible, setTimelineVisible] = useState(true); const [timelineVisible, setTimelineVisible] = useState(true);
const [layerPanelVisible, setLayerPanelVisible] = useState(false);
const [zoomPanelVisible, setZoomPanelVisible] = useState(true);
const [timelineYear, setTimelineYear] = useState(initialTimelineYear); const [timelineYear, setTimelineYear] = useState(initialTimelineYear);
const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(initialTimelineFilterEnabled); const [timelineFilterEnabled, setTimelineFilterEnabled] = useState(initialTimelineFilterEnabled);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -94,6 +89,7 @@ export function useReplayPreview({
const toastIdRef = useRef(0); const toastIdRef = useRef(0);
const toastTimeoutsRef = useRef<number[]>([]); const toastTimeoutsRef = useRef<number[]>([]);
const baselineRef = useRef<PreviewBaseline | null>(null); const baselineRef = useRef<PreviewBaseline | null>(null);
const effects = useMemo(() => createReplayMapEffects(), []);
const flatSteps = useMemo(() => flattenReplaySteps(replay), [replay]); const flatSteps = useMemo(() => flattenReplaySteps(replay), [replay]);
@@ -102,24 +98,26 @@ export function useReplayPreview({
}, [playbackSpeed]); }, [playbackSpeed]);
useEffect(() => { useEffect(() => {
setTimelineYear(initialTimelineYear); const map = getMapInstance();
setTimelineFilterEnabled(initialTimelineFilterEnabled);
setTimelineVisible(true);
baselineRef.current = { baselineRef.current = {
timelineYear: initialTimelineYear, timelineYear: initialTimelineYear,
timelineFilterEnabled: initialTimelineFilterEnabled, timelineFilterEnabled: initialTimelineFilterEnabled,
timelineVisible: true, timelineVisible: true,
layerPanelVisible: false,
zoomPanelVisible: true,
labelVisibility: map ? mapActions.get_label_visibility(map) : {},
mapViewState: initialMapViewState, mapViewState: initialMapViewState,
}; };
}, [initialMapViewState, initialTimelineFilterEnabled, initialTimelineYear, replay?.id]); }, [getMapInstance, initialMapViewState, initialTimelineFilterEnabled, initialTimelineYear, replay?.id]);
useEffect(() => { useEffect(() => {
return () => { return () => {
runIdRef.current += 1; runIdRef.current += 1;
effects.clear(getMapInstance());
toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId));
toastTimeoutsRef.current = []; toastTimeoutsRef.current = [];
}; };
}, []); }, [effects, getMapInstance]);
const clearToasts = useCallback(() => { const clearToasts = useCallback(() => {
toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId));
@@ -128,18 +126,17 @@ export function useReplayPreview({
}, []); }, []);
const resetPresentation = useCallback(() => { const resetPresentation = useCallback(() => {
setTitle(""); setDialogWithRef(null);
setDescriptions("");
setSubtitle(null);
setDialog(null);
setImage(null);
setSidebarOpen(false); setSidebarOpen(false);
setActiveWikiId(null); setActiveWikiId(null);
setLayerPanelVisible(false);
setZoomPanelVisible(true);
playbackSpeedRef.current = 1; playbackSpeedRef.current = 1;
setPlaybackSpeed(1); setPlaybackSpeed(1);
setHiddenGeometryIds([]); setHiddenGeometryIds([]);
effects.clear(getMapInstance());
clearToasts(); clearToasts();
}, [clearToasts]); }, [clearToasts, effects, getMapInstance, setDialogWithRef]);
const addToast = useCallback((message: string) => { const addToast = useCallback((message: string) => {
const text = String(message || "").trim(); const text = String(message || "").trim();
@@ -167,12 +164,17 @@ export function useReplayPreview({
} }
setTimelineVisible(baseline.timelineVisible); setTimelineVisible(baseline.timelineVisible);
setLayerPanelVisible(baseline.layerPanelVisible);
setZoomPanelVisible(baseline.zoomPanelVisible);
setTimelineYear(baseline.timelineYear); setTimelineYear(baseline.timelineYear);
setTimelineFilterEnabled(baseline.timelineFilterEnabled); setTimelineFilterEnabled(baseline.timelineFilterEnabled);
const map = getMapInstance(); const map = getMapInstance();
if (map) { if (map) {
mapActions.toggle_labels(map, true); mapActions.restore_label_visibility(map, baseline.labelVisibility);
if (baseline.mapViewState) { if (baseline.mapViewState) {
map.setProjection({
type: baseline.mapViewState.projection === "globe" ? "globe" : "mercator",
});
mapActions.set_camera_view(map, { mapActions.set_camera_view(map, {
center: baseline.mapViewState.center, center: baseline.mapViewState.center,
zoom: baseline.mapViewState.zoom, zoom: baseline.mapViewState.zoom,
@@ -195,62 +197,65 @@ export function useReplayPreview({
}, [restorePreviewState]); }, [restorePreviewState]);
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null); const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
controllersRef.current = { useEffect(() => {
map: getMapInstance(), controllersRef.current = {
draft, map: getMapInstance(),
setTimelineVisible, draft,
setTimelineFilterEnabled, effects,
setSidebarOpen, setTimelineVisible,
onSelectWiki: (id) => { setTimelineFilterEnabled,
const nextId = String(id || "").trim(); setLayerPanelVisible,
setActiveWikiId(nextId || null); setZoomPanelVisible,
}, setSidebarOpen,
addToast, onSelectWiki: (id) => {
setPlaybackSpeed: (nextSpeed) => { const nextId = String(id || "").trim();
const safe = Number.isFinite(nextSpeed) && nextSpeed > 0 ? nextSpeed : 1; setActiveWikiId(nextId || null);
playbackSpeedRef.current = safe; },
setPlaybackSpeed(safe); addToast,
}, setPlaybackSpeed: (nextSpeed) => {
onYearChange: setTimelineYear, const safe = Number.isFinite(nextSpeed) && nextSpeed > 0 ? nextSpeed : 1;
showGeometries: (ids) => { playbackSpeedRef.current = safe;
const nextIds = normalizeIdList(ids); setPlaybackSpeed(safe);
if (!nextIds.length) return; },
setHiddenGeometryIds((prev) => prev.filter((id) => !nextIds.includes(id))); onYearChange: setTimelineYear,
}, showGeometries: (ids) => {
hideGeometries: (ids) => { const nextIds = normalizeIdList(ids);
const nextIds = normalizeIdList(ids); if (!nextIds.length) return;
if (!nextIds.length) return; setHiddenGeometryIds((prev) => prev.filter((id) => !nextIds.includes(id)));
setHiddenGeometryIds((prev) => { },
const seen = new Set(prev); hideGeometries: (ids) => {
for (const id of nextIds) { const nextIds = normalizeIdList(ids);
seen.add(id); if (!nextIds.length) return;
} setHiddenGeometryIds((prev) => {
return Array.from(seen); const seen = new Set(prev);
}); for (const id of nextIds) {
}, seen.add(id);
showOnlyGeometries: (ids) => { }
const keepIds = new Set(normalizeIdList(ids)); return Array.from(seen);
if (!keepIds.size) return; });
setHiddenGeometryIds( },
draft.features showOnlyGeometries: (ids) => {
.map((feature) => String(feature.properties.id)) const keepIds = new Set(normalizeIdList(ids));
.filter((id) => !keepIds.has(id)) if (!keepIds.size) return;
); setHiddenGeometryIds(
}, draft.features
showAllGeometries: () => { .map((feature) => String(feature.properties.id))
setHiddenGeometryIds([]); .filter((id) => !keepIds.has(id))
}, );
setTitle, },
setDescriptions, showAllGeometries: () => {
setDialog, setHiddenGeometryIds([]);
setImage, },
setSubtitle, setDialog: setDialogWithRef,
}; getDialog: () => dialogRef.current,
};
}, [addToast, draft, effects, getMapInstance]);
const playFromIndex = useCallback(async (startIndex: number) => { const playFromIndex = useCallback(async (startIndex: number) => {
if (!flatSteps.length) return; if (!flatSteps.length) return;
const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex)); const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex));
resetPresentation(); resetPresentation();
effects.clear(getMapInstance());
setTimelineVisible(true); setTimelineVisible(true);
setTimelineYear(initialTimelineYear); setTimelineYear(initialTimelineYear);
setTimelineFilterEnabled(initialTimelineFilterEnabled); setTimelineFilterEnabled(initialTimelineFilterEnabled);
@@ -272,6 +277,8 @@ export function useReplayPreview({
const controllers = controllersRef.current; const controllers = controllersRef.current;
if (!controllers) return; if (!controllers) return;
controllers.map = getMapInstance();
controllers.draft = draft;
const actions = [ const actions = [
...current.step.use_narrow_function, ...current.step.use_narrow_function,
@@ -294,6 +301,9 @@ export function useReplayPreview({
restorePreviewState(); restorePreviewState();
}, [ }, [
flatSteps, flatSteps,
draft,
effects,
getMapInstance,
initialTimelineFilterEnabled, initialTimelineFilterEnabled,
initialTimelineYear, initialTimelineYear,
onSelectStep, onSelectStep,
@@ -312,13 +322,11 @@ export function useReplayPreview({
return { return {
isPlaying, isPlaying,
title,
descriptions,
subtitle,
dialog, dialog,
image,
toasts, toasts,
timelineVisible, timelineVisible,
layerPanelVisible,
zoomPanelVisible,
timelineYear, timelineYear,
timelineFilterEnabled, timelineFilterEnabled,
sidebarOpen, sidebarOpen,
+15 -35
View File
@@ -87,57 +87,37 @@ export type EditorSnapshot = {
replays?: BattleReplay[]; 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 = export type UIOptionName =
| "timeline" // Ẩn/hiện timeline | "timeline" // Ẩn/hiện timeline
| "layer_panel" // Ẩn/hiện panel layer | "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 | "zoom_panel" // Ẩn/hiện nút zoom
| "wiki" // Mở/chọn wiki | "wiki" // Mở/chọn wiki (null/rỗng để đóng)
| "toast" // Hiển thị toast | "toast"; // Hiển thị toast
| "wiki_header" // Focus header trong wiki
| "playback_speed"; // Thay đổi tốc độ phát replay
export type MapFunctionName = export type MapFunctionName =
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing) | "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 đồ | "set_timeline_filter" // Bật/tắt lọc timeline
| "enable_timeline_filter" // Bật timeline filter | "set_labels_visible"; // Ẩn/hiện nhãn (labels) trên bản đồ
| "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
export type GeoFunctionName = 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 | "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 | "set_geometry_visibility" // n/hiện một hoặc nhiều geometry
| "show_geometries" // Hiện một hoặc nhiều geometry | "follow_geometries_path" // Cho camera bám theo chuỗi path geometry
| "hide_geometries" // Ẩn một hoặc nhiều geometry | "hide_others_geometries" // Ẩn các geometry ngoài target set, chỉ giữ geo focus
| "fit_to_geometries" // Legacy: fit camera theo nhiều geometry
| "orbit_camera_around_geometry" // Quay camera quanh một geometry
| "pulse_geometry" // Hiệu ứng pulse/emphasis cho geometry | "pulse_geometry" // Hiệu ứng pulse/emphasis cho geometry
| "animate_dashed_border" // Hiệu ứng border nét đứt chuyển động | "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 | "set_geometry_style" // Đổi style trực tiếp của geometry
| "show_geometry_label" // Hiện label riêng cho geometry | "orbit_camera_around_geometry"; // Quay camera quanh một 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
export type NarrativeFunctionName = export type NarrativeFunctionName =
| "set_title" // Đặt tiêu đề cho bước replay | "set_dialog"; // Đặt kịch bản đối thoại/hình ảnh dẫn chuyện mới (hoặc null để xóa)
| "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
export type ReplayAction<T> = { export type ReplayAction<T> = {
function_name: T; function_name: T;