feat: add hide/show all geometry actions and implement geometry cache optimization for map rendering
Build and Release / release (push) Successful in 1m2s

This commit is contained in:
taDuc
2026-06-17 15:17:02 +07:00
parent 99c3efe678
commit 59de951edd
8 changed files with 171 additions and 46 deletions
@@ -201,7 +201,7 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
text: string; text: string;
image_url?: string; image_url?: string;
} = { } = {
text: asString(values.text), text: asString(values.text).replace(/ /g, " ").replace(/\u00a0/g, " "),
}; };
if (values.image_url) { if (values.image_url) {
data.image_url = asString(values.image_url); data.image_url = asString(values.image_url);
@@ -882,6 +882,26 @@ function GeoFunctionShortcutPanel({
) )
} }
/> />
<ShortcutButton
label="Ẩn Toàn Bộ"
tone="slate"
onClick={() =>
onAppendActions(
[{ function_name: "hide_all_geometries", params: [] }],
"Geo: ẩn toàn bộ"
)
}
/>
<ShortcutButton
label="Hiện Toàn Bộ"
tone="green"
onClick={() =>
onAppendActions(
[{ function_name: "show_all_geometries", params: [] }],
"Geo: hiện toàn bộ"
)
}
/>
<ShortcutButton <ShortcutButton
label="Đặt làm BG" label="Đặt làm BG"
tone="teal" tone="teal"
@@ -1410,7 +1430,7 @@ function FieldInput({
if (field.kind === "rich-text") { if (field.kind === "rich-text") {
return ( return (
<label style={{ display: "grid", gap: 6 }}> <div style={{ display: "grid", gap: 6 }}>
{baseLabel} {baseLabel}
<div style={{ background: "#0b1220", borderRadius: 6, border: "1px solid #334155" }} className="dark"> <div style={{ background: "#0b1220", borderRadius: 6, border: "1px solid #334155" }} className="dark">
<ReactQuillEditor <ReactQuillEditor
@@ -1420,7 +1440,7 @@ function FieldInput({
modules={quillModules} modules={quillModules}
/> />
</div> </div>
</label> </div>
); );
} }
@@ -1717,7 +1737,12 @@ function replaceUiActionsByGroup(
const nextGroupActions = groupOptions const nextGroupActions = groupOptions
.filter((option) => { .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 true;
} }
return draft.selected[option]; return draft.selected[option];
@@ -1734,7 +1759,12 @@ function buildUiEffectsApplyLabel(
) { ) {
const activeLabels = groupOptions const activeLabels = groupOptions
.filter((option) => { .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 true;
} }
return draft.selected[option]; return draft.selected[option];
@@ -1744,6 +1774,9 @@ function buildUiEffectsApplyLabel(
if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") { if (option === "timeline" || option === "layer_panel" || option === "zoom_panel") {
return draft.selected[option] ? `Show ${label}` : `Hide ${label}`; return draft.selected[option] ? `Show ${label}` : `Hide ${label}`;
} }
if (option === "wiki") {
return draft.wiki_id ? `Open ${label}: ${draft.wiki_id}` : `Close ${label}`;
}
return label; return label;
}); });
@@ -240,17 +240,12 @@ function getBackgroundGeometryIdsFromReplay(replay: BattleReplay | null): Set<st
? Math.max(...stages.map((stage) => stage.id)) + 1 ? Math.max(...stages.map((stage) => stage.id)) + 1
: 0; : 0;
const bgIds = getBackgroundGeometryIdsFromReplay(replay); const initialGeoFunctions = [
const geometriesToHide = (replay.target_geometry_ids || []).filter( {
(id: string) => !bgIds.has(String(id)) function_name: "hide_all_geometries" as const,
); params: [],
const initialGeoFunctions = []; },
if (geometriesToHide.length > 0) { ];
initialGeoFunctions.push({
function_name: "set_geometry_visibility" as const,
params: [geometriesToHide, false],
});
}
const nextStage: ReplayStage = { const nextStage: ReplayStage = {
id: nextId, id: nextId,
@@ -583,22 +578,13 @@ function getBackgroundGeometryIdsFromReplay(replay: BattleReplay | null): Set<st
<button <button
type="button" type="button"
onClick={onPlayPreviewFromSelection} onClick={onPlayPreviewFromSelection}
disabled={!replay || selectedStage == null || selectedStepIndex == null} disabled={true /* Tạm thời khóa nút này */}
style={{ style={{
...buttonStyle, ...buttonStyle,
background: background: "#1e293b",
!replay || selectedStage == null || selectedStepIndex == null
? "#1e293b"
: "#0f766e",
border: "none", border: "none",
cursor: cursor: "not-allowed",
!replay || selectedStage == null || selectedStepIndex == null opacity: 0.7,
? "not-allowed"
: "pointer",
opacity:
!replay || selectedStage == null || selectedStepIndex == null
? 0.7
: 1,
}} }}
> >
Play từ step Play từ step
@@ -1435,6 +1421,8 @@ const geoFunctionLabels: Record<GeoFunctionName, string> = {
orbit_camera_around_geometry: "Orbit quanh geo", orbit_camera_around_geometry: "Orbit quanh geo",
set_as_background_geometries: "Đặt làm background", set_as_background_geometries: "Đặt làm background",
remove_from_background_geometries: "Loại khỏi 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[] { function buildStepActionEntries(step: ReplayStep): StepActionEntry[] {
@@ -1591,6 +1579,12 @@ function buildGeoActionEntry(
case "remove_from_background_geometries": case "remove_from_background_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])}`; summary = `geometry=${summarizeGeometryIdsValue(params[0])}`;
break; 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 { return {
+68 -10
View File
@@ -338,7 +338,7 @@ export function buildPolygonLabelFeatureCollection(
continue; continue;
} }
const labelPoint = getPolygonLabelPoint(feature.geometry); const labelPoint = getPolygonLabelPoint(feature.geometry, feature.properties.id);
if (!labelPoint) continue; if (!labelPoint) continue;
const labelFeature: Feature = { const labelFeature: Feature = {
@@ -485,6 +485,32 @@ export function getGeometryRepresentativePoint(geometry: Geometry): Coordinate |
} }
const pathArrowGeometriesCache = new WeakMap<Geometry, Geometry[]>(); const pathArrowGeometriesCache = new WeakMap<Geometry, Geometry[]>();
const pathArrowGeometriesL2Cache = new Map<string, Geometry[]>();
const polygonLabelPointL2Cache = new Map<string, Coordinate | null>();
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 { export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
const features: Feature[] = []; const features: Feature[] = [];
@@ -494,15 +520,30 @@ export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureC
let arrowGeometries = pathArrowGeometriesCache.get(feature.geometry); let arrowGeometries = pathArrowGeometriesCache.get(feature.geometry);
if (!arrowGeometries) { if (!arrowGeometries) {
arrowGeometries = []; const featureId = feature.properties?.id;
const coordinateGroups = getLineCoordinateGroups(feature.geometry); const fingerprint = getGeometryFingerprint(feature.geometry);
const featureType = getFeatureSemanticType(feature); const cacheKey = featureId ? `${featureId}:${fingerprint}` : null;
const isRetreat = featureType === "retreat_route";
for (const coordinates of coordinateGroups) { if (cacheKey && pathArrowGeometriesL2Cache.has(cacheKey)) {
const geometry = buildPathArrowGeometry(coordinates, isRetreat); arrowGeometries = pathArrowGeometriesL2Cache.get(cacheKey)!;
if (geometry) arrowGeometries.push(geometry); 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) { for (const geometry of arrowGeometries) {
@@ -1163,10 +1204,20 @@ function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
const polygonLabelPointCache = new WeakMap<Geometry, Coordinate | null>(); const polygonLabelPointCache = new WeakMap<Geometry, Coordinate | null>();
function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { function getPolygonLabelPoint(geometry: Geometry, featureId?: string | number): Coordinate | null {
if (polygonLabelPointCache.has(geometry)) { if (polygonLabelPointCache.has(geometry)) {
return polygonLabelPointCache.get(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; let result: Coordinate | null = null;
if (geometry.type === "Polygon") { if (geometry.type === "Polygon") {
result = getPolygonLabelCandidate(geometry.coordinates)?.point || null; result = getPolygonLabelCandidate(geometry.coordinates)?.point || null;
@@ -1181,7 +1232,14 @@ function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
} }
result = best?.point || null; result = best?.point || null;
} }
polygonLabelPointCache.set(geometry, result); polygonLabelPointCache.set(geometry, result);
if (cacheKey) {
if (polygonLabelPointL2Cache.size >= MAX_L2_CACHE_SIZE) {
polygonLabelPointL2Cache.clear();
}
polygonLabelPointL2Cache.set(cacheKey, result);
}
return result; return result;
} }
+5 -1
View File
@@ -169,7 +169,9 @@ export type GeoFunctionName =
| "set_geometry_style" | "set_geometry_style"
| "orbit_camera_around_geometry" | "orbit_camera_around_geometry"
| "set_as_background_geometries" | "set_as_background_geometries"
| "remove_from_background_geometries"; | "remove_from_background_geometries"
| "hide_all_geometries"
| "show_all_geometries";
export type NarrativeFunctionName = export type NarrativeFunctionName =
| "set_dialog"; | "set_dialog";
@@ -283,6 +285,8 @@ export type ReplayGeoFunctionParamTupleDocs = {
remove_from_background_geometries: [ remove_from_background_geometries: [
geometry_ids: string[], geometry_ids: string[],
]; ];
hide_all_geometries: [];
show_all_geometries: [];
}; };
export type ReplayNarrativeParamTupleDocs = { export type ReplayNarrativeParamTupleDocs = {
@@ -1193,6 +1193,18 @@ function normalizeReplayMapAndGeoActions(
params, params,
}); });
break; 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: default:
break; break;
} }
+10 -1
View File
@@ -36,6 +36,7 @@ export interface ReplayControllers {
hideGeometries: (ids: string[]) => void; hideGeometries: (ids: string[]) => void;
showOnlyGeometries: (ids: string[]) => void; showOnlyGeometries: (ids: string[]) => void;
showAllGeometries: () => void; showAllGeometries: () => void;
hideAllGeometries: () => void;
setAsBackgroundGeometries: (ids: string[]) => void; setAsBackgroundGeometries: (ids: string[]) => void;
removeFromBackgroundGeometries: (ids: string[]) => void; removeFromBackgroundGeometries: (ids: string[]) => void;
@@ -98,6 +99,12 @@ export const dispatchReplayAction = (
case "hide_others_geometries": case "hide_others_geometries":
controllers.showOnlyGeometries(toStringValues(params[0])); controllers.showOnlyGeometries(toStringValues(params[0]));
return; return;
case "hide_all_geometries":
controllers.hideAllGeometries();
return;
case "show_all_geometries":
controllers.showAllGeometries();
return;
case "pulse_geometry": case "pulse_geometry":
controllers.effects.pulseGeometry( controllers.effects.pulseGeometry(
map, map,
@@ -247,8 +254,10 @@ function normalizeSingleAction(action: unknown): ReplayAction<ReplayFunctionName
case "reset_camera_north": case "reset_camera_north":
return { function_name: "set_camera_view", params: [{ bearing: 0 }] }; return { function_name: "set_camera_view", params: [{ bearing: 0 }] };
case "set_time_filter": case "set_time_filter":
case "show_all_geometries":
return null; return null;
case "hide_all_geometries":
case "show_all_geometries":
return { function_name, params: [] };
// Geo Functions // Geo Functions
case "fly_to_geometries": case "fly_to_geometries":
+17 -4
View File
@@ -6,6 +6,7 @@ import type { BattleReplay, ReplayStage, ReplayStep, DialogState } from "@/uhm/t
import { dispatchReplayAction } from "./replayDispatcher"; import { dispatchReplayAction } from "./replayDispatcher";
import { mapActions } from "./mapActions"; import { mapActions } from "./mapActions";
import { createReplayMapEffects } from "./replayMapEffects"; import { createReplayMapEffects } from "./replayMapEffects";
import { clearGeometryCaches } from "@/uhm/components/map/mapUtils";
export type ReplayPreviewToast = { export type ReplayPreviewToast = {
id: number; id: number;
@@ -205,11 +206,13 @@ export function useReplayPreview({
}); });
} }
} }
clearGeometryCaches();
}, [getMapInstance, resetPresentation, setMapProjection]); }, [getMapInstance, resetPresentation, setMapProjection]);
const resetPreview = useCallback(() => { const resetPreview = useCallback(() => {
runIdRef.current += 1; runIdRef.current += 1;
restorePreviewState(); restorePreviewState();
clearGeometryCaches();
}, [restorePreviewState]); }, [restorePreviewState]);
const stopPreview = useCallback(() => { const stopPreview = useCallback(() => {
@@ -217,6 +220,7 @@ export function useReplayPreview({
isPlayingRef.current = false; isPlayingRef.current = false;
setIsPlaying(false); setIsPlaying(false);
getMapInstance()?.stop(); getMapInstance()?.stop();
clearGeometryCaches();
}, [getMapInstance]); }, [getMapInstance]);
useEffect(() => { useEffect(() => {
@@ -224,7 +228,10 @@ export function useReplayPreview({
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
restorePreviewState(); restorePreviewState();
}, 0); }, 0);
return () => window.clearTimeout(timeoutId); return () => {
window.clearTimeout(timeoutId);
clearGeometryCaches();
};
}, [replay?.id, restorePreviewState]); }, [replay?.id, restorePreviewState]);
const controllers = useMemo<Parameters<typeof dispatchReplayAction>[0]>(() => ({ const controllers = useMemo<Parameters<typeof dispatchReplayAction>[0]>(() => ({
@@ -290,6 +297,13 @@ export function useReplayPreview({
showAllGeometries: () => { showAllGeometries: () => {
setHiddenGeometryIds([]); setHiddenGeometryIds([]);
}, },
hideAllGeometries: () => {
setHiddenGeometryIds(
draft.features
.map((feature) => String(feature.properties.id))
.filter((id) => !backgroundIdsRef.current.has(id))
);
},
setDialog: setDialogWithRef, setDialog: setDialogWithRef,
getDialog: () => dialogRef.current, getDialog: () => dialogRef.current,
}), [ }), [
@@ -301,13 +315,12 @@ export function useReplayPreview({
]); ]);
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null); const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
useEffect(() => { controllersRef.current = controllers;
controllersRef.current = controllers;
}, [controllers]);
const playFromIndex = useCallback(async (startIndex: number) => { const playFromIndex = useCallback(async (startIndex: number) => {
if (!flatSteps.length) return; if (!flatSteps.length) return;
clearGeometryCaches();
const map = getMapInstance(); const map = getMapInstance();
if (map) { if (map) {
map.stop(); // Stop ongoing camera animations/transitions immediately map.stop(); // Stop ongoing camera animations/transitions immediately
+3 -1
View File
@@ -113,7 +113,9 @@ export type GeoFunctionName =
| "set_geometry_style" // Đổi style trực tiếp của geometry | "set_geometry_style" // Đổi style trực tiếp của geometry
| "orbit_camera_around_geometry" // Quay camera quanh một 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) | "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 = 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) | "set_dialog"; // Đặt kịch bản đối thoại/hình ảnh dẫn chuyện mới (hoặc null để xóa)