diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js
index 047a7f485b..cecbefab66 100644
--- a/client/app/scripts/actions/app-actions.js
+++ b/client/app/scripts/actions/app-actions.js
@@ -338,8 +338,16 @@ export function clickNode(nodeId, label, origin, topologyId = null) {
}
export function pauseTimeAtNow() {
- return {
- type: ActionTypes.PAUSE_TIME_AT_NOW
+ return (dispatch, getState) => {
+ dispatch({
+ type: ActionTypes.PAUSE_TIME_AT_NOW
+ });
+ if (!getState().get('nodesLoaded')) {
+ getNodes(getState, dispatch);
+ if (isResourceViewModeSelector(getState())) {
+ getResourceViewNodesSnapshot(getState(), dispatch);
+ }
+ }
};
}
diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js
index 2cfe27dd7b..de50001254 100644
--- a/client/app/scripts/components/time-control.js
+++ b/client/app/scripts/components/time-control.js
@@ -8,6 +8,8 @@ import TimeTravelButton from './time-travel-button';
import { trackMixpanelEvent } from '../utils/tracking-utils';
import { pauseTimeAtNow, resumeTime, startTimeTravel } from '../actions/app-actions';
+import { TIMELINE_TICK_INTERVAL } from '../constants/timer';
+
const className = isSelected => (
classNames('time-control-action', { 'time-control-action-selected': isSelected })
@@ -23,6 +25,15 @@ class TimeControl extends React.Component {
this.getTrackingMetadata = this.getTrackingMetadata.bind(this);
}
+ componentDidMount() {
+ // Force periodic for the paused info.
+ this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_TICK_INTERVAL);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.timer);
+ }
+
getTrackingMetadata(data = {}) {
const { currentTopology } = this.props;
return {
diff --git a/client/app/scripts/components/time-travel-timeline.js b/client/app/scripts/components/time-travel-timeline.js
new file mode 100644
index 0000000000..aca28888fa
--- /dev/null
+++ b/client/app/scripts/components/time-travel-timeline.js
@@ -0,0 +1,385 @@
+import React from 'react';
+import moment from 'moment';
+import classNames from 'classnames';
+import { map, clamp, find, last } from 'lodash';
+import { connect } from 'react-redux';
+import { drag } from 'd3-drag';
+import { scaleUtc } from 'd3-scale';
+import { event as d3Event, select } from 'd3-selection';
+import { Motion, spring } from 'react-motion';
+
+import { trackMixpanelEvent } from '../utils/tracking-utils';
+import {
+ nowInSecondsPrecision,
+ clampToNowInSecondsPrecision,
+ scaleDuration,
+} from '../utils/time-utils';
+
+import { NODES_SPRING_FAST_ANIMATION_CONFIG } from '../constants/animation';
+import { TIMELINE_TICK_INTERVAL } from '../constants/timer';
+
+
+const TICK_SETTINGS_PER_PERIOD = {
+ year: {
+ format: 'YYYY',
+ childPeriod: 'month',
+ intervals: [
+ moment.duration(1, 'year'),
+ ],
+ },
+ month: {
+ format: 'MMMM',
+ parentPeriod: 'year',
+ childPeriod: 'day',
+ intervals: [
+ moment.duration(1, 'month'),
+ moment.duration(3, 'months'),
+ ],
+ },
+ day: {
+ format: 'Do',
+ parentPeriod: 'month',
+ childPeriod: 'minute',
+ intervals: [
+ moment.duration(1, 'day'),
+ moment.duration(1, 'week'),
+ ],
+ },
+ minute: {
+ format: 'HH:mm',
+ parentPeriod: 'day',
+ intervals: [
+ moment.duration(1, 'minute'),
+ moment.duration(5, 'minutes'),
+ moment.duration(15, 'minutes'),
+ moment.duration(1, 'hour'),
+ moment.duration(3, 'hours'),
+ moment.duration(6, 'hours'),
+ ],
+ },
+};
+
+const MIN_DURATION_PER_PX = moment.duration(250, 'milliseconds');
+const INIT_DURATION_PER_PX = moment.duration(1, 'minute');
+const MAX_DURATION_PER_PX = moment.duration(3, 'days');
+const MIN_TICK_SPACING_PX = 70;
+const MAX_TICK_SPACING_PX = 415;
+const ZOOM_SENSITIVITY = 1.0015;
+const FADE_OUT_FACTOR = 1.4;
+
+
+class TimeTravelTimeline extends React.Component {
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ timestampNow: nowInSecondsPrecision(),
+ focusedTimestamp: nowInSecondsPrecision(),
+ durationPerPixel: INIT_DURATION_PER_PX,
+ boundingRect: { width: 0, height: 0 },
+ isPanning: false,
+ };
+
+ this.jumpRelativePixels = this.jumpRelativePixels.bind(this);
+ this.jumpForward = this.jumpForward.bind(this);
+ this.jumpBackward = this.jumpBackward.bind(this);
+ this.jumpTo = this.jumpTo.bind(this);
+
+ this.handleZoom = this.handleZoom.bind(this);
+ this.handlePanStart = this.handlePanStart.bind(this);
+ this.handlePanEnd = this.handlePanEnd.bind(this);
+ this.handlePan = this.handlePan.bind(this);
+
+ this.saveSvgRef = this.saveSvgRef.bind(this);
+ }
+
+ componentDidMount() {
+ this.svg = select('.time-travel-timeline svg');
+ this.drag = drag()
+ .on('start', this.handlePanStart)
+ .on('end', this.handlePanEnd)
+ .on('drag', this.handlePan);
+ this.svg.call(this.drag);
+
+ // Force periodic updates of the availability range as time goes by.
+ this.timer = setInterval(() => {
+ this.setState({ timestampNow: nowInSecondsPrecision() });
+ }, TIMELINE_TICK_INTERVAL);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.timer);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ // Don't update the focused timestamp if we're not paused (so the timeline is hidden).
+ if (nextProps.pausedAt) {
+ this.setState({ focusedTimestamp: nextProps.pausedAt });
+ }
+ // Always update the timeline dimension information.
+ this.setState({ boundingRect: this.svgRef.getBoundingClientRect() });
+ }
+
+ saveSvgRef(ref) {
+ this.svgRef = ref;
+ }
+
+ handlePanStart() {
+ this.setState({ isPanning: true });
+ }
+
+ handlePanEnd() {
+ this.props.onTimelinePanEnd(this.state.focusedTimestamp);
+ this.setState({ isPanning: false });
+ }
+
+ handlePan() {
+ const dragDuration = scaleDuration(this.state.durationPerPixel, -d3Event.dx);
+ const timestamp = moment(this.state.focusedTimestamp).add(dragDuration);
+ const focusedTimestamp = clampToNowInSecondsPrecision(timestamp);
+ this.props.onTimelinePan(focusedTimestamp);
+ this.setState({ focusedTimestamp });
+ }
+
+ handleZoom(ev) {
+ const scale = Math.pow(ZOOM_SENSITIVITY, ev.deltaY);
+ let durationPerPixel = scaleDuration(this.state.durationPerPixel, scale);
+ if (durationPerPixel > MAX_DURATION_PER_PX) durationPerPixel = MAX_DURATION_PER_PX;
+ if (durationPerPixel < MIN_DURATION_PER_PX) durationPerPixel = MIN_DURATION_PER_PX;
+
+ trackMixpanelEvent('scope.time.timeline.zoom', { scale });
+ this.setState({ durationPerPixel });
+ ev.preventDefault();
+ }
+
+ jumpTo(timestamp) {
+ const focusedTimestamp = clampToNowInSecondsPrecision(timestamp);
+ this.props.onInstantJump(focusedTimestamp);
+ this.setState({ focusedTimestamp });
+ }
+
+ jumpRelativePixels(pixels) {
+ const duration = scaleDuration(this.state.durationPerPixel, pixels);
+ const timestamp = moment(this.state.focusedTimestamp).add(duration);
+ this.jumpTo(timestamp);
+ }
+
+ jumpForward() {
+ this.jumpRelativePixels(this.state.boundingRect.width / 4);
+ }
+
+ jumpBackward() {
+ this.jumpRelativePixels(-this.state.boundingRect.width / 4);
+ }
+
+ findOptimalDuration(durations) {
+ const { durationPerPixel } = this.state;
+ const minimalDuration = scaleDuration(durationPerPixel, 1.1 * MIN_TICK_SPACING_PX);
+ return find(durations, d => d >= minimalDuration);
+ }
+
+ getTimeScale(focusedTimestamp) {
+ const roundedTimestamp = moment(focusedTimestamp).utc().startOf('second');
+ const startDate = moment(roundedTimestamp).subtract(this.state.durationPerPixel);
+ const endDate = moment(roundedTimestamp).add(this.state.durationPerPixel);
+ return scaleUtc()
+ .domain([startDate, endDate])
+ .range([-1, 1]);
+ }
+
+ getVerticalShiftForPeriod(period) {
+ const { childPeriod, parentPeriod } = TICK_SETTINGS_PER_PERIOD[period];
+ const currentDuration = this.state.durationPerPixel;
+
+ let shift = 1;
+ if (parentPeriod) {
+ const durationMultiplier = 1 / MAX_TICK_SPACING_PX;
+ const parentPeriodStartInterval = TICK_SETTINGS_PER_PERIOD[parentPeriod].intervals[0];
+ const fadedInDuration = scaleDuration(parentPeriodStartInterval, durationMultiplier);
+ const fadedOutDuration = scaleDuration(fadedInDuration, FADE_OUT_FACTOR);
+
+ const durationLog = d => Math.log(d.asMilliseconds());
+ const transitionFactor = durationLog(fadedOutDuration) - durationLog(currentDuration);
+ const transitionLength = durationLog(fadedOutDuration) - durationLog(fadedInDuration);
+
+ shift = clamp(transitionFactor / transitionLength, 0, 1);
+ }
+
+ if (childPeriod) {
+ shift += this.getVerticalShiftForPeriod(childPeriod, currentDuration);
+ }
+
+ return shift;
+ }
+
+ getTicksForPeriod(period, focusedTimestamp) {
+ // First find the optimal duration between the ticks - if no satisfactory
+ // duration could be found, don't render any ticks for the given period.
+ const { parentPeriod, intervals } = TICK_SETTINGS_PER_PERIOD[period];
+ const duration = this.findOptimalDuration(intervals);
+ if (!duration) return [];
+
+ // Get the boundary values for the displayed part of the timeline.
+ const timeScale = this.getTimeScale(focusedTimestamp);
+ const startPosition = -this.state.boundingRect.width / 2;
+ const endPosition = this.state.boundingRect.width / 2;
+ const startDate = moment(timeScale.invert(startPosition));
+ const endDate = moment(timeScale.invert(endPosition));
+
+ // Start counting the timestamps from the most recent timestamp that is not shown
+ // on screen. The values are always rounded up to the timestamps of the next bigger
+ // period (e.g. for days it would be months, for months it would be years).
+ let timestamp = moment(startDate).utc().startOf(parentPeriod || period);
+ while (timestamp.isBefore(startDate)) {
+ timestamp = moment(timestamp).add(duration);
+ }
+ timestamp = moment(timestamp).subtract(duration);
+
+ // Make that hidden timestamp the first one in the list, but position
+ // it inside the visible range with a prepended arrow to the past.
+ const ticks = [{
+ timestamp: moment(timestamp),
+ position: startPosition,
+ isBehind: true,
+ }];
+
+ // Continue adding ticks till the end of the visible range.
+ do {
+ // If the new timestamp enters into a new bigger period, we round it down to the
+ // beginning of that period. E.g. instead of going [Jan 22nd, Jan 29th, Feb 5th],
+ // we output [Jan 22nd, Jan 29th, Feb 1st]. Right now this case only happens between
+ // days and months, but in theory it could happen whenever bigger periods are not
+ // divisible by the duration we are using as a step between the ticks.
+ let newTimestamp = moment(timestamp).add(duration);
+ if (parentPeriod && newTimestamp.get(parentPeriod) !== timestamp.get(parentPeriod)) {
+ newTimestamp = moment(newTimestamp).utc().startOf(parentPeriod);
+ }
+ timestamp = newTimestamp;
+
+ // If the new tick is too close to the previous one, drop that previous tick.
+ const position = timeScale(timestamp);
+ const previousPosition = last(ticks) && last(ticks).position;
+ if (position - previousPosition < MIN_TICK_SPACING_PX) {
+ ticks.pop();
+ }
+
+ ticks.push({ timestamp, position });
+ } while (timestamp.isBefore(endDate));
+
+ return ticks;
+ }
+
+ renderTimestampTick({ timestamp, position, isBehind }, periodFormat, opacity) {
+ // Ticks are disabled if they are in the future or if they are too transparent.
+ const disabled = timestamp.isAfter(this.state.timestampNow) || opacity < 0.4;
+ const handleClick = () => this.jumpTo(timestamp);
+
+ return (
+
+ {!isBehind && }
+ {!disabled && Jump to {timestamp.utc().format()}}
+
+
+ {timestamp.utc().format(periodFormat)}
+
+
+
+ );
+ }
+
+ renderPeriodTicks(period, focusedTimestamp) {
+ const periodFormat = TICK_SETTINGS_PER_PERIOD[period].format;
+ const ticks = this.getTicksForPeriod(period, focusedTimestamp);
+
+ const verticalShift = this.getVerticalShiftForPeriod(period);
+ const transform = `translate(0, ${62 - (verticalShift * 15)})`;
+
+ // Ticks quickly fade in from the bottom and then slowly
+ // start fading out as they are being pushed to the top.
+ const opacity = verticalShift > 1 ? (6 - verticalShift) / 5 : verticalShift;
+
+ return (
+
+ {map(ticks, tick => this.renderTimestampTick(tick, periodFormat, opacity))}
+
+ );
+ }
+
+ renderDisabledShadow(focusedTimestamp) {
+ const timeScale = this.getTimeScale(focusedTimestamp);
+ const nowShift = timeScale(this.state.timestampNow);
+ const { width, height } = this.state.boundingRect;
+
+ return (
+
+ );
+ }
+
+ renderAxis(focusedTimestamp) {
+ const { width, height } = this.state.boundingRect;
+
+ return (
+
+
+ {this.renderDisabledShadow(focusedTimestamp)}
+
+ {this.renderPeriodTicks('year', focusedTimestamp)}
+ {this.renderPeriodTicks('month', focusedTimestamp)}
+ {this.renderPeriodTicks('day', focusedTimestamp)}
+ {this.renderPeriodTicks('minute', focusedTimestamp)}
+
+
+ );
+ }
+
+ renderAnimatedContent() {
+ const timestamp = this.state.focusedTimestamp.valueOf();
+
+ return (
+
+ {interpolated => this.renderAxis(moment(interpolated.timestamp))}
+
+ );
+ }
+
+ render() {
+ const className = classNames({ panning: this.state.isPanning });
+ const halfWidth = this.state.boundingRect.width / 2;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+
+function mapStateToProps(state) {
+ return {
+ // Used only to trigger recalculations on window resize.
+ viewportWidth: state.getIn(['viewport', 'width']),
+ pausedAt: state.get('pausedAt'),
+ };
+}
+
+export default connect(mapStateToProps)(TimeTravelTimeline);
diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js
index 979913d799..c1f424f154 100644
--- a/client/app/scripts/components/time-travel.js
+++ b/client/app/scripts/components/time-travel.js
@@ -1,62 +1,46 @@
import React from 'react';
-import Slider from 'rc-slider';
import moment from 'moment';
import classNames from 'classnames';
import { connect } from 'react-redux';
-import { debounce, map } from 'lodash';
+import { debounce } from 'lodash';
+import TimeTravelTimeline from './time-travel-timeline';
import { trackMixpanelEvent } from '../utils/tracking-utils';
+import { clampToNowInSecondsPrecision } from '../utils/time-utils';
import {
jumpToTime,
resumeTime,
timeTravelStartTransition,
} from '../actions/app-actions';
-import {
- TIMELINE_TICK_INTERVAL,
- TIMELINE_DEBOUNCE_INTERVAL,
-} from '../constants/timer';
+import { TIMELINE_DEBOUNCE_INTERVAL } from '../constants/timer';
const getTimestampStates = (timestamp) => {
timestamp = timestamp || moment();
return {
- sliderValue: moment(timestamp).valueOf(),
inputValue: moment(timestamp).utc().format(),
};
};
-const ONE_HOUR_MS = moment.duration(1, 'hour');
-const FIVE_MINUTES_MS = moment.duration(5, 'minutes');
-
class TimeTravel extends React.Component {
constructor(props, context) {
super(props, context);
- this.state = {
- // TODO: Showing a three months of history is quite arbitrary;
- // we should instead get some meaningful 'beginning of time' from
- // the backend and make the slider show whole active history.
- sliderMinValue: moment().subtract(6, 'months').valueOf(),
- ...getTimestampStates(props.pausedAt),
- };
+ this.state = getTimestampStates(props.pausedAt);
this.handleInputChange = this.handleInputChange.bind(this);
- this.handleSliderChange = this.handleSliderChange.bind(this);
- this.handleJumpClick = this.handleJumpClick.bind(this);
- this.renderMarks = this.renderMarks.bind(this);
- this.renderMark = this.renderMark.bind(this);
- this.travelTo = this.travelTo.bind(this);
+ this.handleTimelinePan = this.handleTimelinePan.bind(this);
+ this.handleTimelinePanEnd = this.handleTimelinePanEnd.bind(this);
+ this.handleInstantJump = this.handleInstantJump.bind(this);
- this.debouncedUpdateTimestamp = debounce(
- this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
- this.debouncedTrackSliderChange = debounce(
- this.trackSliderChange.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
- }
+ this.trackTimestampEdit = this.trackTimestampEdit.bind(this);
+ this.trackTimelineClick = this.trackTimelineClick.bind(this);
+ this.trackTimelinePan = this.trackTimelinePan.bind(this);
- componentDidMount() {
- // Force periodic re-renders to update the slider position as time goes by.
- this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_TICK_INTERVAL);
+ this.instantUpdateTimestamp = this.instantUpdateTimestamp.bind(this);
+ this.debouncedUpdateTimestamp = debounce(
+ this.instantUpdateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
}
componentWillReceiveProps(props) {
@@ -64,141 +48,82 @@ class TimeTravel extends React.Component {
}
componentWillUnmount() {
- clearInterval(this.timer);
+ // TODO: Get rid of this somehow. See: https://github.com/weaveworks/service-ui/issues/814
this.props.resumeTime();
}
- handleSliderChange(timestamp) {
- this.travelTo(timestamp, true);
- this.debouncedTrackSliderChange();
- }
-
handleInputChange(ev) {
- let timestamp = moment(ev.target.value);
+ const timestamp = moment(ev.target.value);
this.setState({ inputValue: ev.target.value });
if (timestamp.isValid()) {
- timestamp = Math.max(timestamp, this.state.sliderMinValue);
- timestamp = Math.min(timestamp, moment().valueOf());
- this.travelTo(timestamp, true);
-
- trackMixpanelEvent('scope.time.timestamp.edit', {
- layout: this.props.topologyViewMode,
- topologyId: this.props.currentTopology.get('id'),
- parentTopologyId: this.props.currentTopology.get('parentId'),
- });
+ const clampedTimestamp = clampToNowInSecondsPrecision(timestamp);
+ this.instantUpdateTimestamp(clampedTimestamp, this.trackTimestampEdit);
}
}
- handleJumpClick(millisecondsDelta) {
- let timestamp = this.state.sliderValue + millisecondsDelta;
- timestamp = Math.max(timestamp, this.state.sliderMinValue);
- timestamp = Math.min(timestamp, moment().valueOf());
- this.travelTo(timestamp, true);
+ handleTimelinePan(timestamp) {
+ this.setState(getTimestampStates(timestamp));
+ this.debouncedUpdateTimestamp(timestamp);
+ }
+
+ handleTimelinePanEnd(timestamp) {
+ this.instantUpdateTimestamp(timestamp, this.trackTimelinePan);
}
- updateTimestamp(timestamp) {
- this.props.jumpToTime(moment(timestamp));
+ handleInstantJump(timestamp) {
+ this.instantUpdateTimestamp(timestamp, this.trackTimelineClick);
}
- travelTo(timestamp, debounced = false) {
- this.props.timeTravelStartTransition();
- this.setState(getTimestampStates(timestamp));
- if (debounced) {
- this.debouncedUpdateTimestamp(timestamp);
- } else {
+ instantUpdateTimestamp(timestamp, callback) {
+ if (!timestamp.isSame(this.props.pausedAt)) {
this.debouncedUpdateTimestamp.cancel();
- this.updateTimestamp(timestamp);
+ this.setState(getTimestampStates(timestamp));
+ this.props.timeTravelStartTransition();
+ this.props.jumpToTime(moment(timestamp));
+
+ // Used for tracking.
+ if (callback) callback();
}
}
- trackSliderChange() {
- trackMixpanelEvent('scope.time.slider.change', {
+ trackTimestampEdit() {
+ trackMixpanelEvent('scope.time.timestamp.edit', {
layout: this.props.topologyViewMode,
topologyId: this.props.currentTopology.get('id'),
parentTopologyId: this.props.currentTopology.get('parentId'),
});
}
- renderMark({ timestampValue, label }) {
- const sliderMaxValue = moment().valueOf();
- const pos = (sliderMaxValue - timestampValue) / (sliderMaxValue - this.state.sliderMinValue);
-
- // Ignore the month marks that are very close to 'Now'
- if (label !== 'Now' && pos < 0.05) return null;
-
- const style = { marginLeft: `calc(${(1 - pos) * 100}% - 32px)`, width: '64px' };
- return (
-
- );
+ trackTimelineClick() {
+ trackMixpanelEvent('scope.time.timeline.click', {
+ layout: this.props.topologyViewMode,
+ topologyId: this.props.currentTopology.get('id'),
+ parentTopologyId: this.props.currentTopology.get('parentId'),
+ });
}
- renderMarks() {
- const { sliderMinValue } = this.state;
- const sliderMaxValue = moment().valueOf();
- const ticks = [{ timestampValue: sliderMaxValue, label: 'Now' }];
- let monthsBack = 0;
- let timestamp;
-
- do {
- timestamp = moment().utc().subtract(monthsBack, 'months').startOf('month');
- if (timestamp.valueOf() >= sliderMinValue) {
- // Months are broken by the year tag, e.g. November, December, 2016, February, etc...
- let label = timestamp.format('MMMM');
- if (label === 'January') {
- label = timestamp.format('YYYY');
- }
- ticks.push({ timestampValue: timestamp.valueOf(), label });
- }
- monthsBack += 1;
- } while (timestamp.valueOf() >= sliderMinValue);
-
- return (
-
- {map(ticks, tick => this.renderMark(tick))}
-
- );
+ trackTimelinePan() {
+ trackMixpanelEvent('scope.time.timeline.pan', {
+ layout: this.props.topologyViewMode,
+ topologyId: this.props.currentTopology.get('id'),
+ parentTopologyId: this.props.currentTopology.get('parentId'),
+ });
}
render() {
- const { sliderValue, sliderMinValue, inputValue } = this.state;
- const sliderMaxValue = moment().valueOf();
-
- const className = classNames('time-travel', { visible: this.props.showingTimeTravel });
-
return (
-
-
- {this.renderMarks()}
-
-
-
-
this.handleJumpClick(-ONE_HOUR_MS)}>
- 1 hour
-
-
this.handleJumpClick(-FIVE_MINUTES_MS)}>
- 5 mins
-
-
- UTC
-
-
this.handleJumpClick(+FIVE_MINUTES_MS)}>
- 5 mins
-
-
this.handleJumpClick(+ONE_HOUR_MS)}>
- 1 hour
-
+
);
diff --git a/client/app/scripts/constants/animation.js b/client/app/scripts/constants/animation.js
index e24d707705..895b5f5f47 100644
--- a/client/app/scripts/constants/animation.js
+++ b/client/app/scripts/constants/animation.js
@@ -1,2 +1,3 @@
export const NODES_SPRING_ANIMATION_CONFIG = { stiffness: 80, damping: 20, precision: 0.1 };
+export const NODES_SPRING_FAST_ANIMATION_CONFIG = { stiffness: 800, damping: 50, precision: 1 };
diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js
index 65107e23cf..3b14691a85 100644
--- a/client/app/scripts/constants/timer.js
+++ b/client/app/scripts/constants/timer.js
@@ -9,4 +9,4 @@ export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200;
export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500;
export const TIMELINE_DEBOUNCE_INTERVAL = 500;
-export const TIMELINE_TICK_INTERVAL = 500;
+export const TIMELINE_TICK_INTERVAL = 1000;
diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js
index 2e8d76fe35..880a6b56ee 100644
--- a/client/app/scripts/reducers/root.js
+++ b/client/app/scripts/reducers/root.js
@@ -1,6 +1,5 @@
/* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */
import debug from 'debug';
-import moment from 'moment';
import { size, each, includes, isEqual } from 'lodash';
import {
fromJS,
@@ -20,6 +19,7 @@ import {
isResourceViewModeSelector,
} from '../selectors/topology';
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
+import { nowInSecondsPrecision } from '../utils/time-utils';
import { applyPinnedSearches } from '../utils/search-utils';
import {
findTopologyById,
@@ -366,13 +366,14 @@ export function rootReducer(state = initialState, action) {
case ActionTypes.PAUSE_TIME_AT_NOW: {
state = state.set('showingTimeTravel', false);
- return state.set('pausedAt', moment().utc());
+ state = state.set('timeTravelTransitioning', false);
+ return state.set('pausedAt', nowInSecondsPrecision());
}
case ActionTypes.START_TIME_TRAVEL: {
- state = state.set('timeTravelTransitioning', false);
state = state.set('showingTimeTravel', true);
- return state.set('pausedAt', moment().utc());
+ state = state.set('timeTravelTransitioning', false);
+ return state.set('pausedAt', nowInSecondsPrecision());
}
case ActionTypes.JUMP_TO_TIME: {
diff --git a/client/app/scripts/selectors/graph-view/graph.js b/client/app/scripts/selectors/graph-view/graph.js
index 72783d9e04..0d7600aff5 100644
--- a/client/app/scripts/selectors/graph-view/graph.js
+++ b/client/app/scripts/selectors/graph-view/graph.js
@@ -7,7 +7,7 @@ import { canvasWidthSelector, canvasHeightSelector } from '../canvas';
import { activeTopologyOptionsSelector } from '../topology';
import { shownNodesSelector } from '../node-filters';
import { doLayout } from '../../charts/nodes-layout';
-import timer from '../../utils/timer-utils';
+import { timer } from '../../utils/time-utils';
const log = debug('scope:nodes-chart');
diff --git a/client/app/scripts/utils/__tests__/timer-utils-test.js b/client/app/scripts/utils/__tests__/time-utils-test.js
similarity index 91%
rename from client/app/scripts/utils/__tests__/timer-utils-test.js
rename to client/app/scripts/utils/__tests__/time-utils-test.js
index de7cc61296..dc8b7816f7 100644
--- a/client/app/scripts/utils/__tests__/timer-utils-test.js
+++ b/client/app/scripts/utils/__tests__/time-utils-test.js
@@ -1,5 +1,5 @@
import expect from 'expect';
-import timer from '../timer-utils';
+import { timer } from '../time-utils';
describe('timer', () => {
it('records how long a function takes to execute', () => {
diff --git a/client/app/scripts/utils/time-utils.js b/client/app/scripts/utils/time-utils.js
new file mode 100644
index 0000000000..c93e424154
--- /dev/null
+++ b/client/app/scripts/utils/time-utils.js
@@ -0,0 +1,26 @@
+import moment from 'moment';
+
+// Replacement for timely dependency
+export function timer(fn) {
+ const timedFn = (...args) => {
+ const start = new Date();
+ const result = fn.apply(fn, args);
+ timedFn.time = new Date() - start;
+ return result;
+ };
+ return timedFn;
+}
+
+export function nowInSecondsPrecision() {
+ return moment().startOf('second');
+}
+
+export function clampToNowInSecondsPrecision(timestamp) {
+ const now = nowInSecondsPrecision();
+ return timestamp.isAfter(now) ? now : timestamp;
+}
+
+// This is unfortunately not there in moment.js
+export function scaleDuration(duration, scale) {
+ return moment.duration(duration.asMilliseconds() * scale);
+}
diff --git a/client/app/scripts/utils/timer-utils.js b/client/app/scripts/utils/timer-utils.js
deleted file mode 100644
index c030328910..0000000000
--- a/client/app/scripts/utils/timer-utils.js
+++ /dev/null
@@ -1,11 +0,0 @@
-// Replacement for timely dependency
-
-export default function timer(fn) {
- const timedFn = (...args) => {
- const start = new Date();
- const result = fn.apply(fn, args);
- timedFn.time = new Date() - start;
- return result;
- };
- return timedFn;
-}
diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss
index 1f0f12539c..c3d4be41a1 100644
--- a/client/app/styles/_base.scss
+++ b/client/app/styles/_base.scss
@@ -9,6 +9,9 @@
url("../../node_modules/materialize-css/fonts/roboto/Roboto-Regular.ttf");
}
+// TODO: Remove this line once the configure button stops being added to Scope.
+.setup-nav-button { display: none; }
+
.browsehappy {
margin: 0.2em 0;
background: #ccc;
@@ -43,6 +46,19 @@
transition-delay: .5s;
}
+// From https://stackoverflow.com/a/18294634
+.grabbable {
+ cursor: move; /* fallback if grab cursor is unsupported */
+ cursor: grab;
+ cursor: -moz-grab;
+ cursor: -webkit-grab;
+}
+.grabbing {
+ cursor: grabbing;
+ cursor: -moz-grabbing;
+ cursor: -webkit-grabbing;
+}
+
.shadow-2 {
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.16), 0 3px 10px rgba(0, 0, 0, 0.23);
}
@@ -173,13 +189,13 @@
&.time-travel-open {
.details-wrapper {
- margin-top: 65px;
+ margin-top: 85px;
}
}
}
.header {
- margin-top: 38px;
+ padding: 15px 10px 0;
pointer-events: none;
width: 100%;
@@ -239,10 +255,8 @@
}
.time-travel {
- align-items: center;
- display: flex;
position: relative;
- margin: 0 30px 0 15px;
+ margin-bottom: 15px;
z-index: 2001;
transition: all .15s $base-ease;
@@ -250,76 +264,89 @@
height: 0;
&.visible {
- height: 50px;
+ height: 105px;
margin-bottom: 15px;
+ margin-top: -5px;
}
- &-markers {
- position: relative;
+ .button {
+ padding: 2px;
+ pointer-events: all;
+ }
- &-tick {
- text-align: center;
- position: absolute;
+ .time-travel-timeline {
+ align-items: center;
+ display: flex;
+ height: 70px;
- .vertical-tick {
- border: 1px solid $text-tertiary-color;
- border-radius: 1px;
- display: block;
- margin: 1px auto 2px;
- height: 12px;
- width: 0;
- }
+ svg {
+ @extend .grabbable;
+ background-color: rgba(255, 255, 255, 0.85);
+ box-shadow: inset 0 0 7px #aaa;
+ pointer-events: all;
+ margin: 0 7px;
+ width: 100%;
+ height: 100%;
- .link {
- display: inline-block;
- pointer-events: all;
- margin-top: 1px;
- }
- }
- }
+ &.panning { @extend .grabbing; }
- &-slider-wrapper {
- margin: 0 50px 20px 10px;
- pointer-events: all;
- flex-grow: 1;
+ .available-range {
+ fill: #888;
+ fill-opacity: 0.1;
+ }
- .rc-slider-rail { background-color: $text-tertiary-color; }
- }
+ .timestamp-label {
+ margin-left: 2px;
+ padding: 3px;
- &-jump-controls {
- display: flex;
+ &[disabled] {
+ color: #aaa;
+ cursor: inherit;
+ }
+ }
+ }
- .button.jump {
+ &:before, &:after {
+ content: '';
+ position: absolute;
display: block;
- margin: 8px;
- font-size: 0.625rem;
- pointer-events: all;
- text-align: center;
- text-transform: uppercase;
- word-spacing: -1px;
+ left: 50%;
+ border: 1px solid white;
+ border-top: 0;
+ border-bottom: 0;
+ background-color: red;
+ margin-left: -1px;
+ width: 3px;
+ }
- .fa {
- display: block;
- font-size: 150%;
- margin-bottom: 3px;
- }
+ &:before {
+ top: 0;
+ height: 70px;
}
- &-timestamp {
- border: 1px solid #ccc;
- border-radius: 4px;
- padding: 2px 8px;
- pointer-events: all;
- margin: 4px 8px 25px;
-
- input {
- border: 0;
- background-color: transparent;
- font-family: $mono-font;
- font-size: 0.875rem;
- margin-right: 2px;
- outline: 0;
- }
+ &:after {
+ top: 70px;
+ height: 9px;
+ opacity: 0.15;
+ }
+ }
+
+ &-timestamp {
+ background-color: $background-lighter-color;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ padding: 2px 8px;
+ pointer-events: all;
+ margin: 8px auto 25px;
+ opacity: 0.8;
+ width: 215px;
+
+ input {
+ border: 0;
+ background-color: transparent;
+ font-size: 1rem;
+ width: 167px;
+ outline: 0;
}
}
}
@@ -1483,7 +1510,7 @@
.time-control {
position: absolute;
- right: 36px;
+ right: 20px;
&-controls {
align-items: center;
diff --git a/client/app/styles/_variables.scss b/client/app/styles/_variables.scss
index 801f8738f4..52d352f670 100644
--- a/client/app/styles/_variables.scss
+++ b/client/app/styles/_variables.scss
@@ -26,7 +26,7 @@ $text-darker-color: $primary-color;
$white: $background-lighter-color;
$details-window-width: 420px;
-$details-window-padding-left: 36px;
+$details-window-padding-left: 30px;
$border-radius: 4px;
$terminal-header-height: 44px;
diff --git a/client/package.json b/client/package.json
index a4880faf38..5d971a1c91 100644
--- a/client/package.json
+++ b/client/package.json
@@ -12,13 +12,13 @@
"classnames": "2.2.5",
"d3-array": "1.2.0",
"d3-color": "1.0.3",
+ "d3-drag": "1.0.4",
"d3-format": "1.2.0",
"d3-scale": "1.0.5",
"d3-selection": "1.0.5",
"d3-shape": "1.0.6",
"d3-time-format": "2.0.5",
"d3-transition": "1.0.4",
- "d3-drag": "1.0.4",
"d3-zoom": "1.1.4",
"dagre": "0.7.4",
"debug": "2.6.6",