/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import React from 'react'; import {Component} from 'react'; import styled from '@emotion/styled'; import {theme} from './theme'; import {Layout} from './Layout'; import {Typography} from 'antd'; type DataPoint = { time: number; color?: string; label: string; key: string; }; type Props = { onClick?: (keys: Array) => void; selected?: string | null | undefined; points: DataPoint[]; lineHeight: number; maxGap: number; }; type MouseEventHandler = ( event: React.MouseEvent, ) => void; const Markers = styled.div<{totalTime: number}>((props) => ({ position: 'relative', margin: 10, height: props.totalTime, '::before': { content: '""', width: 1, borderLeft: `1px dotted ${theme.disabledColor}`, position: 'absolute', top: 5, bottom: 20, left: 5, }, })); Markers.displayName = 'MarkerTimeline:Markers'; const Point = styled(Layout.Horizontal)<{ positionY: number; onClick: MouseEventHandler | undefined; number: number | undefined; threadColor: string; selected: boolean; cut: boolean; }>((props) => ({ position: 'absolute', top: props.positionY, left: 0, right: 10, cursor: props.onClick ? 'pointer' : 'default', borderRadius: 3, alignItems: 'baseline', lineHeight: '16px', ':hover': { backgroundColor: theme.backgroundWash, '> span': { whiteSpace: 'initial', }, }, '::before': { position: 'relative', textAlign: 'center', fontSize: 8, fontWeight: 500, content: props.number ? `'${props.number}'` : '""', display: 'inline-block', width: 9, height: 9, flexShrink: 0, color: theme.textColorSecondary, lineHeight: '9px', borderRadius: '999em', border: theme.dividerColor, backgroundColor: props.threadColor, marginRight: 6, marginTop: 3, zIndex: 3, boxShadow: props.selected ? `0 0 0 4px ${theme.selectionBackgroundColor}` : undefined, }, '::after': { content: props.cut ? '""' : undefined, position: 'absolute', width: 11, top: -20, left: 0, height: 2, background: theme.backgroundDefault, borderTop: `1px solid ${theme.dividerColor}`, borderBottom: `1px solid ${theme.dividerColor}`, transform: `skewY(-10deg)`, }, })); Point.displayName = 'MakerTimeline:Point'; const Time = styled.span({ color: theme.textColorSecondary, fontWeight: 300, marginRight: 4, }); Time.displayName = 'MakerTimeline:Time'; const Name = styled(Typography.Text)({ overflow: 'hidden', opacity: 0.8, textOverflow: 'ellipsis', marginTop: -1, marginLeft: theme.space.tiny, fontFamily: 'monospace', }); Name.displayName = 'MakerTimeline:Name'; type TimePoint = { timestamp: number; markerNames: Array; markerKeys: Array; isCut: boolean; positionY: number; color: string; }; type State = { timePoints: Array; }; export class MarkerTimeline extends Component { static defaultProps = { lineHeight: 22, maxGap: 100, }; static getDerivedStateFromProps(props: Props) { const sortedMarkers: [number, DataPoint[]][] = 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()) .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 || theme.backgroundDefault; 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 ( onClick(p.markerKeys) : undefined} selected={ this.props.selected ? p.markerKeys.includes(this.props.selected) : false } number={ p.markerNames.length > 1 ? p.markerNames.length : undefined }> {' '} {p.markerNames.join(', ')} ); })} ); } }