Skip to content

Commit

Permalink
feat: add cursor sync mechanism (#304)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
nickofthyme committed Aug 14, 2019
1 parent 8b74025 commit c8c1d9d
Show file tree
Hide file tree
Showing 10 changed files with 353 additions and 114 deletions.
167 changes: 76 additions & 91 deletions .playground/playgroud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,103 +9,88 @@ import {
Position,
ScaleType,
Settings,
mergeWithDefaultTheme,
AreaSeries,
LineSeries,
} from '../src';
import { KIBANA_METRICS } from '../src/utils/data_samples/test_dataset_kibana';
import { CursorEvent } from '../src/specs/settings';
import { CursorUpdateListener } from '../src/chart_types/xy_chart/store/chart_state';

export class Playground extends React.Component {
ref1 = React.createRef<Chart>();
ref2 = React.createRef<Chart>();
ref3 = React.createRef<Chart>();

onCursorUpdate: CursorUpdateListener = (event?: CursorEvent) => {
this.ref1.current!.dispatchExternalCursorEvent(event);
this.ref2.current!.dispatchExternalCursorEvent(event);
this.ref3.current!.dispatchExternalCursorEvent(event);
};

render() {
return <>{this.renderChart(Position.Right)}</>;
}
renderChart(legendPosition: Position) {
const theme = mergeWithDefaultTheme({
lineSeriesStyle: {
line: {
stroke: 'violet',
strokeWidth: 4,
},
point: {
fill: 'yellow',
stroke: 'black',
strokeWidth: 2,
radius: 6,
},
},
});
console.log(theme.areaSeriesStyle);
return (
<div className="chart">
<Chart>
<Settings debug={false} showLegend={true} legendPosition={legendPosition} rotation={0} theme={theme} />
<Axis
id={getAxisId('timestamp')}
title="timestamp"
position={Position.Bottom}
tickFormat={niceTimeFormatter([1555819200000, 1555905600000])}
/>
<Axis id={getAxisId('count')} title="count" position={Position.Left} tickFormat={(d) => d.toFixed(2)} />

<AreaSeries
id={getSpecId('dataset B')}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
data={KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15)}
xAccessor={0}
yAccessors={[1]}
stackAccessors={[0]}
areaSeriesStyle={{
line: {
// opacity:1,
strokeWidth: 10,
},
point: {
visible: true,
strokeWidth: 3,
radius: 10,
},
}}
/>
<AreaSeries
id={getSpecId('dataset C')}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
data={KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15)}
xAccessor={0}
yAccessors={[1]}
stackAccessors={[0]}
areaSeriesStyle={{
line: {
// opacity:1,
strokeWidth: 10,
},
point: {
visible: true,
strokeWidth: 3,
radius: 10,
},
}}
/>
<AreaSeries
id={getSpecId('dataset A with long title')}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
data={KIBANA_METRICS.metrics.kibana_os_load[0].data.slice(0, 15)}
xAccessor={0}
areaSeriesStyle={{
point: {
visible: true,
strokeWidth: 3,
radius: 10,
},
line: {
strokeWidth: 10,
},
}}
yAccessors={[1]}
/>
</Chart>
</div>
<>
{renderChart(
'1',
this.ref1,
KIBANA_METRICS.metrics.kibana_os_load[0].data.slice(0, 15),
this.onCursorUpdate,
true,
)}
{renderChart(
'2',
this.ref2,
KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15),
this.onCursorUpdate,
true,
)}
{renderChart('2', this.ref3, KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(15, 30), this.onCursorUpdate)}
</>
);
}
}

function renderChart(
key: string,
ref: React.RefObject<Chart>,
data: any,
onCursorUpdate?: CursorUpdateListener,
timeSeries: boolean = false,
) {
return (
<div key={key} className="chart">
<Chart ref={ref}>
<Settings tooltip={{ type: 'vertical' }} debug={false} showLegend={true} onCursorUpdate={onCursorUpdate} />
<Axis
id={getAxisId('timestamp')}
title="timestamp"
position={Position.Bottom}
tickFormat={niceTimeFormatter([1555819200000, 1555905600000])}
/>
<Axis id={getAxisId('count')} title="count" position={Position.Left} tickFormat={(d) => d.toFixed(2)} />
<LineSeries
id={getSpecId('dataset A with long title')}
xScaleType={timeSeries ? ScaleType.Time : ScaleType.Linear}
yScaleType={ScaleType.Linear}
data={data}
xAccessor={0}
lineSeriesStyle={{
line: {
stroke: 'red',
opacity: 1,
},
}}
yAccessors={[1]}
/>
<LineSeries
id={getSpecId('dataset B')}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
data={KIBANA_METRICS.metrics.kibana_os_load[1].data.slice(0, 15)}
xAccessor={0}
yAccessors={[1]}
stackAccessors={[0]}
/>
</Chart>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { computeXScale } from '../utils/scales';
import { BasicSeriesSpec } from '../utils/specs';
import { Dimensions } from '../../../utils/dimensions';
import { getGroupId, getSpecId } from '../../../utils/ids';
import { ScaleType } from '../../../utils/scales/scales';
import { getCursorBandPosition, getSnapPosition } from './crosshair_utils';
import { ScaleType, Scale } from '../../../utils/scales/scales';
import { getCursorBandPosition, getSnapPosition, getPosition } from './crosshair_utils';
import { computeSeriesDomains } from '../store/utils';

describe('Crosshair utils linear scale', () => {
Expand Down Expand Up @@ -1397,4 +1397,26 @@ describe('Crosshair utils linear scale', () => {
});
});
});

describe('getPosition', () => {
// @ts-ignore
const scale: Scale = {
scale: jest.fn(),
};

beforeEach(() => {
(scale.scale as jest.Mock).mockClear();
});

it('should return value from scale', () => {
(scale.scale as jest.Mock).mockReturnValue(20);
const result = getPosition(10, scale);
expect(result).toBe(20);
});

it('should call scale with correct args', () => {
getPosition(10, scale);
expect(scale.scale).toBeCalledWith(10);
});
});
});
4 changes: 4 additions & 0 deletions src/chart_types/xy_chart/crosshair/crosshair_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export interface SnappedPosition {

export const DEFAULT_SNAP_POSITION_BAND = 1;

export function getPosition(value: string | number, scale: Scale): number | undefined {
return scale.scale(value);
}

export function getSnapPosition(
value: string | number,
scale: Scale,
Expand Down
120 changes: 114 additions & 6 deletions src/chart_types/xy_chart/store/chart_state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,24 +313,36 @@ describe('Chart Store', () => {
expect(store.onBrushEndListener).toEqual(brushEndListener);
});

test('can set a cursor hover listener', () => {
const listener = (): void => {
return;
};
store.setOnCursorUpdateListener(listener);

expect(store.onCursorUpdateListener).toEqual(listener);
});

test('can remove listeners', () => {
store.removeElementClickListener();
expect(store.onElementClickListener).toEqual(undefined);
expect(store.onElementClickListener).toBeUndefined();

store.removeElementOverListener();
expect(store.onElementOverListener).toEqual(undefined);
expect(store.onElementOverListener).toBeUndefined();

store.removeElementOutListener();
expect(store.onElementOutListener).toEqual(undefined);
expect(store.onElementOutListener).toBeUndefined();

store.removeOnLegendItemOverListener();
expect(store.onLegendItemOverListener).toEqual(undefined);
expect(store.onLegendItemOverListener).toBeUndefined();

store.removeOnLegendItemPlusClickListener();
expect(store.onLegendItemPlusClickListener).toEqual(undefined);
expect(store.onLegendItemPlusClickListener).toBeUndefined();

store.removeOnLegendItemMinusClickListener();
expect(store.onLegendItemMinusClickListener).toEqual(undefined);
expect(store.onLegendItemMinusClickListener).toBeUndefined();

store.removeOnCursorUpdateListener();
expect(store.onCursorUpdateListener).toBeUndefined();
});

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

describe('can use a custom tooltip header formatter', () => {
jest.unmock('../crosshair/crosshair_utils');
jest.resetModules();

beforeEach(() => {
const axisSpec: AxisSpec = {
id: AXIS_ID,
Expand Down Expand Up @@ -641,6 +656,25 @@ describe('Chart Store', () => {
store.setCursorPosition(10, 10);
expect(store.tooltipData[0].value).toBe(1);
});

test('should update cursor postion with hover event', () => {
const legendListener = jest.fn(
(): void => {
return;
},
);

store.legendItems = new Map([[firstLegendItem.key, firstLegendItem], [secondLegendItem.key, secondLegendItem]]);
store.selectedLegendItemKey.set(null);
store.onCursorUpdateListener = undefined;

store.setCursorPosition(1, 1);
expect(legendListener).not.toBeCalled();

store.setOnCursorUpdateListener(legendListener);
store.setCursorPosition(1, 1);
expect(legendListener).toBeCalled();
});
});

test('can disable brush based on scale and listener', () => {
Expand Down Expand Up @@ -903,4 +937,78 @@ describe('Chart Store', () => {
store.computeChart();
expect(store.tooltipType.get()).toBe(TooltipType.Follow);
});

describe('isActiveChart', () => {
it('should return true if no activeChartId is defined', () => {
store.activeChartId = undefined;
expect(store.isActiveChart.get()).toBe(true);
});

it('should return true if activeChartId is defined and matches chart id', () => {
store.activeChartId = store.id;
expect(store.isActiveChart.get()).toBe(true);
});

it('should return false if activeChartId is defined and does NOT match chart id', () => {
store.activeChartId = '123';
expect(store.isActiveChart.get()).toBe(false);
});
});

describe('setActiveChartId', () => {
it('should set activeChartId with value', () => {
store.activeChartId = undefined;
store.setActiveChartId('test-id');
expect(store.activeChartId).toBe('test-id');
});

it('should set activeChartId to undefined if no value', () => {
store.activeChartId = 'test';
store.setActiveChartId();
expect(store.activeChartId).toBeUndefined();
});
});

describe('setCursorValue', () => {
const getPosition = jest.fn();
// TODO: fix mocking implementation
jest.doMock('../crosshair/crosshair_utils', () => ({
getPosition,
}));

const scale = new ScaleContinuous(ScaleType.Linear, [0, 100], [0, 100]);
beforeEach(() => {
// @ts-ignore
store.setCursorPosition = jest.fn();
});

it('should not call setCursorPosition if xScale is not defined', () => {
store.xScale = undefined;
store.setCursorValue(1);
expect(store.setCursorPosition).not.toBeCalled();
});

it.skip('should call getPosition with args', () => {
(getPosition as jest.Mock).mockReturnValue(undefined);
store.xScale = scale;
store.setCursorValue(1);
expect(getPosition).toBeCalledWith(1, store.xScale);
});

it.skip('should not call setCursorPosition if xPosition is not defined', () => {
store.xScale = scale;
(getPosition as jest.Mock).mockReturnValue(undefined);
store.setCursorValue(1);
expect(store.setCursorPosition).not.toBeCalled();
});

it('should call setCursorPosition with correct args', () => {
store.xScale = scale;
store.chartDimensions.left = 10;
store.chartDimensions.top = 10;
(getPosition as jest.Mock).mockReturnValue(20);
store.setCursorValue(20);
expect(store.setCursorPosition).toBeCalledWith(30, 10, false);
});
});
});
Loading

0 comments on commit c8c1d9d

Please sign in to comment.