From 2a3193a3faf0a6d5164022893d7bd00073687836 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 26 May 2026 17:11:00 +0700 Subject: [PATCH] perf: optimize map rendering by memoizing Map component and caching expensive geo-spatial calculations --- src/app/editor/[id]/page.tsx | 41 +++---- src/uhm/components/Map.tsx | 6 +- src/uhm/components/map/mapUtils.ts | 162 ++++++++++++++++----------- src/uhm/components/map/useMapSync.ts | 10 +- 4 files changed, 126 insertions(+), 93 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index f6249fd..d2cb39e 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1456,6 +1456,16 @@ function EditorPageContent() { : EMPTY_FEATURE_COLLECTION; 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 map = getCurrentMapInstance(); const geometryId = String(payload.geometry.id || "").trim(); @@ -2743,11 +2753,19 @@ function EditorPageContent() { ? sectionCommits.find((commit) => commit.id === projectState.head_commit_id) || 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. - const handleCreateFeature = (feature: Feature) => { + const handleCreateFeature = useCallback((feature: Feature) => { editor.createFeature(feature); setSelectedFeatureIds([feature.properties.id]); - }; + }, [editor]); // Base draft for label lookup only. It must not decide which geometry is rendered. const labelContextBaseDraft = useMemo(() => { @@ -2993,13 +3011,7 @@ function EditorPageContent() { onSelectFeatureIds={setSelectedFeatureIds} onCreateFeature={handleCreateFeature} onAddFeatureToProject={handleAddGlobalGeometryToProject} - onDeleteFeature={(id) => { - if (Array.isArray(id)) { - editor.deleteFeatures(id); - } else { - editor.deleteFeature(id); - } - }} + onDeleteFeature={handleDeleteFeature} onHideFeature={handleHideGeometryLocal} onUpdateFeature={editor.updateFeature} allowGeometryEditing={!isAnyPreviewMode} @@ -3024,16 +3036,7 @@ function EditorPageContent() { ? (replayPreviewActiveEntity ? previewEntityFocusToken : null) : geometryFocusRequest?.key ?? null } - focusPadding={ - isAnyPreviewMode - ? { - top: 84, - right: isReplayPreviewWikiSidebarOpen ? previewSidebarWidth + 80 : 84, - bottom: 116, - left: 84, - } - : 96 - } + focusPadding={mapFocusPadding} imageOverlay={imageOverlay} onImageOverlayChange={setImageOverlay} onBindGeometries={handleBindGeometries} diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index ce01761..2443095 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -1,6 +1,6 @@ "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 { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState"; @@ -72,7 +72,7 @@ type MapProps = { onViewModeChange?: (mode: "local" | "global") => void; }; -const Map = forwardRef(function Map({ +const Map = memo(forwardRef(function Map({ mode, onSetMode, renderDraft, @@ -551,7 +551,7 @@ const Map = forwardRef(function Map({ ) : null} ); -}); +})); export default Map; diff --git a/src/uhm/components/map/mapUtils.ts b/src/uhm/components/map/mapUtils.ts index 44b3eee..678d27d 100644 --- a/src/uhm/components/map/mapUtils.ts +++ b/src/uhm/components/map/mapUtils.ts @@ -266,16 +266,22 @@ export function decoratePointFeaturesWithLabels( timelineYear?: number | null ): FeatureCollection { const getLabel = createFeatureLabelResolver(labelContext, timelineYear); - return { - ...fc, - features: fc.features.map((feature) => ({ + let changed = false; + const nextFeatures = fc.features.map((feature) => { + const point_label = getLabel(feature); + if (feature.properties.point_label === point_label) { + return feature; + } + changed = true; + return { ...feature, properties: { ...feature.properties, - point_label: getLabel(feature), + point_label, }, - })), - }; + }; + }); + return changed ? { ...fc, features: nextFeatures } : fc; } export function decorateLineFeaturesWithLabels( @@ -284,18 +290,26 @@ export function decorateLineFeaturesWithLabels( timelineYear?: number | null ): FeatureCollection { const getLabel = createFeatureLabelResolver(labelContext, timelineYear); - return { - ...fc, - features: fc.features.map((feature) => ({ + let changed = false; + const nextFeatures = 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, properties: { ...feature.properties, - line_label: isLineGeometry(feature.geometry) ? getLabel(feature) : null, + line_label, }, - })), - }; + }; + }); + return changed ? { ...fc, features: nextFeatures } : fc; } +const polygonLabelFeaturesCache = new WeakMap(); + export function buildPolygonLabelFeatureCollection( fc: FeatureCollection, labelContext: FeatureCollection = fc, @@ -308,10 +322,16 @@ export function buildPolygonLabelFeatureCollection( const label = getLabel(feature); if (!label) continue; + const cached = polygonLabelFeaturesCache.get(feature); + if (cached && cached.label === label) { + features.push(cached.feature); + continue; + } + const labelPoint = getPolygonLabelPoint(feature.geometry); if (!labelPoint) continue; - features.push({ + const labelFeature: Feature = { type: "Feature", properties: { ...feature.properties, @@ -322,7 +342,9 @@ export function buildPolygonLabelFeatureCollection( type: "Point", coordinates: labelPoint, }, - }); + }; + polygonLabelFeaturesCache.set(feature, { label, feature: labelFeature }); + features.push(labelFeature); } return { type: "FeatureCollection", features }; @@ -452,16 +474,26 @@ export function getGeometryRepresentativePoint(geometry: Geometry): Coordinate | return null; } +const pathArrowGeometriesCache = new WeakMap(); + export function buildPathArrowFeatureCollection(fc: FeatureCollection): FeatureCollection { const features: Feature[] = []; for (const feature of fc.features) { if (!isPathFeature(feature)) continue; - const coordinateGroups = getLineCoordinateGroups(feature.geometry); - for (const coordinates of coordinateGroups) { - const geometry = buildPathArrowGeometry(coordinates); - if (!geometry) continue; + let arrowGeometries = pathArrowGeometriesCache.get(feature.geometry); + if (!arrowGeometries) { + arrowGeometries = []; + 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({ type: "Feature", properties: { ...feature.properties }, @@ -986,12 +1018,16 @@ function getLineCoordinateGroups(geometry: Geometry): Coordinate[][] { return []; } -function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { - if (geometry.type === "Polygon") { - return getPolygonLabelCandidate(geometry.coordinates)?.point || null; - } +const polygonLabelPointCache = new WeakMap(); - 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; for (const polygon of geometry.coordinates) { const candidate = getPolygonLabelCandidate(polygon); @@ -1000,10 +1036,10 @@ function getPolygonLabelPoint(geometry: Geometry): Coordinate | null { best = candidate; } } - return best?.point || null; + result = best?.point || null; } - - return null; + polygonLabelPointCache.set(geometry, result); + return result; } 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 { - return { - ...fc, - features: fc.features.map((feature) => { - const geomType = feature.geometry?.type; - if (geomType === "Point" || geomType === "MultiPoint") { - // 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; - } - + let changed = false; + const nextFeatures = fc.features.map((feature) => { + const geomType = feature.geometry?.type; + if (geomType === "Point" || geomType === "MultiPoint") { + // Point - giữ nguyên màu của preset/icon 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; } diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index d8ea6f2..667d0a2 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -137,7 +137,11 @@ export function useMapSync({ countriesSource.setData(labeledGeometries); placesSource.setData(labeledPoints); 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) => { setSelectedFeatureState(map, id, true); @@ -153,8 +157,6 @@ export function useMapSync({ } }, [mapRef]); - - const tryCenterToUserLocation = useCallback(() => { if (geolocationCenteredRef.current) return; if (fitToDraftBoundsRef.current) return; @@ -256,5 +258,3 @@ export function useMapSync({ }, }; } - -