perf: optimize map rendering by memoizing Map component and caching expensive geo-spatial calculations

This commit is contained in:
taDuc
2026-05-26 17:11:00 +07:00
parent 8c4a9cc85f
commit 2a3193a3fa
4 changed files with 126 additions and 93 deletions
+22 -19
View File
@@ -1456,6 +1456,16 @@ function EditorPageContent() {
: EMPTY_FEATURE_COLLECTION; : EMPTY_FEATURE_COLLECTION;
const isReplayPreviewWikiSidebarOpen = isAnyPreviewMode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen); const isReplayPreviewWikiSidebarOpen = isAnyPreviewMode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen);
const mapFocusPadding = useMemo(() => {
if (!isAnyPreviewMode) return 96;
return {
top: 84,
right: isReplayPreviewWikiSidebarOpen ? previewSidebarWidth + 80 : 84,
bottom: 116,
left: 84,
};
}, [isAnyPreviewMode, isReplayPreviewWikiSidebarOpen, previewSidebarWidth]);
const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => { const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => {
const map = getCurrentMapInstance(); const map = getCurrentMapInstance();
const geometryId = String(payload.geometry.id || "").trim(); const geometryId = String(payload.geometry.id || "").trim();
@@ -2743,11 +2753,19 @@ function EditorPageContent() {
? sectionCommits.find((commit) => commit.id === projectState.head_commit_id) || null ? sectionCommits.find((commit) => commit.id === projectState.head_commit_id) || null
: null; : null;
const handleDeleteFeature = useCallback((id: string | number | (string | number)[]) => {
if (Array.isArray(id)) {
editor.deleteFeatures(id);
} else {
editor.deleteFeature(id);
}
}, [editor]);
// Tạo geometry từ map engine rồi select ngay geometry mới. // Tạo geometry từ map engine rồi select ngay geometry mới.
const handleCreateFeature = (feature: Feature) => { const handleCreateFeature = useCallback((feature: Feature) => {
editor.createFeature(feature); editor.createFeature(feature);
setSelectedFeatureIds([feature.properties.id]); setSelectedFeatureIds([feature.properties.id]);
}; }, [editor]);
// Base draft for label lookup only. It must not decide which geometry is rendered. // Base draft for label lookup only. It must not decide which geometry is rendered.
const labelContextBaseDraft = useMemo(() => { const labelContextBaseDraft = useMemo(() => {
@@ -2993,13 +3011,7 @@ function EditorPageContent() {
onSelectFeatureIds={setSelectedFeatureIds} onSelectFeatureIds={setSelectedFeatureIds}
onCreateFeature={handleCreateFeature} onCreateFeature={handleCreateFeature}
onAddFeatureToProject={handleAddGlobalGeometryToProject} onAddFeatureToProject={handleAddGlobalGeometryToProject}
onDeleteFeature={(id) => { onDeleteFeature={handleDeleteFeature}
if (Array.isArray(id)) {
editor.deleteFeatures(id);
} else {
editor.deleteFeature(id);
}
}}
onHideFeature={handleHideGeometryLocal} onHideFeature={handleHideGeometryLocal}
onUpdateFeature={editor.updateFeature} onUpdateFeature={editor.updateFeature}
allowGeometryEditing={!isAnyPreviewMode} allowGeometryEditing={!isAnyPreviewMode}
@@ -3024,16 +3036,7 @@ function EditorPageContent() {
? (replayPreviewActiveEntity ? previewEntityFocusToken : null) ? (replayPreviewActiveEntity ? previewEntityFocusToken : null)
: geometryFocusRequest?.key ?? null : geometryFocusRequest?.key ?? null
} }
focusPadding={ focusPadding={mapFocusPadding}
isAnyPreviewMode
? {
top: 84,
right: isReplayPreviewWikiSidebarOpen ? previewSidebarWidth + 80 : 84,
bottom: 116,
left: 84,
}
: 96
}
imageOverlay={imageOverlay} imageOverlay={imageOverlay}
onImageOverlayChange={setImageOverlay} onImageOverlayChange={setImageOverlay}
onBindGeometries={handleBindGeometries} onBindGeometries={handleBindGeometries}
+3 -3
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle } from "react"; import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle, memo } from "react";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
@@ -72,7 +72,7 @@ type MapProps = {
onViewModeChange?: (mode: "local" | "global") => void; onViewModeChange?: (mode: "local" | "global") => void;
}; };
const Map = forwardRef<MapHandle, MapProps>(function Map({ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
mode, mode,
onSetMode, onSetMode,
renderDraft, renderDraft,
@@ -551,7 +551,7 @@ const Map = forwardRef<MapHandle, MapProps>(function Map({
) : null} ) : null}
</div> </div>
); );
}); }));
export default Map; export default Map;
+96 -66
View File
@@ -266,16 +266,22 @@ export function decoratePointFeaturesWithLabels(
timelineYear?: number | null timelineYear?: number | null
): FeatureCollection { ): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear); const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
return { let changed = false;
...fc, const nextFeatures = fc.features.map((feature) => {
features: fc.features.map((feature) => ({ const point_label = getLabel(feature);
if (feature.properties.point_label === point_label) {
return feature;
}
changed = true;
return {
...feature, ...feature,
properties: { properties: {
...feature.properties, ...feature.properties,
point_label: getLabel(feature), point_label,
}, },
})), };
}; });
return changed ? { ...fc, features: nextFeatures } : fc;
} }
export function decorateLineFeaturesWithLabels( export function decorateLineFeaturesWithLabels(
@@ -284,18 +290,26 @@ export function decorateLineFeaturesWithLabels(
timelineYear?: number | null timelineYear?: number | null
): FeatureCollection { ): FeatureCollection {
const getLabel = createFeatureLabelResolver(labelContext, timelineYear); const getLabel = createFeatureLabelResolver(labelContext, timelineYear);
return { let changed = false;
...fc, const nextFeatures = fc.features.map((feature) => {
features: fc.features.map((feature) => ({ const line_label = isLineGeometry(feature.geometry) ? getLabel(feature) : null;
if (feature.properties.line_label === line_label) {
return feature;
}
changed = true;
return {
...feature, ...feature,
properties: { properties: {
...feature.properties, ...feature.properties,
line_label: isLineGeometry(feature.geometry) ? getLabel(feature) : null, line_label,
}, },
})), };
}; });
return changed ? { ...fc, features: nextFeatures } : fc;
} }
const polygonLabelFeaturesCache = new WeakMap<any, { label: string; feature: Feature }>();
export function buildPolygonLabelFeatureCollection( export function buildPolygonLabelFeatureCollection(
fc: FeatureCollection, fc: FeatureCollection,
labelContext: FeatureCollection = fc, labelContext: FeatureCollection = fc,
@@ -308,10 +322,16 @@ export function buildPolygonLabelFeatureCollection(
const label = getLabel(feature); const label = getLabel(feature);
if (!label) continue; if (!label) continue;
const cached = polygonLabelFeaturesCache.get(feature);
if (cached && cached.label === label) {
features.push(cached.feature);
continue;
}
const labelPoint = getPolygonLabelPoint(feature.geometry); const labelPoint = getPolygonLabelPoint(feature.geometry);
if (!labelPoint) continue; if (!labelPoint) continue;
features.push({ const labelFeature: Feature = {
type: "Feature", type: "Feature",
properties: { properties: {
...feature.properties, ...feature.properties,
@@ -322,7 +342,9 @@ export function buildPolygonLabelFeatureCollection(
type: "Point", type: "Point",
coordinates: labelPoint, coordinates: labelPoint,
}, },
}); };
polygonLabelFeaturesCache.set(feature, { label, feature: labelFeature });
features.push(labelFeature);
} }
return { type: "FeatureCollection", features }; return { type: "FeatureCollection", features };
@@ -452,16 +474,26 @@ export function getGeometryRepresentativePoint(geometry: Geometry): Coordinate |
return null; return null;
} }
const pathArrowGeometriesCache = new WeakMap<any, Geometry[]>();
export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection { export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection {
const features: Feature[] = []; const features: Feature[] = [];
for (const feature of fc.features) { for (const feature of fc.features) {
if (!isPathFeature(feature)) continue; if (!isPathFeature(feature)) continue;
const coordinateGroups = getLineCoordinateGroups(feature.geometry); let arrowGeometries = pathArrowGeometriesCache.get(feature.geometry);
for (const coordinates of coordinateGroups) { if (!arrowGeometries) {
const geometry = buildPathArrowGeometry(coordinates); arrowGeometries = [];
if (!geometry) continue; const coordinateGroups = getLineCoordinateGroups(feature.geometry);
for (const coordinates of coordinateGroups) {
const geometry = buildPathArrowGeometry(coordinates);
if (geometry) arrowGeometries.push(geometry);
}
pathArrowGeometriesCache.set(feature.geometry, arrowGeometries);
}
for (const geometry of arrowGeometries) {
features.push({ features.push({
type: "Feature", type: "Feature",
properties: { ...feature.properties }, properties: { ...feature.properties },
@@ -986,12 +1018,16 @@ function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] {
return []; return [];
} }
function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { const polygonLabelPointCache = new WeakMap<any, Coordinate | null>();
if (geometry.type === "Polygon") {
return getPolygonLabelCandidate(geometry.coordinates)?.point || null;
}
if (geometry.type === "MultiPolygon") { function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
if (polygonLabelPointCache.has(geometry)) {
return polygonLabelPointCache.get(geometry)!;
}
let result: Coordinate | null = null;
if (geometry.type === "Polygon") {
result = getPolygonLabelCandidate(geometry.coordinates)?.point || null;
} else if (geometry.type === "MultiPolygon") {
let best: { point: Coordinate; distance: number } | null = null; let best: { point: Coordinate; distance: number } | null = null;
for (const polygon of geometry.coordinates) { for (const polygon of geometry.coordinates) {
const candidate = getPolygonLabelCandidate(polygon); const candidate = getPolygonLabelCandidate(polygon);
@@ -1000,10 +1036,10 @@ function getPolygonLabelPoint(geometry: Geometry): Coordinate | null {
best = candidate; best = candidate;
} }
} }
return best?.point || null; result = best?.point || null;
} }
polygonLabelPointCache.set(geometry, result);
return null; return result;
} }
function getPolygonLabelCandidate(polygon: PolygonCoordinates): { point: Coordinate; distance: number } | null { function getPolygonLabelCandidate(polygon: PolygonCoordinates): { point: Coordinate; distance: number } | null {
@@ -1074,45 +1110,39 @@ export function hashStringToColor(str: string): string {
} }
export function decorateFeaturesWithEntityColors(fc: FeatureCollection): FeatureCollection { export function decorateFeaturesWithEntityColors(fc: FeatureCollection): FeatureCollection {
return { let changed = false;
...fc, const nextFeatures = fc.features.map((feature) => {
features: fc.features.map((feature) => { const geomType = feature.geometry?.type;
const geomType = feature.geometry?.type; if (geomType === "Point" || geomType === "MultiPoint") {
if (geomType === "Point" || geomType === "MultiPoint") { // Point - giữ nguyên màu của preset/icon
// Point - giữ nguyên màu của preset/icon
return feature;
}
if (geomType === "LineString" || geomType === "MultiLineString") {
const entityIds = getFeatureEntityIds(feature);
if (entityIds.length > 0) {
const sortedCombined = [...entityIds].sort().join("+");
return {
...feature,
properties: {
...feature.properties,
entity_color: hashStringToColor(sortedCombined),
},
};
}
return feature;
}
if (geomType === "Polygon" || geomType === "MultiPolygon") {
const geoId = String(feature.properties?.id || "");
if (geoId) {
return {
...feature,
properties: {
...feature.properties,
entity_color: hashStringToColor(geoId),
},
};
}
return feature;
}
return feature; return feature;
}), }
};
let entity_color: string | undefined;
if (geomType === "LineString" || geomType === "MultiLineString") {
const entityIds = getFeatureEntityIds(feature);
if (entityIds.length > 0) {
const sortedCombined = [...entityIds].sort().join("+");
entity_color = hashStringToColor(sortedCombined);
}
} else if (geomType === "Polygon" || geomType === "MultiPolygon") {
const geoId = String(feature.properties?.id || "");
if (geoId) {
entity_color = hashStringToColor(geoId);
}
}
if ((feature.properties as any).entity_color === entity_color) {
return feature;
}
changed = true;
return {
...feature,
properties: {
...feature.properties,
entity_color,
},
};
});
return changed ? { ...fc, features: nextFeatures } : fc;
} }
+5 -5
View File
@@ -137,7 +137,11 @@ export function useMapSync({
countriesSource.setData(labeledGeometries); countriesSource.setData(labeledGeometries);
placesSource.setData(labeledPoints); placesSource.setData(labeledPoints);
polygonLabelSource.setData(polygonLabels); polygonLabelSource.setData(polygonLabels);
(map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined)?.setData(pathArrowShapes);
const pathArrowSource = map.getSource(PATH_ARROW_SOURCE_ID) as maplibregl.GeoJSONSource | undefined;
if (pathArrowSource) {
pathArrowSource.setData(pathArrowShapes);
}
currentSelectedIds.forEach((id) => { currentSelectedIds.forEach((id) => {
setSelectedFeatureState(map, id, true); setSelectedFeatureState(map, id, true);
@@ -153,8 +157,6 @@ export function useMapSync({
} }
}, [mapRef]); }, [mapRef]);
const tryCenterToUserLocation = useCallback(() => { const tryCenterToUserLocation = useCallback(() => {
if (geolocationCenteredRef.current) return; if (geolocationCenteredRef.current) return;
if (fitToDraftBoundsRef.current) return; if (fitToDraftBoundsRef.current) return;
@@ -256,5 +258,3 @@ export function useMapSync({
}, },
}; };
} }