mirror of
https://github.com/weaveworks/scope.git
synced 2026-08-19 04:16:21 +00:00
Time Travel 3.0 (#2703)
* Experimental. * Getting somewhere. * Good zooming behaviour. * Working timeline zooming & panning. * Clickable timestamps. * Dragging cursor * Timeline panning buttons. * Capping at current time. * Scale limits. * Better ticks. * Time tags fading in smoothly. * Removed seconds. * Better tick spacing. * Vertical panning as zooming. * Organizing the code.. * Replaced d3-zoom with native events. * Got rid of scaleX * More code beautified. * Almost done polishing the code. * Some cleanup. * Better request triggers. * More cleaning up. * Styled the timestamp input. * Final cleanup. * Update yarn.lock * Zoom tracking. * Animate timeline translations. * Fixed the PAUSE button glitch and updating the time control info. * Opacity fix and timeline arrows removed. * Fixed the red vertical bar. * Use preventDefault() on timeline scrolling.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<g transform={`translate(${position}, 0)`} key={timestamp.format()}>
|
||||
{!isBehind && <line y2="75" stroke="#ddd" strokeWidth="1" />}
|
||||
{!disabled && <title>Jump to {timestamp.utc().format()}</title>}
|
||||
<foreignObject width="100" height="20">
|
||||
<a className="timestamp-label" disabled={disabled} onClick={!disabled && handleClick}>
|
||||
{timestamp.utc().format(periodFormat)}
|
||||
</a>
|
||||
</foreignObject>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<g className={period} transform={transform} style={{ opacity }}>
|
||||
{map(ticks, tick => this.renderTimestampTick(tick, periodFormat, opacity))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
renderDisabledShadow(focusedTimestamp) {
|
||||
const timeScale = this.getTimeScale(focusedTimestamp);
|
||||
const nowShift = timeScale(this.state.timestampNow);
|
||||
const { width, height } = this.state.boundingRect;
|
||||
|
||||
return (
|
||||
<rect
|
||||
className="available-range"
|
||||
transform={`translate(${nowShift}, 0)`}
|
||||
width={width} height={height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderAxis(focusedTimestamp) {
|
||||
const { width, height } = this.state.boundingRect;
|
||||
|
||||
return (
|
||||
<g id="axis">
|
||||
<rect
|
||||
className="tooltip-container"
|
||||
transform={`translate(${-width / 2}, 0)`}
|
||||
width={width} height={height} fillOpacity={0}
|
||||
/>
|
||||
{this.renderDisabledShadow(focusedTimestamp)}
|
||||
<g className="ticks">
|
||||
{this.renderPeriodTicks('year', focusedTimestamp)}
|
||||
{this.renderPeriodTicks('month', focusedTimestamp)}
|
||||
{this.renderPeriodTicks('day', focusedTimestamp)}
|
||||
{this.renderPeriodTicks('minute', focusedTimestamp)}
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
renderAnimatedContent() {
|
||||
const timestamp = this.state.focusedTimestamp.valueOf();
|
||||
|
||||
return (
|
||||
<Motion style={{ timestamp: spring(timestamp, NODES_SPRING_FAST_ANIMATION_CONFIG) }}>
|
||||
{interpolated => this.renderAxis(moment(interpolated.timestamp))}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const className = classNames({ panning: this.state.isPanning });
|
||||
const halfWidth = this.state.boundingRect.width / 2;
|
||||
|
||||
return (
|
||||
<div className="time-travel-timeline">
|
||||
<a className="button jump-backward" onClick={this.jumpBackward}>
|
||||
<span className="fa fa-chevron-left" />
|
||||
</a>
|
||||
<svg className={className} ref={this.saveSvgRef} onWheel={this.handleZoom}>
|
||||
<g className="view" transform={`translate(${halfWidth}, 0)`}>
|
||||
<title>Scroll to zoom, drag to pan</title>
|
||||
{this.renderAnimatedContent()}
|
||||
</g>
|
||||
</svg>
|
||||
<a className="button jump-forward" onClick={this.jumpForward}>
|
||||
<span className="fa fa-chevron-right" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
@@ -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.trackTimestampEdit = this.trackTimestampEdit.bind(this);
|
||||
this.trackTimelineClick = this.trackTimelineClick.bind(this);
|
||||
this.trackTimelinePan = this.trackTimelinePan.bind(this);
|
||||
|
||||
this.instantUpdateTimestamp = this.instantUpdateTimestamp.bind(this);
|
||||
this.debouncedUpdateTimestamp = debounce(
|
||||
this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
|
||||
this.debouncedTrackSliderChange = debounce(
|
||||
this.trackSliderChange.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Force periodic re-renders to update the slider position as time goes by.
|
||||
this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_TICK_INTERVAL);
|
||||
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);
|
||||
}
|
||||
|
||||
updateTimestamp(timestamp) {
|
||||
this.props.jumpToTime(moment(timestamp));
|
||||
}
|
||||
|
||||
travelTo(timestamp, debounced = false) {
|
||||
this.props.timeTravelStartTransition();
|
||||
handleTimelinePan(timestamp) {
|
||||
this.setState(getTimestampStates(timestamp));
|
||||
if (debounced) {
|
||||
this.debouncedUpdateTimestamp(timestamp);
|
||||
} else {
|
||||
this.debouncedUpdateTimestamp(timestamp);
|
||||
}
|
||||
|
||||
handleTimelinePanEnd(timestamp) {
|
||||
this.instantUpdateTimestamp(timestamp, this.trackTimelinePan);
|
||||
}
|
||||
|
||||
handleInstantJump(timestamp) {
|
||||
this.instantUpdateTimestamp(timestamp, this.trackTimelineClick);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={style}
|
||||
className="time-travel-markers-tick"
|
||||
key={timestampValue}>
|
||||
<span className="vertical-tick" />
|
||||
<a className="link" onClick={() => this.travelTo(timestampValue)}>{label}</a>
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className="time-travel-markers">
|
||||
{map(ticks, tick => this.renderMark(tick))}
|
||||
</div>
|
||||
);
|
||||
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 (
|
||||
<div className={className}>
|
||||
<div className="time-travel-slider-wrapper">
|
||||
{this.renderMarks()}
|
||||
<Slider
|
||||
onChange={this.handleSliderChange}
|
||||
value={sliderValue}
|
||||
min={sliderMinValue}
|
||||
max={sliderMaxValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="time-travel-jump-controls">
|
||||
<a className="button jump" onClick={() => this.handleJumpClick(-ONE_HOUR_MS)}>
|
||||
<span className="fa fa-fast-backward" /> 1 hour
|
||||
</a>
|
||||
<a className="button jump" onClick={() => this.handleJumpClick(-FIVE_MINUTES_MS)}>
|
||||
<span className="fa fa-step-backward" /> 5 mins
|
||||
</a>
|
||||
<span className="time-travel-jump-controls-timestamp">
|
||||
<input value={inputValue} onChange={this.handleInputChange} /> UTC
|
||||
</span>
|
||||
<a className="button jump" onClick={() => this.handleJumpClick(+FIVE_MINUTES_MS)}>
|
||||
<span className="fa fa-step-forward" /> 5 mins
|
||||
</a>
|
||||
<a className="button jump" onClick={() => this.handleJumpClick(+ONE_HOUR_MS)}>
|
||||
<span className="fa fa-fast-forward" /> 1 hour
|
||||
</a>
|
||||
<div className={classNames('time-travel', { visible: this.props.showingTimeTravel })}>
|
||||
<TimeTravelTimeline
|
||||
onTimelinePan={this.handleTimelinePan}
|
||||
onTimelinePanEnd={this.handleTimelinePanEnd}
|
||||
onInstantJump={this.handleInstantJump}
|
||||
/>
|
||||
<div className="time-travel-timestamp">
|
||||
<input
|
||||
value={this.state.inputValue}
|
||||
onChange={this.handleInputChange}
|
||||
/> UTC
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
+1
-1
@@ -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', () => {
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
&-tick {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
|
||||
.vertical-tick {
|
||||
border: 1px solid $text-tertiary-color;
|
||||
border-radius: 1px;
|
||||
display: block;
|
||||
margin: 1px auto 2px;
|
||||
height: 12px;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-block;
|
||||
pointer-events: all;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-slider-wrapper {
|
||||
margin: 0 50px 20px 10px;
|
||||
.button {
|
||||
padding: 2px;
|
||||
pointer-events: all;
|
||||
flex-grow: 1;
|
||||
|
||||
.rc-slider-rail { background-color: $text-tertiary-color; }
|
||||
}
|
||||
|
||||
&-jump-controls {
|
||||
.time-travel-timeline {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 70px;
|
||||
|
||||
.button.jump {
|
||||
display: block;
|
||||
margin: 8px;
|
||||
font-size: 0.625rem;
|
||||
svg {
|
||||
@extend .grabbable;
|
||||
background-color: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: inset 0 0 7px #aaa;
|
||||
pointer-events: all;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
word-spacing: -1px;
|
||||
margin: 0 7px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.fa {
|
||||
display: block;
|
||||
font-size: 150%;
|
||||
margin-bottom: 3px;
|
||||
&.panning { @extend .grabbing; }
|
||||
|
||||
.available-range {
|
||||
fill: #888;
|
||||
fill-opacity: 0.1;
|
||||
}
|
||||
|
||||
.timestamp-label {
|
||||
margin-left: 2px;
|
||||
padding: 3px;
|
||||
|
||||
&[disabled] {
|
||||
color: #aaa;
|
||||
cursor: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-timestamp {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
pointer-events: all;
|
||||
margin: 4px 8px 25px;
|
||||
&:before, &:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: 50%;
|
||||
border: 1px solid white;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
background-color: red;
|
||||
margin-left: -1px;
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
font-family: $mono-font;
|
||||
font-size: 0.875rem;
|
||||
margin-right: 2px;
|
||||
outline: 0;
|
||||
}
|
||||
&:before {
|
||||
top: 0;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
&: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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user