Table: Highlight row on shared crosshair (#78392)

* 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
pull/79441/head
Victor Marin 2 years ago committed by GitHub
parent 7a006c32bb
commit 5aff3389f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  2. 8
      packages/grafana-data/src/dataframe/index.ts
  3. 8
      packages/grafana-data/src/dataframe/utils.ts
  4. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  5. 301
      packages/grafana-ui/src/components/Table/RowsList.tsx
  6. 118
      packages/grafana-ui/src/components/Table/Table.tsx
  7. 1
      packages/grafana-ui/src/components/Table/types.ts
  8. 30
      packages/grafana-ui/src/components/Table/utils.ts
  9. 8
      pkg/services/featuremgmt/registry.go
  10. 1
      pkg/services/featuremgmt/toggles_gen.csv
  11. 4
      pkg/services/featuremgmt/toggles_gen.go
  12. 14
      public/app/plugins/panel/table/TablePanel.tsx

@ -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

@ -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';

@ -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);
}

@ -166,6 +166,7 @@ export interface FeatureToggles {
alertingSimplifiedRouting?: boolean;
logRowsPopoverMenu?: boolean;
pluginsSkipHostEnvVars?: boolean;
tableSharedCrosshair?: boolean;
regressionTransformation?: boolean;
displayAnonymousStats?: boolean;
alertStateHistoryAnnotationsFromLoki?: boolean;

@ -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>
</>
);
};

@ -1,7 +1,5 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Cell,
useAbsoluteLayout,
useExpanded,
useFilters,
@ -20,10 +18,9 @@ import { useTheme2 } from '../../themes';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { Pagination } from '../Pagination/Pagination';
import { getExpandedRowHeight, ExpandedRow } from './ExpandedRow';
import { FooterRow } from './FooterRow';
import { HeaderRow } from './HeaderRow';
import { TableCell } from './TableCell';
import { RowsList } from './RowsList';
import { useFixScrollbarContainer, useResetVariableListSizeCache } from './hooks';
import { getInitialState, useTableStateReducer } from './reducer';
import { useTableStyles } from './styles';
@ -50,6 +47,7 @@ export const Table = memo((props: Props) => {
enablePagination,
cellHeight = TableCellHeight.Sm,
timeRange,
enableSharedCrosshair = false,
} = props;
const listRef = useRef<VariableSizeList>(null);
@ -231,64 +229,6 @@ export const Table = memo((props: Props) => {
useResetVariableListSizeCache(extendedState, listRef, data);
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
const rowIndexForPagination = useCallback(
(index: number) => {
return state.pageIndex * state.pageSize + index;
},
[state.pageIndex, state.pageSize]
);
const RenderRow = useCallback(
({ index, style }: { index: number; style: CSSProperties }) => {
const indexForPagination = rowIndexForPagination(index);
const row = rows[indexForPagination];
prepareRow(row);
const expandedRowStyle = state.expanded[row.index] ? css({ '&:hover': { background: 'inherit' } }) : {};
return (
<div {...row.getRowProps({ style })} className={cx(tableStyles.row, expandedRowStyle)}>
{/*add the nested data to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
{nestedDataField && state.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>
);
},
[
rowIndexForPagination,
rows,
prepareRow,
state.expanded,
tableStyles,
nestedDataField,
width,
cellHeight,
onCellFilterAdded,
timeRange,
data,
]
);
const onNavigate = useCallback(
(toPage: number) => {
gotoPage(toPage - 1);
@ -322,24 +262,6 @@ export const Table = memo((props: Props) => {
);
}
const getItemSize = (index: number): number => {
const indexForPagination = rowIndexForPagination(index);
const row = rows[indexForPagination];
if (state.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 (
<div
{...getTableProps()}
@ -356,20 +278,26 @@ export const Table = memo((props: Props) => {
)}
{itemCount > 0 ? (
<div data-testid={selectors.components.Panels.Visualization.Table.body} ref={variableSizeListScrollbarRef}>
<CustomScrollbar onScroll={handleScroll} hideHorizontalTrack={true}>
<VariableSizeList
// This component needs an unmount/remount when row height or page changes
key={tableStyles.rowHeight + state.pageIndex}
height={listHeight}
itemCount={itemCount}
itemSize={getItemSize}
width={'100%'}
ref={listRef}
style={{ overflow: undefined }}
>
{RenderRow}
</VariableSizeList>
</CustomScrollbar>
<RowsList
data={data}
rows={rows}
width={width}
cellHeight={cellHeight}
headerHeight={headerHeight}
rowHeight={tableStyles.rowHeight}
itemCount={itemCount}
pageIndex={state.pageIndex}
listHeight={listHeight}
listRef={listRef}
tableState={state}
prepareRow={prepareRow}
timeRange={timeRange}
onCellFilterAdded={onCellFilterAdded}
nestedDataField={nestedDataField}
tableStyles={tableStyles}
footerPaginationEnabled={Boolean(enablePagination)}
enableSharedCrosshair={enableSharedCrosshair}
/>
</div>
) : (
<div style={{ height: height - headerHeight, width }} className={tableStyles.noData}>

@ -94,6 +94,7 @@ export interface Props {
cellHeight?: schema.TableCellHeight;
/** @alpha Used by SparklineCell when provided */
timeRange?: TimeRange;
enableSharedCrosshair?: boolean;
}
/**

@ -533,3 +533,33 @@ export function getAlignmentFactor(
return alignmentFactor;
}
}
// since the conversion from timeseries panel crosshair to time is pixel based, we need
// to set a threshold where the table row highlights when the crosshair is hovered over a certain point
// because multiple pixels (converted to times) may represent the same point/row in table
export function isPointTimeValAroundTableTimeVal(pointTime: number, rowTime: number, threshold: number) {
return Math.abs(Math.floor(pointTime) - rowTime) < threshold;
}
// calculate the threshold for which we consider a point in a chart
// to match a row in a table based on a time value
export function calculateAroundPointThreshold(timeField: Field): number {
let max = -Number.MAX_VALUE;
let min = Number.MAX_VALUE;
if (timeField.values.length < 2) {
return 0;
}
for (let i = 0; i < timeField.values.length; i++) {
const value = timeField.values[i];
if (value > max) {
max = value;
}
if (value < min) {
min = value;
}
}
return (max - min) / timeField.values.length;
}

@ -1241,6 +1241,14 @@ var (
Owner: grafanaPluginsPlatformSquad,
Created: time.Date(2023, time.November, 15, 12, 0, 0, 0, time.UTC),
},
{
Name: "tableSharedCrosshair",
Description: "Enables shared crosshair in table panel",
FrontendOnly: true,
Stage: FeatureStageExperimental,
Owner: grafanaBiSquad,
Created: time.Date(2023, time.December, 12, 12, 0, 0, 0, time.UTC),
},
{
Name: "regressionTransformation",
Description: "Enables regression analysis transformation",

@ -147,6 +147,7 @@ datatrails,experimental,@grafana/dashboards-squad,2023-11-15,false,false,false,t
alertingSimplifiedRouting,experimental,@grafana/alerting-squad,2023-11-10,false,false,false,false
logRowsPopoverMenu,experimental,@grafana/observability-logs,2023-11-16,false,false,false,true
pluginsSkipHostEnvVars,experimental,@grafana/plugins-platform-backend,2023-11-15,false,false,false,false
tableSharedCrosshair,experimental,@grafana/grafana-bi-squad,2023-12-12,false,false,false,true
regressionTransformation,experimental,@grafana/grafana-bi-squad,2023-11-24,false,false,false,true
displayAnonymousStats,GA,@grafana/identity-access-team,2023-11-29,false,false,false,true
alertStateHistoryAnnotationsFromLoki,experimental,@grafana/alerting-squad,2023-11-30,false,false,true,false

1 Name Stage Owner Created requiresDevMode RequiresLicense RequiresRestart FrontendOnly
147 alertingSimplifiedRouting experimental @grafana/alerting-squad 2023-11-10 false false false false
148 logRowsPopoverMenu experimental @grafana/observability-logs 2023-11-16 false false false true
149 pluginsSkipHostEnvVars experimental @grafana/plugins-platform-backend 2023-11-15 false false false false
150 tableSharedCrosshair experimental @grafana/grafana-bi-squad 2023-12-12 false false false true
151 regressionTransformation experimental @grafana/grafana-bi-squad 2023-11-24 false false false true
152 displayAnonymousStats GA @grafana/identity-access-team 2023-11-29 false false false true
153 alertStateHistoryAnnotationsFromLoki experimental @grafana/alerting-squad 2023-11-30 false false true false

@ -599,6 +599,10 @@ const (
// Disables passing host environment variable to plugin processes
FlagPluginsSkipHostEnvVars = "pluginsSkipHostEnvVars"
// FlagTableSharedCrosshair
// Enables shared crosshair in table panel
FlagTableSharedCrosshair = "tableSharedCrosshair"
// FlagRegressionTransformation
// Enables regression analysis transformation
FlagRegressionTransformation = "regressionTransformation"

@ -1,8 +1,15 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataFrame, FieldMatcherID, getFrameDisplayName, PanelProps, SelectableValue } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import {
DashboardCursorSync,
DataFrame,
FieldMatcherID,
getFrameDisplayName,
PanelProps,
SelectableValue,
} from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
@ -37,6 +44,8 @@ export function TablePanel(props: Props) {
tableHeight = height - inputHeight - padding;
}
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
const tableElement = (
<Table
height={tableHeight}
@ -53,6 +62,7 @@ export function TablePanel(props: Props) {
enablePagination={options.footer?.enablePagination}
cellHeight={options.cellHeight}
timeRange={timeRange}
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
/>
);

Loading…
Cancel
Save