diff --git a/src/ui/components/MarkerTimeline.js b/src/ui/components/MarkerTimeline.js new file mode 100644 index 000000000..d5370453f --- /dev/null +++ b/src/ui/components/MarkerTimeline.js @@ -0,0 +1,210 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import {Component} from 'react'; +import styled from '../styled/index.js'; +import Text from './Text.js'; +import FlexRow from './FlexRow.js'; +import {colors} from './colors.js'; + +type DataPoint = { + time: number, + color?: string, + label: string, + key: string, +}; + +type Props = {| + onClick?: (keys: Array) => mixed, + selected?: ?string, + points: Array, + lineHeight: number, + maxGap: number, +|}; + +const Markers = styled('div')(props => ({ + position: 'relative', + margin: 10, + height: props.totalTime, + '::before': { + content: '""', + width: 1, + borderLeft: `1px dotted ${colors.light30}`, + position: 'absolute', + top: 5, + bottom: 20, + left: 5, + }, +})); + +const Point = styled(FlexRow)(props => ({ + position: 'absolute', + top: props.positionY, + left: 0, + right: 10, + cursor: props.onClick ? 'pointer' : 'default', + borderRadius: 3, + alignItems: 'center', + ':hover': { + background: props.onClick ? colors.light02 : 'transparent', + }, + '::before': { + position: 'relative', + textAlign: 'center', + fontSize: 8, + fontWeight: '500', + content: props.number ? `'${props.number}'` : '""', + display: 'inline-block', + width: 9, + height: 9, + flexShrink: 0, + color: 'rgba(0,0,0,0.4)', + lineHeight: '9px', + borderRadius: '999em', + border: '1px solid rgba(0,0,0,0.2)', + backgroundColor: props.threadColor, + marginRight: 6, + boxShadow: props.selected + ? `0 0 0 2px ${colors.macOSTitleBarIconSelected}` + : null, + }, + '::after': { + content: props.cut ? '""' : null, + position: 'absolute', + width: 11, + top: -20, + left: 0, + height: 2, + background: colors.white, + borderTop: `1px solid ${colors.light30}`, + borderBottom: `1px solid ${colors.light30}`, + transform: `skewY(-10deg)`, + }, +})); + +const Time = styled('span')({ + color: colors.light30, + fontWeight: '300', + marginRight: 4, +}); + +const Code = styled(Text)({ + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +type TimePoint = { + timestamp: number, + markerNames: Array, + markerKeys: Array, + isCut: boolean, + positionY: number, + color: string, +}; + +type State = {| + timePoints: Array, +|}; + +export default class MarkerTimeline extends Component { + static defaultProps = { + lineHeight: 22, + maxGap: 100, + }; + + static getDerivedStateFromProps(props: Props) { + const sortedMarkers: Array<[number, Array]> = Array.from( + props.points + .reduce((acc: Map>, cv: DataPoint) => { + const list = acc.get(cv.time); + if (list) { + list.push(cv); + } else { + acc.set(cv.time, [cv]); + } + return acc; + }, (new Map(): Map>)) + .entries(), + ).sort((a, b) => a[0] - b[0]); + + const smallestGap = sortedMarkers.reduce((acc, cv, i, arr) => { + if (i > 0) { + return Math.min(acc, cv[0] - arr[i - 1][0]); + } else { + return acc; + } + }, Infinity); + + let positionY = 0; + const timePoints: Array = []; + + for (let i = 0; i < sortedMarkers.length; i++) { + const [timestamp, points] = sortedMarkers[i]; + let isCut = false; + const color = sortedMarkers[i][1][0].color || colors.white; + + if (i > 0) { + const relativeTimestamp = timestamp - sortedMarkers[i - 1][0]; + const gap = (relativeTimestamp / smallestGap) * props.lineHeight; + if (gap > props.maxGap) { + positionY += props.maxGap; + isCut = true; + } else { + positionY += gap; + } + } + + timePoints.push({ + timestamp, + markerNames: points.map(p => p.label), + markerKeys: points.map(p => p.key), + positionY, + isCut, + color, + }); + } + + return {timePoints}; + } + + state: State = { + timePoints: [], + }; + + render() { + const {timePoints} = this.state; + const {onClick} = this.props; + + if (!this.props.points || this.props.points.length === 0) { + return null; + } + + return ( + + {timePoints.map((p: TimePoint, i: number) => { + return ( + 1 ? p.markerNames.join(', ') : null} + positionY={p.positionY} + onClick={onClick ? () => onClick(p.markerKeys) : undefined} + selected={p.markerKeys.includes(this.props.selected)} + number={p.markerNames.length > 1 ? p.markerNames.length : null}> + {' '} + {p.markerNames.join(', ')} + + ); + })} + + ); + } +} diff --git a/src/ui/components/__tests__/MarkerTimeline.electron.js b/src/ui/components/__tests__/MarkerTimeline.electron.js new file mode 100644 index 000000000..494649ff2 --- /dev/null +++ b/src/ui/components/__tests__/MarkerTimeline.electron.js @@ -0,0 +1,87 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import MarkerTimeline from '../MarkerTimeline'; + +test('merges points with same timestamp', () => { + const points = [ + {key: 'marker1', label: 'marker1', time: 41}, + {key: 'marker2', label: 'marker2', time: 41}, + ]; + + const {timePoints} = MarkerTimeline.getDerivedStateFromProps({ + lineHeight: 22, + maxGap: 100, + points, + }); + expect(timePoints[0].markerNames).toContain('marker1'); + expect(timePoints[0].markerNames).toContain('marker2'); +}); + +test('sorts points', () => { + const {timePoints} = MarkerTimeline.getDerivedStateFromProps({ + lineHeight: 22, + maxGap: 100, + points: [ + {key: 'marker1', label: 'marker1', time: 20}, + {key: 'marker2', label: 'marker2', time: -50}, + ], + }); + expect(timePoints[0].timestamp).toBe(-50); + expect(timePoints[1].timestamp).toBe(20); +}); + +test('handles negative timestamps', () => { + const points = [{label: 'preStartPoint', key: 'preStartPoint', time: -50}]; + + const {timePoints} = MarkerTimeline.getDerivedStateFromProps({ + lineHeight: 22, + maxGap: 100, + points, + }); + expect(timePoints[0].timestamp).toBe(-50); +}); + +test('no points', () => { + const {timePoints} = MarkerTimeline.getDerivedStateFromProps({ + lineHeight: 22, + maxGap: 100, + points: [], + }); + expect(timePoints).toMatchSnapshot(); +}); + +test('handles single point', () => { + const points = [{key: '1', label: 'single point', time: 0}]; + + const {timePoints} = MarkerTimeline.getDerivedStateFromProps({ + lineHeight: 22, + maxGap: 100, + points, + }); + expect(timePoints).toMatchSnapshot(); +}); + +test('cuts long gaps', () => { + const points = [ + {key: '1', label: 'single point', time: 1}, + {key: '2', label: 'single point', time: 1000}, + {key: '3', label: 'single point', time: 1001}, + ]; + + const MAX_GAP = 100; + + const {timePoints} = MarkerTimeline.getDerivedStateFromProps({ + lineHeight: 22, + maxGap: MAX_GAP, + points, + }); + + expect(timePoints[0].isCut).toBe(false); + expect(timePoints[1].isCut).toBe(true); + expect(timePoints[1].positionY).toBe(timePoints[0].positionY + MAX_GAP); +}); diff --git a/src/ui/components/__tests__/__snapshots__/MarkerTimeline.electron.js.snap b/src/ui/components/__tests__/__snapshots__/MarkerTimeline.electron.js.snap new file mode 100644 index 000000000..617d0581d --- /dev/null +++ b/src/ui/components/__tests__/__snapshots__/MarkerTimeline.electron.js.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`handles single point 1`] = ` +Array [ + Object { + "color": "#ffffff", + "isCut": false, + "markerKeys": Array [ + "1", + ], + "markerNames": Array [ + "single point", + ], + "positionY": 0, + "timestamp": 0, + }, +] +`; + +exports[`no points 1`] = `Array []`; diff --git a/src/ui/index.js b/src/ui/index.js index c75df1280..f5443d32d 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -131,6 +131,8 @@ export {default as Heading} from './components/Heading.js'; // filters export type {Filter} from './components/filter/types.js'; +export {default as MarkerTimeline} from './components/MarkerTimeline.js'; + // export { SearchBox,