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

terrestris / react-geo / 18933910017

30 Oct 2025 08:04AM UTC coverage: 67.733%. Remained the same
18933910017

push

github

web-flow
Merge pull request #4435 from terrestris/dependabot/npm_and_yarn/babel/preset-typescript-7.28.5

build(deps-dev): bump @babel/preset-typescript from 7.27.1 to 7.28.5

672 of 1082 branches covered (62.11%)

Branch coverage included in aggregate %.

1234 of 1732 relevant lines covered (71.25%)

13.13 hits per line

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

49.84
/src/Panel/TimeLayerSliderPanel/TimeLayerSliderPanel.tsx
1
import './TimeLayerSliderPanel.less';
2

3
import React, { useMemo, useCallback, useEffect, useState } from 'react';
4

5
import {
6
  faCalendar,
7
  faPauseCircle,
8
  faPlayCircle,
9
  faSync
10
} from '@fortawesome/free-solid-svg-icons';
11
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
12

13
import { DatePicker, Popover, Select, Spin } from 'antd';
14
import dayjs, { Dayjs } from 'dayjs';
15
import minmax from 'dayjs/plugin/minMax';
16
import { parse, end, Duration } from 'iso8601-duration';
17
import _isFinite from 'lodash/isFinite';
18
import _isFunction from 'lodash/isFunction';
19
import _isNil from 'lodash/isNil';
20
import _isString from 'lodash/isString';
21
import {ImageWMS, TileWMS} from 'ol/source';
22

23
import logger from '@terrestris/base-util/dist/Logger';
24
import { WmsLayer } from '@terrestris/ol-util/dist/typeUtils/typeUtils';
25

26
import SimpleButton from '../../Button/SimpleButton/SimpleButton';
27
import ToggleButton from '../../Button/ToggleButton/ToggleButton';
28
import TimeSlider, {TimeSliderMark, TimeSliderProps} from '../../Slider/TimeSlider/TimeSlider';
29

30
dayjs.extend(minmax);
1✔
31

32
const RangePicker = DatePicker.RangePicker;
1✔
33
const Option = Select.Option;
1✔
34

35
export interface TimeLayerSliderPanelTooltips{
36
  dataRange: string;
37
  days: string;
38
  hours: string;
39
  minutes: string;
40
  months: string;
41
  setToMostRecent: string;
42
  weeks: string;
43
  years: string;
44
}
45

46
export type PlaybackSpeedUnit = 'minute' | 'hours' | 'days' | 'weeks' | 'months' | 'years';
47

48
export type TimeLayerSliderPanelProps = {
49
  autoPlaySpeedOptions?: number[];
50
  timeAwareLayers: WmsLayer[];
51
  tooltips?: TimeLayerSliderPanelTooltips;
52
} & TimeSliderProps & React.HTMLAttributes<HTMLDivElement>;
53

54
const defaultTimeFormat = 'DD.MM.YYYY HH:mm:ss';
1✔
55
const iso8601Format = 'YYYY-MM-DDTHH:mm:ss.SSS[Z]';
1✔
56

57
/**
58
 * The panel combining all time slider related parts.
59
 */
60
export const TimeLayerSliderPanel: React.FC<TimeLayerSliderPanelProps> = ({
1✔
61
  autoPlaySpeedOptions = [0.5, 1, 2, 5, 10, 100, 300],
5✔
62
  className,
63
  duration,
64
  formatString,
65
  max = dayjs(),
×
66
  markFormatString,
67
  maxNumberOfMarks = 10,
5✔
68
  min = dayjs().subtract(1, 'days'),
×
69
  onChange,
70
  onChangeComplete,
71
  timeAwareLayers = [],
×
72
  tooltips = {
5✔
73
    setToMostRecent: 'Set to most recent date',
74
    minutes: 'Minutes',
75
    hours: 'Hours',
76
    days: 'Days',
77
    weeks: 'Weeks',
78
    months: 'Months',
79
    years: 'Years',
80
    dataRange: 'Set data range'
81
  },
82
  value = dayjs()
5✔
83
}) => {
84

85
  const [playbackSpeed, setPlaybackSpeed] = useState<number>(1);
5✔
86
  const [playbackSpeedUnit, setPlaybackSpeedUnit] = useState<PlaybackSpeedUnit>('hours');
5✔
87
  const [autoPlayActive, setAutoPlayActive] = useState(false);
5✔
88
  const [startDate, setStartDate] = useState<Dayjs>();
5✔
89
  const [endDate, setEndDate] = useState<Dayjs>();
5✔
90
  const [loadingCount, setLoadingCount] = useState(0);
5✔
91

92
  const isLoading = useMemo(() => loadingCount > 0, [loadingCount]);
5✔
93

94
  const parseDuration = useCallback((d: string | Duration): Duration | undefined => {
5✔
95
    if (_isString(d)) {
1!
96
      return parse(d);
×
97
    }
98
    return d;
1✔
99
  }, []);
100

101
  const parseTimeFormat = useCallback((tf?: string): string => {
5✔
102
    if (_isNil(tf)) {
1!
103
      return defaultTimeFormat;
1✔
104
    }
105
    if (tf?.toLocaleLowerCase() === 'iso8601') {
×
106
      return iso8601Format;
×
107
    }
108
    return tf;
×
109
  }, []);
110

111
  const durationInternal = useMemo(() => {
5✔
112
    if (!_isNil(duration)) {
3!
113
      return parseDuration(duration);
×
114
    }
115

116
    if (!_isNil(timeAwareLayers) && timeAwareLayers.length > 0) {
3✔
117
      // Currently we only support a single time aware layer.
118
      return parseDuration(timeAwareLayers[0].get('duration'));
1✔
119
    }
120

121
    return undefined;
2✔
122
  }, [duration, timeAwareLayers, parseDuration]);
123

124
  const formatStringInternal = useMemo((): string => {
5✔
125
    if (!_isNil(formatString)) {
3!
126
      return parseTimeFormat(formatString);
×
127
    }
128

129
    if (!_isNil(timeAwareLayers) && timeAwareLayers.length > 0) {
3✔
130
      // Currently we only support a single time aware layer.
131
      return parseTimeFormat(timeAwareLayers[0].get('timeFormat'));
1✔
132
    }
133

134
    return defaultTimeFormat; // Default format
2✔
135
  }, [formatString, parseTimeFormat, timeAwareLayers]);
136

137
  const autoPlay = () => setAutoPlayActive(prevState => !prevState);
5✔
138

139
  const onTimeChanged = useCallback((val: Dayjs | [Dayjs, Dayjs]) => {
5✔
140
    if (_isFunction(onChange)) {
×
141
      onChange(val);
×
142
    }
143
    if (_isFunction(onChangeComplete)) {
×
144
      onChangeComplete(val);
×
145
    }
146
  }, [onChange, onChangeComplete]);
147

148
  const updateDataRange = ([startDayjs, endDayjs]: [Dayjs, Dayjs]) => {
5✔
149
    setStartDate(startDayjs);
1✔
150
    setEndDate(endDayjs);
1✔
151
  };
152

153
  const setSliderToMostRecent = () => {
5✔
154
    setEndDate(max);
×
155
    wmsTimeHandler(max);
×
156
    onTimeChanged(max);
×
157
  };
158

159
  const onPlaybackSpeedChange= (val: number) => setPlaybackSpeed(val);
5✔
160

161
  const wmsTimeHandler = useCallback((val: Dayjs | [Dayjs, Dayjs]) => {
5✔
162
    timeAwareLayers.forEach(layer => {
5✔
163
      if (!_isNil(layer) && layer.get('type') === 'WMSTime') {
3!
164
        const source = layer.getSource();
×
165
        const params = source?.getParams();
×
166
        let time: Dayjs;
167
        if (Array.isArray(val)) {
×
168
          time = val[0];
×
169
        } else {
170
          time = val;
×
171
        }
172

173
        if (!time.isValid()) {
×
174
          logger.warn(`Invalid time value: ${time}`);
×
175
          return;
×
176
        }
177

178
        let newTimeParam: string;
179
        if (
×
180
          formatStringInternal?.toLowerCase().includes('hh') &&
×
181
          layer.get('roundToFullHours')
182
        ) {
183
          time.set('minute', 0);
×
184
          time.set('second', 0);
×
185
          newTimeParam = time.toISOString();
×
186
        } else {
187
          newTimeParam = time.format(formatStringInternal);
×
188
        }
189

190
        if (params.TIME !== newTimeParam) {
×
191
          params.TIME = newTimeParam;
×
192
          source?.updateParams(params);
×
193
          source?.refresh();
×
194
        }
195
      }
196
    });
197
  }, [timeAwareLayers, formatStringInternal]);
198

199
  const findRangeForLayers = useCallback(() => {
5✔
200
    if (timeAwareLayers.length === 0) {
3✔
201
      return;
2✔
202
    }
203

204
    const startDatesFromLayers: Dayjs[] = [];
1✔
205
    const endDatesFromLayers: Dayjs[] = [];
1✔
206

207
    timeAwareLayers.forEach(l => {
1✔
208
      const layerType = l.get('type');
1✔
209
      if (layerType === 'WMSTime') {
1!
210
        const layerStartDate = l.get('startDate');
×
211
        const layerEndDate = l.get('endDate');
×
212
        let sdm;
213
        let edm;
214
        if (layerStartDate) {
×
215
          sdm = dayjs(layerStartDate);
×
216
        }
217
        if (layerEndDate) {
×
218
          edm = dayjs(layerEndDate);
×
219
        }
220
        if (sdm) {
×
221
          startDatesFromLayers.push(sdm);
×
222
        }
223
        if (edm) {
×
224
          endDatesFromLayers.push(edm);
×
225
        }
226
      }
227
    });
228

229
    const newStartDate = startDatesFromLayers.length > 0 ? dayjs.min(startDatesFromLayers): min;
1!
230
    const newEndDate = endDatesFromLayers.length > 0 ? dayjs.max(endDatesFromLayers) : max;
1!
231
    if (!_isNil(newStartDate) && !_isNil(newEndDate)) {
1!
232
      updateDataRange([newStartDate, newEndDate]);
1✔
233
    }
234
  }, [timeAwareLayers, min, max]);
235

236
  const onPlaybackUnitChange = (unit: PlaybackSpeedUnit) => setPlaybackSpeedUnit(unit);
5✔
237

238
  useEffect(() => {
5✔
239
    if (timeAwareLayers.length === 0) {
3✔
240
      return;
2✔
241
    }
242

243
    const handleTileLoadStart = () => {
1✔
244
      setLoadingCount(prevCount => prevCount + 1);
×
245
    };
246
    const handleTileLoadEnd = () => {
1✔
247
      setLoadingCount(prevCount => Math.max(prevCount - 1, 0));
×
248
    };
249
    const handleImageLoadStart = () => {
1✔
250
      setLoadingCount(prevCount => prevCount + 1);
×
251
    };
252
    const handleImageLoadEnd = () => {
1✔
253
      setLoadingCount(prevCount => Math.max(prevCount - 1, 0));
×
254
    };
255

256
    timeAwareLayers.forEach(layer => {
1✔
257
      if (layer.get('type') === 'WMSTime') {
1!
258
        const source = layer.getSource();
×
259

260
        if (source instanceof TileWMS) {
×
261
          source.on('tileloadstart', handleTileLoadStart);
×
262
          source.on('tileloadend', handleTileLoadEnd);
×
263
          source.on('tileloaderror', handleTileLoadEnd);
×
264
        } else if (source instanceof ImageWMS) {
×
265
          source.on('imageloadstart', handleImageLoadStart);
×
266
          source.on('imageloadend', handleImageLoadEnd);
×
267
          source.on('imageloaderror', handleImageLoadEnd);
×
268
        }
269
      }
270
    });
271

272
    return () => {
1✔
273
      timeAwareLayers.forEach(layer => {
1✔
274
        if (layer.get('type') === 'WMSTime') {
1!
275
          const source = layer.getSource();
×
276

277
          if (source instanceof TileWMS) {
×
278
            source.un('tileloadstart', handleTileLoadStart);
×
279
            source.un('tileloadend', handleTileLoadEnd);
×
280
            source.un('tileloaderror', handleTileLoadEnd);
×
281
          } else if (source instanceof ImageWMS) {
×
282
            source.un('imageloadstart', handleImageLoadStart);
×
283
            source.un('imageloadend', handleImageLoadEnd);
×
284
            source.un('imageloaderror', handleImageLoadEnd);
×
285
          }
286
        }
287
      });
288
    };
289
  }, [timeAwareLayers]);
290

291
  useEffect(() => {
5✔
292
    if (!autoPlayActive || Array.isArray(value) || _isNil(endDate)) {
5✔
293
      return;
4✔
294
    }
295

296
    const interval = window.setInterval(() => {
1✔
297
      if (value >= endDate) {
×
298
        clearInterval(interval);
×
299
        setAutoPlayActive(false);
×
300
        return;
×
301
      }
302
      const newValue = value.clone();
×
303

304
      if (_isFinite(playbackSpeed)) {
×
305
        onTimeChanged(newValue.clone().add(playbackSpeed, playbackSpeedUnit));
×
306
      } else {
307
        const time = dayjs(
×
308
          newValue
309
            .clone()
310
            .add(1, playbackSpeedUnit)
311
            .format()
312
        );
313
        onTimeChanged(time);
×
314
      }
315
    }, 1000);
316
    return () => clearInterval(interval);
1✔
317
  }, [autoPlayActive, endDate, playbackSpeed, wmsTimeHandler, onChange, playbackSpeedUnit, onTimeChanged, value]);
318

319
  useEffect(() => {
5✔
320
    if (!_isNil(timeAwareLayers) && _isNil(startDate) && _isNil(endDate)) {
4✔
321
      findRangeForLayers();
3✔
322
    }
323
  }, [timeAwareLayers, findRangeForLayers, startDate, endDate]);
324

325
  useEffect(() => {
5✔
326
    if (_isNil(formatStringInternal)) {
5!
327
      return;
×
328
    }
329
    // update time for all time aware layers if value changes
330
    wmsTimeHandler(value);
5✔
331
  }, [value, formatStringInternal, wmsTimeHandler]);
332

333
  const futureClass = useMemo(() => {
5✔
334
    if (Array.isArray(value)) {
5!
335
      return '';
×
336
    }
337
    return dayjs().isBefore(value) ? ' timeslider-in-future' : '';
5!
338
  }, [value]);
339
  const extraCls = className ?? '';
5✔
340
  const disabledCls = useMemo(() => {
5✔
341
    return timeAwareLayers.length < 1 ? 'no-layers-available' : '';
3✔
342
  }, [timeAwareLayers]);
343

344
  const marks: TimeSliderMark[] = useMemo(() => {
5✔
345
    if (_isNil(startDate) || _isNil(endDate)) {
4✔
346
      return [];
3✔
347
    }
348
    const mid = startDate!.clone().add(endDate!.diff(startDate) / 2);
1✔
349
    if (_isNil(durationInternal)) {
1!
350
      return [{
1✔
351
        timestamp: startDate,
352
        markConfig: {
353
          label: startDate.format(markFormatString ?? formatStringInternal)
2✔
354
        }
355
      }, {
356
        timestamp: mid,
357
        markConfig: {
358
          label: mid.format(markFormatString ?? formatStringInternal)
2✔
359
        }
360
      },{
361
        timestamp: endDate,
362
        markConfig: {
363
          label: endDate.format(markFormatString ?? formatStringInternal),
2✔
364
          style: {
365
            left: 'unset',
366
            right: 0,
367
            transform: 'translate(50%)'
368
          }
369
        }
370
      }] satisfies TimeSliderMark[];
371
    }
372

373
    // If a duration is given, we create marks for the start, end and every possible step in between.
374
    const durationBasedMarks: TimeSliderMark[] = [{
×
375
      timestamp: startDate,
376
      markConfig: {
377
        label: startDate.format(markFormatString ?? formatStringInternal)
×
378
      }
379
    }];
380

381
    const durationInst = durationInternal;
×
382
    let nextCandidate = dayjs(end(durationInst, startDate.toDate()));
×
383
    while (nextCandidate.isBefore(endDate) || nextCandidate.isSame(endDate)) {
×
384
      durationBasedMarks.push({
×
385
        timestamp: nextCandidate,
386
        markConfig: {
387
          label: nextCandidate.format(markFormatString ?? formatStringInternal)
×
388
        }
389
      });
390
      nextCandidate = dayjs(end(durationInst, nextCandidate.toDate()));
×
391
    }
392

393
    if (durationBasedMarks.length > maxNumberOfMarks) {
×
394
      const step = Math.ceil(durationBasedMarks.length / maxNumberOfMarks);
×
395
      // If we have more marks than the maximum, we reduce the number of marks
396
      // by taking only every nth mark.
397
      return durationBasedMarks.filter((_, index) => index % step === 0);
×
398
    }
399

400
    return durationBasedMarks;
×
401
  }, [startDate, endDate, durationInternal, markFormatString, formatStringInternal, maxNumberOfMarks]);
402

403
  const speedOptions = useMemo(() => autoPlaySpeedOptions.map(function (val: number) {
5✔
404
    return (
35✔
405
      <Option
406
        key={val}
407
        value={val}
408
      >
409
        {val}
410
      </Option>
411
    );
412
  }), [autoPlaySpeedOptions]);
413

414
  return (
5✔
415
    <div className={`time-layer-slider ${disabledCls}`.trim()}>
416
      <Popover
417
        placement="topRight"
418
        title={tooltips.dataRange}
419
        trigger="click"
420
        content={
421
          <RangePicker
422
            defaultValue={[startDate, endDate]}
423
            onOk={range => {
424
              if (!range) {
×
425
                return;
×
426
              }
427
              const [startRange, endRange] = range;
×
428
              if (!startRange || !endRange) {
×
429
                return;
×
430
              }
431

432
              updateDataRange([startRange, endRange]);
×
433
            }}
434
            showTime={{ format: 'HH:mm' }}
435
          />
436
        }
437
      >
438
        <SimpleButton
439
          className="change-datarange-button"
440
          icon={<FontAwesomeIcon icon={faCalendar} />}
441
        />
442
      </Popover>
443
      <SimpleButton
444
        icon={<FontAwesomeIcon icon={faSync} />}
445
        onClick={setSliderToMostRecent}
446
        tooltip={tooltips.setToMostRecent}
447
        type="primary"
448
      />
449
      <div className="time-slider-container">
450
        <TimeSlider
451
          className={`${extraCls} timeslider ${futureClass}`.trim()}
452
          defaultValue={startDate}
453
          duration={durationInternal}
454
          formatString={formatStringInternal}
455
          markFormatString={markFormatString}
456
          marks={marks}
457
          max={endDate}
458
          min={startDate}
459
          onChangeComplete={onTimeChanged}
460
          value={value}
461
        />
462
        <div className="spin-indicator">
463
          <Spin spinning={isLoading} size="small" />
464
        </div>
465
      </div>
466
      {
467
        !Array.isArray(value) && (
10✔
468
          <div className="time-value">
469
            {value.format(markFormatString ?? formatStringInternal ?? 'DD.MM.YYYY HH:mm:ss')}
10!
470
          </div>
471
        )
472
      }
473
      <ToggleButton
474
        aria-label={autoPlayActive ? 'Pause' : 'Autoplay'}
5✔
475
        className={extraCls + ' playback'}
476
        icon={<FontAwesomeIcon icon={faPlayCircle} />}
477
        onChange={autoPlay}
478
        pressed={autoPlayActive}
479
        pressedIcon={<FontAwesomeIcon icon={faPauseCircle} />}
480
        tooltip={autoPlayActive ? 'Pause' : 'Autoplay'}
5✔
481
        type="primary"
482
      />
483
      <Select<number>
484
        className={extraCls + ' speed-picker'}
485
        defaultValue={1}
486
        dropdownStyle={{ minWidth: '100px' }}
487
        onChange={onPlaybackSpeedChange}
488
        popupMatchSelectWidth={false}
489
        value={playbackSpeed}
490
      >
491
        {speedOptions}
492
      </Select>
493
      <span>x</span>
494
      <Select<PlaybackSpeedUnit>
495
        className={extraCls + ' speed-picker'}
496
        defaultValue={'minute'}
497
        dropdownStyle={{ minWidth: '100px' }}
498
        onChange={onPlaybackUnitChange}
499
        popupMatchSelectWidth={false}
500
        value={playbackSpeedUnit}
501
      >
502
        <Option value="minutes">{tooltips.minutes}</Option>
503
        <Option value="hours">{tooltips.hours}</Option>
504
        <Option value="days">{tooltips.days}</Option>
505
        <Option value="weeks">{tooltips.weeks}</Option>
506
        <Option value="months">{tooltips.months}</Option>
507
        <Option value="years">{tooltips.years}</Option>
508
      </Select>
509
    </div>
510
  );
511
};
512

513
export default TimeLayerSliderPanel;
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

© 2025 Coveralls, Inc