Skip to content

Commit bd181b5

Browse files
feat(tooltip): add custom headerFormatter (#233)
BREAKING CHANGE: Previously, you could define `tooltipType` and `tooltipSnap` props in a Settings component; this commit removes these from `SettingsSpecProps` and instead there is a single `tooltip` prop which can accept either a `TooltipType` or a full `TooltipProps` object which may include `type`, `snap`, and/or `headerFormattter` for formatting the header.
1 parent 3b31cea commit bd181b5

File tree

11 files changed

+157
-52
lines changed

11 files changed

+157
-52
lines changed

src/components/tooltips.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import classNames from 'classnames';
22
import { inject, observer } from 'mobx-react';
33
import React from 'react';
4+
import { TooltipValue, TooltipValueFormatter } from '../lib/utils/interactions';
45
import { ChartStore } from '../state/chart_state';
56

67
interface TooltipProps {
@@ -10,14 +11,22 @@ interface TooltipProps {
1011
class TooltipsComponent extends React.Component<TooltipProps> {
1112
static displayName = 'Tooltips';
1213

14+
renderHeader(headerData?: TooltipValue, formatter?: TooltipValueFormatter) {
15+
if (!headerData) {
16+
return null;
17+
}
18+
19+
return formatter ? formatter(headerData) : headerData.value;
20+
}
21+
1322
render() {
14-
const { isTooltipVisible, tooltipData, tooltipPosition } = this.props.chartStore!;
23+
const { isTooltipVisible, tooltipData, tooltipPosition, tooltipHeaderFormatter } = this.props.chartStore!;
1524
if (!isTooltipVisible.get()) {
1625
return <div className="echTooltip echTooltip--hidden" />;
1726
}
1827
return (
1928
<div className="echTooltip" style={{ transform: tooltipPosition.transform }}>
20-
<p className="echTooltip__header">{tooltipData[0] && tooltipData[0].value}</p>
29+
<div className="echTooltip__header">{this.renderHeader(tooltipData[0], tooltipHeaderFormatter)}</div>
2130
<div className="echTooltip__table">
2231
<table>
2332
<tbody>

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * from './specs';
22
export { Chart } from './components/chart';
3-
export { TooltipType } from './lib/utils/interactions';
3+
export { TooltipType, TooltipValue, TooltipValueFormatter } from './lib/utils/interactions';
44
export { getAxisId, getGroupId, getSpecId, getAnnotationId } from './lib/utils/ids';
55
export { ScaleType } from './lib/utils/scales/scales';
66
export { Position, Rendering, Rotation } from './lib/series/specs';

src/lib/series/tooltip.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,6 @@ export function formatTooltip(
5252
};
5353
}
5454

55-
function emptyFormatter(value: any): string {
56-
return `${value}`;
55+
function emptyFormatter<T>(value: T): T {
56+
return value;
5757
}

src/lib/utils/interactions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export interface TooltipValue {
2727
isXValue: boolean;
2828
seriesKey: string;
2929
}
30+
31+
export type TooltipValueFormatter = (data: TooltipValue) => JSX.Element | string;
32+
3033
export interface HighlightedElement {
3134
position: {
3235
x: number;

src/specs/settings.test.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ describe('Settings spec component', () => {
3232
rendering: 'svg' as Rendering,
3333
animateData: true,
3434
showLegend: true,
35-
tooltipType: TooltipType.None,
36-
tooltipSnap: false,
35+
tooltip: {
36+
type: TooltipType.None,
37+
snap: false,
38+
},
3739
legendPosition: Position.Bottom,
3840
showLegendDisplayValue: false,
3941
debug: true,
@@ -74,8 +76,10 @@ describe('Settings spec component', () => {
7476
rendering: 'svg' as Rendering,
7577
animateData: true,
7678
showLegend: true,
77-
tooltipType: TooltipType.None,
78-
tooltipSnap: false,
79+
tooltip: {
80+
type: TooltipType.None,
81+
snap: false,
82+
},
7983
legendPosition: Position.Bottom,
8084
showLegendDisplayValue: false,
8185
debug: true,

src/specs/settings.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { DomainRange, Position, Rendering, Rotation } from '../lib/series/specs'
44
import { LIGHT_THEME } from '../lib/themes/light_theme';
55
import { Theme } from '../lib/themes/theme';
66
import { Domain } from '../lib/utils/domain';
7-
import { TooltipType } from '../lib/utils/interactions';
7+
import { TooltipType, TooltipValueFormatter } from '../lib/utils/interactions';
88
import {
99
BrushEndListener,
1010
ChartStore,
@@ -16,17 +16,29 @@ import {
1616
export const DEFAULT_TOOLTIP_TYPE = TooltipType.VerticalCursor;
1717
export const DEFAULT_TOOLTIP_SNAP = true;
1818

19+
interface TooltipProps {
20+
type?: TooltipType;
21+
snap?: boolean;
22+
headerFormatter?: TooltipValueFormatter;
23+
}
24+
25+
function isTooltipProps(config: TooltipType | TooltipProps): config is TooltipProps {
26+
return typeof config === 'object';
27+
}
28+
29+
function isTooltipType(config: TooltipType | TooltipProps): config is TooltipType {
30+
return typeof config === 'string';
31+
}
32+
1933
interface SettingSpecProps {
2034
chartStore?: ChartStore;
2135
theme?: Theme;
2236
rendering: Rendering;
2337
rotation: Rotation;
2438
animateData: boolean;
2539
showLegend: boolean;
26-
/** Specify the tooltip type */
27-
tooltipType?: TooltipType;
28-
/** Snap tooltip to grid */
29-
tooltipSnap?: boolean;
40+
/** Either a TooltipType or an object with configuration of type, snap, and/or headerFormatter */
41+
tooltip?: TooltipType | TooltipProps;
3042
debug: boolean;
3143
legendPosition?: Position;
3244
showLegendDisplayValue: boolean;
@@ -50,8 +62,7 @@ function updateChartStore(props: SettingSpecProps) {
5062
rendering,
5163
animateData,
5264
showLegend,
53-
tooltipType,
54-
tooltipSnap,
65+
tooltip,
5566
legendPosition,
5667
showLegendDisplayValue,
5768
onElementClick,
@@ -75,8 +86,14 @@ function updateChartStore(props: SettingSpecProps) {
7586
chartStore.animateData = animateData;
7687
chartStore.debug = debug;
7788

78-
chartStore.tooltipType.set(tooltipType!);
79-
chartStore.tooltipSnap.set(tooltipSnap!);
89+
if (tooltip && isTooltipProps(tooltip)) {
90+
const { type, snap, headerFormatter } = tooltip;
91+
chartStore.tooltipType.set(type!);
92+
chartStore.tooltipSnap.set(snap!);
93+
chartStore.tooltipHeaderFormatter = headerFormatter;
94+
} else if (tooltip && isTooltipType(tooltip)) {
95+
chartStore.tooltipType.set(tooltip);
96+
}
8097

8198
chartStore.setShowLegend(showLegend);
8299
chartStore.legendPosition = legendPosition;
@@ -119,8 +136,10 @@ export class SettingsComponent extends PureComponent<SettingSpecProps> {
119136
animateData: true,
120137
showLegend: false,
121138
debug: false,
122-
tooltipType: DEFAULT_TOOLTIP_TYPE,
123-
tooltipSnap: DEFAULT_TOOLTIP_SNAP,
139+
tooltip: {
140+
type: DEFAULT_TOOLTIP_TYPE,
141+
snap: DEFAULT_TOOLTIP_SNAP,
142+
},
124143
showLegendDisplayValue: true,
125144
};
126145
componentDidMount() {

src/state/chart_state.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,39 @@ describe('Chart Store', () => {
635635
expect(store.isTooltipVisible.get()).toBe(true);
636636
});
637637

638+
describe('can use a custom tooltip header formatter', () => {
639+
beforeEach(() => {
640+
const axisSpec: AxisSpec = {
641+
id: AXIS_ID,
642+
groupId: spec.groupId,
643+
hide: true,
644+
showOverlappingTicks: false,
645+
showOverlappingLabels: false,
646+
position: Position.Bottom,
647+
tickSize: 30,
648+
tickPadding: 10,
649+
tickFormat: (value: any) => `foo ${value}`,
650+
};
651+
652+
store.addAxisSpec(axisSpec);
653+
store.addSeriesSpec(spec);
654+
store.tooltipType.set(TooltipType.Crosshairs);
655+
store.computeChart();
656+
});
657+
658+
test('with no tooltipHeaderFormatter defined, should return value formatted using xAxis tickFormatter', () => {
659+
store.tooltipHeaderFormatter = undefined;
660+
store.setCursorPosition(10, 10);
661+
expect(store.tooltipData[0].value).toBe('foo 1');
662+
});
663+
664+
test('with tooltipHeaderFormatter defined, should return value formatted', () => {
665+
store.tooltipHeaderFormatter = (value: TooltipValue) => `${value}`;
666+
store.setCursorPosition(10, 10);
667+
expect(store.tooltipData[0].value).toBe(1);
668+
});
669+
});
670+
638671
test('can disable brush based on scale and listener', () => {
639672
store.xScale = undefined;
640673
expect(store.isBrushEnabled()).toBe(false);

src/state/chart_state.timescales.test.ts

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,16 @@ describe('Render chart', () => {
4646
test('check mouse position correctly return inverted value', () => {
4747
store.setCursorPosition(15, 10); // check first valid tooltip
4848
expect(store.tooltipData.length).toBe(2); // x value + y value
49-
expect(store.tooltipData[0].value).toBe(`${day1}`); // x value
50-
expect(store.tooltipData[1].value).toBe('10'); // y value
49+
expect(store.tooltipData[0].value).toBe(day1); // x value
50+
expect(store.tooltipData[1].value).toBe(10); // y value
5151
store.setCursorPosition(35, 10); // check first valid tooltip
5252
expect(store.tooltipData.length).toBe(2); // x value + y value
53-
expect(store.tooltipData[0].value).toBe(`${day2}`); // x value
54-
expect(store.tooltipData[1].value).toBe('22'); // y value
53+
expect(store.tooltipData[0].value).toBe(day2); // x value
54+
expect(store.tooltipData[1].value).toBe(22); // y value
5555
store.setCursorPosition(76, 10); // check first valid tooltip
5656
expect(store.tooltipData.length).toBe(2); // x value + y value
57-
expect(store.tooltipData[0].value).toBe(`${day3}`); // x value
58-
expect(store.tooltipData[1].value).toBe('6'); // y value
57+
expect(store.tooltipData[0].value).toBe(day3); // x value
58+
expect(store.tooltipData[1].value).toBe(6); // y value
5959
});
6060
});
6161
describe('line, utc-time, 5m interval', () => {
@@ -97,16 +97,16 @@ describe('Render chart', () => {
9797
test('check mouse position correctly return inverted value', () => {
9898
store.setCursorPosition(15, 10); // check first valid tooltip
9999
expect(store.tooltipData.length).toBe(2); // x value + y value
100-
expect(store.tooltipData[0].value).toBe(`${date1}`); // x value
101-
expect(store.tooltipData[1].value).toBe('10'); // y value
100+
expect(store.tooltipData[0].value).toBe(date1); // x value
101+
expect(store.tooltipData[1].value).toBe(10); // y value
102102
store.setCursorPosition(35, 10); // check first valid tooltip
103103
expect(store.tooltipData.length).toBe(2); // x value + y value
104-
expect(store.tooltipData[0].value).toBe(`${date2}`); // x value
105-
expect(store.tooltipData[1].value).toBe('22'); // y value
104+
expect(store.tooltipData[0].value).toBe(date2); // x value
105+
expect(store.tooltipData[1].value).toBe(22); // y value
106106
store.setCursorPosition(76, 10); // check first valid tooltip
107107
expect(store.tooltipData.length).toBe(2); // x value + y value
108-
expect(store.tooltipData[0].value).toBe(`${date3}`); // x value
109-
expect(store.tooltipData[1].value).toBe('6'); // y value
108+
expect(store.tooltipData[0].value).toBe(date3); // x value
109+
expect(store.tooltipData[1].value).toBe(6); // y value
110110
});
111111
});
112112
describe('line, non utc-time, 5m + 1s interval', () => {
@@ -164,16 +164,16 @@ describe('Render chart', () => {
164164
test('check mouse position correctly return inverted value', () => {
165165
store.setCursorPosition(15, 10); // check first valid tooltip
166166
expect(store.tooltipData.length).toBe(2); // x value + y value
167-
expect(store.tooltipData[0].value).toBe(`${date1}`); // x value
168-
expect(store.tooltipData[1].value).toBe('10'); // y value
167+
expect(store.tooltipData[0].value).toBe(date1); // x value
168+
expect(store.tooltipData[1].value).toBe(10); // y value
169169
store.setCursorPosition(35, 10); // check first valid tooltip
170170
expect(store.tooltipData.length).toBe(2); // x value + y value
171-
expect(store.tooltipData[0].value).toBe(`${date2}`); // x value
172-
expect(store.tooltipData[1].value).toBe('22'); // y value
171+
expect(store.tooltipData[0].value).toBe(date2); // x value
172+
expect(store.tooltipData[1].value).toBe(22); // y value
173173
store.setCursorPosition(76, 10); // check first valid tooltip
174174
expect(store.tooltipData.length).toBe(2); // x value + y value
175-
expect(store.tooltipData[0].value).toBe(`${date3}`); // x value
176-
expect(store.tooltipData[1].value).toBe('6'); // y value
175+
expect(store.tooltipData[0].value).toBe(date3); // x value
176+
expect(store.tooltipData[1].value).toBe(6); // y value
177177
});
178178
});
179179
});

src/state/chart_state.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
isFollowTooltipType,
6363
TooltipType,
6464
TooltipValue,
65+
TooltipValueFormatter,
6566
} from '../lib/utils/interactions';
6667
import { Scale, ScaleType } from '../lib/utils/scales/scales';
6768
import { DEFAULT_TOOLTIP_SNAP, DEFAULT_TOOLTIP_TYPE } from '../specs/settings';
@@ -175,6 +176,7 @@ export class ChartStore {
175176
tooltipType = observable.box(DEFAULT_TOOLTIP_TYPE);
176177
tooltipSnap = observable.box(DEFAULT_TOOLTIP_SNAP);
177178
tooltipPosition = observable.object<{ transform: string }>({ transform: '' });
179+
tooltipHeaderFormatter?: TooltipValueFormatter;
178180

179181
/** cursorPosition is used by tooltip, so this is a way to expose the position for other uses */
180182
rawCursorPosition = observable.object<{ x: number; y: number }>({ x: -1, y: -1 }, undefined, {
@@ -377,7 +379,9 @@ export class ChartStore {
377379

378380
// format only one time the x value
379381
if (!xValueInfo) {
380-
xValueInfo = formatTooltip(indexedGeometry, spec, true, false, xAxis);
382+
// if we have a tooltipHeaderFormatter, then don't pass in the xAxis as the user will define a formatter
383+
const formatterAxis = this.tooltipHeaderFormatter ? undefined : xAxis;
384+
xValueInfo = formatTooltip(indexedGeometry, spec, true, false, formatterAxis);
381385
return [xValueInfo, ...acc, formattedTooltip];
382386
}
383387

stories/bar_chart.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,9 +915,13 @@ storiesOf('Bar Chart', module)
915915
.add('with high data volume', () => {
916916
const dg = new DataGenerator();
917917
const data = dg.generateSimpleSeries(15000);
918+
const tooltipProps = {
919+
type: TooltipType.Follow,
920+
};
921+
918922
return (
919923
<Chart className={'story-chart'}>
920-
<Settings tooltipType={TooltipType.Follow} />
924+
<Settings tooltip={tooltipProps} />
921925
<Axis id={getAxisId('bottom')} position={Position.Bottom} title={'Bottom axis'} />
922926
<Axis
923927
id={getAxisId('left2')}

0 commit comments

Comments
 (0)