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

DLR-SC / ESID / 15257190808

26 May 2025 03:08PM UTC coverage: 51.267% (-0.9%) from 52.215%
15257190808

push

github

kunkoala
:wrench: enhance drag and drop functionalities

- Added `isDragging` prop to `DataCard`, `CardTooltip`, and `MainCard` components to manage drag state.
- Updated tooltip visibility logic to show when dragging or hovering.
- Adjusted cursor style during dragging for better user experience.

397 of 504 branches covered (78.77%)

Branch coverage included in aggregate %.

8 of 8 new or added lines in 3 files covered. (100.0%)

119 existing lines in 12 files now uncovered.

3771 of 7626 relevant lines covered (49.45%)

4.72 hits per line

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

63.21
/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';
1✔
11
import {useTheme} from '@mui/material/styles';
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

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

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

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

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

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

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

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

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

52
  /** The default selected region's data. */
53
  defaultSelectedValue: GeoJsonProperties;
54

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

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

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

64
  /** The currently selected region's data. */
65
  selectedArea: GeoJsonProperties;
66

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

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

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

76
  /** The heatmap legend configuration. */
77
  legend: HeatmapLegend;
78

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

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

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

88
  /** Optional localization settings for the heatmap. */
89
  localization?: Localization;
90

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

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

126
  const root = useRoot(mapId);
5✔
127

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

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

142
  const chart = useMapChart(root, chartSettings);
5✔
143

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

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

174
  // 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.
175
  useLayoutEffect(() => {
5✔
176
    if (!polygonSeries) return;
2✔
177
    const polygonTemplate = polygonSeries.mapPolygons.template;
1✔
178

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

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

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

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

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

267
    const handleDataValidated = () => {
1✔
268
      if (!polygonSeries.isDisposed()) {
1✔
269
        updatePolygons();
1✔
270
      }
1✔
271
    };
1✔
272

273
    polygonSeries.events.on('datavalidated', handleDataValidated);
1✔
274
    handleDataValidated();
1✔
275

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

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

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

293
        values.forEach((value) => valueMap.set(value.id, value.value));
2✔
294

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

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

327
    const handleDataValidated = () => {
2✔
328
      if (!polygonSeries.isDisposed()) {
2✔
329
        updatePolygons();
2✔
330
      }
2✔
331
    };
2✔
332

333
    polygonSeries.events.on('datavalidated', handleDataValidated);
2✔
334
    handleDataValidated();
2✔
335

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

356
  return (
5✔
357
    <Box
5✔
358
      id={mapId}
5✔
359
      height={mapHeight}
5✔
360
      sx={{
5✔
361
        position: 'relative',
5✔
362
        width: '100%',
5✔
363
      }}
5✔
364
    >
365
      {chart && <MapControlBar chart={chart} onHomeClick={handleHomeClick} maxZoomLevel={maxZoomLevel} />}
5✔
366
    </Box>
5✔
367
  );
368
}
5✔
369

UNCOV
370
function getColorFromLegend(
×
UNCOV
371
  value: number,
×
UNCOV
372
  legend: HeatmapLegend,
×
UNCOV
373
  aggregatedMinMax?: {min: number; max: number}
×
UNCOV
374
): am5.Color {
×
375
  // assume legend stops are absolute
376
  let normalizedValue = value;
×
377
  // if aggregated values (min/max) are properly set, the legend items are already normalized => need to normalize value too
378
  if (aggregatedMinMax && aggregatedMinMax.min < aggregatedMinMax.max) {
×
379
    const {min: aggregatedMin, max: aggregatedMax} = aggregatedMinMax;
×
UNCOV
380
    normalizedValue = (value - aggregatedMin) / (aggregatedMax - aggregatedMin);
×
381
  } else if (aggregatedMinMax) {
×
382
    // log error if any of the above checks fail
383
    console.error('Error: invalid MinMax array in getColorFromLegend', [value, legend, aggregatedMinMax]);
×
384
    // return completely transparent fill if errors occur
385
    return am5.color('rgba(0,0,0,0)');
×
386
  }
×
UNCOV
387
  if (normalizedValue <= legend.steps[0].value) {
×
388
    return am5.color(legend.steps[0].color);
×
UNCOV
389
  } else if (normalizedValue >= legend.steps[legend.steps.length - 1].value) {
×
390
    return am5.color(legend.steps[legend.steps.length - 1].color);
×
391
  } else {
×
392
    let upperTick = legend.steps[0];
×
393
    let lowerTick = legend.steps[0];
×
394
    for (let i = 1; i < legend.steps.length; i++) {
×
395
      if (normalizedValue <= legend.steps[i].value) {
×
396
        upperTick = legend.steps[i];
×
397
        lowerTick = legend.steps[i - 1];
×
398
        break;
×
399
      }
×
400
    }
×
401
    return am5.Color.interpolate(
×
402
      (normalizedValue - lowerTick.value) / (upperTick.value - lowerTick.value),
×
403
      am5.color(lowerTick.color),
×
404
      am5.color(upperTick.color)
×
405
    );
×
406
  }
×
407
}
×
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