openreplay/frontend/app/components/Session_/TimeTable/TimeTable.tsx
Bart Riepe af4160eb20
feat(ui): add consistent timestamps to (almost) all items in the player ui
This also tries to make the autoscroll functionality a bit more consistent, where all items are always shown in the list, but items which have not yet occurred will be partially transparent until they happen.

Due to that change, autoscroll behavior which previously always went all the way to the bottom of a list didn't make sense anymore, so now it scrolls to the current item.
2022-08-23 09:34:55 +09:00

317 lines
No EOL
9.6 KiB
TypeScript

import React from 'react';
import { List, AutoSizer } from 'react-virtualized';
import cn from 'classnames';
import { Duration } from "luxon";
import { NoContent, IconButton, Button } from 'UI';
import { percentOf } from 'App/utils';
import { formatMs } from 'App/date';
import BarRow from './BarRow';
import stl from './timeTable.module.css';
import autoscrollStl from '../autoscroll.module.css'; //aaa
type Timed = {
time: number;
};
type Durationed = {
duration: number;
};
type CanBeRed = {
//+isRed: boolean,
isRed: () => boolean;
};
type Row = Timed & Durationed & CanBeRed & { [key: string]: any, key: string };
type Line = {
color: string; // Maybe use typescript?
hint?: string;
onClick?: any;
} & Timed;
type Column = {
label: string;
width: number;
dataKey?: string;
render?: (row: any) => void
referenceLines?: Array<Line>;
style?: Object;
} & RenderOrKey;
// type RenderOrKey = { // Disjoint?
// render: Row => React.Node
// } | {
// dataKey: string,
// }
type RenderOrKey =
| {
render?: (row: Row) => React.ReactNode;
key?: string;
}
| {
dataKey: string;
};
type Props = {
className?: string;
rows: Array<Row>;
children: Array<Column>;
tableHeight?: number
activeIndex?: number
renderPopup?: boolean
navigation?: boolean
referenceLines?: any[]
additionalHeight?: number
hoverable?: boolean
onRowClick?: (row: any, index: number) => void
};
type TimeLineInfo = {
timestart: number;
timewidth: number;
};
type State = TimeLineInfo & typeof initialState;
//const TABLE_HEIGHT = 195;
let _additionalHeight = 0;
const ROW_HEIGHT = 32;
//const VISIBLE_COUNT = Math.ceil(TABLE_HEIGHT/ROW_HEIGHT);
const TIME_SECTIONS_COUNT = 8;
const ZERO_TIMEWIDTH = 1000;
function formatTime(ms: number) {
if (ms < 0) return '';
if (ms < 1000) return Duration.fromMillis(ms).toFormat('0.SSS')
return Duration.fromMillis(ms).toFormat('mm:ss');
}
function computeTimeLine(rows: Array<Row>, firstVisibleRowIndex: number, visibleCount: number): TimeLineInfo {
const visibleRows = rows.slice(firstVisibleRowIndex, firstVisibleRowIndex + visibleCount + _additionalHeight);
let timestart = visibleRows.length > 0 ? Math.min(...visibleRows.map((r) => r.time)) : 0;
// TODO: GraphQL requests do not have a duration, so their timeline is borked. Assume a duration of 0.2s for every GraphQL request
const timeend = visibleRows.length > 0 ? Math.max(...visibleRows.map((r) => r.time + (r.duration ?? 200))) : 0;
let timewidth = timeend - timestart;
const offset = timewidth / 70;
if (timestart >= offset) {
timestart -= offset;
}
timewidth *= 1.5; // += offset;
if (timewidth === 0) {
timewidth = ZERO_TIMEWIDTH;
}
return {
timestart,
timewidth,
};
}
const initialState = {
firstVisibleRowIndex: 0,
};
export default class TimeTable extends React.PureComponent<Props, State> {
state = {
...computeTimeLine(this.props.rows, initialState.firstVisibleRowIndex, this.visibleCount),
...initialState,
};
get tableHeight() {
return this.props.tableHeight || 195;
}
get visibleCount() {
return Math.ceil(this.tableHeight / ROW_HEIGHT);
}
scroller = React.createRef<List>();
autoScroll = true;
componentDidMount() {
if (this.scroller.current) {
this.scroller.current.scrollToRow(this.props.activeIndex);
}
}
componentDidUpdate(prevProps: any, prevState: any) {
if (
prevState.firstVisibleRowIndex !== this.state.firstVisibleRowIndex ||
(this.props.rows.length <= this.visibleCount + _additionalHeight && prevProps.rows.length !== this.props.rows.length)
) {
this.setState({
...computeTimeLine(this.props.rows, this.state.firstVisibleRowIndex, this.visibleCount),
});
}
if (this.props.activeIndex && this.props.activeIndex >= 0 && prevProps.activeIndex !== this.props.activeIndex && this.scroller.current) {
this.scroller.current.scrollToRow(this.props.activeIndex);
}
}
onScroll = ({ scrollTop, scrollHeight, clientHeight }: { scrollTop: number; scrollHeight: number; clientHeight: number }): void => {
const firstVisibleRowIndex = Math.floor(scrollTop / ROW_HEIGHT + 0.33);
if (this.state.firstVisibleRowIndex !== firstVisibleRowIndex) {
this.autoScroll = scrollHeight - clientHeight - scrollTop < ROW_HEIGHT / 2;
this.setState({ firstVisibleRowIndex });
}
};
renderRow = ({ index, key, style: rowStyle }: any) => {
const { activeIndex } = this.props;
const { children: columns, rows, renderPopup, hoverable, onRowClick } = this.props;
const { timestart, timewidth } = this.state;
const row = rows[index];
return (
<div
style={rowStyle}
key={key}
className={cn('border-b border-color-gray-light-shade', stl.row, {
[stl.hoverable]: hoverable,
'error color-red': !!row.isRed && row.isRed(),
'cursor-pointer': typeof onRowClick === 'function',
[stl.activeRow]: activeIndex === index,
[stl.inactiveRow]: !activeIndex || index > activeIndex,
})}
onClick={typeof onRowClick === 'function' ? () => onRowClick(row, index) : undefined}
id="table-row"
>
{columns.map(({ dataKey, render, width }) => (
<div className={stl.cell} style={{ width: `${width}px` }}>
{render ? render(row) : row[dataKey || ''] || <i className="color-gray-light">{'empty'}</i>}
</div>
))}
<div className={cn('relative flex-1 flex', stl.timeBarWrapper)}>
<BarRow resource={row} timestart={timestart} timewidth={timewidth} popup={renderPopup} />
</div>
</div>
);
};
onPrevClick = () => {
let prevRedIndex = -1;
for (let i = this.state.firstVisibleRowIndex - 1; i >= 0; i--) {
if (this.props.rows[i].isRed()) {
prevRedIndex = i;
break;
}
}
if (this.scroller.current != null) {
this.scroller.current.scrollToRow(prevRedIndex);
}
};
onNextClick = () => {
let prevRedIndex = -1;
for (let i = this.state.firstVisibleRowIndex + 1; i < this.props.rows.length; i++) {
if (this.props.rows[i].isRed()) {
prevRedIndex = i;
break;
}
}
if (this.scroller.current != null) {
this.scroller.current.scrollToRow(prevRedIndex);
}
};
render() {
const { className, rows, children: columns, navigation = false, referenceLines = [], additionalHeight = 0, activeIndex } = this.props;
const { timewidth, timestart } = this.state;
_additionalHeight = additionalHeight;
const sectionDuration = Math.round(timewidth / TIME_SECTIONS_COUNT);
const timeColumns: number[] = [];
if (timewidth > 0) {
for (let i = 0; i < TIME_SECTIONS_COUNT; i++) {
timeColumns.push(timestart + i * sectionDuration);
}
}
const visibleRefLines = referenceLines.filter(({ time }) => time > timestart && time < timestart + timewidth);
const columnsSumWidth = columns.reduce((sum, { width }) => sum + width, 0);
return (
<div className={cn(className, 'relative')}>
{navigation && (
<div className={cn(autoscrollStl.navButtons, 'flex items-center')}>
<Button
variant="text-primary"
icon="chevron-up"
tooltip={{
title: 'Previous Error',
delay: 0,
}}
onClick={this.onPrevClick}
/>
<Button
variant="text-primary"
icon="chevron-down"
tooltip={{
title: 'Next Error',
delay: 0,
}}
onClick={this.onNextClick}
/>
</div>
)}
<div className={stl.headers}>
<div className={stl.infoHeaders}>
{columns.map(({ label, width }) => (
<div className={stl.headerCell} style={{ width: `${width}px` }}>
{label}
</div>
))}
</div>
<div className={stl.waterfallHeaders}>
{timeColumns.map((time, i) => (
<div className={stl.timeCell} key={`tc-${i}`}>
{formatTime(time)}
</div>
))}
</div>
</div>
<NoContent size="small" show={rows.length === 0}>
<div className="relative">
<div className={stl.timePart} style={{ left: `${columnsSumWidth}px` }}>
{timeColumns.map((_, index) => (
<div key={`tc-${index}`} className={stl.timeCell} />
))}
{visibleRefLines.map(({ time, color, onClick }) => (
<div
className={cn(stl.refLine, `bg-${color}`)}
style={{
left: `${percentOf(time - timestart, timewidth)}%`,
cursor: typeof onClick === 'function' ? 'click' : 'auto',
}}
onClick={onClick}
/>
))}
</div>
<AutoSizer disableHeight>
{({ width }: { width: number }) => (
<List
ref={this.scroller}
className={stl.list}
height={this.tableHeight + additionalHeight}
width={width}
overscanRowCount={20}
rowCount={rows.length}
rowHeight={ROW_HEIGHT}
rowRenderer={this.renderRow}
onScroll={this.onScroll}
scrollToAlignment="start"
forceUpdateProp={timestart | timewidth | (activeIndex || 0)}
/>
)}
</AutoSizer>
</div>
</NoContent>
</div>
);
}
}