diff --git a/client/app/scripts/components/time-travel-timeline.js b/client/app/scripts/components/time-travel-timeline.js index ce13a1abf..68b77f434 100644 --- a/client/app/scripts/components/time-travel-timeline.js +++ b/client/app/scripts/components/time-travel-timeline.js @@ -1,13 +1,14 @@ import React from 'react'; import moment from 'moment'; import classNames from 'classnames'; -import { map, clamp, find, last } from 'lodash'; +import { map, clamp, find, last, debounce } 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 { defaultWheelDelta } from '../utils/zoom-utils'; import { linearGradientValue } from '../utils/math-utils'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { @@ -17,7 +18,7 @@ import { } from '../utils/time-utils'; import { NODES_SPRING_FAST_ANIMATION_CONFIG } from '../constants/animation'; -import { TIMELINE_TICK_INTERVAL } from '../constants/timer'; +import { TIMELINE_TICK_INTERVAL, ZOOM_TRACK_DEBOUNCE_INTERVAL } from '../constants/timer'; const TICK_SETTINGS_PER_PERIOD = { @@ -65,12 +66,27 @@ 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 ZOOM_SENSITIVITY = 2; const FADE_OUT_FACTOR = 1.4; const TICKS_ROW_SPACING = 16; const MAX_TICK_ROWS = 3; +function getTimeScale({ focusedTimestamp, durationPerPixel }) { + const roundedTimestamp = moment(focusedTimestamp).utc().startOf('second'); + const startDate = moment(roundedTimestamp).subtract(durationPerPixel); + const endDate = moment(roundedTimestamp).add(durationPerPixel); + return scaleUtc() + .domain([startDate, endDate]) + .range([-1, 1]); +} + +function findOptimalDurationFit(durations, { durationPerPixel }) { + const minimalDuration = scaleDuration(durationPerPixel, 1.1 * MIN_TICK_SPACING_PX); + return find(durations, d => d >= minimalDuration); +} + + class TimeTravelTimeline extends React.Component { constructor(props, context) { super(props, context); @@ -94,6 +110,7 @@ class TimeTravelTimeline extends React.Component { this.handlePan = this.handlePan.bind(this); this.saveSvgRef = this.saveSvgRef.bind(this); + this.debouncedTrackZoom = debounce(this.trackZoom.bind(this), ZOOM_TRACK_DEBOUNCE_INTERVAL); } componentDidMount() { @@ -127,6 +144,13 @@ class TimeTravelTimeline extends React.Component { this.svgRef = ref; } + trackZoom() { + const periods = ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds']; + const duration = scaleDuration(this.state.durationPerPixel, MAX_TICK_SPACING_PX); + const zoomedPeriod = find(periods, period => Math.floor(duration.get(period)) && period); + trackMixpanelEvent('scope.time.timeline.zoom', { zoomedPeriod }); + } + handlePanStart() { this.setState({ isPanning: true }); } @@ -145,13 +169,13 @@ class TimeTravelTimeline extends React.Component { } handleZoom(ev) { - const scale = Math.pow(ZOOM_SENSITIVITY, ev.deltaY); + const scale = Math.pow(ZOOM_SENSITIVITY, defaultWheelDelta(ev)); 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 }); + this.debouncedTrackZoom(); ev.preventDefault(); } @@ -175,24 +199,8 @@ class TimeTravelTimeline extends React.Component { 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) { + getVerticalShiftForPeriod(period, { durationPerPixel }) { const { childPeriod, parentPeriod } = TICK_SETTINGS_PER_PERIOD[period]; - const currentDuration = this.state.durationPerPixel; let shift = 1; if (parentPeriod) { @@ -202,28 +210,28 @@ class TimeTravelTimeline extends React.Component { const fadedOutDuration = scaleDuration(fadedInDuration, FADE_OUT_FACTOR); const durationLog = d => Math.log(d.asMilliseconds()); - const transitionFactor = durationLog(fadedOutDuration) - durationLog(currentDuration); + const transitionFactor = durationLog(fadedOutDuration) - durationLog(durationPerPixel); const transitionLength = durationLog(fadedOutDuration) - durationLog(fadedInDuration); shift = clamp(transitionFactor / transitionLength, 0, 1); } if (childPeriod) { - shift += this.getVerticalShiftForPeriod(childPeriod, currentDuration); + shift += this.getVerticalShiftForPeriod(childPeriod, { durationPerPixel }); } return shift; } - getTicksForPeriod(period, focusedTimestamp) { + getTicksForPeriod(period, timelineTransform) { // 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); + const duration = findOptimalDurationFit(intervals, timelineTransform); if (!duration) return []; // Get the boundary values for the displayed part of the timeline. - const timeScale = this.getTimeScale(focusedTimestamp); + const timeScale = getTimeScale(timelineTransform); const startPosition = -this.state.boundingRect.width / 2; const endPosition = this.state.boundingRect.width / 2; const startDate = moment(timeScale.invert(startPosition)); @@ -290,11 +298,11 @@ class TimeTravelTimeline extends React.Component { ); } - renderPeriodTicks(period, focusedTimestamp) { + renderPeriodTicks(period, timelineTransform) { const periodFormat = TICK_SETTINGS_PER_PERIOD[period].format; - const ticks = this.getTicksForPeriod(period, focusedTimestamp); + const ticks = this.getTicksForPeriod(period, timelineTransform); - const ticksRow = MAX_TICK_ROWS - this.getVerticalShiftForPeriod(period); + const ticksRow = MAX_TICK_ROWS - this.getVerticalShiftForPeriod(period, timelineTransform); const transform = `translate(0, ${ticksRow * TICKS_ROW_SPACING})`; // Ticks quickly fade in from the bottom and then slowly start @@ -311,8 +319,8 @@ class TimeTravelTimeline extends React.Component { ); } - renderDisabledShadow(focusedTimestamp) { - const timeScale = this.getTimeScale(focusedTimestamp); + renderDisabledShadow(timelineTransform) { + const timeScale = getTimeScale(timelineTransform); const nowShift = timeScale(this.state.timestampNow); const { width, height } = this.state.boundingRect; @@ -325,7 +333,7 @@ class TimeTravelTimeline extends React.Component { ); } - renderAxis(focusedTimestamp) { + renderAxis(timelineTransform) { const { width, height } = this.state.boundingRect; return ( @@ -335,23 +343,31 @@ class TimeTravelTimeline extends React.Component { transform={`translate(${-width / 2}, 0)`} width={width} height={height} fillOpacity={0} /> - {this.renderDisabledShadow(focusedTimestamp)} + {this.renderDisabledShadow(timelineTransform)} - {this.renderPeriodTicks('year', focusedTimestamp)} - {this.renderPeriodTicks('month', focusedTimestamp)} - {this.renderPeriodTicks('day', focusedTimestamp)} - {this.renderPeriodTicks('minute', focusedTimestamp)} + {this.renderPeriodTicks('year', timelineTransform)} + {this.renderPeriodTicks('month', timelineTransform)} + {this.renderPeriodTicks('day', timelineTransform)} + {this.renderPeriodTicks('minute', timelineTransform)} ); } renderAnimatedContent() { - const timestamp = this.state.focusedTimestamp.valueOf(); + const focusedTimestampValue = this.state.focusedTimestamp.valueOf(); + const durationPerPixelValue = this.state.durationPerPixel.asMilliseconds(); return ( - - {interpolated => this.renderAxis(moment(interpolated.timestamp))} + + {interpolated => this.renderAxis({ + focusedTimestamp: moment(interpolated.focusedTimestampValue), + durationPerPixel: moment.duration(interpolated.durationPerPixelValue), + })} ); } diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js index 3b14691a8..887f3df9b 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -6,7 +6,9 @@ export const TOPOLOGY_LOADER_DELAY = 100; export const TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL = 10; export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200; -export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500; -export const TIMELINE_DEBOUNCE_INTERVAL = 500; +export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500; +export const ZOOM_TRACK_DEBOUNCE_INTERVAL = 10000; + +export const TIMELINE_DEBOUNCE_INTERVAL = 500; export const TIMELINE_TICK_INTERVAL = 1000; diff --git a/client/app/scripts/utils/zoom-utils.js b/client/app/scripts/utils/zoom-utils.js new file mode 100644 index 000000000..a4a5afcb7 --- /dev/null +++ b/client/app/scripts/utils/zoom-utils.js @@ -0,0 +1,5 @@ + +// See https://github.com/d3/d3-zoom/blob/807f02c7a5fe496fbd08cc3417b62905a8ce95fa/src/zoom.js +export function defaultWheelDelta(ev) { + return ev.deltaY * (ev.deltaMode ? 120 : 1) * 0.002; +}