Skip to content

Commit 789f85a

Browse files
authored
fix: decuple brush cursor from chart rendering (#331)
This PR remove the unnecessary style recalculation caused by chancing the cursor style on the document body from the chart_state. This was noticeable on this PR elastic/kibana#36517 (review) where the style reflow calculation tooks up to 75ms. This PR decouple the chart container from the chart renderer, allowing to set correctly the cursor changing the class without re-rendering the chart component, as the actual implementation. The cursor style now depends also on the status of the highlighted geometries. If one geometry is highlighted but no onElementClick or onElementOver listener is available, the cursor will be default if the brush is disabled, or crosshair if the brush is Enabled. The pointer is shown only if we have one between onElementClick or onElementOver enabled and we are over a geometry
1 parent a9ff5e1 commit 789f85a

File tree

7 files changed

+149
-100
lines changed

7 files changed

+149
-100
lines changed

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -902,21 +902,63 @@ describe('Chart Store', () => {
902902
store.cursorPosition.x = -1;
903903
store.cursorPosition.y = -1;
904904
store.onBrushEndListener = brushEndListener;
905-
expect(store.isCrosshairCursorVisible.get()).toBe(false);
905+
expect(store.chartCursor.get()).toBe('default');
906906
});
907907

908908
test('when cursor is within chart bounds and brush enabled', () => {
909909
store.cursorPosition.x = 10;
910910
store.cursorPosition.y = 10;
911911
store.onBrushEndListener = brushEndListener;
912-
expect(store.isCrosshairCursorVisible.get()).toBe(true);
912+
expect(store.chartCursor.get()).toBe('crosshair');
913913
});
914914

915915
test('when cursor is within chart bounds and brush disabled', () => {
916916
store.cursorPosition.x = 10;
917917
store.cursorPosition.y = 10;
918918
store.onBrushEndListener = undefined;
919-
expect(store.isCrosshairCursorVisible.get()).toBe(false);
919+
expect(store.chartCursor.get()).toBe('default');
920+
});
921+
test('when cursor is within chart bounds and brush enabled but over one geom', () => {
922+
store.cursorPosition.x = 10;
923+
store.cursorPosition.y = 10;
924+
store.onBrushEndListener = brushEndListener;
925+
const geom1: IndexedGeometry = {
926+
color: 'red',
927+
geometryId: {
928+
specId: getSpecId('specId1'),
929+
seriesKey: [2],
930+
},
931+
value: {
932+
x: 0,
933+
y: 1,
934+
accessor: 'y1',
935+
},
936+
x: 0,
937+
y: 0,
938+
width: 0,
939+
height: 0,
940+
seriesStyle: {
941+
rect: {
942+
opacity: 1,
943+
},
944+
rectBorder: {
945+
strokeWidth: 1,
946+
visible: false,
947+
},
948+
displayValue: {
949+
fill: 'black',
950+
fontFamily: '',
951+
fontSize: 2,
952+
offsetX: 0,
953+
offsetY: 0,
954+
padding: 2,
955+
},
956+
},
957+
};
958+
store.highlightedGeometries.replace([geom1]);
959+
expect(store.chartCursor.get()).toBe('crosshair');
960+
store.onElementClickListener = jest.fn();
961+
expect(store.chartCursor.get()).toBe('pointer');
920962
});
921963
});
922964
test('should set tooltip type to follow when single value x scale', () => {

src/chart_types/xy_chart/store/chart_state.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -238,17 +238,16 @@ export class ChartStore {
238238
legendPosition = observable.box<Position>(Position.Right);
239239
showLegendDisplayValue = observable.box(true);
240240

241-
/**
242-
* determine if crosshair cursor should be visible based on cursor position and brush enablement
243-
*/
244-
isCrosshairCursorVisible = computed(() => {
241+
chartCursor = computed(() => {
245242
const { x: xPos, y: yPos } = this.cursorPosition;
246243

247244
if (yPos < 0 || xPos < 0) {
248-
return false;
245+
return 'default';
249246
}
250-
251-
return this.isBrushEnabled();
247+
if (this.highlightedGeometries.length > 0 && (this.onElementClickListener || this.onElementOverListener)) {
248+
return 'pointer';
249+
}
250+
return this.isBrushEnabled() ? 'crosshair' : 'default';
252251
});
253252

254253
/**
@@ -466,13 +465,6 @@ export class ChartStore {
466465
} else {
467466
this.tooltipData.replace(tooltipValues);
468467
}
469-
470-
// TODO move this into the renderer
471-
if (oneHighlighted) {
472-
document.body.style.cursor = 'pointer';
473-
} else {
474-
document.body.style.cursor = 'default';
475-
}
476468
});
477469

478470
legendItemTooltipValues = computed(() => {
@@ -549,8 +541,6 @@ export class ChartStore {
549541
this.highlightedGeometries.clear();
550542
this.tooltipData.clear();
551543

552-
document.body.style.cursor = 'default';
553-
554544
if (this.onCursorUpdateListener && this.isActiveChart.get()) {
555545
this.onCursorUpdateListener();
556546
}

src/components/_container.scss

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,26 @@
1111
display: flex;
1212
height: 100%;
1313

14-
&--isBrushEnabled {
15-
cursor: crosshair;
16-
}
17-
1814
&--column {
1915
flex-direction: column;
2016
}
2117
}
18+
19+
.echChartCursorContainer {
20+
position: absolute;
21+
top: 0;
22+
bottom: 0;
23+
right: 0;
24+
left: 0;
25+
box-sizing: border-box;
26+
}
27+
28+
.echChartResizer {
29+
z-index: -10000000;
30+
position: absolute;
31+
bottom: 0;
32+
top: 0;
33+
left: 0;
34+
right: 0;
35+
box-sizing: border-box;
36+
}

src/components/chart.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ChartResizer } from './chart_resizer';
99
import { Crosshair } from './crosshair';
1010
import { Highlighter } from './highlighter';
1111
import { Legend } from './legend/legend';
12-
import { ReactiveChart as ReactChart } from './react_canvas/reactive_chart';
12+
import { ChartContainer } from './react_canvas/chart_container';
1313
import { Tooltips } from './tooltips';
1414
import { isHorizontal } from '../chart_types/xy_chart/utils/axis_utils';
1515
import { Position } from '../chart_types/xy_chart/utils/specs';
@@ -94,8 +94,8 @@ export class Chart extends React.Component<ChartProps, ChartState> {
9494
<ChartResizer />
9595
<Crosshair />
9696
{// TODO reenable when SVG rendered is aligned with canvas one
97-
renderer === 'svg' && <ReactChart />}
98-
{renderer === 'canvas' && <ReactChart />}
97+
renderer === 'svg' && <ChartContainer />}
98+
{renderer === 'canvas' && <ChartContainer />}
9999
<Tooltips />
100100
<AnnotationTooltip />
101101
<Highlighter />

src/components/chart_resizer.tsx

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,7 @@ class Resizer extends React.Component<ResizerProps> {
3535
};
3636

3737
render() {
38-
return (
39-
<div
40-
ref={this.containerRef}
41-
style={{
42-
zIndex: -10000000,
43-
position: 'absolute',
44-
bottom: 0,
45-
top: 0,
46-
left: 0,
47-
right: 0,
48-
boxSizing: 'border-box',
49-
}}
50-
/>
51-
);
38+
return <div ref={this.containerRef} className="echChartResizer" />;
5239
}
5340

5441
private handleResize = (entries: ResizeObserverEntry[]) => {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react';
2+
import { inject, observer } from 'mobx-react';
3+
import { ChartStore } from '../../chart_types/xy_chart/store/chart_state';
4+
import { ReactiveChart } from './reactive_chart';
5+
interface ReactiveChartProps {
6+
chartStore?: ChartStore; // FIX until we find a better way on ts mobx
7+
}
8+
9+
class ChartContainerComponent extends React.Component<ReactiveChartProps> {
10+
static displayName = 'ChartContainer';
11+
12+
render() {
13+
const { chartInitialized } = this.props.chartStore!;
14+
if (!chartInitialized.get()) {
15+
return null;
16+
}
17+
const { setCursorPosition } = this.props.chartStore!;
18+
return (
19+
<div
20+
className="echChartCursorContainer"
21+
style={{
22+
cursor: this.props.chartStore!.chartCursor.get(),
23+
}}
24+
onMouseMove={({ nativeEvent: { offsetX, offsetY } }) => {
25+
setCursorPosition(offsetX, offsetY);
26+
}}
27+
onMouseLeave={() => {
28+
setCursorPosition(-1, -1);
29+
}}
30+
onMouseUp={() => {
31+
if (this.props.chartStore!.isBrushing.get()) {
32+
return;
33+
}
34+
this.props.chartStore!.handleChartClick();
35+
}}
36+
>
37+
<ReactiveChart />
38+
</div>
39+
);
40+
}
41+
}
42+
43+
export const ChartContainer = inject('chartStore')(observer(ChartContainerComponent));

src/components/react_canvas/reactive_chart.tsx

Lines changed: 32 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import classNames from 'classnames';
21
import { inject, observer } from 'mobx-react';
32
import React from 'react';
43
import { Layer, Rect, Stage } from 'react-konva';
@@ -352,7 +351,6 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
352351
chartRotation,
353352
chartTransform,
354353
debug,
355-
setCursorPosition,
356354
isChartEmpty,
357355
} = this.props.chartStore!;
358356

@@ -373,74 +371,48 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
373371
};
374372
}
375373

376-
const className = classNames({
377-
'echChart--isBrushEnabled': this.props.chartStore!.isCrosshairCursorVisible.get(),
378-
});
379-
380374
return (
381-
<div
375+
<Stage
376+
width={parentDimensions.width}
377+
height={parentDimensions.height}
382378
style={{
383-
position: 'absolute',
384-
top: 0,
385-
bottom: 0,
386-
right: 0,
387-
left: 0,
388-
boxSizing: 'border-box',
389-
}}
390-
onMouseMove={({ nativeEvent: { offsetX, offsetY } }) => {
391-
setCursorPosition(offsetX, offsetY);
392-
}}
393-
onMouseLeave={() => {
394-
setCursorPosition(-1, -1);
379+
width: '100%',
380+
height: '100%',
395381
}}
396-
onMouseUp={() => {
397-
if (this.props.chartStore!.isBrushing.get()) {
398-
return;
399-
}
400-
this.props.chartStore!.handleChartClick();
401-
}}
402-
className={className}
382+
{...brushProps}
403383
>
404-
<Stage
405-
width={parentDimensions.width}
406-
height={parentDimensions.height}
407-
style={{
408-
width: '100%',
409-
height: '100%',
410-
}}
411-
{...brushProps}
384+
<Layer hitGraphEnabled={false} listening={false}>
385+
{this.renderGrids()}
386+
</Layer>
387+
<Layer hitGraphEnabled={false} listening={false}>
388+
{this.renderAxes()}
389+
</Layer>
390+
391+
<Layer
392+
x={chartDimensions.left + chartTransform.x}
393+
y={chartDimensions.top + chartTransform.y}
394+
rotation={chartRotation}
395+
hitGraphEnabled={false}
396+
listening={false}
412397
>
413-
<Layer hitGraphEnabled={false} listening={false}>
414-
{this.renderGrids()}
415-
</Layer>
416-
<Layer hitGraphEnabled={false} listening={false}>
417-
{this.renderAxes()}
418-
</Layer>
419-
420-
<Layer
421-
x={chartDimensions.left + chartTransform.x}
422-
y={chartDimensions.top + chartTransform.y}
423-
rotation={chartRotation}
424-
hitGraphEnabled={false}
425-
listening={false}
426-
>
427-
{this.sortAndRenderElements()}
428-
</Layer>
398+
{this.sortAndRenderElements()}
399+
</Layer>
429400

401+
{debug && (
430402
<Layer hitGraphEnabled={false} listening={false}>
431-
{debug && this.renderDebugChartBorders()}
403+
{this.renderDebugChartBorders()}
432404
</Layer>
433-
{isBrushEnabled && (
434-
<Layer hitGraphEnabled={false} listening={false}>
435-
{this.renderBrushTool()}
436-
</Layer>
437-
)}
438-
405+
)}
406+
{isBrushEnabled && (
439407
<Layer hitGraphEnabled={false} listening={false}>
440-
{this.renderBarValues()}
408+
{this.renderBrushTool()}
441409
</Layer>
442-
</Stage>
443-
</div>
410+
)}
411+
412+
<Layer hitGraphEnabled={false} listening={false}>
413+
{this.renderBarValues()}
414+
</Layer>
415+
</Stage>
444416
);
445417
}
446418

0 commit comments

Comments
 (0)