Skip to content

Commit 98ff170

Browse files
feat(annotations): render line annotations via LineAnnotation spec (#126)
1 parent 34d676a commit 98ff170

20 files changed

+2773
-21
lines changed

.storybook/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ function loadStories() {
4141
require('../stories/rotations.tsx');
4242
require('../stories/styling.tsx');
4343
require('../stories/grid.tsx');
44+
require('../stories/annotations.tsx');
4445
}
4546

4647
configure(loadStories, module);

src/components/_annotation.scss

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
.elasticChartsAnnotation {
2+
@include euiFontSizeXS;
3+
pointer-events: none;
4+
position: absolute;
5+
z-index: $euiZLevel9;
6+
max-width: $euiSizeXL * 10;
7+
overflow: hidden;
8+
overflow-wrap: break-word;
9+
transition: opacity $euiAnimSpeedNormal;
10+
user-select: none;
11+
}
12+
13+
.elasticChartsAnnotation--hidden, .elasticChartsAnnotation__tooltip--hidden {
14+
opacity: 0;
15+
}
16+
17+
.elasticChartsAnnotation__tooltip {
18+
@include euiBottomShadow($color: $euiColorFullShade);
19+
@include euiFontSizeXS;
20+
pointer-events: none;
21+
position: absolute;
22+
z-index: $euiZLevel9;
23+
background-color: rgba(tintOrShade($euiColorFullShade, 25%, 80%), 0.9);
24+
color: $euiColorGhost;
25+
border-radius: $euiBorderRadius;
26+
max-width: $euiSizeXL * 10;
27+
overflow: hidden;
28+
overflow-wrap: break-word;
29+
transition: opacity $euiAnimSpeedNormal;
30+
user-select: none;
31+
}
32+
33+
.elasticChartsAnnotation__header {
34+
margin: 0;
35+
background: rgba(shade($euiColorGhost, 20%), 0.9);
36+
color: $euiColorFullShade;
37+
padding: 0 8px;
38+
}
39+
40+
.elasticChartsAnnotation__details {
41+
margin: 0;
42+
padding: 0 8px;
43+
}

src/components/_chart.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
@import 'tooltip';
33
@import 'crosshair';
44
@import 'highlighter';
5+
@import 'annotation';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { inject, observer } from 'mobx-react';
2+
import React from 'react';
3+
import { AnnotationTypes } from '../lib/series/specs';
4+
import { AnnotationId } from '../lib/utils/ids';
5+
import { AnnotationLineProps } from '../state/annotation_utils';
6+
import { ChartStore } from '../state/chart_state';
7+
8+
interface AnnotationTooltipProps {
9+
chartStore?: ChartStore;
10+
}
11+
12+
class AnnotationTooltipComponent extends React.Component<AnnotationTooltipProps> {
13+
static displayName = 'AnnotationTooltip';
14+
15+
renderTooltip() {
16+
const annotationTooltipState = this.props.chartStore!.annotationTooltipState.get();
17+
if (!annotationTooltipState || !annotationTooltipState.isVisible) {
18+
return <div className="elasticChartsAnnotation__tooltip elasticChartsAnnotation__tooltip--hidden" />;
19+
}
20+
21+
const transform = annotationTooltipState.transform;
22+
const chartDimensions = this.props.chartStore!.chartDimensions;
23+
24+
const style = {
25+
transform,
26+
top: chartDimensions.top,
27+
left: chartDimensions.left,
28+
};
29+
30+
return (
31+
<div className="elasticChartsAnnotation__tooltip" style={{ ...style }}>
32+
<p className="elasticChartsAnnotation__header">{annotationTooltipState.header}</p>
33+
<div className="elasticChartsAnnotation__details">
34+
{annotationTooltipState.details}
35+
</div>
36+
</div>
37+
);
38+
}
39+
40+
renderAnnotationLineMarkers(annotationLines: AnnotationLineProps[], id: AnnotationId): JSX.Element[] {
41+
const { chartDimensions } = this.props.chartStore!;
42+
43+
const markers: JSX.Element[] = [];
44+
45+
annotationLines.forEach((line: AnnotationLineProps, index: number) => {
46+
if (!line.marker) {
47+
return;
48+
}
49+
50+
const { transform, icon, color } = line.marker;
51+
52+
const style = {
53+
color,
54+
transform,
55+
top: chartDimensions.top,
56+
left: chartDimensions.left,
57+
};
58+
59+
const markerElement = (
60+
<div className="elasticChartsAnnotation" style={{ ...style }} key={`annotation-${id}-${index}`}>
61+
{icon}
62+
</div>
63+
);
64+
65+
markers.push(markerElement);
66+
});
67+
68+
return markers;
69+
}
70+
71+
renderAnnotationMarkers(): JSX.Element[] {
72+
const { annotationDimensions, annotationSpecs } = this.props.chartStore!;
73+
const markers: JSX.Element[] = [];
74+
75+
annotationDimensions.forEach((annotationLines: AnnotationLineProps[], id: AnnotationId) => {
76+
const annotationSpec = annotationSpecs.get(id);
77+
if (!annotationSpec) {
78+
return;
79+
}
80+
81+
switch (annotationSpec.annotationType) {
82+
case AnnotationTypes.Line:
83+
const lineMarkers = this.renderAnnotationLineMarkers(annotationLines, id);
84+
markers.push(...lineMarkers);
85+
break;
86+
}
87+
});
88+
89+
return markers;
90+
}
91+
92+
render() {
93+
return (
94+
<React.Fragment>
95+
{this.renderAnnotationMarkers()}
96+
{this.renderTooltip()}
97+
</React.Fragment>
98+
);
99+
}
100+
}
101+
102+
export const AnnotationTooltip = inject('chartStore')(observer(AnnotationTooltipComponent));

src/components/chart.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Provider } from 'mobx-react';
33
import React, { CSSProperties, Fragment } from 'react';
44
import { SpecsParser } from '../specs/specs_parser';
55
import { ChartStore } from '../state/chart_state';
6+
import { AnnotationTooltip } from './annotation_tooltips';
67
import { ChartResizer } from './chart_resizer';
78
import { Crosshair } from './crosshair';
89
import { Highlighter } from './highlighter';
@@ -50,6 +51,7 @@ export class Chart extends React.Component<ChartProps> {
5051
{renderer === 'svg' && <SVGChart />}
5152
{renderer === 'canvas' && <ReactChart />}
5253
<Tooltips />
54+
<AnnotationTooltip />
5355
<Legend />
5456
<LegendButton />
5557
<Highlighter />
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
import { Group, Line } from 'react-konva';
3+
import { AnnotationLineStyle } from '../../lib/themes/theme';
4+
import { Dimensions } from '../../lib/utils/dimensions';
5+
import { AnnotationLineProps } from '../../state/annotation_utils';
6+
7+
interface AnnotationProps {
8+
chartDimensions: Dimensions;
9+
debug: boolean;
10+
lines: AnnotationLineProps[];
11+
lineStyle: AnnotationLineStyle;
12+
}
13+
14+
export class Annotation extends React.PureComponent<AnnotationProps> {
15+
render() {
16+
return this.renderAnnotation();
17+
}
18+
private renderAnnotationLine = (lineConfig: AnnotationLineProps, i: number) => {
19+
const { line } = this.props.lineStyle;
20+
const { position } = lineConfig;
21+
22+
const lineProps = {
23+
points: position,
24+
...line,
25+
};
26+
27+
return <Line key={`tick-${i}`} {...lineProps} />;
28+
}
29+
30+
private renderAnnotation = () => {
31+
const { chartDimensions, lines } = this.props;
32+
33+
return (
34+
<Group x={chartDimensions.left} y={chartDimensions.top}>
35+
{lines.map(this.renderAnnotationLine)}
36+
</Group>
37+
);
38+
}
39+
}

src/components/react_canvas/reactive_chart.tsx

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { inject, observer } from 'mobx-react';
22
import React from 'react';
33
import { Layer, Rect, Stage } from 'react-konva';
4+
import { AnnotationLineStyle } from '../../lib/themes/theme';
5+
import { AnnotationId } from '../../lib/utils/ids';
6+
import { AnnotationDimensions } from '../../state/annotation_utils';
47
import { ChartStore, Point } from '../../state/chart_state';
58
import { BrushExtent } from '../../state/utils';
9+
import { Annotation } from './annotation';
610
import { AreaGeometries } from './area_geometries';
711
import { Axis } from './axis';
812
import { BarGeometries } from './bar_geometries';
@@ -167,6 +171,33 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
167171
return gridComponents;
168172
}
169173

174+
renderAnnotations = () => {
175+
const { annotationDimensions, annotationSpecs, chartDimensions, debug } = this.props.chartStore!;
176+
177+
const annotationComponents: JSX.Element[] = [];
178+
annotationDimensions.forEach((annotation: AnnotationDimensions, id: AnnotationId) => {
179+
const spec = annotationSpecs.get(id);
180+
if (!spec) {
181+
return;
182+
}
183+
184+
// We merge custom style w/ the default on addAnnotationSpec, so this is guaranteed
185+
// to be complete by the time we get to rendering
186+
const lineStyle = spec.style as AnnotationLineStyle;
187+
188+
annotationComponents.push(
189+
<Annotation
190+
key={`annotation-${id}`}
191+
chartDimensions={chartDimensions}
192+
debug={debug}
193+
lines={annotation}
194+
lineStyle={lineStyle}
195+
/>,
196+
);
197+
});
198+
return annotationComponents;
199+
}
200+
170201
renderBrushTool = () => {
171202
const { brushing, brushStart, brushEnd } = this.state;
172203
const { chartDimensions, chartRotation, chartTransform } = this.props.chartStore!;
@@ -242,15 +273,15 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
242273
const clippings = debug
243274
? {}
244275
: {
245-
clipX: 0,
246-
clipY: 0,
247-
clipWidth: [90, -90].includes(chartRotation)
248-
? chartDimensions.height
249-
: chartDimensions.width,
250-
clipHeight: [90, -90].includes(chartRotation)
251-
? chartDimensions.width
252-
: chartDimensions.height,
253-
};
276+
clipX: 0,
277+
clipY: 0,
278+
clipWidth: [90, -90].includes(chartRotation)
279+
? chartDimensions.height
280+
: chartDimensions.width,
281+
clipHeight: [90, -90].includes(chartRotation)
282+
? chartDimensions.width
283+
: chartDimensions.height,
284+
};
254285

255286
let brushProps = {};
256287
const isBrushEnabled = this.props.chartStore!.isBrushEnabled();
@@ -261,7 +292,7 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
261292
};
262293
}
263294

264-
const gridClippings = {
295+
const layerClippings = {
265296
clipX: chartDimensions.left,
266297
clipY: chartDimensions.top,
267298
clipWidth: chartDimensions.width,
@@ -297,7 +328,7 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
297328
}}
298329
{...brushProps}
299330
>
300-
<Layer hitGraphEnabled={false} listening={false} {...gridClippings}>
331+
<Layer hitGraphEnabled={false} listening={false} {...layerClippings}>
301332
{this.renderGrids()}
302333
</Layer>
303334

@@ -325,6 +356,10 @@ class Chart extends React.Component<ReactiveChartProps, ReactiveChartState> {
325356
<Layer hitGraphEnabled={false} listening={false}>
326357
{this.renderAxes()}
327358
</Layer>
359+
360+
<Layer hitGraphEnabled={false} listening={false}>
361+
{this.renderAnnotations()}
362+
</Layer>
328363
</Stage>
329364
</div>
330365
);

src/lib/series/specs.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { GridLineConfig } from '../themes/theme';
1+
import { AnnotationLineStyle, GridLineConfig } from '../themes/theme';
22
import { Accessor } from '../utils/accessor';
3-
import { AxisId, GroupId, SpecId } from '../utils/ids';
3+
import { AnnotationId, AxisId, GroupId, SpecId } from '../utils/ids';
44
import { ScaleContinuousType, ScaleType } from '../utils/scales/scales';
55
import { CurveType } from './curves';
66
import { DataSeriesColorsValues } from './series';
@@ -149,3 +149,53 @@ export enum Position {
149149
Left = 'left',
150150
Right = 'right',
151151
}
152+
153+
export const AnnotationTypes = Object.freeze({
154+
Line: 'line' as AnnotationType,
155+
Rectangle: 'rectangle' as AnnotationType,
156+
Text: 'text' as AnnotationType,
157+
});
158+
159+
export type AnnotationType = 'line' | 'rectangle' | 'text';
160+
161+
export const AnnotationDomainTypes = Object.freeze({
162+
XDomain: 'xDomain' as AnnotationDomainType,
163+
YDomain: 'yDomain' as AnnotationDomainType,
164+
});
165+
166+
export type AnnotationDomainType = 'xDomain' | 'yDomain';
167+
export interface AnnotationDatum {
168+
dataValue: any;
169+
details?: string;
170+
header?: string;
171+
}
172+
173+
export interface LineAnnotationSpec {
174+
/** The id of the annotation */
175+
annotationId: AnnotationId;
176+
/** Annotation type: line, rectangle, text */
177+
annotationType: AnnotationType;
178+
/** The ID of the axis group, generated via getGroupId method
179+
* @default __global__
180+
*/
181+
groupId: GroupId; // defaults to __global__; needed for yDomain position
182+
/** Annotation domain type: AnnotationDomainTypes.XDomain or AnnotationDomainTypes.YDomain */
183+
domainType: AnnotationDomainType;
184+
/** Data values defined with value, details, and header */
185+
dataValues: AnnotationDatum[];
186+
/** Custom line styles */
187+
style?: Partial<AnnotationLineStyle>;
188+
/** Custom marker */
189+
marker?: JSX.Element;
190+
/**
191+
* Custom marker dimensions; will be computed internally
192+
* Any user-supplied values will be overwritten
193+
*/
194+
markerDimensions?: {
195+
width: number;
196+
height: number;
197+
};
198+
}
199+
200+
// TODO: RectangleAnnotationSpec & TextAnnotationSpec
201+
export type AnnotationSpec = LineAnnotationSpec;

0 commit comments

Comments
 (0)