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

terrestris / react-geo / 12949804443

24 Jan 2025 12:39PM UTC coverage: 57.917% (+0.7%) from 57.197%
12949804443

push

github

web-flow
Merge pull request #4175 from ahennr/fix/timelayersliderpanel

TimeSlider / TimeLayerSliderPanel: Pass value correctly to layer / component and use DayJs

492 of 962 branches covered (51.14%)

Branch coverage included in aggregate %.

83 of 155 new or added lines in 2 files covered. (53.55%)

3 existing lines in 2 files now uncovered.

993 of 1602 relevant lines covered (61.99%)

11.29 hits per line

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

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

3
import {
4
  faCalendar,
5
  faPauseCircle,
6
  faPlayCircle,
7
  faSync
8
} from '@fortawesome/free-solid-svg-icons';
9
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
10
import logger from '@terrestris/base-util/dist/Logger';
11
import { WmsLayer } from '@terrestris/ol-util/dist/typeUtils/typeUtils';
12
import { DatePicker, Popover, Select, Spin } from 'antd';
13
import dayjs, { Dayjs } from 'dayjs';
14
import minmax from 'dayjs/plugin/minMax';
15
import _isFinite from 'lodash/isFinite';
16
import _isFunction from 'lodash/isFunction';
17
import _isNil from 'lodash/isNil';
18
import {ImageWMS, TileWMS} from 'ol/source';
19
import React, {useCallback, useEffect, useMemo, useState} from 'react';
20

21
import SimpleButton from '../../Button/SimpleButton/SimpleButton';
22
import ToggleButton from '../../Button/ToggleButton/ToggleButton';
23
import TimeSlider, {TimeSliderMark, TimeSliderProps} from '../../Slider/TimeSlider/TimeSlider';
24

25
dayjs.extend(minmax);
1✔
26

27
const RangePicker = DatePicker.RangePicker;
1✔
28
const Option = Select.Option;
1✔
29

30
export type TimeLayerSliderPanelTooltips = {
31
  dataRange: string;
32
  days: string;
33
  hours: string;
34
  minutes: string;
35
  months: string;
36
  setToMostRecent: string;
37
  weeks: string;
38
  years: string;
39
};
40

41
export type PlaybackSpeedUnit = 'minute' | 'hours' | 'days' | 'weeks' | 'months' | 'years';
42

43
export type TimeLayerSliderPanelProps = {
44
  autoPlaySpeedOptions?: number[];
45
  timeAwareLayers: WmsLayer[];
46
  tooltips?: TimeLayerSliderPanelTooltips;
47
} & TimeSliderProps & React.HTMLAttributes<HTMLDivElement>;
48

49
/**
50
 * The panel combining all time slider related parts.
51
 */
52
export const TimeLayerSliderPanel: React.FC<TimeLayerSliderPanelProps> = ({
1✔
53
  autoPlaySpeedOptions = [0.5, 1, 2, 5, 10, 100, 300],
5✔
54
  className,
55
  formatString = 'YYYY-MM-DD HH:mm',
5✔
56
  max = dayjs(),
×
57
  min = dayjs().add(1, 'days'),
×
58
  onChange,
59
  onChangeComplete,
60
  timeAwareLayers = [],
×
61
  tooltips = {
5✔
62
    setToMostRecent: 'Set to most recent date',
63
    minutes: 'Minutes',
64
    hours: 'Hours',
65
    days: 'Days',
66
    weeks: 'Weeks',
67
    months: 'Months',
68
    years: 'Years',
69
    dataRange: 'Set data range'
70
  },
71
  value = dayjs()
5✔
72
}) => {
73

74
  const [playbackSpeed, setPlaybackSpeed] = useState<number>(1);
5✔
75
  const [playbackSpeedUnit, setPlaybackSpeedUnit] = useState<PlaybackSpeedUnit>('hours');
5✔
76
  const [autoPlayActive, setAutoPlayActive] = useState(false);
5✔
77
  const [startDate, setStartDate] = useState<Dayjs>(min);
5✔
78
  const [endDate, setEndDate] = useState<Dayjs>(max);
5✔
79
  const [loadingCount, setLoadingCount] = useState(0);
5✔
80

81
  const isLoading = loadingCount > 0;
5✔
82

83
  const autoPlay = () => setAutoPlayActive(prevState => !prevState);
5✔
84

85
  const onTimeChanged = useCallback((val: Dayjs | [Dayjs, Dayjs]) => {
5✔
NEW
86
    if (_isFunction(onChange)) {
×
NEW
87
      onChange(val);
×
88
    }
NEW
89
    if (_isFunction(onChangeComplete)) {
×
NEW
90
      onChangeComplete(val);
×
91
    }
92
  }, [onChange, onChangeComplete]);
93

94
  const updateDataRange = ([start, end]: [Dayjs, Dayjs]) => {
5✔
95
    setStartDate(start);
1✔
96
    setEndDate(end);
1✔
97
  };
98

99
  const setSliderToMostRecent = () => {
5✔
NEW
100
    setEndDate(max);
×
NEW
101
    wmsTimeHandler(max);
×
NEW
102
    onTimeChanged(max);
×
103
  };
104

105
  const onPlaybackSpeedChange= (val: number) => setPlaybackSpeed(val);
5✔
106

107
  const wmsTimeHandler = useCallback((val: Dayjs | [Dayjs, Dayjs]) => {
5✔
108
    timeAwareLayers.forEach(layer => {
4✔
109
      if (!_isNil(layer) && layer.get('type') === 'WMSTime') {
2!
NEW
110
        const source = layer.getSource();
×
NEW
111
        const params = source?.getParams();
×
112
        let time: Dayjs;
NEW
113
        if (Array.isArray(val)) {
×
NEW
114
          time = val[0];
×
115
        } else {
NEW
116
          time = val;
×
117
        }
118

NEW
119
        if (!time.isValid()) {
×
NEW
120
          logger.warn(`Invalid time value: ${time}`);
×
NEW
121
          return;
×
122
        }
123

NEW
124
        const timeFormat = layer.get('timeFormat');
×
125
        let newTimeParam: string;
NEW
126
        if (
×
127
          timeFormat.toLowerCase().includes('hh') &&
×
128
            layer.get('roundToFullHours')
129
        ) {
NEW
130
          time.set('minute', 0);
×
NEW
131
          time.set('second', 0);
×
NEW
132
          newTimeParam = time.toISOString();
×
133
        } else {
NEW
134
          newTimeParam = time.format(timeFormat);
×
135
        }
136

NEW
137
        if (params.TIME !== newTimeParam) {
×
NEW
138
          params.TIME = newTimeParam;
×
NEW
139
          source?.updateParams(params);
×
NEW
140
          source?.refresh();
×
141
        }
142
      }
143
    });
144
  }, [timeAwareLayers]);
145

146
  const findRangeForLayers = useCallback(() => {
5✔
147
    if (timeAwareLayers.length === 0) {
3✔
148
      return;
2✔
149
    }
150

151
    const startDatesFromLayers: Dayjs[] = [];
1✔
152
    const endDatesFromLayers: Dayjs[] = [];
1✔
153

154
    timeAwareLayers.forEach(l => {
1✔
155
      const layerType = l.get('type');
1✔
156
      if (layerType === 'WMSTime') {
1!
NEW
157
        const layerStartDate = l.get('startDate');
×
NEW
158
        const layerEndDate = l.get('endDate');
×
159
        let sdm;
160
        let edm;
NEW
161
        if (layerStartDate) {
×
NEW
162
          sdm = dayjs(layerStartDate);
×
163
        }
NEW
164
        if (layerEndDate) {
×
NEW
165
          edm = dayjs(layerEndDate);
×
166
        }
NEW
167
        if (sdm) {
×
NEW
168
          startDatesFromLayers.push(sdm);
×
169
        }
NEW
170
        if (edm) {
×
NEW
171
          endDatesFromLayers.push(edm);
×
172
        }
173
      }
174
    });
175

176
    const newStartDate = startDatesFromLayers.length > 0 ? dayjs.min(startDatesFromLayers): startDate;
1!
177
    const newEndDate = endDatesFromLayers.length > 0 ? dayjs.max(endDatesFromLayers) : endDate;
1!
178
    if (!_isNil(newStartDate) && !_isNil(newEndDate)) {
1!
179
      updateDataRange([newStartDate, newEndDate]);
1✔
180
    }
181
  }, [endDate, startDate, timeAwareLayers]);
182

183
  const onPlaybackUnitChange = (unit: PlaybackSpeedUnit) => setPlaybackSpeedUnit(unit);
5✔
184

185
  useEffect(() => {
5✔
186
    if (timeAwareLayers.length === 0) {
3✔
187
      return;
2✔
188
    }
189

190
    const handleTileLoadStart = () => {
1✔
NEW
191
      setLoadingCount(prevCount => prevCount + 1);
×
192
    };
193
    const handleTileLoadEnd = () => {
1✔
NEW
194
      setLoadingCount(prevCount => Math.max(prevCount - 1, 0));
×
195
    };
196
    const handleImageLoadStart = () => {
1✔
NEW
197
      setLoadingCount(prevCount => prevCount + 1);
×
198
    };
199
    const handleImageLoadEnd = () => {
1✔
NEW
200
      setLoadingCount(prevCount => Math.max(prevCount - 1, 0));
×
201
    };
202

203
    timeAwareLayers.forEach(layer => {
1✔
204
      if (layer.get('type') === 'WMSTime') {
1!
NEW
205
        const source = layer.getSource();
×
206

NEW
207
        if (source instanceof TileWMS) {
×
NEW
208
          source.on('tileloadstart', handleTileLoadStart);
×
NEW
209
          source.on('tileloadend', handleTileLoadEnd);
×
NEW
210
          source.on('tileloaderror', handleTileLoadEnd);
×
NEW
211
        } else if (source instanceof ImageWMS) {
×
NEW
212
          source.on('imageloadstart', handleImageLoadStart);
×
NEW
213
          source.on('imageloadend', handleImageLoadEnd);
×
NEW
214
          source.on('imageloaderror', handleImageLoadEnd);
×
215
        }
216
      }
217
    });
218

219
    return () => {
1✔
220
      timeAwareLayers.forEach(layer => {
1✔
221
        if (layer.get('type') === 'WMSTime') {
1!
222
          const source = layer.getSource();
×
223

224
          if (source instanceof TileWMS) {
×
NEW
225
            source.un('tileloadstart', handleTileLoadStart);
×
NEW
226
            source.un('tileloadend', handleTileLoadEnd);
×
NEW
227
            source.un('tileloaderror', handleTileLoadEnd);
×
228
          } else if (source instanceof ImageWMS) {
×
NEW
229
            source.un('imageloadstart', handleImageLoadStart);
×
NEW
230
            source.un('imageloadend', handleImageLoadEnd);
×
NEW
231
            source.un('imageloaderror', handleImageLoadEnd);
×
232
          }
233
        }
234
      });
235
    };
236
  }, [timeAwareLayers]);
237

238
  useEffect(() => {
5✔
239
    if (!autoPlayActive || Array.isArray(value)) {
4✔
240
      return;
3✔
241
    }
242

243
    const interval = window.setInterval(() => {
1✔
NEW
244
      if (value >= endDate) {
×
NEW
245
        clearInterval(interval);
×
NEW
246
        setAutoPlayActive(false);
×
UNCOV
247
        return;
×
248
      }
NEW
249
      const newValue = value.clone();
×
250

NEW
251
      if (_isFinite(playbackSpeed)) {
×
NEW
252
        onTimeChanged(newValue.clone().add(playbackSpeed, playbackSpeedUnit));
×
253
      } else {
NEW
254
        const time = dayjs(
×
255
          newValue
256
            .clone()
257
            .add(1, playbackSpeedUnit)
258
            .format()
259
        );
NEW
260
        onTimeChanged(time);
×
261
      }
262
    }, 1000);
263
    return () => clearInterval(interval);
1✔
264
  }, [autoPlayActive, endDate, playbackSpeed, wmsTimeHandler, onChange, playbackSpeedUnit, onTimeChanged, value]);
265

266
  useEffect(() => {
5✔
267
    setStartDate(min);
3✔
268
    setEndDate(max);
3✔
269
  }, [min, max]);
270

271
  useEffect(() => {
5✔
272
    if (!_isNil(timeAwareLayers)) {
3!
273
      findRangeForLayers();
3✔
274
    }
275
  }, [timeAwareLayers, findRangeForLayers]);
276

277
  useEffect(() => {
5✔
278
    // update time for all time aware layers if value changes
279
    wmsTimeHandler(value);
4✔
280
  }, [value, wmsTimeHandler]);
281

282
  const futureClass = useMemo(() => {
5✔
283
    if (Array.isArray(value)) {
5!
NEW
284
      return '';
×
285
    }
286
    return dayjs().isBefore(value) ? ' timeslider-in-future' : '';
5!
287
  }, [value]);
288
  const extraCls = className ?? '';
5✔
289
  const disabledCls = useMemo(() => {
5✔
290
    return timeAwareLayers.length < 1 ? 'no-layers-available' : '';
3✔
291
  }, [timeAwareLayers]);
292

293
  const marks: TimeSliderMark[] = useMemo(() => {
5✔
294
    if (_isNil(startDate) || _isNil(endDate)) {
3!
NEW
295
      return [];
×
296
    }
297
    const mid = startDate!.clone().add(endDate!.diff(startDate) / 2);
3✔
298
    return [{
3✔
299
      timestamp: startDate,
300
      markConfig: {
301
        label: startDate.format(formatString)
302
      }
303
    }, {
304
      timestamp: mid,
305
      markConfig: {
306
        label: mid.format(formatString)
307
      }
308
    },{
309
      timestamp: endDate,
310
      markConfig: {
311
        label: endDate.format(formatString),
312
        style: {
313
          left: 'unset',
314
          right: 0,
315
          transform: 'translate(50%)'
316
        }
317
      }
318
    }] satisfies TimeSliderMark[];
319
  }, [endDate, formatString, startDate]);
320

321
  const speedOptions = useMemo(() => autoPlaySpeedOptions.map(function (val: number) {
5✔
322
    return (
35✔
323
      <Option
324
        key={val}
325
        value={val}
326
      >
327
        {val}
328
      </Option>
329
    );
330
  }), [autoPlaySpeedOptions]);
331

332
  return (
5✔
333
    <div className={`time-layer-slider ${disabledCls}`.trim()}>
334
      <Popover
335
        placement="topRight"
336
        title={tooltips.dataRange}
337
        trigger="click"
338
        content={
339
          <RangePicker
340
            defaultValue={[startDate, endDate]}
341
            onOk={range => {
NEW
342
              if (!range) {
×
NEW
343
                return;
×
344
              }
NEW
345
              const [start, end] = range;
×
NEW
346
              if (!start || !end) {
×
NEW
347
                return;
×
348
              }
349

NEW
350
              updateDataRange([start, end]);
×
351
            }}
352
            showTime={{ format: 'HH:mm' }}
353
          />
354
        }
355
      >
356
        <SimpleButton
357
          className="change-datarange-button"
358
          icon={<FontAwesomeIcon icon={faCalendar} />}
359
        />
360
      </Popover>
361
      <SimpleButton
362
        icon={<FontAwesomeIcon icon={faSync} />}
363
        onClick={setSliderToMostRecent}
364
        tooltip={tooltips.setToMostRecent}
365
        type="primary"
366
      />
367
      <div className="time-slider-container">
368
        <TimeSlider
369
          className={`${extraCls} timeslider ${futureClass}`.trim()}
370
          defaultValue={startDate}
371
          formatString={formatString}
372
          marks={marks}
373
          max={endDate}
374
          min={startDate}
375
          onChangeComplete={onTimeChanged}
376
          value={value}
377
        />
378
        <div className="spin-indicator">
379
          <Spin spinning={isLoading} size="small" />
380
        </div>
381
      </div>
382
      {
383
        !Array.isArray(value) && (
10✔
384
          <div className="time-value">
385
            {value.format(formatString || 'DD.MM.YYYY HH:mm:ss')}
5!
386
          </div>
387
        )
388
      }
389
      <ToggleButton
390
        aria-label={autoPlayActive ? 'Pause' : 'Autoplay'}
5✔
391
        className={extraCls + ' playback'}
392
        icon={<FontAwesomeIcon icon={faPlayCircle} />}
393
        onChange={autoPlay}
394
        pressed={autoPlayActive}
395
        pressedIcon={<FontAwesomeIcon icon={faPauseCircle} />}
396
        tooltip={autoPlayActive ? 'Pause' : 'Autoplay'}
5✔
397
        type="primary"
398
      />
399
      <Select<number>
400
        className={extraCls + ' speed-picker'}
401
        defaultValue={1}
402
        dropdownStyle={{ minWidth: '100px' }}
403
        onChange={onPlaybackSpeedChange}
404
        popupMatchSelectWidth={false}
405
        value={playbackSpeed}
406
      >
407
        {speedOptions}
408
      </Select>
409
      <span>x</span>
410
      <Select<PlaybackSpeedUnit>
411
        className={extraCls + ' speed-picker'}
412
        defaultValue={'minute'}
413
        dropdownStyle={{ minWidth: '100px' }}
414
        onChange={onPlaybackUnitChange}
415
        popupMatchSelectWidth={false}
416
        value={playbackSpeedUnit}
417
      >
418
        <Option value="minutes">{tooltips.minutes}</Option>
419
        <Option value="hours">{tooltips.hours}</Option>
420
        <Option value="days">{tooltips.days}</Option>
421
        <Option value="weeks">{tooltips.weeks}</Option>
422
        <Option value="months">{tooltips.months}</Option>
423
        <Option value="years">{tooltips.years}</Option>
424
      </Select>
425
    </div>
426
  );
427
};
428

429
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

© 2026 Coveralls, Inc