Skip to content

Commit c8c1d9d

Browse files
authored
feat: add cursor sync mechanism (#304)
Add `CursorUpdateListener` to `Settings` component props to allow Chart consumer to synchronize cursors across multiple Charts by calling `dispatchExternalCursorEvent` on the `Chart` ref to update cursor value.
1 parent 8b74025 commit c8c1d9d

File tree

10 files changed

+353
-114
lines changed

10 files changed

+353
-114
lines changed

.playground/playgroud.tsx

Lines changed: 76 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -9,103 +9,88 @@ import {
99
Position,
1010
ScaleType,
1111
Settings,
12-
mergeWithDefaultTheme,
13-
AreaSeries,
12+
LineSeries,
1413
} from '../src';
1514
import { KIBANA_METRICS } from '../src/utils/data_samples/test_dataset_kibana';
15+
import { CursorEvent } from '../src/specs/settings';
16+
import { CursorUpdateListener } from '../src/chart_types/xy_chart/store/chart_state';
1617

1718
export class Playground extends React.Component {
19+
ref1 = React.createRef<Chart>();
20+
ref2 = React.createRef<Chart>();
21+
ref3 = React.createRef<Chart>();
22+
23+
onCursorUpdate: CursorUpdateListener = (event?: CursorEvent) => {
24+
this.ref1.current!.dispatchExternalCursorEvent(event);
25+
this.ref2.current!.dispatchExternalCursorEvent(event);
26+
this.ref3.current!.dispatchExternalCursorEvent(event);
27+
};
28+
1829
render() {
19-
return <>{this.renderChart(Position.Right)}</>;
20-
}
21-
renderChart(legendPosition: Position) {
22-
const theme = mergeWithDefaultTheme({
23-
lineSeriesStyle: {
24-
line: {
25-
stroke: 'violet',
26-
strokeWidth: 4,
27-
},
28-
point: {
29-
fill: 'yellow',
30-
stroke: 'black',
31-
strokeWidth: 2,
32-
radius: 6,
33-
},
34-
},
35-
});
36-
console.log(theme.areaSeriesStyle);
3730
return (
38-
<div className="chart">
39-
<Chart>
40-
<Settings debug={false} showLegend={true} legendPosition={legendPosition} rotation={0} theme={theme} />
41-
<Axis
42-
id={getAxisId('timestamp')}
43-
title="timestamp"
44-
position={Position.Bottom}
45-
tickFormat={niceTimeFormatter([1555819200000, 1555905600000])}
46-
/>
47-
<Axis id={getAxisId('count')} title="count" position={Position.Left} tickFormat={(d) => d.toFixed(2)} />
48-
49-
<AreaSeries
50-
id={getSpecId('dataset B')}
51-
xScaleType={ScaleType.Time}
52-
yScaleType={ScaleType.Linear}
53-
data={KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15)}
54-
xAccessor={0}
55-
yAccessors={[1]}
56-
stackAccessors={[0]}
57-
areaSeriesStyle={{
58-
line: {
59-
// opacity:1,
60-
strokeWidth: 10,
61-
},
62-
point: {
63-
visible: true,
64-
strokeWidth: 3,
65-
radius: 10,
66-
},
67-
}}
68-
/>
69-
<AreaSeries
70-
id={getSpecId('dataset C')}
71-
xScaleType={ScaleType.Time}
72-
yScaleType={ScaleType.Linear}
73-
data={KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15)}
74-
xAccessor={0}
75-
yAccessors={[1]}
76-
stackAccessors={[0]}
77-
areaSeriesStyle={{
78-
line: {
79-
// opacity:1,
80-
strokeWidth: 10,
81-
},
82-
point: {
83-
visible: true,
84-
strokeWidth: 3,
85-
radius: 10,
86-
},
87-
}}
88-
/>
89-
<AreaSeries
90-
id={getSpecId('dataset A with long title')}
91-
xScaleType={ScaleType.Time}
92-
yScaleType={ScaleType.Linear}
93-
data={KIBANA_METRICS.metrics.kibana_os_load[0].data.slice(0, 15)}
94-
xAccessor={0}
95-
areaSeriesStyle={{
96-
point: {
97-
visible: true,
98-
strokeWidth: 3,
99-
radius: 10,
100-
},
101-
line: {
102-
strokeWidth: 10,
103-
},
104-
}}
105-
yAccessors={[1]}
106-
/>
107-
</Chart>
108-
</div>
31+
<>
32+
{renderChart(
33+
'1',
34+
this.ref1,
35+
KIBANA_METRICS.metrics.kibana_os_load[0].data.slice(0, 15),
36+
this.onCursorUpdate,
37+
true,
38+
)}
39+
{renderChart(
40+
'2',
41+
this.ref2,
42+
KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15),
43+
this.onCursorUpdate,
44+
true,
45+
)}
46+
{renderChart('2', this.ref3, KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(15, 30), this.onCursorUpdate)}
47+
</>
10948
);
11049
}
11150
}
51+
52+
function renderChart(
53+
key: string,
54+
ref: React.RefObject<Chart>,
55+
data: any,
56+
onCursorUpdate?: CursorUpdateListener,
57+
timeSeries: boolean = false,
58+
) {
59+
return (
60+
<div key={key} className="chart">
61+
<Chart ref={ref}>
62+
<Settings tooltip={{ type: 'vertical' }} debug={false} showLegend={true} onCursorUpdate={onCursorUpdate} />
63+
<Axis
64+
id={getAxisId('timestamp')}
65+
title="timestamp"
66+
position={Position.Bottom}
67+
tickFormat={niceTimeFormatter([1555819200000, 1555905600000])}
68+
/>
69+
<Axis id={getAxisId('count')} title="count" position={Position.Left} tickFormat={(d) => d.toFixed(2)} />
70+
<LineSeries
71+
id={getSpecId('dataset A with long title')}
72+
xScaleType={timeSeries ? ScaleType.Time : ScaleType.Linear}
73+
yScaleType={ScaleType.Linear}
74+
data={data}
75+
xAccessor={0}
76+
lineSeriesStyle={{
77+
line: {
78+
stroke: 'red',
79+
opacity: 1,
80+
},
81+
}}
82+
yAccessors={[1]}
83+
/>
84+
<LineSeries
85+
id={getSpecId('dataset B')}
86+
xScaleType={ScaleType.Time}
87+
yScaleType={ScaleType.Linear}
88+
data={KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15)}
89+
xAccessor={0}
90+
yAccessors={[1]}
91+
stackAccessors={[0]}
92+
/>
93+
</Chart>
94+
</div>
95+
);
96+
}

src/chart_types/xy_chart/crosshair/crosshair_utils.linear_snap.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { computeXScale } from '../utils/scales';
22
import { BasicSeriesSpec } from '../utils/specs';
33
import { Dimensions } from '../../../utils/dimensions';
44
import { getGroupId, getSpecId } from '../../../utils/ids';
5-
import { ScaleType } from '../../../utils/scales/scales';
6-
import { getCursorBandPosition, getSnapPosition } from './crosshair_utils';
5+
import { ScaleType, Scale } from '../../../utils/scales/scales';
6+
import { getCursorBandPosition, getSnapPosition, getPosition } from './crosshair_utils';
77
import { computeSeriesDomains } from '../store/utils';
88

99
describe('Crosshair utils linear scale', () => {
@@ -1397,4 +1397,26 @@ describe('Crosshair utils linear scale', () => {
13971397
});
13981398
});
13991399
});
1400+
1401+
describe('getPosition', () => {
1402+
// @ts-ignore
1403+
const scale: Scale = {
1404+
scale: jest.fn(),
1405+
};
1406+
1407+
beforeEach(() => {
1408+
(scale.scale as jest.Mock).mockClear();
1409+
});
1410+
1411+
it('should return value from scale', () => {
1412+
(scale.scale as jest.Mock).mockReturnValue(20);
1413+
const result = getPosition(10, scale);
1414+
expect(result).toBe(20);
1415+
});
1416+
1417+
it('should call scale with correct args', () => {
1418+
getPosition(10, scale);
1419+
expect(scale.scale).toBeCalledWith(10);
1420+
});
1421+
});
14001422
});

src/chart_types/xy_chart/crosshair/crosshair_utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export interface SnappedPosition {
1010

1111
export const DEFAULT_SNAP_POSITION_BAND = 1;
1212

13+
export function getPosition(value: string | number, scale: Scale): number | undefined {
14+
return scale.scale(value);
15+
}
16+
1317
export function getSnapPosition(
1418
value: string | number,
1519
scale: Scale,

src/chart_types/xy_chart/store/chart_state.test.ts

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,24 +313,36 @@ describe('Chart Store', () => {
313313
expect(store.onBrushEndListener).toEqual(brushEndListener);
314314
});
315315

316+
test('can set a cursor hover listener', () => {
317+
const listener = (): void => {
318+
return;
319+
};
320+
store.setOnCursorUpdateListener(listener);
321+
322+
expect(store.onCursorUpdateListener).toEqual(listener);
323+
});
324+
316325
test('can remove listeners', () => {
317326
store.removeElementClickListener();
318-
expect(store.onElementClickListener).toEqual(undefined);
327+
expect(store.onElementClickListener).toBeUndefined();
319328

320329
store.removeElementOverListener();
321-
expect(store.onElementOverListener).toEqual(undefined);
330+
expect(store.onElementOverListener).toBeUndefined();
322331

323332
store.removeElementOutListener();
324-
expect(store.onElementOutListener).toEqual(undefined);
333+
expect(store.onElementOutListener).toBeUndefined();
325334

326335
store.removeOnLegendItemOverListener();
327-
expect(store.onLegendItemOverListener).toEqual(undefined);
336+
expect(store.onLegendItemOverListener).toBeUndefined();
328337

329338
store.removeOnLegendItemPlusClickListener();
330-
expect(store.onLegendItemPlusClickListener).toEqual(undefined);
339+
expect(store.onLegendItemPlusClickListener).toBeUndefined();
331340

332341
store.removeOnLegendItemMinusClickListener();
333-
expect(store.onLegendItemMinusClickListener).toEqual(undefined);
342+
expect(store.onLegendItemMinusClickListener).toBeUndefined();
343+
344+
store.removeOnCursorUpdateListener();
345+
expect(store.onCursorUpdateListener).toBeUndefined();
334346
});
335347

336348
test('can respond to a brush end event', () => {
@@ -611,6 +623,9 @@ describe('Chart Store', () => {
611623
});
612624

613625
describe('can use a custom tooltip header formatter', () => {
626+
jest.unmock('../crosshair/crosshair_utils');
627+
jest.resetModules();
628+
614629
beforeEach(() => {
615630
const axisSpec: AxisSpec = {
616631
id: AXIS_ID,
@@ -641,6 +656,25 @@ describe('Chart Store', () => {
641656
store.setCursorPosition(10, 10);
642657
expect(store.tooltipData[0].value).toBe(1);
643658
});
659+
660+
test('should update cursor postion with hover event', () => {
661+
const legendListener = jest.fn(
662+
(): void => {
663+
return;
664+
},
665+
);
666+
667+
store.legendItems = new Map([[firstLegendItem.key, firstLegendItem], [secondLegendItem.key, secondLegendItem]]);
668+
store.selectedLegendItemKey.set(null);
669+
store.onCursorUpdateListener = undefined;
670+
671+
store.setCursorPosition(1, 1);
672+
expect(legendListener).not.toBeCalled();
673+
674+
store.setOnCursorUpdateListener(legendListener);
675+
store.setCursorPosition(1, 1);
676+
expect(legendListener).toBeCalled();
677+
});
644678
});
645679

646680
test('can disable brush based on scale and listener', () => {
@@ -903,4 +937,78 @@ describe('Chart Store', () => {
903937
store.computeChart();
904938
expect(store.tooltipType.get()).toBe(TooltipType.Follow);
905939
});
940+
941+
describe('isActiveChart', () => {
942+
it('should return true if no activeChartId is defined', () => {
943+
store.activeChartId = undefined;
944+
expect(store.isActiveChart.get()).toBe(true);
945+
});
946+
947+
it('should return true if activeChartId is defined and matches chart id', () => {
948+
store.activeChartId = store.id;
949+
expect(store.isActiveChart.get()).toBe(true);
950+
});
951+
952+
it('should return false if activeChartId is defined and does NOT match chart id', () => {
953+
store.activeChartId = '123';
954+
expect(store.isActiveChart.get()).toBe(false);
955+
});
956+
});
957+
958+
describe('setActiveChartId', () => {
959+
it('should set activeChartId with value', () => {
960+
store.activeChartId = undefined;
961+
store.setActiveChartId('test-id');
962+
expect(store.activeChartId).toBe('test-id');
963+
});
964+
965+
it('should set activeChartId to undefined if no value', () => {
966+
store.activeChartId = 'test';
967+
store.setActiveChartId();
968+
expect(store.activeChartId).toBeUndefined();
969+
});
970+
});
971+
972+
describe('setCursorValue', () => {
973+
const getPosition = jest.fn();
974+
// TODO: fix mocking implementation
975+
jest.doMock('../crosshair/crosshair_utils', () => ({
976+
getPosition,
977+
}));
978+
979+
const scale = new ScaleContinuous(ScaleType.Linear, [0, 100], [0, 100]);
980+
beforeEach(() => {
981+
// @ts-ignore
982+
store.setCursorPosition = jest.fn();
983+
});
984+
985+
it('should not call setCursorPosition if xScale is not defined', () => {
986+
store.xScale = undefined;
987+
store.setCursorValue(1);
988+
expect(store.setCursorPosition).not.toBeCalled();
989+
});
990+
991+
it.skip('should call getPosition with args', () => {
992+
(getPosition as jest.Mock).mockReturnValue(undefined);
993+
store.xScale = scale;
994+
store.setCursorValue(1);
995+
expect(getPosition).toBeCalledWith(1, store.xScale);
996+
});
997+
998+
it.skip('should not call setCursorPosition if xPosition is not defined', () => {
999+
store.xScale = scale;
1000+
(getPosition as jest.Mock).mockReturnValue(undefined);
1001+
store.setCursorValue(1);
1002+
expect(store.setCursorPosition).not.toBeCalled();
1003+
});
1004+
1005+
it('should call setCursorPosition with correct args', () => {
1006+
store.xScale = scale;
1007+
store.chartDimensions.left = 10;
1008+
store.chartDimensions.top = 10;
1009+
(getPosition as jest.Mock).mockReturnValue(20);
1010+
store.setCursorValue(20);
1011+
expect(store.setCursorPosition).toBeCalledWith(30, 10, false);
1012+
});
1013+
});
9061014
});

0 commit comments

Comments
 (0)