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 ( +
+ + + + + + Scroll to zoom, drag to pan + {this.renderAnimatedContent()} + + + + + +
+ ); + } +} + + +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 ( -
- - this.travelTo(timestampValue)}>{label} -
- ); + 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 - +
+ +
+ UTC
); 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",