Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 230 additions & 33 deletions static/src/js/components/Map/Map.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, {
useEffect,
useRef,
useState
useState,
RefObject
} from 'react'
import { renderToString } from 'react-dom/server'

Expand Down Expand Up @@ -31,6 +32,8 @@ import TileLayer from 'ol/layer/Tile'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import VectorTileLayer from 'ol/layer/VectorTile'
import { unByKey } from 'ol/Observable'
import { EventsKey } from 'ol/events'

import {
FaCircle,
Expand All @@ -45,6 +48,13 @@ import {
Map as MapIcon
// @ts-expect-error The file does not have types
} from '@edsc/earthdata-react-icons/horizon-design-system/hds/ui'
import {
metricsMapFramePerformance,
metricsMapRenderPerformance,
MapPerformanceEvent
} from '../../util/metrics/metricsMap'

import { computePercentile } from '../../util/metrics/helpers'

import EDSCIcon from '../EDSCIcon/EDSCIcon'

Expand Down Expand Up @@ -99,9 +109,28 @@ import {
} from '../../types/sharedTypes'
import { MapView, ShapefileSlice } from '../../zustand/types'

interface MapPerformanceWindow {
windowStart: number
frames: number
Comment thread
eudoroolivares2016 marked this conversation as resolved.
slowFrames: number
verySlowFrames: number
renderTimes: number[]
maxRenderTimeMs: number
}

const createEmptyPerformanceWindow = (): MapPerformanceWindow => ({
windowStart: performance.now(),
frames: 0,
slowFrames: 0,
verySlowFrames: 0,
renderTimes: [],
maxRenderTimeMs: 0
})

let previousGranulesKey: string
let previousProjectionCode: ProjectionCode
let layersAdded = false
const PERFORMANCE_WINDOW_MS = 3000

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a bamboo var


// Render the times icon to an SVG string for use in the focused granule overlay
// Disable a testing-library rule because this isn't a test
Expand Down Expand Up @@ -177,6 +206,32 @@ const overlayLayers: Record<string, TileLayer | VectorTileLayer | null> = {
[mapLayers.placeLabels]: null
}

// Times a single prerender → postrender pass for a layer using .once(),
// so it only fires for the very next render triggered after this is called.
export const timeLayerRenderOnce = (
layer: VectorLayer,
label: string,
granuleCount: number,
collectionId: string
): EventsKey[] => {
let start = 0

const preKey = layer.once(RenderEventType.PRERENDER as LayerRenderEventTypes, () => {
start = performance.now()
performance.mark(`${label}-prerender`)
})

const postKey = layer.once(RenderEventType.POSTRENDER as LayerRenderEventTypes, () => {
const duration = performance.now() - start
performance.mark(`${label}-postrender`)
performance.measure(label, `${label}-prerender`, `${label}-postrender`)

metricsMapRenderPerformance(granuleCount, duration, label, collectionId)
})

return [preKey, postKey]
}

// Create a view for the map. This will change when the padding needs to be updated
const createView = ({
center,
Expand Down Expand Up @@ -242,6 +297,7 @@ const clearFocusedGranuleSource = (map: OlMap) => {
}

// Remove the drawing interaction from the map
// TODO double check all drawings go through this otherwise it might be a listener leak
const removeDrawingInteraction = (map: OlMap) => {
map.getInteractions().getArray().forEach((interaction) => {
if (interaction.get('id') === 'spatial-drawing-interaction') {
Expand All @@ -257,6 +313,48 @@ const hasFiniteExtent = (extent: import('ol/extent').Extent | null | undefined):
return extent.every((coordinate) => Number.isFinite(coordinate))
}

const flushMapPerformanceMetrics = (
performanceWindowRef: RefObject<MapPerformanceWindow>,
collectionId: string
) => {
if (!collectionId) {
return
}

const metrics = performanceWindowRef.current
if (metrics.frames === 0) {
metrics.windowStart = performance.now()

return
}

const sortedRenderTimes = [...metrics.renderTimes].sort(
(a, b) => a - b
)

const event: MapPerformanceEvent = {
windowDurationMs:
performance.now() - metrics.windowStart,

render: {
frames: metrics.frames,
p50RenderTimeMs: computePercentile(sortedRenderTimes, 0.5),
p95RenderTimeMs: computePercentile(sortedRenderTimes, 0.95),
p99RenderTimeMs: computePercentile(sortedRenderTimes, 0.99),
maxRenderTimeMs: metrics.maxRenderTimeMs,
slowFrames: metrics.slowFrames,
verySlowFrames: metrics.verySlowFrames
},
collectionId
}
metricsMapFramePerformance(event)
// Clear the metrics for the next window
// Reinitialize performance window ref to default state
// TODO really make sure it makes sense to disable this rule
// eslint-disable-next-line no-param-reassign
performanceWindowRef.current = createEmptyPerformanceWindow()
}

interface MapProps {
/** The base layers of the map */
base: {
Expand Down Expand Up @@ -415,6 +513,15 @@ const Map: React.FC<MapProps> = ({
// Create a ref for the map and the map dome element
const mapRef = useRef<OlMap>(undefined)
const mapElRef = useRef<HTMLDivElement>(null)
const lastFrameRef = useRef<number>(performance.now())
const frameTimesRef = useRef<number[]>([])
const isTrackingFrameRef = useRef(false)
const performanceWindowRef = useRef<MapPerformanceWindow>(createEmptyPerformanceWindow())
// Mirror the latest collection into refs so the long-lived
const focusedCollectionIdRef = useRef(focusedCollectionId)
useEffect(() => {
focusedCollectionIdRef.current = focusedCollectionId
}, [focusedCollectionId])

const [isLayerSwitcherOpen, setIsLayerSwitcherOpen] = useState(false)

Expand Down Expand Up @@ -450,6 +557,64 @@ const Map: React.FC<MapProps> = ({
})
mapRef.current = map

// Only accumulate frame deltas while an interaction (pan or zoom) is in
// progress, so idle frames don't dilute the stats. On moveend we roll the
// collected frame times up into a single summary and hand it off to the
// metrics helper, rather than reporting one event per frame.
const handlePostRenderPerf = () => {
if (!isTrackingFrameRef.current) return

const now = performance.now()
frameTimesRef.current.push(now - lastFrameRef.current)
lastFrameRef.current = now
}

const handleMoveStartPerf = () => {
isTrackingFrameRef.current = true
frameTimesRef.current = []
lastFrameRef.current = performance.now()
}

const handleMoveEndPerf = () => {
if (!isTrackingFrameRef.current) return

isTrackingFrameRef.current = false

const frameTimes = frameTimesRef.current
if (frameTimes.length === 0) return

const metrics = performanceWindowRef.current

metrics.frames += frameTimes.length

metrics.renderTimes.push(...frameTimes)

metrics.slowFrames += frameTimes.filter(
(time) => time > 33
).length

metrics.verySlowFrames += frameTimes.filter(
(time) => time > 100
).length

metrics.maxRenderTimeMs = Math.max(
metrics.maxRenderTimeMs,
...frameTimes
)

// Real time elapsed since the window opened, until the moveend that happened to trigger a flush check that passed."
if (
performance.now() - metrics.windowStart
>= PERFORMANCE_WINDOW_MS
) {
flushMapPerformanceMetrics(performanceWindowRef, focusedCollectionIdRef.current)
}
}

map.on('postrender', handlePostRenderPerf)
map.on('movestart', handleMoveStartPerf)
map.on('moveend', handleMoveEndPerf)

// Handle the map draw start event
const handleDrawingStart = (spatialType: string) => {
// Remove any existing drawing interaction
Expand Down Expand Up @@ -680,6 +845,11 @@ const Map: React.FC<MapProps> = ({
map.un('moveend', handleMoveEnd)
map.un('pointermove', handlePointerMove)

// Cleanup the performance tracking event listeners
map.un('postrender', handlePostRenderPerf)
map.un('movestart', handleMoveStartPerf)
map.un('moveend', handleMoveEndPerf)

eventEmitter.off(mapEventTypes.DRAWSTART, handleDrawingStart)
eventEmitter.off(mapEventTypes.DRAWCANCEL, handleDrawingCancel)
eventEmitter.off(mapEventTypes.MOVEMAP, handleMoveMap)
Expand Down Expand Up @@ -984,51 +1154,78 @@ const Map: React.FC<MapProps> = ({

// When the granules change, draw the granule backgrounds
useEffect(() => {
if (granules && granules.length > 0) {
// If the granules haven't changed and the projection hasn't changed, don't redraw the granule backgrounds
// Redraw the granule backgrounds if the product layer from the gibs tag has changed
if (granulesKey === previousGranulesKey && projectionCode === previousProjectionCode) return
if (granulesKey === previousGranulesKey
&& projectionCode === previousProjectionCode) return undefined
// Update the previous values
previousGranulesKey = granulesKey
previousProjectionCode = projectionCode

// Clear the existing granule backgrounds
granuleBackgroundsSource.clear()
// Clear any existing granule highlights
unhighlightGranule(granuleHighlightsSource)

// Clear any existing focused granules
clearFocusedGranuleSource(mapRef.current as OlMap)

// Clear the granule imagery layers
granuleImageryLayerGroup.getLayers().clear()

// Draw the granule backgrounds
drawGranuleBackgroundsAndImagery({
gibsLayersByCollection,
granuleImageryLayerGroup,
granulesMetadata: granules,
map: mapRef.current as OlMap,
projectionCode,
vectorSource: granuleBackgroundsSource
})

// If there is a focused granule draw it
if (focusedGranuleId) {
drawFocusedGranule({
collectionId: focusedCollectionId,
focusedGranuleSource,
granuleBackgroundsSource,
granuleId: focusedGranuleId,
isProjectPage,
map: (mapRef.current as OlMap),
onExcludeGranule,
setGranuleId,
shouldMoveMap: false,
timesIconSvg
})
}

// Time this specific update's render pass for all layers
const timingKeys = [
...timeLayerRenderOnce(granuleBackgroundsLayer, 'granule-backgrounds', granules.length, focusedCollectionId),
...timeLayerRenderOnce(granuleOutlinesLayer, 'granule-outlines', granules.length, focusedCollectionId)
]

// Update the previous values
previousGranulesKey = granulesKey
previousProjectionCode = projectionCode
// Clean up the timing listeners if this effect reruns/unmounts before they fire
return () => {
timingKeys.forEach((key) => {
unByKey(key)
})
}
}

// Clear the existing granule backgrounds
granuleBackgroundsSource.clear()

// Clear any existing granule highlights
unhighlightGranule(granuleHighlightsSource)

// Clear any existing focused granules
clearFocusedGranuleSource(mapRef.current as OlMap)

// Clear the granule imagery layers
granuleImageryLayerGroup.getLayers().clear()

// Draw the granule backgrounds
drawGranuleBackgroundsAndImagery({
gibsLayersByCollection,
granuleImageryLayerGroup,
granulesMetadata: granules,
map: mapRef.current as OlMap,
projectionCode,
vectorSource: granuleBackgroundsSource
})
// Clear any existing focused granules
clearFocusedGranuleSource(mapRef.current as OlMap)

// If there is a focused granule draw it
if (focusedGranuleId) {
drawFocusedGranule({
collectionId: focusedCollectionId,
focusedGranuleSource,
granuleBackgroundsSource,
granuleId: focusedGranuleId,
isProjectPage,
map: (mapRef.current as OlMap),
onExcludeGranule,
setGranuleId,
shouldMoveMap: false,
timesIconSvg
})
}
return undefined
}, [granules, granulesKey, projectionCode])

// When the spatial search changes, draw the spatial search
Expand Down
4 changes: 2 additions & 2 deletions static/src/js/containers/MapContainer/MapContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { projectionConfigs } from '../../util/map/crs'
// @ts-expect-error The file does not have types
import murmurhash3 from '../../util/murmurhash3'
import hasGibsLayerForProjection from '../../util/hasGibsLayerForProjection'
import { metricsMap } from '../../util/metrics/metricsMap'
import { metricsMapButtons } from '../../util/metrics/metricsMap'

import {
backgroundGranulePointStyle,
Expand Down Expand Up @@ -340,7 +340,7 @@ export const MapContainer = () => {
setZoom(newZoom)
setProjection(newProjectionCode)

metricsMap(`Set Projection: ${Projection}`)
metricsMapButtons(`Set Projection: ${Projection}`)
onChangeMap({ ...newMap })
}, [projection])

Expand Down
2 changes: 1 addition & 1 deletion static/src/js/routes/Search/Search.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export const Search = () => {
placement="top"
overlay={
(tooltipProps) => renderTooltip({
children: 'Include collections labeled as planned, deprecated, preprint, in review, superseded, or not provided in results',
children: 'Include collections labeled as planned, deprecated, preprint, in review, or not provided in results',
...tooltipProps
})
}
Expand Down
2 changes: 1 addition & 1 deletion static/src/js/routes/Search/__tests__/Search.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ describe('Search component', () => {
const icons = screen.getAllByRole('graphics-symbol')
const tooltip = icons[1]
await user.hover(tooltip)
expect(screen.getByText('Include collections labeled as planned, deprecated, preprint, in review, superseded, or not provided in results')).toBeInTheDocument()
expect(screen.getByText('Include collections labeled as planned, deprecated, preprint, in review, or not provided in results')).toBeInTheDocument()
})

test('does not render the "Include inactive collections" checkbox if showInactiveCollections is false', async () => {
Expand Down
Loading
Loading