From 8fc9456a6ade575785f8a8f3c12431b80c732891 Mon Sep 17 00:00:00 2001 From: taDuc Date: Thu, 14 May 2026 03:41:58 +0700 Subject: [PATCH] init: replay mode --- src/app/editor/[id]/page.tsx | 722 ++++++++++-------- src/uhm/components/Map.tsx | 85 ++- src/uhm/components/editor/ModeHint.tsx | 7 + src/uhm/components/map/mapUtils.ts | 5 + src/uhm/components/map/useMapInteraction.ts | 6 +- src/uhm/components/map/useMapLayers.ts | 2 +- src/uhm/lib/editor/session/sessionTypes.ts | 3 +- src/uhm/lib/map/engines/selectingEngine.ts | 23 +- .../{geotypes/index.ts => geotypeLayers.ts} | 60 +- .../lib/map/styles/geotypes/attack_route.ts | 2 +- src/uhm/lib/map/styles/geotypes/battle.ts | 2 +- src/uhm/lib/map/styles/geotypes/bridge.ts | 2 +- src/uhm/lib/map/styles/geotypes/capital.ts | 2 +- src/uhm/lib/map/styles/geotypes/castle.ts | 2 +- src/uhm/lib/map/styles/geotypes/city.ts | 2 +- .../lib/map/styles/geotypes/civilization.ts | 2 +- src/uhm/lib/map/styles/geotypes/country.ts | 2 +- .../lib/map/styles/geotypes/defense_line.ts | 2 +- src/uhm/lib/map/styles/geotypes/empire.ts | 2 +- src/uhm/lib/map/styles/geotypes/fortress.ts | 2 +- .../lib/map/styles/geotypes/invasion_route.ts | 2 +- src/uhm/lib/map/styles/geotypes/kingdom.ts | 2 +- .../map/styles/geotypes/migration_route.ts | 2 +- .../map/styles/geotypes/person_activity.ts | 2 +- .../map/styles/geotypes/person_birthplace.ts | 2 +- .../map/styles/geotypes/person_deathplace.ts | 2 +- src/uhm/lib/map/styles/geotypes/port.ts | 2 +- .../lib/map/styles/geotypes/rebellion_zone.ts | 2 +- .../lib/map/styles/geotypes/refugee_route.ts | 2 +- .../lib/map/styles/geotypes/retreat_route.ts | 2 +- src/uhm/lib/map/styles/geotypes/ruin.ts | 2 +- .../lib/map/styles/geotypes/shipping_route.ts | 2 +- src/uhm/lib/map/styles/geotypes/state.ts | 2 +- src/uhm/lib/map/styles/geotypes/temple.ts | 2 +- .../lib/map/styles/geotypes/trade_route.ts | 2 +- src/uhm/lib/map/styles/geotypes/war.ts | 2 +- .../styles/{geotypes => shared}/lineLabels.ts | 0 .../styles/{geotypes => shared}/pointStyle.ts | 0 .../{geotypes => shared}/polygonLabels.ts | 0 .../{geotypes => shared}/styleBuilders.ts | 0 src/uhm/types/commit_snapshot.ts | 48 ++ 41 files changed, 619 insertions(+), 396 deletions(-) rename src/uhm/lib/map/styles/{geotypes/index.ts => geotypeLayers.ts} (59%) rename src/uhm/lib/map/styles/{geotypes => shared}/lineLabels.ts (100%) rename src/uhm/lib/map/styles/{geotypes => shared}/pointStyle.ts (100%) rename src/uhm/lib/map/styles/{geotypes => shared}/polygonLabels.ts (100%) rename src/uhm/lib/map/styles/{geotypes => shared}/styleBuilders.ts (100%) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 6c28e29..b24e219 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -97,9 +97,12 @@ export default function Page() { const localCreatedEntityIdsRef = useRef>(new Set()); const lastSelectedFeatureIdRef = useRef(null); + const [replayFeatureId, setReplayFeatureId] = useState(null); + const [hideOutside, setHideOutside] = useState(false); + const { mode, - setMode, + setMode: internalSetMode, initialData, setInitialData, isSaving, @@ -409,6 +412,52 @@ export default function Page() { restoreCommit, } = sectionCommands; + const setMode = useCallback((m: EditorMode, featureId?: string | number) => { + if (m === "replay" && featureId) { + setReplayFeatureId(featureId); + } else if (m !== "replay") { + setReplayFeatureId(null); + setHideOutside(false); + } + internalSetMode(m); + }, [internalSetMode]); + + const onSetMode = setMode; + + const effectiveGeometryVisibility = useMemo(() => { + const visibility: Record = { ...geometryVisibility }; + + if (mode === "replay" && replayFeatureId) { + // Ẩn chính geo được chọn làm replay + visibility[String(replayFeatureId)] = false; + + if (hideOutside) { + // Tìm feature đang replay để lấy danh sách binding + const replayFeature = editor.draft.features.find( + (f) => String(f.properties.id) === String(replayFeatureId) + ); + const boundIds = new Set(); + if (replayFeature?.properties?.binding) { + replayFeature.properties.binding.forEach((id: string) => boundIds.add(String(id))); + } + + // Ẩn tất cả các geo không nằm trong binding + editor.draft.features.forEach((f) => { + const fid = String(f.properties.id); + if (fid !== String(replayFeatureId) && !boundIds.has(fid)) { + visibility[fid] = false; + } + }); + } + } + + return visibility; + }, [geometryVisibility, mode, replayFeatureId, hideOutside, editor.draft.features]); + + const onToggleHideOutside = useCallback(() => { + setHideOutside((prev) => !prev); + }, []); + const openProject = useCallback(async () => { if (!projectId) return; try { @@ -693,7 +742,7 @@ export default function Page() { useEffect(() => { if (!selectedFeatureIds || selectedFeatureIds.length === 0) return; - const stillExistIds = selectedFeatureIds.filter(id => + const stillExistIds = selectedFeatureIds.filter(id => timelineVisibleDraft.features.some(feature => String(feature.properties.id) === String(id)) ); if (stillExistIds.length !== selectedFeatureIds.length) { @@ -964,7 +1013,7 @@ export default function Page() { bindingPatches, nextChecked ? "Bind geometry vào GEO" : "Unbind geometry khỏi GEO" ); - + // Assume selectedFeature (the first one) reflects the representative binding in UI const firstFeaturePrevBindings = normalizeFeatureBindingIds(selectedFeatures[0]); const firstFeatureHas = firstFeaturePrevBindings.includes(id); @@ -1223,36 +1272,40 @@ export default function Page() { return (
- + {mode !== "replay" && ( + <> + - { - setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); - }} - /> + { + setLeftPanelWidth((prev) => clampNumber(prev + deltaX, 220, 520)); + }} + /> + + )} {blockedPendingSubmissionId ? (
@@ -1301,6 +1354,7 @@ export default function Page() { {isBackgroundVisibilityReady ? ( ) : (
)} - + {mode !== "replay" && ( + + )}
) : blockedPendingSubmissionId ? null : ( // Wiki-only mode: avoid mounting Map/Timeline (WebGL + geometry fetching) to reduce lag.
)} - { - // dragging handle (between map and right panel): moving right increases right panel width - setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); - }} - /> + {mode !== "replay" && ( + <> + { + // dragging handle (between map and right panel): moving right increases right panel width + setRightPanelWidth((prev) => clampNumber(prev - deltaX, 260, 720)); + }} + /> - { - setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false })); - }} - width={rightPanelWidth} - topContent={ -
- { - setSearchKind(next); - setSearchQuery(""); - setSearchQueryDraft(""); - }} - query={searchQuery} - onQueryChange={setSearchQuery} - onLocalQueryChange={setSearchQueryDraft} - /> + { + setGeometryVisibility((prev) => ({ ...prev, [typeKey]: prev[typeKey] === false })); + }} + width={rightPanelWidth} + topContent={ +
+ { + setSearchKind(next); + setSearchQuery(""); + setSearchQueryDraft(""); + }} + query={searchQuery} + onQueryChange={setSearchQuery} + onLocalQueryChange={setSearchQueryDraft} + /> - {searchKind === "entity" && searchQueryDraft.trim().length > 0 ? ( -
-
-
Entity Results
-
- {isEntitySearchLoading ? "Searching…" : `${entitySearchResults.length} results`} -
-
-
- {entitySearchResults.slice(0, 8).map((e) => ( -
-
-
- {e.name} -
-
- {e.id} -
+ {searchKind === "entity" && searchQueryDraft.trim().length > 0 ? ( +
+
+
Entity Results
+
+ {isEntitySearchLoading ? "Searching…" : `${entitySearchResults.length} results`}
-
- ))} - {!isEntitySearchLoading && entitySearchResults.length === 0 ? ( -
No results.
- ) : null} -
-
- ) : null} - - {searchKind === "wiki" && searchQueryDraft.trim().length > 0 ? ( -
-
-
Wiki Results
-
- {isWikiSearching ? "Searching…" : `${wikiSearchResults.length} results`} -
-
-
- {wikiSearchResults.slice(0, 8).map((w) => ( -
-
-
- {(w.title || "").trim() || "Untitled wiki"} -
-
- {w.id} -
-
- -
- ))} - {!isWikiSearching && wikiSearchResults.length === 0 ? ( -
No results.
- ) : null} -
-
- ) : null} - - {searchKind === "geo" && searchQueryDraft.trim().length > 0 ? ( -
-
-
Geo Results
-
- {isGeoSearching ? "Searching…" : `${geoSearchResults.length} entities`} -
-
-
- {geoSearchResults.slice(0, 6).map((item) => ( -
-
-
-
- {item.name?.trim() || item.entity_id} -
-
- {item.entity_id} -
-
-
- {Array.isArray(item.geometries) ? item.geometries.length : 0} geos -
-
- {item.description?.trim() ? ( -
- {item.description.trim()} -
- ) : null} - {Array.isArray(item.geometries) && item.geometries.length ? ( -
- {item.geometries.map((geo) => ( -
-
-
- #{geo.id} -
-
- type: {geo.type || "unknown"}{" "} - {geo.time_start != null || geo.time_end != null - ? `| time: ${geo.time_start ?? "?"} → ${geo.time_end ?? "?"}` - : ""} -
-
- +
+ {entitySearchResults.slice(0, 8).map((e) => ( +
+
+
+ {e.name}
- ))} +
+ {e.id} +
+
+
- ) : ( -
- No geometry linked. -
- )} + ))} + {!isEntitySearchLoading && entitySearchResults.length === 0 ? ( +
No results.
+ ) : null}
- ))} - {!isGeoSearching && geoSearchResults.length === 0 ? ( -
No results.
- ) : null} -
+
+ ) : null} + + {searchKind === "wiki" && searchQueryDraft.trim().length > 0 ? ( +
+
+
Wiki Results
+
+ {isWikiSearching ? "Searching…" : `${wikiSearchResults.length} results`} +
+
+
+ {wikiSearchResults.slice(0, 8).map((w) => ( +
+
+
+ {(w.title || "").trim() || "Untitled wiki"} +
+
+ {w.id} +
+
+ +
+ ))} + {!isWikiSearching && wikiSearchResults.length === 0 ? ( +
No results.
+ ) : null} +
+
+ ) : null} + + {searchKind === "geo" && searchQueryDraft.trim().length > 0 ? ( +
+
+
Geo Results
+
+ {isGeoSearching ? "Searching…" : `${geoSearchResults.length} entities`} +
+
+
+ {geoSearchResults.slice(0, 6).map((item) => ( +
+
+
+
+ {item.name?.trim() || item.entity_id} +
+
+ {item.entity_id} +
+
+
+ {Array.isArray(item.geometries) ? item.geometries.length : 0} geos +
+
+ {item.description?.trim() ? ( +
+ {item.description.trim()} +
+ ) : null} + {Array.isArray(item.geometries) && item.geometries.length ? ( +
+ {item.geometries.map((geo) => ( +
+
+
+ #{geo.id} +
+
+ type: {geo.type || "unknown"}{" "} + {geo.time_start != null || geo.time_end != null + ? `| time: ${geo.time_start ?? "?"} → ${geo.time_end ?? "?"}` + : ""} +
+
+ +
+ ))} +
+ ) : ( +
+ No geometry linked. +
+ )} +
+ ))} + {!isGeoSearching && geoSearchResults.length === 0 ? ( +
No results.
+ ) : null} +
+
+ ) : null} + + + + + + + + {!wikiOnly && selectedFeature ? ( + + ) : null}
- ) : null} - - - - - - - - {!wikiOnly && selectedFeature ? ( - - ) : null} -
- } - /> + } + /> + + )}
); } diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index e6f3cbc..2f48cee 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -26,6 +26,7 @@ type MapProps = { geometryVisibility?: Record; selectedFeatureIds: (string | number)[]; onSelectFeatureIds: (ids: (string | number)[]) => void; + onSetMode?: (mode: EditorMode, featureId?: string | number) => void; labelContextDraft?: FeatureCollection; onCreateFeature?: (feature: FeatureCollection["features"][number]) => void; onDeleteFeature?: (id: string | number) => void; @@ -40,10 +41,13 @@ type MapProps = { focusFeatureCollection?: FeatureCollection | null; focusRequestKey?: string | number | null; focusPadding?: number | import("maplibre-gl").PaddingOptions; + hideOutside?: boolean; + onToggleHideOutside?: () => void; }; export default function Map({ mode, + onSetMode, draft, backgroundVisibility, geometryVisibility, @@ -63,10 +67,13 @@ export default function Map({ focusFeatureCollection = null, focusRequestKey = null, focusPadding, + hideOutside = false, + onToggleHideOutside, }: MapProps) { const modeRef = useRef(mode); const draftRef = useRef(draft); const onSelectFeatureIdsRef = useRef(onSelectFeatureIds); + const onSetModeRef = useRef(onSetMode); const onHoverFeatureChangeRef = useRef(onHoverFeatureChange); const onCreateRef = useRef(onCreateFeature); const onDeleteRef = useRef(onDeleteFeature); @@ -75,6 +82,7 @@ export default function Map({ useEffect(() => { modeRef.current = mode; }, [mode]); useEffect(() => { draftRef.current = draft; }, [draft]); useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); + useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); useEffect(() => { onHoverFeatureChangeRef.current = onHoverFeatureChange; }, [onHoverFeatureChange]); useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); @@ -106,6 +114,7 @@ export default function Map({ allowGeometryEditing, selectedFeatureIds, onSelectFeatureIdsRef, + onSetModeRef, onCreateRef, onDeleteRef, onUpdateRef, @@ -150,6 +159,14 @@ export default function Map({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMapLoaded]); + useEffect(() => { + const map = mapRef.current; + if (map && isMapLoaded) { + // Trigger resize after a short delay to allow layout to settle + setTimeout(() => map.resize(), 100); + } + }, [mode, isMapLoaded]); + return (
@@ -198,7 +215,7 @@ export default function Map({ >
+ {mode === "replay" && ( + <> + + +
+ + Hide Outside + +
+
+
+
+
+ + )} +