Skip to content

Commit 98a7c6a

Browse files
authored
Fix #3282. Implemented range animation (#3331)
1 parent f96f790 commit 98a7c6a

12 files changed

Lines changed: 112 additions & 61 deletions

File tree

web/client/actions/__tests__/timeline-test.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ const {
1515
RANGE_DATA_LOADED,
1616
rangeDataLoaded,
1717
LOADING,
18-
timeDataLoading
19-
} = require('../timeline');
18+
timeDataLoading,
19+
ENABLE_OFFSET,
20+
enableOffset
21+
} = require('../timeline');
2022

2123
describe('timeline actions', () => {
2224
it('onRangeChanged', () => {
@@ -39,4 +41,10 @@ describe('timeline actions', () => {
3941
expect(retVal).toExist();
4042
expect(retVal.type).toBe(LOADING);
4143
});
44+
it('enableOffset', () => {
45+
const retval = enableOffset(true);
46+
expect(retval).toExist();
47+
expect(retval.type).toBe(ENABLE_OFFSET);
48+
expect(retval.enabled).toBe(true);
49+
});
4250
});

web/client/actions/dimension.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
const UPDATE_LAYER_DIMENSION_DATA = "DIMENSION:UPDATE_LAYER_DIMENSION_DATA";
99
const SET_CURRENT_TIME = "TIME_MANAGER:SET_CURRENT_TIME";
1010
const SET_OFFSET_TIME = "TIME_MANAGER:SET_OFFSET_TIME";
11+
const MOVE_TIME = "TIME_MANAGER:MOVE_TIME";
1112

1213
/**
1314
*
@@ -20,7 +21,16 @@ const updateLayerDimensionData = (layerId, dimension, data) => ({ type: UPDATE_L
2021
* @param {string|date} time the current time to set
2122
*/
2223
const setCurrentTime = time => ({ type: SET_CURRENT_TIME, time });
24+
/**
25+
* Set the current offset.
26+
* @param {string} time the current offset time in ISO format. If undefined, the current time is implicit set to single time mode. (against range)
27+
*/
2328
const setCurrentOffset = offsetTime => ({ type: SET_OFFSET_TIME, offsetTime });
29+
/**
30+
* Set the current time and shift the current offset to maintain the same interval.
31+
* @param {string} time the current time to set in ISO format
32+
*/
33+
const moveTime = time => ({ type: MOVE_TIME, time});
2434

2535

2636
module.exports = {
@@ -29,6 +39,7 @@ module.exports = {
2939
setCurrentTime,
3040
SET_CURRENT_TIME,
3141
setCurrentOffset,
32-
SET_OFFSET_TIME
33-
42+
SET_OFFSET_TIME,
43+
moveTime,
44+
MOVE_TIME
3445
};

web/client/actions/timeline.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,11 @@ const SELECT_LAYER = "TIMELINE:SELECT_LAYER";
5656
*/
5757
const selectLayer = layerId => ({ type: SELECT_LAYER, layerId});
5858

59-
const SELECT_OFFSET = "TIMELINE:SELECT_OFFSET";
60-
const selectOffset = offset => ({ type: SELECT_OFFSET, offset});
61-
6259
const ENABLE_OFFSET = "TIMELINE:ENABLE_OFFSET";
60+
/**
61+
* Toggles ranged(offset) vs single time mode
62+
* @param {boolean} enabled if true, enables ranged mode
63+
*/
6364
const enableOffset = enabled => ({ type: ENABLE_OFFSET, enabled});
6465

6566
/**
@@ -83,8 +84,6 @@ module.exports = {
8384
timeDataLoading,
8485
SELECT_LAYER,
8586
selectLayer,
86-
SELECT_OFFSET,
87-
selectOffset,
8887
ENABLE_OFFSET,
8988
enableOffset
9089
};

web/client/epics/dimension.js

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ const { Observable } = require('rxjs');
33
const { updateLayerDimension, changeLayerProperties, ADD_LAYER} = require('../actions/layers');
44
const {MAP_CONFIG_LOADED} = require('../actions/config');
55

6-
const { SET_CURRENT_TIME, updateLayerDimensionData} = require('../actions/dimension');
7-
const { layersWithTimeDataSelector } = require('../selectors/dimension');
6+
const { SET_CURRENT_TIME, MOVE_TIME, SET_OFFSET_TIME, updateLayerDimensionData} = require('../actions/dimension');
7+
const { layersWithTimeDataSelector, offsetTimeSelector, currentTimeSelector } = require('../selectors/dimension');
88
const {describeDomains} = require('../api/MultiDim');
99
const { castArray, pick, find } = require('lodash');
1010

@@ -28,8 +28,13 @@ module.exports = {
2828
/**
2929
* Sync current time param of the layer with the current time element
3030
*/
31-
updateLayerDimensionOnCurrentTimeSelection: action$ =>
32-
action$.ofType(SET_CURRENT_TIME).switchMap(({time}) => Observable.of(updateLayerDimension('time', time))),
31+
updateLayerDimensionOnCurrentTimeSelection: (action$, { getState = () => { } } = {}) =>
32+
action$.ofType(SET_CURRENT_TIME, SET_OFFSET_TIME, MOVE_TIME).switchMap(() => {
33+
const currentTime = currentTimeSelector(getState());
34+
const offsetTime = offsetTimeSelector(getState());
35+
const time = offsetTime ? `${currentTime}/${offsetTime}` : currentTime;
36+
return Observable.of(updateLayerDimension('time', time));
37+
}),
3338

3439
/**
3540
* Check the presence of Multidimensional API extension, then setup layers properly.

web/client/epics/playback.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const {
1313
framesLoading
1414
} = require('../actions/playback');
1515
const {
16-
setCurrentTime, SET_CURRENT_TIME
16+
moveTime, SET_CURRENT_TIME, MOVE_TIME, SET_OFFSET_TIME
1717
} = require('../actions/dimension');
1818
const {
1919
selectLayer,
@@ -116,10 +116,10 @@ module.exports = {
116116
updateCurrentTimeFromAnimation: (action$, { getState = () => { } } = {}) =>
117117
action$.ofType(SET_CURRENT_FRAME)
118118
.map(() => currentFrameValueSelector(getState()))
119-
.map(t => t ? setCurrentTime(t) : stop()),
119+
.map(t => t ? moveTime(t) : stop()),
120120
timeDimensionPlayback: (action$, { getState = () => { } } = {}) =>
121121
action$.ofType(SET_FRAMES).exhaustMap(() =>
122-
Rx.Observable.interval(frameDurationSelector(getState()) * 1000)
122+
Rx.Observable.interval(frameDurationSelector(getState()) * 1000).startWith(0) // start immediately
123123
.let(pausable(
124124
action$
125125
.ofType(PLAY, PAUSE)
@@ -155,7 +155,7 @@ module.exports = {
155155
*/
156156
playbackFollowCursor: (action$, { getState = () => { } } = {}) =>
157157
action$
158-
.ofType(SET_CURRENT_TIME)
158+
.ofType(SET_CURRENT_TIME, MOVE_TIME, SET_OFFSET_TIME)
159159
.filter(() => statusSelector(getState()) === STATUS.PLAY && isOutOfRange(currentTimeSelector(getState()), rangeSelector(getState())))
160160
.switchMap(() => Rx.Observable.of(
161161
onRangeChanged(

web/client/epics/timeline.js

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ const Rx = require('rxjs');
33
const {isString, get, head, castArray} = require('lodash');
44
const moment = require('moment');
55

6-
const { SELECT_TIME, RANGE_CHANGED, ENABLE_OFFSET, timeDataLoading, rangeDataLoaded } = require('../actions/timeline');
6+
const { SELECT_TIME, RANGE_CHANGED, ENABLE_OFFSET, timeDataLoading, rangeDataLoaded, onRangeChanged } = require('../actions/timeline');
77
const { setCurrentTime, UPDATE_LAYER_DIMENSION_DATA, setCurrentOffset } = require('../actions/dimension');
88

99

1010
const {getLayerFromId} = require('../selectors/layers');
11-
const { rangeSelector, offsetEnabledSelector, selectedLayerName, selectedLayerUrl } = require('../selectors/timeline');
12-
const { layerTimeSequenceSelectorCreator, timeDataSelector, layersWithTimeDataSelector, offsetTimeSelector, currentTimeSelector } = require('../selectors/dimension');
11+
const { rangeSelector, selectedLayerName, selectedLayerUrl } = require('../selectors/timeline');
12+
const { layerTimeSequenceSelectorCreator, offsetEnabledSelector, timeDataSelector, layersWithTimeDataSelector, offsetTimeSelector, currentTimeSelector } = require('../selectors/dimension');
1313

1414
const { getNearestDate, roundRangeResolution, isTimeDomainInterval } = require('../utils/TimeUtils');
1515
const { getHistogram, describeDomains, getDomainValues } = require('../api/MultiDim');
@@ -164,24 +164,46 @@ module.exports = {
164164
}),
165165
/**
166166
* When offset is initiated this epic sets both initial current time and offset if any does not exist
167+
* The policy is:
168+
* - if current time is not defined, it will be placed to the center of the current timeline's viewport. If the viewport is undefined it is set to "now"
169+
* - if offsetTime is not defined, it will be placed at 1/ RATIO * (current viewport size) distance from current time (to make it visible). If viewport is not defined, 1 day from the current time
170+
* - At the end, if the viewport is not defined, it will be placed to center the current time. This way the range will be visible when the timeline is available.
171+
*
167172
*/
168173
settingInitialOffsetValue: (action$, {getState = () => {}} = {}) =>
169174
action$.ofType(ENABLE_OFFSET)
170175
.switchMap( (action) => {
176+
const RATIO = 5; // ratio of the size of the offset to set relative to the current viewport, if set
171177
const state = getState();
172178
const time = currentTimeSelector(state);
173-
const {start, end} = rangeSelector(state);
174-
const currentOffset = offsetTimeSelector(state);
175-
const rangeDistance = moment(end).diff(start);
176-
let currentMoment = moment(start).add(rangeDistance / 2 ).toISOString();
177-
178-
const initialOffsetTime = moment(time ? time : currentMoment).add(rangeDistance / 5);
179-
let setTime = action.enabled && !time ? Rx.Observable.of(setCurrentTime(currentMoment)) : Rx.Observable.empty();
180-
let setOff = action.enabled && !currentOffset || action.enabled && moment(currentOffset).diff(time) < 0 ? Rx.Observable.of(setCurrentOffset(initialOffsetTime.toISOString()))
181-
: Rx.Observable.empty();
182-
return setTime.concat(setOff);
179+
const currentViewRange = rangeSelector(state);
180+
// find out current viewport range, if exist, to define a good offset to use as default
181+
if (action.enabled) {
182+
const {
183+
start = 0,
184+
end = 1000 * 60 * 60 * 24 * RATIO // this makes the offset 1 day by default, if timeline is not initialized
185+
} = currentViewRange || {};
186+
const currentOffset = offsetTimeSelector(state);
187+
const rangeDistance = moment(end).diff(start);
188+
// Set current moment, if not set yet, to current viewport center. otherwise, it is set to now.
189+
let currentMoment = currentViewRange ? moment(start).add(rangeDistance / 2).toISOString() : moment(new Date());
190+
191+
const initialOffsetTime = moment(time ? time : currentMoment).add(rangeDistance / RATIO);
192+
let setTime = action.enabled && !time ? Rx.Observable.of(setCurrentTime(currentMoment.toISOString())) : Rx.Observable.empty();
193+
let setOff = action.enabled && !currentOffset || action.enabled && moment(currentOffset.toISOString()).diff(time) < 0 ? Rx.Observable.of(setCurrentOffset(initialOffsetTime.toISOString()))
194+
: Rx.Observable.empty();
195+
const centerToCurrentViewRange = currentViewRange ? Rx.Observable.empty() : Rx.Observable.of(
196+
onRangeChanged({
197+
start: moment(currentMoment).add(-1 * rangeDistance / 2),
198+
end: moment(currentMoment).add(rangeDistance / 2)
199+
})
200+
);
201+
return setTime.concat(setOff).concat(centerToCurrentViewRange);
202+
}
203+
return Rx.Observable.of(setCurrentOffset());
204+
// disable by setting off the offset
183205
}),
184-
/**
206+
/**
185207
* Update the time data when the timeline range changes, or when the layer dimension data is
186208
* updated (for instance when a layer is added to the map)
187209
*/

web/client/plugins/Timeline.jsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ const { createSelector } = require('reselect');
1212
const Timeline = require('./timeline/Timeline');
1313
const InlineDateTimeSelector = require('../components/time/InlineDateTimeSelector');
1414
const Toolbar = require('../components/misc/toolbar/Toolbar');
15-
const { currentTimeSelector, layersWithTimeDataSelector } = require('../selectors/dimension');
16-
17-
const { offsetEnabledSelector, selectedLayerSelector, currentTimeRangeSelector } = require('../selectors/timeline');
15+
const { offsetEnabledSelector, currentTimeSelector, layersWithTimeDataSelector } = require('../selectors/dimension');
16+
const { selectedLayerSelector, currentTimeRangeSelector } = require('../selectors/timeline');
1817
const { withState, compose, branch, renderNothing } = require('recompose');
19-
const { selectTime, enableOffset, selectOffset } = require('../actions/timeline');
18+
const { selectTime, enableOffset } = require('../actions/timeline');
19+
const { setCurrentOffset } = require('../actions/dimension');
20+
2021
const { selectPlaybackRange } = require('../actions/playback');
2122
const { playbackRangeSelector } = require('../selectors/playback');
2223

@@ -51,7 +52,7 @@ const TimelinePlugin = compose(
5152
), {
5253
setCurrentTime: selectTime,
5354
onOffsetEnabled: enableOffset,
54-
setOffset: selectOffset,
55+
setOffset: setCurrentOffset,
5556
setPlaybackRange: selectPlaybackRange
5657
}),
5758
branch(({ layers = [] }) => Object.keys(layers).length === 0, renderNothing),
@@ -86,7 +87,8 @@ const TimelinePlugin = compose(
8687
}}
8788
className={`timeline-plugin${hideLayersName ? ' hide-layers-name' : ''}${offsetEnabled ? ' with-time-offset' : ''}`}>
8889

89-
{offsetEnabled && <InlineDateTimeSelector
90+
{offsetEnabled // if range is present and configured, show the floating start point.
91+
&& <InlineDateTimeSelector
9092
glyph="range-start"
9193
tooltip="timeline.currentTime"
9294
date={currentTime || currentTimeRange && currentTimeRange.start}
@@ -99,12 +101,14 @@ const TimelinePlugin = compose(
99101
}} />}
100102

101103
<div className="timeline-plugin-toolbar">
102-
{offsetEnabled && currentTimeRange ?
103-
<InlineDateTimeSelector
104+
{offsetEnabled && currentTimeRange
105+
// if range enabled, show time end in the timeline
106+
? <InlineDateTimeSelector
104107
glyph={'range-end'}
105108
tooltip="Offset time"
106109
date={currentTimeRange.end}
107-
onUpdate={end => isValidOffset(currentTime, end) && setOffset(end)} /> :
110+
onUpdate={end => isValidOffset(currentTime, end) && setOffset(end)} />
111+
: // show current time if using single time
108112
<InlineDateTimeSelector
109113
glyph={'time-current'}
110114
tooltip="timeline.currentTime"
@@ -132,7 +136,6 @@ const TimelinePlugin = compose(
132136
tooltip: offsetEnabled ? 'Disable current time with offset' : 'Enable current time with offset',
133137
onClick: () => {
134138
onOffsetEnabled(!offsetEnabled);
135-
setOptions({ ...options, playbackEnabled: false });
136139

137140
}
138141
},
@@ -143,7 +146,6 @@ const TimelinePlugin = compose(
143146
active: playbackEnabled,
144147
visible: !!Playback,
145148
onClick: () => {
146-
onOffsetEnabled(false);
147149
setOptions({ ...options, playbackEnabled: !playbackEnabled });
148150
setPlaybackRange(playbackRange);
149151
}

web/client/plugins/timeline/Timeline.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,14 @@ const enhance = compose(
267267
moment: date => moment(date).utc()
268268
}
269269
}),
270+
// add view range to the options, to sync current range with state one and allow to control it
270271
withPropsOnChange(['viewRange', 'options'], ({ viewRange = {}, options}) => ({
271272
options: {
272273
...options,
273-
...(viewRange)
274+
...(viewRange) // TODO: if the new view range is very far from the current one, the animation takes a lot. We should allow also to disable animation (animation: false in the options)
274275
}
275276
})),
276-
// items enhancer
277+
// items enhancer. Add background items for playback and time ranges
277278
withPropsOnChange(
278279
['items', 'currentTime', 'offsetEnabled', 'hideLayersName', 'playbackRange', 'playbackEnabled', 'selectedLayer', 'currentTimeRange'],
279280
({

web/client/reducers/dimension.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
const { UPDATE_LAYER_DIMENSION_DATA, SET_CURRENT_TIME, SET_OFFSET_TIME } = require('../actions/dimension');
1+
const { UPDATE_LAYER_DIMENSION_DATA, SET_CURRENT_TIME, SET_OFFSET_TIME, MOVE_TIME } = require('../actions/dimension');
22
const { set } = require('../utils/ImmutableUtils');
3+
const moment = require('moment');
4+
35

46
/**
57
* Provide state for current time and dimension info.
@@ -39,6 +41,14 @@ module.exports = (state = {}, action) => {
3941
case SET_OFFSET_TIME: {
4042
return set('offsetTime', action.offsetTime, state);
4143
}
44+
case MOVE_TIME: { // same as SET_CURRENT_TIME, but if offsetTime is defined, it moves it together with time
45+
if (state.offsetTime && state.currentTime) {
46+
const currentRange = moment(state.offsetTime).diff(state.currentTime);
47+
const nextOffsetTime = moment(action.time).add(currentRange);
48+
return set(`currentTime`, action.time, set('offsetTime', nextOffsetTime.toISOString(), state));
49+
}
50+
return set(`currentTime`, action.time, state);
51+
}
4252
default:
4353
return state;
4454
}

web/client/reducers/timeline.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const { RANGE_CHANGED } = require('../actions/timeline');
2-
const { RANGE_DATA_LOADED, LOADING, SELECT_LAYER, ENABLE_OFFSET, SELECT_OFFSET, MOUSE_EVENT } = require('../actions/timeline');
2+
const { RANGE_DATA_LOADED, LOADING, SELECT_LAYER, MOUSE_EVENT } = require('../actions/timeline');
33
const { set } = require('../utils/ImmutableUtils');
44

55

@@ -64,12 +64,6 @@ module.exports = (state = {}, action) => {
6464
case SELECT_LAYER: {
6565
return set('selectedLayer', action.layerId, state);
6666
}
67-
case SELECT_OFFSET: {
68-
return set('offsetTime', action.offset, state);
69-
}
70-
case ENABLE_OFFSET: {
71-
return set('offsetEnabled', action.enabled, state);
72-
}
7367
case MOUSE_EVENT: {
7468
return set('mouseEvent', action.eventData, state);
7569
}

0 commit comments

Comments
 (0)