mirror of
https://github.com/grafana/grafana.git
synced 2025-12-22 12:44:34 +08:00
Compare commits
2 Commits
zoltan/pos
...
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 {
|
export interface TableSparklineCellOptions extends GraphFieldConfig {
|
||||||
hideValue?: boolean;
|
hideValue?: boolean;
|
||||||
|
/**
|
||||||
|
* Enable interactive hover to inspect values along the sparkline
|
||||||
|
*/
|
||||||
|
interactionEnabled?: boolean;
|
||||||
type: TableCellDisplayMode.Sparkline;
|
type: TableCellDisplayMode.Sparkline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const defaultTableSparklineCellOptions: Partial<TableSparklineCellOptions> = {
|
||||||
|
interactionEnabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Colored background cell options
|
* Colored background cell options
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ TableSparklineCellOptions: {
|
|||||||
GraphFieldConfig
|
GraphFieldConfig
|
||||||
type: TableCellDisplayMode & "sparkline"
|
type: TableCellDisplayMode & "sparkline"
|
||||||
hideValue?: bool
|
hideValue?: bool
|
||||||
|
// Enable interactive hover to inspect values along the sparkline
|
||||||
|
interactionEnabled?: bool | *true
|
||||||
} @cuetsy(kind="interface")
|
} @cuetsy(kind="interface")
|
||||||
|
|
||||||
// Colored background cell options
|
// Colored background cell options
|
||||||
|
|||||||
@@ -1,30 +1,264 @@
|
|||||||
import { render } from '@testing-library/react';
|
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';
|
import { Sparkline } from './Sparkline';
|
||||||
|
|
||||||
describe('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', () => {
|
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(() =>
|
expect(() =>
|
||||||
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={sparkline} />)
|
render(<Sparkline width={800} height={600} theme={createTheme()} sparkline={mockSparkline} />)
|
||||||
).not.toThrow();
|
).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 { isEqual } from 'lodash';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import { AlignedData, Range } from 'uplot';
|
import { AlignedData, Range } from 'uplot';
|
||||||
@@ -33,6 +34,7 @@ export interface SparklineProps extends Themeable2 {
|
|||||||
height: number;
|
height: number;
|
||||||
config?: FieldConfig<GraphFieldConfig>;
|
config?: FieldConfig<GraphFieldConfig>;
|
||||||
sparkline: FieldSparkline;
|
sparkline: FieldSparkline;
|
||||||
|
onHover?: (value: number | null, index: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -106,14 +108,14 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepareConfig(data: DataFrame) {
|
prepareConfig(data: DataFrame) {
|
||||||
const { theme } = this.props;
|
const { theme, onHover, config } = this.props;
|
||||||
const builder = new UPlotConfigBuilder();
|
const builder = new UPlotConfigBuilder();
|
||||||
|
|
||||||
builder.setCursor({
|
// Check if interaction is enabled (default to true)
|
||||||
show: false,
|
// interactionEnabled is on TableSparklineCellOptions which extends GraphFieldConfig
|
||||||
x: false, // no crosshairs
|
const customConfig = config?.custom;
|
||||||
y: false,
|
const interactionEnabled =
|
||||||
});
|
customConfig && 'interactionEnabled' in customConfig ? customConfig.interactionEnabled : true;
|
||||||
|
|
||||||
// X is the first field in the alligned frame
|
// X is the first field in the alligned frame
|
||||||
const xField = data.fields[0];
|
const xField = data.fields[0];
|
||||||
@@ -141,6 +143,9 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
|||||||
placement: AxisPlacement.Hidden,
|
placement: AxisPlacement.Hidden,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track the series color for cursor point styling
|
||||||
|
let seriesColor: string | undefined;
|
||||||
|
|
||||||
for (let i = 0; i < data.fields.length; i++) {
|
for (let i = 0; i < data.fields.length; i++) {
|
||||||
const field = data.fields[i];
|
const field = data.fields[i];
|
||||||
const config: FieldConfig<GraphFieldConfig> = field.config;
|
const config: FieldConfig<GraphFieldConfig> = field.config;
|
||||||
@@ -168,7 +173,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const colorMode = getFieldColorModeForField(field);
|
const colorMode = getFieldColorModeForField(field);
|
||||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||||
const pointsMode =
|
const pointsMode =
|
||||||
customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
|
customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints;
|
||||||
|
|
||||||
@@ -183,7 +188,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
|||||||
lineWidth: customConfig.lineWidth,
|
lineWidth: customConfig.lineWidth,
|
||||||
lineInterpolation: customConfig.lineInterpolation,
|
lineInterpolation: customConfig.lineInterpolation,
|
||||||
showPoints: pointsMode,
|
showPoints: pointsMode,
|
||||||
pointSize: customConfig.pointSize,
|
pointSize: customConfig.pointSize || 5, // Ensure minimum size for cursor point calculation
|
||||||
fillOpacity: customConfig.fillOpacity,
|
fillOpacity: customConfig.fillOpacity,
|
||||||
fillColor: customConfig.fillColor,
|
fillColor: customConfig.fillColor,
|
||||||
lineStyle: customConfig.lineStyle,
|
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;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { data, configBuilder } = this.state;
|
const { data, configBuilder } = this.state;
|
||||||
const { width, height } = this.props;
|
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 { css } from '@emotion/css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
import { FieldConfig, getMinMaxAndDelta, Field, isDataFrameWithValue } from '@grafana/data';
|
import { FieldConfig, getMinMaxAndDelta, Field, isDataFrameWithValue } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
@@ -32,12 +33,20 @@ export const defaultSparklineCellConfig: TableSparklineCellOptions = {
|
|||||||
barAlignment: BarAlignment.Center,
|
barAlignment: BarAlignment.Center,
|
||||||
showPoints: VisibilityMode.Never,
|
showPoints: VisibilityMode.Never,
|
||||||
hideValue: false,
|
hideValue: false,
|
||||||
|
interactionEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SparklineCell = (props: SparklineCellProps) => {
|
export const SparklineCell = (props: SparklineCellProps) => {
|
||||||
const { field, value, theme, timeRange, rowIdx, width } = props;
|
const { field, value, theme, timeRange, rowIdx, width } = props;
|
||||||
const sparkline = prepareSparklineValue(value, field);
|
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) {
|
if (!sparkline) {
|
||||||
return (
|
return (
|
||||||
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
||||||
@@ -80,8 +89,10 @@ export const SparklineCell = (props: SparklineCellProps) => {
|
|||||||
let valueWidth = 0;
|
let valueWidth = 0;
|
||||||
let valueElement: React.ReactNode = null;
|
let valueElement: React.ReactNode = null;
|
||||||
if (!hideValue) {
|
if (!hideValue) {
|
||||||
const newValue = isDataFrameWithValue(value) ? value.value : null;
|
// Use hover value if hovering, otherwise use the default value
|
||||||
const displayValue = field.display!(newValue);
|
const defaultValue = isDataFrameWithValue(value) ? value.value : null;
|
||||||
|
const displayRawValue = hoverValue ?? defaultValue;
|
||||||
|
const displayValue = field.display!(displayRawValue);
|
||||||
const alignmentFactor = getAlignmentFactor(field, displayValue, rowIdx!);
|
const alignmentFactor = getAlignmentFactor(field, displayValue, rowIdx!);
|
||||||
|
|
||||||
valueWidth =
|
valueWidth =
|
||||||
@@ -94,7 +105,14 @@ export const SparklineCell = (props: SparklineCellProps) => {
|
|||||||
return (
|
return (
|
||||||
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
|
||||||
{valueElement}
|
{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>
|
</MaybeWrapWithLink>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TableCellEditorProps } from '../TableCellOptionEditor';
|
|||||||
type OptionKey = keyof TableSparklineCellOptions;
|
type OptionKey = keyof TableSparklineCellOptions;
|
||||||
|
|
||||||
const optionIds: Array<keyof TableSparklineCellOptions> = [
|
const optionIds: Array<keyof TableSparklineCellOptions> = [
|
||||||
|
'interactionEnabled',
|
||||||
'hideValue',
|
'hideValue',
|
||||||
'drawStyle',
|
'drawStyle',
|
||||||
'lineInterpolation',
|
'lineInterpolation',
|
||||||
@@ -31,6 +32,12 @@ function getChartCellConfig(cfg: GraphFieldConfig): SetFieldConfigOptionsArgs<Gr
|
|||||||
...graphFieldConfig,
|
...graphFieldConfig,
|
||||||
useCustomConfig: (builder) => {
|
useCustomConfig: (builder) => {
|
||||||
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({
|
builder.addBooleanSwitch({
|
||||||
path: 'hideValue',
|
path: 'hideValue',
|
||||||
name: 'Hide value',
|
name: 'Hide value',
|
||||||
|
|||||||
Reference in New Issue
Block a user