diff --git a/src/uhm/components/editor/ReplayEffectsSidebar.tsx b/src/uhm/components/editor/ReplayEffectsSidebar.tsx index c486fd8..1cf6064 100644 --- a/src/uhm/components/editor/ReplayEffectsSidebar.tsx +++ b/src/uhm/components/editor/ReplayEffectsSidebar.tsx @@ -201,7 +201,7 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = { text: string; image_url?: string; } = { - text: asString(values.text), + text: asString(values.text).replace(/ /g, " ").replace(/\u00a0/g, " "), }; if (values.image_url) { data.image_url = asString(values.image_url); @@ -882,6 +882,26 @@ function GeoFunctionShortcutPanel({ ) } /> + + onAppendActions( + [{ function_name: "hide_all_geometries", params: [] }], + "Geo: ẩn toàn bộ" + ) + } + /> + + onAppendActions( + [{ function_name: "show_all_geometries", params: [] }], + "Geo: hiện toàn bộ" + ) + } + /> +
{baseLabel}
- +
); } @@ -1717,7 +1737,12 @@ function replaceUiActionsByGroup( const nextGroupActions = groupOptions .filter((option) => { - if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") { + if ( + option === "timeline" || + option === "layer_panel" || + option === "zoom_panel" || + option === "wiki" + ) { return true; } return draft.selected[option]; @@ -1734,7 +1759,12 @@ function buildUiEffectsApplyLabel( ) { const activeLabels = groupOptions .filter((option) => { - if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") { + if ( + option === "timeline" || + option === "layer_panel" || + option === "zoom_panel" || + option === "wiki" + ) { return true; } return draft.selected[option]; @@ -1744,6 +1774,9 @@ function buildUiEffectsApplyLabel( if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") { return draft.selected[option] ? `Show ${label}` : `Hide ${label}`; } + if (option === "wiki") { + return draft.wiki_id ? `Open ${label}: ${draft.wiki_id}` : `Close ${label}`; + } return label; }); diff --git a/src/uhm/components/editor/ReplayTimelineSidebar.tsx b/src/uhm/components/editor/ReplayTimelineSidebar.tsx index 330babc..4fa8d99 100644 --- a/src/uhm/components/editor/ReplayTimelineSidebar.tsx +++ b/src/uhm/components/editor/ReplayTimelineSidebar.tsx @@ -240,17 +240,12 @@ function getBackgroundGeometryIdsFromReplay(replay: BattleReplay | null): Set stage.id)) + 1 : 0; - const bgIds = getBackgroundGeometryIdsFromReplay(replay); - const geometriesToHide = (replay.target_geometry_ids || []).filter( - (id: string) => !bgIds.has(String(id)) - ); - const initialGeoFunctions = []; - if (geometriesToHide.length > 0) { - initialGeoFunctions.push({ - function_name: "set_geometry_visibility" as const, - params: [geometriesToHide, false], - }); - } + const initialGeoFunctions = [ + { + function_name: "hide_all_geometries" as const, + params: [], + }, + ]; const nextStage: ReplayStage = { id: nextId, @@ -583,22 +578,13 @@ function getBackgroundGeometryIdsFromReplay(replay: BattleReplay | null): Set Play từ step @@ -1435,6 +1421,8 @@ const geoFunctionLabels: Record = { orbit_camera_around_geometry: "Orbit quanh geo", set_as_background_geometries: "Đặt làm background", remove_from_background_geometries: "Loại khỏi background", + hide_all_geometries: "Ẩn toàn bộ geo", + show_all_geometries: "Hiện toàn bộ geo", }; function buildStepActionEntries(step: ReplayStep): StepActionEntry[] { @@ -1591,6 +1579,12 @@ function buildGeoActionEntry( case "remove_from_background_geometries": summary = `geometry=${summarizeGeometryIdsValue(params[0])}`; break; + case "hide_all_geometries": + summary = "Ẩn toàn bộ geo ngoại trừ background"; + break; + case "show_all_geometries": + summary = "Hiện toàn bộ geo"; + break; } return { diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts index 28360ef..83b829f 100644 --- a/src/uhm/components/map/mapUtils.ts +++ b/src/uhm/components/map/mapUtils.ts @@ -338,7 +338,7 @@ export function buildPolygonLabelFeatureCollection( continue; } - const labelPoint = getPolygonLabelPoint(feature.geometry); + const labelPoint = getPolygonLabelPoint(feature.geometry, feature.properties.id); if (!labelPoint) continue; const labelFeature: Feature = { @@ -485,6 +485,32 @@ export function getGeometryRepresentativePoint(geometry: Geometry): Coordinate | } const pathArrowGeometriesCache = new WeakMap(); +const pathArrowGeometriesL2Cache = new Map(); +const polygonLabelPointL2Cache = new Map(); +const MAX_L2_CACHE_SIZE = 1000; + +export function clearGeometryCaches() { + pathArrowGeometriesL2Cache.clear(); + polygonLabelPointL2Cache.clear(); +} + +function getGeometryFingerprint(geometry: Geometry): string { + const coords = geometry.coordinates; + if (!Array.isArray(coords)) return geometry.type; + + if (typeof coords[0] === "number") { + return `${geometry.type}:${coords[0]},${coords[1]}`; + } + + const flat = coords.flat(4) as number[]; + if (flat.length === 0) return geometry.type; + + const len = flat.length; + const first = flat[0]; + const last = flat[len - 1]; + const mid = flat[Math.floor(len / 2)]; + return `${geometry.type}:${len}:${first}:${mid}:${last}`; +} export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection { const features: Feature[] = []; @@ -494,15 +520,30 @@ export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureC let arrowGeometries = pathArrowGeometriesCache.get(feature.geometry); if (!arrowGeometries) { - arrowGeometries = []; - const coordinateGroups = getLineCoordinateGroups(feature.geometry); - const featureType = getFeatureSemanticType(feature); - const isRetreat = featureType === "retreat_route"; - for (const coordinates of coordinateGroups) { - const geometry = buildPathArrowGeometry(coordinates, isRetreat); - if (geometry) arrowGeometries.push(geometry); + const featureId = feature.properties?.id; + const fingerprint = getGeometryFingerprint(feature.geometry); + const cacheKey = featureId ? `${featureId}:${fingerprint}` : null; + + if (cacheKey && pathArrowGeometriesL2Cache.has(cacheKey)) { + arrowGeometries = pathArrowGeometriesL2Cache.get(cacheKey)!; + pathArrowGeometriesCache.set(feature.geometry, arrowGeometries); + } else { + arrowGeometries = []; + const coordinateGroups = getLineCoordinateGroups(feature.geometry); + const featureType = getFeatureSemanticType(feature); + const isRetreat = featureType === "retreat_route"; + for (const coordinates of coordinateGroups) { + const geometry = buildPathArrowGeometry(coordinates, isRetreat); + if (geometry) arrowGeometries.push(geometry); + } + pathArrowGeometriesCache.set(feature.geometry, arrowGeometries); + if (cacheKey) { + if (pathArrowGeometriesL2Cache.size >= MAX_L2_CACHE_SIZE) { + pathArrowGeometriesL2Cache.clear(); + } + pathArrowGeometriesL2Cache.set(cacheKey, arrowGeometries); + } } - pathArrowGeometriesCache.set(feature.geometry, arrowGeometries); } for (const geometry of arrowGeometries) { @@ -1163,10 +1204,20 @@ function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] { const polygonLabelPointCache = new WeakMap(); -function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { +function getPolygonLabelPoint(geometry: Geometry, featureId?: string | number): Coordinate | null { if (polygonLabelPointCache.has(geometry)) { return polygonLabelPointCache.get(geometry)!; } + + const fingerprint = getGeometryFingerprint(geometry); + const cacheKey = featureId ? `${featureId}:${fingerprint}` : null; + + if (cacheKey && polygonLabelPointL2Cache.has(cacheKey)) { + const result = polygonLabelPointL2Cache.get(cacheKey)!; + polygonLabelPointCache.set(geometry, result); + return result; + } + let result: Coordinate | null = null; if (geometry.type === "Polygon") { result = getPolygonLabelCandidate(geometry.coordinates)?.point || null; @@ -1181,7 +1232,14 @@ function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { } result = best?.point || null; } + polygonLabelPointCache.set(geometry, result); + if (cacheKey) { + if (polygonLabelPointL2Cache.size >= MAX_L2_CACHE_SIZE) { + polygonLabelPointL2Cache.clear(); + } + polygonLabelPointL2Cache.set(cacheKey, result); + } return result; } diff --git a/src/uhm/doc/commit_snapshot.ts b/src/uhm/doc/commit_snapshot.ts index 7cb7173..7cf61e4 100644 --- a/src/uhm/doc/commit_snapshot.ts +++ b/src/uhm/doc/commit_snapshot.ts @@ -169,7 +169,9 @@ export type GeoFunctionName = | "set_geometry_style" | "orbit_camera_around_geometry" | "set_as_background_geometries" - | "remove_from_background_geometries"; + | "remove_from_background_geometries" + | "hide_all_geometries" + | "show_all_geometries"; export type NarrativeFunctionName = | "set_dialog"; @@ -283,6 +285,8 @@ export type ReplayGeoFunctionParamTupleDocs = { remove_from_background_geometries: [ geometry_ids: string[], ]; + hide_all_geometries: []; + show_all_geometries: []; }; export type ReplayNarrativeParamTupleDocs = { diff --git a/src/uhm/lib/editor/snapshot/editorSnapshot.ts b/src/uhm/lib/editor/snapshot/editorSnapshot.ts index 389482b..472708e 100644 --- a/src/uhm/lib/editor/snapshot/editorSnapshot.ts +++ b/src/uhm/lib/editor/snapshot/editorSnapshot.ts @@ -1193,6 +1193,18 @@ function normalizeReplayMapAndGeoActions( params, }); break; + case "hide_all_geometries": + normalizedGeoActions.push({ + function_name: "hide_all_geometries", + params: [], + }); + break; + case "show_all_geometries": + normalizedGeoActions.push({ + function_name: "show_all_geometries", + params: [], + }); + break; default: break; } diff --git a/src/uhm/lib/replay/replayDispatcher.ts b/src/uhm/lib/replay/replayDispatcher.ts index a69b3ad..63f6c7e 100644 --- a/src/uhm/lib/replay/replayDispatcher.ts +++ b/src/uhm/lib/replay/replayDispatcher.ts @@ -36,6 +36,7 @@ export interface ReplayControllers { hideGeometries: (ids: string[]) => void; showOnlyGeometries: (ids: string[]) => void; showAllGeometries: () => void; + hideAllGeometries: () => void; setAsBackgroundGeometries: (ids: string[]) => void; removeFromBackgroundGeometries: (ids: string[]) => void; @@ -98,6 +99,12 @@ export const dispatchReplayAction = ( case "hide_others_geometries": controllers.showOnlyGeometries(toStringValues(params[0])); return; + case "hide_all_geometries": + controllers.hideAllGeometries(); + return; + case "show_all_geometries": + controllers.showAllGeometries(); + return; case "pulse_geometry": controllers.effects.pulseGeometry( map, @@ -247,8 +254,10 @@ function normalizeSingleAction(action: unknown): ReplayAction { runIdRef.current += 1; restorePreviewState(); + clearGeometryCaches(); }, [restorePreviewState]); const stopPreview = useCallback(() => { @@ -217,6 +220,7 @@ export function useReplayPreview({ isPlayingRef.current = false; setIsPlaying(false); getMapInstance()?.stop(); + clearGeometryCaches(); }, [getMapInstance]); useEffect(() => { @@ -224,7 +228,10 @@ export function useReplayPreview({ const timeoutId = window.setTimeout(() => { restorePreviewState(); }, 0); - return () => window.clearTimeout(timeoutId); + return () => { + window.clearTimeout(timeoutId); + clearGeometryCaches(); + }; }, [replay?.id, restorePreviewState]); const controllers = useMemo[0]>(() => ({ @@ -290,6 +297,13 @@ export function useReplayPreview({ showAllGeometries: () => { setHiddenGeometryIds([]); }, + hideAllGeometries: () => { + setHiddenGeometryIds( + draft.features + .map((feature) => String(feature.properties.id)) + .filter((id) => !backgroundIdsRef.current.has(id)) + ); + }, setDialog: setDialogWithRef, getDialog: () => dialogRef.current, }), [ @@ -301,13 +315,12 @@ export function useReplayPreview({ ]); const controllersRef = useRef[0] | null>(null); - useEffect(() => { - controllersRef.current = controllers; - }, [controllers]); + controllersRef.current = controllers; const playFromIndex = useCallback(async (startIndex: number) => { if (!flatSteps.length) return; + clearGeometryCaches(); const map = getMapInstance(); if (map) { map.stop(); // Stop ongoing camera animations/transitions immediately diff --git a/src/uhm/types/projects.ts b/src/uhm/types/projects.ts index 38ce1f0..243d21d 100644 --- a/src/uhm/types/projects.ts +++ b/src/uhm/types/projects.ts @@ -113,7 +113,9 @@ export type GeoFunctionName = | "set_geometry_style" // Đổi style trực tiếp của geometry | "orbit_camera_around_geometry" // Quay camera quanh một geometry | "set_as_background_geometries" // Đặt các geometry làm background (luôn hiện) - | "remove_from_background_geometries"; // Loại các geometry khỏi background + | "remove_from_background_geometries" // Loại các geometry khỏi background + | "hide_all_geometries" // Ẩn toàn bộ geometry (ngoại trừ background) + | "show_all_geometries"; // Hiện toàn bộ geometry export type NarrativeFunctionName = | "set_dialog"; // Đặt kịch bản đối thoại/hình ảnh dẫn chuyện mới (hoặc null để xóa)