feat: implement replay system with action dispatchers and context switching between main and playback modes

This commit is contained in:
taDuc
2026-05-15 19:39:02 +07:00
parent 3682f25282
commit 4c81862bb4
15 changed files with 595 additions and 59 deletions
+50 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { type CSSProperties, useEffect, useRef } from "react";
import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle, useCallback } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
@@ -19,6 +19,16 @@ export type MapHoverPayload = {
lngLat: { lng: number; lat: number };
};
export type MapHandle = {
getViewState: () => {
center: { lng: number; lat: number };
zoom: number;
pitch: number;
bearing: number;
projection: string;
} | null;
};
type MapProps = {
mode: EditorMode;
draft: FeatureCollection;
@@ -45,7 +55,7 @@ type MapProps = {
onToggleHideOutside?: () => void;
};
export default function Map({
const Map = forwardRef<MapHandle, MapProps>(function Map({
mode,
onSetMode,
draft,
@@ -69,7 +79,7 @@ export default function Map({
focusPadding,
hideOutside = false,
onToggleHideOutside,
}: MapProps) {
}, ref) {
const modeRef = useRef<MapProps["mode"]>(mode);
const draftRef = useRef<FeatureCollection>(draft);
const onSelectFeatureIdsRef = useRef(onSelectFeatureIds);
@@ -100,8 +110,21 @@ export default function Map({
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
getViewState,
} = useMapInstance();
useImperativeHandle(ref, () => ({
getViewState,
}), [getViewState]);
const handleLogViewState = useCallback(() => {
const state = getViewState();
console.log("Current Map View State:", state);
if (state) {
alert(`Captured View State:\nCenter: ${state.center.lng.toFixed(4)}, ${state.center.lat.toFixed(4)}\nZoom: ${state.zoom.toFixed(2)}\nPitch: ${state.pitch.toFixed(1)}°\nBearing: ${state.bearing.toFixed(1)}°\nProjection: ${state.projection}`);
}
}, [getViewState]);
const {
editingEngineRef,
setupMapInteractions,
@@ -251,6 +274,27 @@ export default function Map({
Thoát Replay Edit
</button>
<button
type="button"
onClick={handleLogViewState}
title="Capture current map view state"
style={{
...zoomButtonStyle,
width: "auto",
padding: "0 12px",
fontSize: "12px",
fontWeight: 700,
background: "#1e293b",
color: "#38bdf8",
border: "1px solid #334155",
borderRadius: "999px",
cursor: "pointer",
marginRight: "8px",
}}
>
Capture View
</button>
<div
onClick={onToggleHideOutside}
style={{
@@ -406,7 +450,9 @@ export default function Map({
</div>
</div>
);
}
});
export default Map;
const zoomButtonStyle: React.CSSProperties = {
width: "28px",
@@ -20,6 +20,7 @@ type Props = {
isEntitySubmitting: boolean;
onApplyGeometryMetadata: () => Promise<{ ok: boolean; error?: string }>;
changeCount: number;
onReplayEdit?: (id: string | number) => void;
};
export default function SelectedGeometryPanel({
@@ -30,6 +31,7 @@ export default function SelectedGeometryPanel({
isEntitySubmitting,
onApplyGeometryMetadata,
changeCount,
onReplayEdit,
}: Props) {
const [collapsed, setCollapsed] = useState(false);
const [geoApplyFeedback, setGeoApplyFeedback] = useState<
@@ -201,6 +203,20 @@ export default function SelectedGeometryPanel({
>
Apply
</button>
{onReplayEdit && selectedFeatures.length > 0 && (
<button
type="button"
onClick={() => onReplayEdit(selectedFeatures[0].properties.id)}
style={{
...primaryGeometryButtonStyle,
background: "#1e293b",
border: "1px solid #334155",
color: "#38bdf8",
}}
>
Replay Edit
</button>
)}
{visibleGeoApplyFeedback ? (
<div
style={{
+15
View File
@@ -125,6 +125,20 @@ export function useMapInstance() {
setZoomLevel(next);
}, [zoomBounds]);
const getViewState = useCallback(() => {
const map = mapRef.current;
if (!map) return null;
const center = map.getCenter();
const projection = map.getProjection();
return {
center: { lng: center.lng, lat: center.lat },
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
projection: String(projection?.type || "mercator"),
};
}, []);
return {
mapRef,
containerRef,
@@ -138,5 +152,6 @@ export function useMapInstance() {
geolocationCenteredRef,
handleZoomByStep,
handleZoomSliderChange,
getViewState,
};
}
@@ -64,6 +64,8 @@ export function useMapInteraction({
useEffect(() => {
if (mode !== "select" || !selectedFeatureIds || selectedFeatureIds.length === 0) {
editingEngineRef.current?.clearEditing();
// Clear the internal selection state of the select engine to stay in sync with React state
engineBindingsRef.current.select?.clearSelection?.(false);
}
}, [mode, selectedFeatureIds]);