mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 16:54:59 +08:00
Compare commits
2 Commits
docs/add-t
...
feat/spark
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f2bdbcc1 | ||
|
|
27a3503e61 |
8
packages/grafana-schema/src/common/common.gen.ts
generated
8
packages/grafana-schema/src/common/common.gen.ts
generated
@@ -831,9 +831,17 @@ export interface TableBarGaugeCellOptions {
|
||||
*/
|
||||
export interface TableSparklineCellOptions extends GraphFieldConfig {
|
||||
hideValue?: boolean;
|
||||
/**
|
||||
* Enable interactive hover to inspect values along the sparkline
|
||||
*/
|
||||
interactionEnabled?: boolean;
|
||||
type: TableCellDisplayMode.Sparkline;
|
||||
}
|
||||
|
||||
export const defaultTableSparklineCellOptions: Partial<TableSparklineCellOptions> = {
|
||||
interactionEnabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Colored background cell options
|
||||
*/
|
||||
|
||||
@@ -63,6 +63,8 @@ TableSparklineCellOptions: {
|
||||
GraphFieldConfig
|
||||
type: TableCellDisplayMode & "sparkline"
|
||||
hideValue?: bool
|
||||
// Enable interactive hover to inspect values along the sparkline
|
||||
interactionEnabled?: bool | *true
|
||||
} @cuetsy(kind="interface")
|
||||
|
||||
// Colored background cell options
|
||||
|
||||
@@ -1,30 +1,264 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createTheme, FieldSparkline, FieldType } from '@grafana/data';
|
||||
import { createTheme, FieldConfig, FieldSparkline, FieldType } from '@grafana/data';
|
||||
import { GraphFieldConfig } from '@grafana/schema';
|
||||
|
||||
import { Sparkline } from './Sparkline';
|
||||
|
||||
describe('Sparkline', () => {
|
||||
const mockSparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
state: {
|
||||
range: { min: 1, max: 5, delta: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should render without throwing an error', () => {
|
||||
const sparkline: FieldSparkline = {
|
||||
x: {
|
||||
name: 'x',
|
||||
values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000],
|
||||
type: FieldType.time,
|
||||
config: {},
|
||||
},
|
||||
y: {
|
||||
name: 'y',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
type: FieldType.number,
|
||||
config: {},
|
||||
state: {
|
||||
range: { min: 1, max: 5, delta: 1 },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
|
||||
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={mockSparkline} />)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
describe('hover interaction', () => {
|
||||
it('should call onHover with value when interaction is enabled and cursor moves', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// Get the Sparkline instance to access the config builder
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// Find and execute the setLegend hook
|
||||
const setLegendHook = hooks?.setLegend?.[0];
|
||||
if (setLegendHook) {
|
||||
// Simulate hover over data point at index 2 (value: 3)
|
||||
const mockUPlot = {
|
||||
cursor: { idxs: [2, 2] },
|
||||
data: [mockSparkline.x!.values, mockSparkline.y.values],
|
||||
} as Partial<uPlot>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot as any);
|
||||
|
||||
expect(onHover).toHaveBeenCalledWith(3, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should call onHover with null when cursor leaves', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
const setLegendHook = hooks?.setLegend?.[0];
|
||||
if (setLegendHook) {
|
||||
// Simulate cursor leaving (no valid index)
|
||||
const mockUPlot = {
|
||||
cursor: { idxs: [null, null] },
|
||||
data: [mockSparkline.x!.values, mockSparkline.y.values],
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot as any);
|
||||
|
||||
expect(onHover).toHaveBeenCalledWith(null, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should not set up hover hooks when interaction is disabled', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: false,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// setLegend hook should not be registered
|
||||
expect(hooks?.setLegend).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should not set up hover hooks when onHover is not provided', () => {
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// setLegend hook should not be registered when no onHover callback
|
||||
expect(hooks?.setLegend).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should enable interaction by default when not explicitly configured', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={mockSparkline}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
// setLegend hook should be registered (interaction enabled by default)
|
||||
expect(hooks?.setLegend).toBeDefined();
|
||||
expect(hooks?.setLegend?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle non-finite values correctly during hover', () => {
|
||||
const onHover = jest.fn();
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
custom: {
|
||||
interactionEnabled: true,
|
||||
} as GraphFieldConfig & { interactionEnabled?: boolean },
|
||||
};
|
||||
|
||||
const sparklineWithNaN: FieldSparkline = {
|
||||
...mockSparkline,
|
||||
y: {
|
||||
...mockSparkline.y,
|
||||
values: [1, NaN, 3, Infinity, 5],
|
||||
},
|
||||
};
|
||||
|
||||
const view = render(
|
||||
<Sparkline
|
||||
width={800}
|
||||
height={600}
|
||||
theme={createTheme()}
|
||||
sparkline={sparklineWithNaN}
|
||||
config={config}
|
||||
onHover={onHover}
|
||||
/>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = (view.container.firstChild as any)?.__reactFiber$?.return?.stateNode;
|
||||
if (instance?.state?.configBuilder) {
|
||||
const builder = instance.state.configBuilder;
|
||||
const hooks = builder.getConfig().hooks;
|
||||
|
||||
const setLegendHook = hooks?.setLegend?.[0];
|
||||
if (setLegendHook) {
|
||||
// Hover over NaN value at index 1
|
||||
const mockUPlot1 = {
|
||||
cursor: { idxs: [1, 1] },
|
||||
data: [sparklineWithNaN.x!.values, sparklineWithNaN.y.values],
|
||||
} as Partial<uPlot>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot1 as any);
|
||||
expect(onHover).toHaveBeenCalledWith(null, null);
|
||||
|
||||
onHover.mockClear();
|
||||
|
||||
// Hover over Infinity value at index 3
|
||||
const mockUPlot3 = {
|
||||
cursor: { idxs: [3, 3] },
|
||||
data: [sparklineWithNaN.x!.values, sparklineWithNaN.y.values],
|
||||
} as Partial<uPlot>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setLegendHook(mockUPlot3 as any);
|
||||
expect(onHover).toHaveBeenCalledWith(null, null);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { isEqual } from 'lodash';
|
||||
import { PureComponent } from 'react';
|
||||
import { AlignedData, Range } from 'uplot';
|
||||
@@ -33,6 +34,7 @@ export interface SparklineProps extends Themeable2 {
|
||||
height: number;
|
||||
config?: FieldConfig<GraphFieldConfig>;
|
||||
sparkline: FieldSparkline;
|
||||
onHover?: (value: number | null, index: number | null) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -106,14 +108,14 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
}
|
||||
|
||||
prepareConfig(data: DataFrame) {
|
||||
const { theme } = this.props;
|
||||
const { theme, onHover, config } = this.props;
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
show: false,
|
||||
x: false, // no crosshairs
|
||||
y: false,
|
||||
});
|
||||
// Check if interaction is enabled (default to true)
|
||||
// interactionEnabled is on TableSparklineCellOptions which extends GraphFieldConfig
|
||||
const customConfig = config?.custom;
|
||||
const interactionEnabled =
|
||||
customConfig && 'interactionEnabled' in customConfig ? customConfig.interactionEnabled : true;
|
||||
|
||||
// X is the first field in the alligned frame
|
||||
const xField = data.fields[0];
|
||||
@@ -141,6 +143,9 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
placement: AxisPlacement.Hidden,
|
||||
});
|
||||
|
||||
// Track the series color for cursor point styling
|
||||
let seriesColor: string | undefined;
|
||||
|
||||
for (let i = 0; i < data.fields.length; i++) {
|
||||
const field = data.fields[i];
|
||||
const config: FieldConfig<GraphFieldConfig> = field.config;
|
||||
@@ -168,7 +173,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
});
|
||||
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
const pointsMode =
|
||||
customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
|
||||
|
||||
@@ -183,7 +188,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
lineWidth: customConfig.lineWidth,
|
||||
lineInterpolation: customConfig.lineInterpolation,
|
||||
showPoints: pointsMode,
|
||||
pointSize: customConfig.pointSize,
|
||||
pointSize: customConfig.pointSize || 5, // Ensure minimum size for cursor point calculation
|
||||
fillOpacity: customConfig.fillOpacity,
|
||||
fillColor: customConfig.fillColor,
|
||||
lineStyle: customConfig.lineStyle,
|
||||
@@ -192,12 +197,72 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
});
|
||||
}
|
||||
|
||||
// Configure cursor after series so we have the series color
|
||||
if (interactionEnabled && onHover) {
|
||||
// Enable cursor with vertical bar indicator for hover interaction
|
||||
// Vertical bar is more visible on small sparklines (25-30px height) than a dot
|
||||
builder.setCursor({
|
||||
show: true,
|
||||
x: true, // show vertical line (bar indicator)
|
||||
y: false, // no horizontal line
|
||||
points: {
|
||||
show: false, // don't show dots - use vertical bar instead
|
||||
// Provide safe functions that don't access this.frames (which is undefined for Sparkline)
|
||||
stroke: () => 'transparent',
|
||||
fill: () => 'transparent',
|
||||
size: () => 0,
|
||||
width: () => 0,
|
||||
},
|
||||
focus: {
|
||||
prox: 30, // proximity in CSS pixels for hover detection
|
||||
},
|
||||
});
|
||||
|
||||
// Track cursor position and call onHover with the value at that position
|
||||
// Using setLegend hook which fires on hover (not just drag-to-select like setSelect)
|
||||
builder.addHook('setLegend', (u: uPlot) => {
|
||||
const dataIdx = u.cursor.idxs?.[1]; // Get the data index from the cursor
|
||||
if (dataIdx != null) {
|
||||
const yData = u.data[1]; // Y-axis data (values)
|
||||
if (yData && dataIdx < yData.length) {
|
||||
const value = yData[dataIdx];
|
||||
if (value != null && isFinite(value)) {
|
||||
onHover(value, dataIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reset on mouse leave or when no valid data point
|
||||
onHover(null, null);
|
||||
});
|
||||
} else {
|
||||
// Default behavior: cursor disabled
|
||||
builder.setCursor({
|
||||
show: false,
|
||||
x: false, // no crosshairs
|
||||
y: false,
|
||||
});
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, configBuilder } = this.state;
|
||||
const { width, height } = this.props;
|
||||
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
||||
|
||||
// Style the vertical cursor bar to be more visible on small sparklines
|
||||
const cursorStyles = css({
|
||||
'.u-cursor-x': {
|
||||
borderLeft: '2px solid !important',
|
||||
opacity: '1 !important',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cursorStyles}>
|
||||
<UPlotChart data={data} config={configBuilder} width={width} height={height} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { FieldConfig, getMinMaxAndDelta, Field, isDataFrameWithValue } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
@@ -32,12 +33,20 @@ export const defaultSparklineCellConfig: TableSparklineCellOptions = {
|
||||
barAlignment: BarAlignment.Center,
|
||||
showPoints: VisibilityMode.Never,
|
||||
hideValue: false,
|
||||
interactionEnabled: true,
|
||||
};
|
||||
|
||||
export const SparklineCell = (props: SparklineCellProps) => {
|
||||
const { field, value, theme, timeRange, rowIdx, width } = props;
|
||||
const sparkline = prepareSparklineValue(value, field);
|
||||
|
||||
// Hover state management for interactive sparklines
|
||||
const [hoverValue, setHoverValue] = useState<number | null>(null);
|
||||
|
||||
const handleHover = useCallback((value: number | null, index: number | null) => {
|
||||
setHoverValue(value);
|
||||
}, []);
|
||||
|
||||
if (!sparkline) {
|
||||
return (
|
||||
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
||||
@@ -80,8 +89,10 @@ export const SparklineCell = (props: SparklineCellProps) => {
|
||||
let valueWidth = 0;
|
||||
let valueElement: React.ReactNode = null;
|
||||
if (!hideValue) {
|
||||
const newValue = isDataFrameWithValue(value) ? value.value : null;
|
||||
const displayValue = field.display!(newValue);
|
||||
// Use hover value if hovering, otherwise use the default value
|
||||
const defaultValue = isDataFrameWithValue(value) ? value.value : null;
|
||||
const displayRawValue = hoverValue ?? defaultValue;
|
||||
const displayValue = field.display!(displayRawValue);
|
||||
const alignmentFactor = getAlignmentFactor(field, displayValue, rowIdx!);
|
||||
|
||||
valueWidth =
|
||||
@@ -94,7 +105,14 @@ export const SparklineCell = (props: SparklineCellProps) => {
|
||||
return (
|
||||
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
||||
{valueElement}
|
||||
<Sparkline width={width - valueWidth} height={25} sparkline={sparkline} config={config} theme={theme} />
|
||||
<Sparkline
|
||||
width={width - valueWidth}
|
||||
height={25}
|
||||
sparkline={sparkline}
|
||||
config={config}
|
||||
theme={theme}
|
||||
onHover={handleHover}
|
||||
/>
|
||||
</MaybeWrapWithLink>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TableCellEditorProps } from '../TableCellOptionEditor';
|
||||
type OptionKey = keyof TableSparklineCellOptions;
|
||||
|
||||
const optionIds: Array<keyof TableSparklineCellOptions> = [
|
||||
'interactionEnabled',
|
||||
'hideValue',
|
||||
'drawStyle',
|
||||
'lineInterpolation',
|
||||
@@ -31,6 +32,12 @@ function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<Gr
|
||||
...graphFieldConfig,
|
||||
useCustomConfig: (builder) => {
|
||||
graphFieldConfig.useCustomConfig?.(builder);
|
||||
builder.addBooleanSwitch({
|
||||
path: 'interactionEnabled',
|
||||
name: 'Enable hover interaction',
|
||||
description: 'Allow users to hover over the sparkline to inspect values',
|
||||
defaultValue: true,
|
||||
});
|
||||
builder.addBooleanSwitch({
|
||||
path: 'hideValue',
|
||||
name: 'Hide value',
|
||||
|
||||
Reference in New Issue
Block a user