• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DLR-SC / ESID / 17584931298

09 Sep 2025 01:54PM UTC coverage: 51.908% (-0.8%) from 52.681%
17584931298

push

github

kunkoala
:beetle: :wrench: fix test by adding export context

488 of 624 branches covered (78.21%)

Branch coverage included in aggregate %.

4639 of 9253 relevant lines covered (50.14%)

10.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

65.19
/src/components/Sidebar/MapComponents/HeatMap.tsx
1
// SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR)
2
// SPDX-License-Identifier: Apache-2.0
3

4
// React imports
5
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
1✔
6

7
// Third-party
8
import * as am5 from '@amcharts/amcharts5';
1✔
9
import * as am5map from '@amcharts/amcharts5/map';
1✔
10
import Box from '@mui/material/Box';
1✔
11
import useTheme from '@mui/material/styles/useTheme';
1✔
12
import {Feature, GeoJSON, GeoJsonProperties} from 'geojson';
13

14
// Local components
15
import MapControlBar from './MapControlBar';
1✔
16
import useMapChart from 'components/shared/HeatMap/Map';
1✔
17
import usePolygonSeries from 'components/shared/HeatMap/Polygon';
1✔
18
import useRoot from 'components/shared/Root';
1✔
19

20
// Types and interfaces
21
import {HeatmapLegend} from 'types/heatmapLegend';
22
import {Localization} from 'types/localization';
23

24
// Utils
25
import {useConst} from 'util/hooks';
1✔
26
import useExporting from 'components/shared/Exporting';
1✔
27
import {useExportingRegistry} from 'context/ExportContext';
1✔
28

29
interface MapProps {
30
  /** The data to be displayed on the map, in GeoJSON format. */
31
  mapData: undefined | GeoJSON;
32

33
  /** Optional unique identifier for the map. Default is 'map'. */
34
  mapId?: string;
35

36
  /** Optional height for the map. Default is '650px'. */
37
  mapHeight?: string;
38

39
  /** Optional default fill color for the map regions. Default is '#8c8c8c'. */
40
  defaultFill?: number | string;
41

42
  /** Optional fill opacity for the map regions. Default is 1. */
43
  fillOpacity?: number;
44

45
  /** Optional maximum zoom level for the map. Default is 4. */
46
  maxZoomLevel?: number;
47

48
  /** Optional function to generate tooltip text for each region based on its data. Default is a function that returns the region's ID. */
49
  tooltipText?: (regionData: GeoJsonProperties) => string;
50

51
  /** Optional function to generate tooltip text while data is being fetched. Default is a function that returns 'Loading...'. */
52
  tooltipTextWhileFetching?: (regionData: GeoJsonProperties) => string;
53

54
  /** The default selected region's data. */
55
  defaultSelectedValue: GeoJsonProperties;
56

57
  /** Optional flag indicating if data is being fetched. Default is false. */
58
  isDataFetching?: boolean;
59

60
  /** Array of values for the map regions, where each value includes an ID and a corresponding numeric value. */
61
  values: {id: string | number; value: number}[] | undefined;
62

63
  /** Callback function to update the selected region's data. */
64
  setSelectedArea: (area: GeoJsonProperties) => void;
65

66
  /** The currently selected region's data. */
67
  selectedArea: GeoJsonProperties;
68

69
  /** The maximum aggregated value for the heatmap legend. */
70
  aggregatedMax: number;
71

72
  /** Callback function to update the maximum aggregated value. */
73
  setAggregatedMax: (max: number) => void;
74

75
  /** Optional fixed maximum value for the heatmap legend. */
76
  fixedLegendMaxValue?: number | null;
77

78
  /** The heatmap legend configuration. */
79
  legend: HeatmapLegend;
80

81
  /** Reference to the heatmap legend element. */
82
  legendRef: React.MutableRefObject<am5.HeatLegend | null>;
83

84
  /** Optional flag indicating if data loading takes a long time. Default is false. */
85
  longLoad?: boolean;
86

87
  /** Optional callback function to update the long load flag. Default is an empty function. */
88
  setLongLoad?: (longLoad: boolean) => void;
89

90
  /** Optional localization settings for the heatmap. */
91
  localization?: Localization;
92

93
  /** Optional identifier for mapping values to regions. Default is 'id'. */
94
  areaId?: string;
95
}
96

97
/**
98
 * React Component to render a Heatmap.
99
 */
100
export default function HeatMap({
5✔
101
  mapData,
5✔
102
  mapId = 'map',
5✔
103
  mapHeight = '650px',
5✔
104
  defaultFill = '#8c8c8c',
5✔
105
  fillOpacity = 1,
5✔
106
  maxZoomLevel = 4,
5✔
107
  tooltipText = () => '{id}',
5✔
108
  tooltipTextWhileFetching = () => 'Loading...',
5✔
109
  defaultSelectedValue,
5✔
110
  isDataFetching = false,
5✔
111
  values,
5✔
112
  setSelectedArea,
5✔
113
  selectedArea,
5✔
114
  aggregatedMax,
5✔
115
  setAggregatedMax,
5✔
116
  fixedLegendMaxValue,
5✔
117
  legend,
5✔
118
  legendRef,
5✔
119
  longLoad = false,
5✔
120
  setLongLoad = () => {},
5✔
121
  localization,
5✔
122
  areaId = 'id',
5✔
123
}: MapProps) {
5✔
124
  const theme = useTheme();
5✔
125
  const lastSelectedPolygon = useRef<am5map.MapPolygon | null>(null);
5✔
126
  const [longLoadTimeout, setLongLoadTimeout] = useState<number>();
5✔
127

128
  const {register} = useExportingRegistry();
5✔
129
  const root = useRoot(mapId);
5✔
130

131
  // MapControlBar.tsx
132
  // Add home button click handler
133
  const handleHomeClick = useCallback(() => {
5✔
134
    setSelectedArea(defaultSelectedValue);
×
135
  }, [setSelectedArea, defaultSelectedValue]);
5✔
136

137
  const chartSettings = useMemo(() => {
5✔
138
    return {
1✔
139
      projection: am5map.geoMercator(),
1✔
140
      maxZoomLevel: maxZoomLevel,
1✔
141
      maxPanOut: 0.4,
1✔
142
    };
1✔
143
  }, [maxZoomLevel]);
5✔
144

145
  const chart = useMapChart(root, chartSettings);
5✔
146

147
  const polygonSettings = useMemo(() => {
5✔
148
    if (!mapData) return null;
1!
149
    return {
1✔
150
      geoJSON: mapData,
1✔
151
      tooltipPosition: 'fixed',
1✔
152
      layer: 0,
1✔
153
    } as am5map.IMapPolygonSeriesSettings;
1✔
154
  }, [mapData]);
5✔
155

156
  const polygonSeries = usePolygonSeries(
5✔
157
    root,
5✔
158
    chart,
5✔
159
    polygonSettings,
5✔
160
    useConst((polygonSeries: am5map.MapPolygonSeries) => {
5✔
161
      const polygonTemplate = polygonSeries.mapPolygons.template;
1✔
162
      // Set properties for each polygon
163
      polygonTemplate.setAll({
1✔
164
        fill: am5.color(defaultFill),
1✔
165
        stroke: am5.color(theme.palette.background.default),
1✔
166
        strokeWidth: 1,
1✔
167
        fillOpacity: fillOpacity,
1✔
168
      });
1✔
169
      polygonTemplate.states.create('hover', {
1✔
170
        stroke: am5.color(theme.palette.primary.main),
1✔
171
        strokeWidth: 2,
1✔
172
        layer: 1,
1✔
173
      });
1✔
174
    })
5✔
175
  );
5✔
176

177
  // This effect is responsible for setting the selected area when a region is clicked and showing the value of the hovered region in the legend.
178
  useLayoutEffect(() => {
5✔
179
    if (!polygonSeries) return;
2✔
180
    const polygonTemplate = polygonSeries.mapPolygons.template;
1✔
181

182
    polygonTemplate.events.on('click', function (ev) {
1✔
183
      if (ev.target.dataItem?.dataContext) {
×
184
        setSelectedArea(ev.target.dataItem.dataContext as GeoJsonProperties);
×
185
      }
×
186
    });
1✔
187

188
    polygonTemplate.events.on('pointerover', (e) => {
1✔
189
      if (legendRef && legendRef.current) {
×
190
        const value = (e.target.dataItem?.dataContext as GeoJsonProperties)?.value as number;
×
191
        legendRef.current.showValue(
×
192
          value,
×
193
          localization?.formatNumber ? localization.formatNumber(value) : value.toString()
×
194
        );
×
195
      }
×
196
    });
1✔
197
    //hide tooltip on heat legend when not hovering anymore event
198
    polygonTemplate.events.on('pointerout', () => {
1✔
199
      if (legendRef && legendRef.current) {
×
200
        void legendRef.current.hideTooltip();
×
201
      }
×
202
    });
1✔
203
    // This effect should only run when the polygon series is set
204
  }, [polygonSeries, legendRef, localization, setSelectedArea, theme.palette.primary.main]);
5✔
205

206
  // This effect is responsible for showing the loading indicator if the data is not ready within 1 second. This
207
  // prevents that the indicator is showing for every little change.
208
  useEffect(() => {
5✔
209
    if (isDataFetching) {
5!
210
      setLongLoadTimeout(
×
211
        window.setTimeout(() => {
×
212
          setLongLoad(true);
×
213
        }, 1000)
×
214
      );
×
215
    } else {
5✔
216
      clearTimeout(longLoadTimeout);
5✔
217
      setLongLoad(false);
5✔
218
    }
5✔
219
    // This effect should only re-run when the fetching state changes
220
    // eslint-disable-next-line
221
  }, [isDataFetching, setLongLoad, setLongLoadTimeout]); // longLoadTimeout is deliberately ignored here.
5✔
222

223
  // Set aggregatedMax if fixedLegendMaxValue is set or values are available
224
  useEffect(() => {
5✔
225
    if (fixedLegendMaxValue) {
1!
226
      setAggregatedMax(fixedLegendMaxValue);
×
227
    } else if (values) {
1✔
228
      let max = 1;
1✔
229
      values.forEach((value) => {
1✔
230
        max = Math.max(value.value, max);
2✔
231
      });
1✔
232
      setAggregatedMax(max);
1✔
233
    }
1✔
234
    // This effect should only re-run when the fixedLegendMaxValue or values change
235
  }, [fixedLegendMaxValue, setAggregatedMax, values]);
5✔
236

237
  // Highlight selected polygon and reset last selected polygon
238
  useEffect(() => {
5✔
239
    if (!polygonSeries || polygonSeries.isDisposed()) return;
2✔
240
    // Reset last selected polygon
241
    const updatePolygons = () => {
1✔
242
      if (lastSelectedPolygon.current) {
1!
243
        lastSelectedPolygon.current.states.create('default', {
×
244
          stroke: am5.color(theme.palette.background.default),
×
245
          strokeWidth: 1,
×
246
          layer: 0,
×
247
        });
×
248
        lastSelectedPolygon.current.states.apply('default');
×
249
      }
×
250
      // Highlight selected polygon
251
      polygonSeries.mapPolygons.each((mapPolygon) => {
1✔
252
        if (mapPolygon.dataItem?.dataContext) {
×
253
          const areaData = mapPolygon.dataItem.dataContext as Feature;
×
254
          const id: string | number = areaData[areaId as keyof Feature] as string | number;
×
255
          if (id == selectedArea![areaId as keyof GeoJsonProperties]) {
×
256
            mapPolygon.states.create('default', {
×
257
              stroke: am5.color(theme.palette.primary.main),
×
258
              strokeWidth: 2,
×
259
              layer: 1,
×
260
            });
×
261
            if (!mapPolygon.isHover()) {
×
262
              mapPolygon.states.apply('default');
×
263
            }
×
264
            lastSelectedPolygon.current = mapPolygon;
×
265
          }
×
266
        }
×
267
      });
1✔
268
    };
1✔
269

270
    const handleDataValidated = () => {
1✔
271
      if (!polygonSeries.isDisposed()) {
1✔
272
        updatePolygons();
1✔
273
      }
1✔
274
    };
1✔
275

276
    polygonSeries.events.on('datavalidated', handleDataValidated);
1✔
277
    handleDataValidated();
1✔
278

279
    // Cleanup event listeners on component unmount or when dependencies change
280
    return () => {
1✔
281
      if (!polygonSeries.isDisposed()) {
1!
282
        polygonSeries.events.off('datavalidated', handleDataValidated);
×
283
      }
×
284
    };
1✔
285
    // This effect should only re-run when the selectedArea or polygonSeries change
286
  }, [areaId, polygonSeries, selectedArea, theme.palette.background.default, theme.palette.primary.main]);
5✔
287

288
  // Update fill color and tooltip of map polygons based on values
289
  useEffect(() => {
5✔
290
    if (!polygonSeries || polygonSeries.isDisposed()) return;
5✔
291

292
    const updatePolygons = () => {
2✔
293
      if (!isDataFetching && values && Number.isFinite(aggregatedMax) && polygonSeries) {
2✔
294
        const valueMap = new Map<string | number, number>();
2✔
295

296
        values.forEach((value) => valueMap.set(value.id, value.value));
2✔
297

298
        polygonSeries.mapPolygons.template.entities.forEach((polygon) => {
2✔
299
          const regionData = polygon.dataItem?.dataContext as GeoJsonProperties;
×
300
          if (regionData) {
×
301
            regionData.value = valueMap.get(regionData[areaId] as string | number) ?? Number.NaN;
×
302

303
            let fillColor = am5.color(defaultFill);
×
304
            if (Number.isFinite(regionData.value) && typeof regionData.value === 'number') {
×
305
              fillColor = getColorFromLegend(regionData.value, legend, {
×
306
                min: 0,
×
307
                max: aggregatedMax,
×
308
              });
×
309
            }
×
310
            polygon.setAll({
×
311
              tooltipText: tooltipText(regionData),
×
312
              fill: fillColor,
×
313
            });
×
314
          }
×
315
        });
2✔
316
      } else if (longLoad || !values) {
2!
317
        polygonSeries.mapPolygons.template.entities.forEach((polygon) => {
×
318
          const regionData = polygon.dataItem?.dataContext as GeoJsonProperties;
×
319
          if (regionData) {
×
320
            regionData.value = Number.NaN;
×
321
            polygon.setAll({
×
322
              tooltipText: tooltipTextWhileFetching(regionData),
×
323
              fill: am5.color(theme.palette.text.disabled),
×
324
            });
×
325
          }
×
326
        });
×
327
      }
×
328
    };
2✔
329

330
    const handleDataValidated = () => {
2✔
331
      if (!polygonSeries.isDisposed()) {
2✔
332
        updatePolygons();
2✔
333
      }
2✔
334
    };
2✔
335

336
    polygonSeries.events.on('datavalidated', handleDataValidated);
2✔
337
    handleDataValidated();
2✔
338

339
    // Cleanup event listeners on component unmount or when dependencies change
340
    return () => {
2✔
341
      if (!polygonSeries.isDisposed()) {
2✔
342
        polygonSeries.events.off('datavalidated', handleDataValidated);
1✔
343
      }
1✔
344
    };
2✔
345
  }, [
5✔
346
    polygonSeries,
5✔
347
    values,
5✔
348
    aggregatedMax,
5✔
349
    defaultFill,
5✔
350
    legend,
5✔
351
    tooltipText,
5✔
352
    longLoad,
5✔
353
    tooltipTextWhileFetching,
5✔
354
    theme.palette.text.disabled,
5✔
355
    areaId,
5✔
356
    isDataFetching,
5✔
357
  ]);
5✔
358

359
  const exportSettings = useMemo(() => {
5✔
360
    return {
1✔
361
      filePrefix: 'map',
1✔
362
    };
1✔
363
  }, []);
5✔
364

365
  const exporting = useExporting(root, exportSettings);
5✔
366

367
  useEffect(() => {
5✔
368
    if (exporting) {
2✔
369
      register('map', exporting);
1✔
370
    }
1✔
371
  }, [exporting, register]);
5✔
372

373
  return (
5✔
374
    <Box
5✔
375
      id={mapId}
5✔
376
      height={mapHeight}
5✔
377
      sx={{
5✔
378
        position: 'relative',
5✔
379
        width: '100%',
5✔
380
      }}
5✔
381
    >
382
      {chart && <MapControlBar chart={chart} onHomeClick={handleHomeClick} maxZoomLevel={maxZoomLevel} />}
5✔
383
    </Box>
5✔
384
  );
385
}
5✔
386

387
function getColorFromLegend(
×
388
  value: number,
×
389
  legend: HeatmapLegend,
×
390
  aggregatedMinMax?: {min: number; max: number}
×
391
): am5.Color {
×
392
  // assume legend stops are absolute
393
  let normalizedValue = value;
×
394
  // if aggregated values (min/max) are properly set, the legend items are already normalized => need to normalize value too
395
  if (aggregatedMinMax && aggregatedMinMax.min < aggregatedMinMax.max) {
×
396
    const {min: aggregatedMin, max: aggregatedMax} = aggregatedMinMax;
×
397
    normalizedValue = (value - aggregatedMin) / (aggregatedMax - aggregatedMin);
×
398
  } else if (aggregatedMinMax) {
×
399
    // log error if any of the above checks fail
400
    console.error('Error: invalid MinMax array in getColorFromLegend', [value, legend, aggregatedMinMax]);
×
401
    // return completely transparent fill if errors occur
402
    return am5.color('rgba(0,0,0,0)');
×
403
  }
×
404
  if (normalizedValue <= legend.steps[0].value) {
×
405
    return am5.color(legend.steps[0].color);
×
406
  } else if (normalizedValue >= legend.steps[legend.steps.length - 1].value) {
×
407
    return am5.color(legend.steps[legend.steps.length - 1].color);
×
408
  } else {
×
409
    let upperTick = legend.steps[0];
×
410
    let lowerTick = legend.steps[0];
×
411
    for (let i = 1; i < legend.steps.length; i++) {
×
412
      if (normalizedValue <= legend.steps[i].value) {
×
413
        upperTick = legend.steps[i];
×
414
        lowerTick = legend.steps[i - 1];
×
415
        break;
×
416
      }
×
417
    }
×
418
    return am5.Color.interpolate(
×
419
      (normalizedValue - lowerTick.value) / (upperTick.value - lowerTick.value),
×
420
      am5.color(lowerTick.color),
×
421
      am5.color(upperTick.color)
×
422
    );
×
423
  }
×
424
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc