Skip to content

Commit

Permalink
Table: Highlight row on shared crosshair (#78392)
Browse files Browse the repository at this point in the history
* bidirectional shared crosshair table WIP

* add shared crosshair to table panel

* lower around point threshold

* add feature toggle

* add index based verification

* add adaptive threshold

* switch to debounceTime

* lower debounce to 100

* raise debounce back to 200

* revert azure dashboard

* re-render only rows list on data hover event

* further break down table component

* refactor

* raise debounce time

* fix build
  • Loading branch information
mdvictor committed Dec 13, 2023
1 parent 7a006c3 commit 5aff338
Show file tree
Hide file tree
Showing 12 changed files with 397 additions and 98 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ Experimental features might be changed or removed without prior notice.
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected |
| `pluginsSkipHostEnvVars` | Disables passing host environment variable to plugin processes |
| `tableSharedCrosshair` | Enables shared crosshair in table panel |
| `regressionTransformation` | Enables regression analysis transformation |

## Development feature toggles
Expand Down
8 changes: 7 additions & 1 deletion packages/grafana-data/src/dataframe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,11 @@ export * from './dimensions';
export * from './ArrayDataFrame';
export * from './DataFrameJSON';
export * from './frameComparisons';
export { anySeriesWithTimeField, isTimeSeriesFrame, isTimeSeriesFrames, isTimeSeriesField } from './utils';
export {
anySeriesWithTimeField,
hasTimeField,
isTimeSeriesFrame,
isTimeSeriesFrames,
isTimeSeriesField,
} from './utils';
export { StreamingDataFrame, StreamingFrameAction, type StreamingFrameOptions, closestIdx } from './StreamingDataFrame';
8 changes: 8 additions & 0 deletions packages/grafana-data/src/dataframe/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,11 @@ export function anySeriesWithTimeField(data: DataFrame[]) {
}
return false;
}

/**
* Indicates if there is any time field in the data frame
* @param data
*/
export function hasTimeField(data: DataFrame): boolean {
return data.fields.some((field) => field.type === FieldType.time);
}
1 change: 1 addition & 0 deletions packages/grafana-data/src/types/featureToggles.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export interface FeatureToggles {
alertingSimplifiedRouting?: boolean;
logRowsPopoverMenu?: boolean;
pluginsSkipHostEnvVars?: boolean;
tableSharedCrosshair?: boolean;
regressionTransformation?: boolean;
displayAnonymousStats?: boolean;
alertStateHistoryAnnotationsFromLoki?: boolean;
Expand Down
301 changes: 301 additions & 0 deletions packages/grafana-ui/src/components/Table/RowsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, UIEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import { Cell, Row, TableState } from 'react-table';
import { VariableSizeList } from 'react-window';
import { Subscription, debounceTime } from 'rxjs';

import {
DataFrame,
DataHoverClearEvent,
DataHoverEvent,
Field,
FieldType,
TimeRange,
hasTimeField,
} from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';

import { useTheme2 } from '../../themes';
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
import { usePanelContext } from '../PanelChrome';

import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
import { TableCell } from './TableCell';
import { TableStyles } from './styles';
import { TableFilterActionCallback } from './types';
import { calculateAroundPointThreshold, isPointTimeValAroundTableTimeVal } from './utils';

interface RowsListProps {
data: DataFrame;
rows: Row[];
enableSharedCrosshair: boolean;
headerHeight: number;
rowHeight: number;
itemCount: number;
pageIndex: number;
listHeight: number;
width: number;
cellHeight?: TableCellHeight;
listRef: React.RefObject<VariableSizeList>;
tableState: TableState;
tableStyles: TableStyles;
nestedDataField?: Field;
prepareRow: (row: Row) => void;
onCellFilterAdded?: TableFilterActionCallback;
timeRange?: TimeRange;
footerPaginationEnabled: boolean;
}

export const RowsList = (props: RowsListProps) => {
const {
data,
rows,
headerHeight,
footerPaginationEnabled,
rowHeight,
itemCount,
pageIndex,
tableState,
prepareRow,
onCellFilterAdded,
width,
cellHeight = TableCellHeight.Sm,
timeRange,
tableStyles,
nestedDataField,
listHeight,
listRef,
enableSharedCrosshair = false,
} = props;

const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(undefined);

const theme = useTheme2();
const panelContext = usePanelContext();

const threshold = useMemo(() => {
const timeField = data.fields.find((f) => f.type === FieldType.time);

if (!timeField) {
return 0;
}

return calculateAroundPointThreshold(timeField);
}, [data]);

const onRowHover = useCallback(
(idx: number, frame: DataFrame) => {
if (!panelContext || !enableSharedCrosshair || !hasTimeField(frame)) {
return;
}

const timeField: Field = frame!.fields.find((f) => f.type === FieldType.time)!;

panelContext.eventBus.publish(
new DataHoverEvent({
point: {
time: timeField.values[idx],
},
})
);
},
[enableSharedCrosshair, panelContext]
);

const onRowLeave = useCallback(() => {
if (!panelContext || !enableSharedCrosshair) {
return;
}

panelContext.eventBus.publish(new DataHoverClearEvent());
}, [enableSharedCrosshair, panelContext]);

const onDataHoverEvent = useCallback(
(evt: DataHoverEvent) => {
if (evt.payload.point?.time && evt.payload.rowIndex !== undefined) {
const timeField = data.fields.find((f) => f.type === FieldType.time);
const time = timeField!.values[evt.payload.rowIndex];
const pointTime = evt.payload.point.time;

// If the time value of the hovered point is around the time value of the
// row with same index, highlight the row
if (isPointTimeValAroundTableTimeVal(pointTime, time, threshold)) {
setRowHighlightIndex(evt.payload.rowIndex);
return;
}

// If the time value of the hovered point is not around the time value of the
// row with same index, try to find a row with same time value
const matchedRowIndex = timeField!.values.findIndex((t) =>
isPointTimeValAroundTableTimeVal(pointTime, t, threshold)
);

if (matchedRowIndex !== -1) {
setRowHighlightIndex(matchedRowIndex);
return;
}

setRowHighlightIndex(undefined);
}
},
[data.fields, threshold]
);

useEffect(() => {
if (!panelContext || !enableSharedCrosshair || !hasTimeField(data) || footerPaginationEnabled) {
return;
}

const subs = new Subscription();

subs.add(
panelContext.eventBus
.getStream(DataHoverEvent)
.pipe(debounceTime(250))
.subscribe({
next: (evt) => {
if (panelContext.eventBus === evt.origin) {
return;
}

onDataHoverEvent(evt);
},
})
);

subs.add(
panelContext.eventBus
.getStream(DataHoverClearEvent)
.pipe(debounceTime(250))
.subscribe({
next: (evt) => {
if (panelContext.eventBus === evt.origin) {
return;
}

setRowHighlightIndex(undefined);
},
})
);

return () => {
subs.unsubscribe();
};
}, [data, enableSharedCrosshair, footerPaginationEnabled, onDataHoverEvent, panelContext]);

let scrollTop: number | undefined = undefined;
if (rowHighlightIndex !== undefined) {
const firstMatchedRowIndex = rows.findIndex((row) => row.index === rowHighlightIndex);

if (firstMatchedRowIndex !== -1) {
scrollTop = headerHeight + (firstMatchedRowIndex - 1) * rowHeight;
}
}

const rowIndexForPagination = useCallback(
(index: number) => {
return tableState.pageIndex * tableState.pageSize + index;
},
[tableState.pageIndex, tableState.pageSize]
);

const RenderRow = useCallback(
({ index, style, rowHighlightIndex }: { index: number; style: CSSProperties; rowHighlightIndex?: number }) => {
const indexForPagination = rowIndexForPagination(index);
const row = rows[indexForPagination];

prepareRow(row);

const expandedRowStyle = tableState.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {};

if (rowHighlightIndex !== undefined && row.index === rowHighlightIndex) {
style = { ...style, backgroundColor: theme.components.table.rowHoverBackground };
}

return (
<div
{...row.getRowProps({ style })}
className={cx(tableStyles.row, expandedRowStyle)}
onMouseEnter={() => onRowHover(index, data)}
onMouseLeave={onRowLeave}
>
{/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
{nestedDataField && tableState.expanded[row.index] && (
<ExpandedRow
nestedData={nestedDataField}
tableStyles={tableStyles}
rowIndex={index}
width={width}
cellHeight={cellHeight}
/>
)}
{row.cells.map((cell: Cell, index: number) => (
<TableCell
key={index}
tableStyles={tableStyles}
cell={cell}
onCellFilterAdded={onCellFilterAdded}
columnIndex={index}
columnCount={row.cells.length}
timeRange={timeRange}
frame={data}
/>
))}
</div>
);
},
[
cellHeight,
data,
nestedDataField,
onCellFilterAdded,
onRowHover,
onRowLeave,
prepareRow,
rowIndexForPagination,
rows,
tableState.expanded,
tableStyles,
theme.components.table.rowHoverBackground,
timeRange,
width,
]
);

const getItemSize = (index: number): number => {
const indexForPagination = rowIndexForPagination(index);
const row = rows[indexForPagination];
if (tableState.expanded[row.index] && nestedDataField) {
return getExpandedRowHeight(nestedDataField, index, tableStyles);
}

return tableStyles.rowHeight;
};

const handleScroll: UIEventHandler = (event) => {
const { scrollTop } = event.currentTarget;

if (listRef.current !== null) {
listRef.current.scrollTo(scrollTop);
}
};

return (
<>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true} scrollTop={scrollTop}>
<VariableSizeList
// This component needs an unmount/remount when row height or page changes
key={rowHeight + pageIndex}
height={listHeight}
itemCount={itemCount}
itemSize={getItemSize}
width={'100%'}
ref={listRef}
style={{ overflow: undefined }}
>
{({ index, style }) => RenderRow({ index, style, rowHighlightIndex })}
</VariableSizeList>
</CustomScrollbar>
</>
);
};

0 comments on commit 5aff338

Please sign in to comment.