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