diff --git a/.storybook/config.ts b/.storybook/config.ts index 53121afe9a..bd429173b4 100644 --- a/.storybook/config.ts +++ b/.storybook/config.ts @@ -41,6 +41,7 @@ function loadStories() { require('../stories/rotations.tsx'); require('../stories/styling.tsx'); require('../stories/grid.tsx'); + require('../stories/annotations.tsx'); } configure(loadStories, module); diff --git a/src/components/_annotation.scss b/src/components/_annotation.scss new file mode 100644 index 0000000000..ab66748aa2 --- /dev/null +++ b/src/components/_annotation.scss @@ -0,0 +1,43 @@ +.elasticChartsAnnotation { + @include euiFontSizeXS; + pointer-events: none; + position: absolute; + z-index: $euiZLevel9; + max-width: $euiSizeXL * 10; + overflow: hidden; + overflow-wrap: break-word; + transition: opacity $euiAnimSpeedNormal; + user-select: none; +} + +.elasticChartsAnnotation--hidden, .elasticChartsAnnotation__tooltip--hidden { + opacity: 0; +} + +.elasticChartsAnnotation__tooltip { + @include euiBottomShadow($color: $euiColorFullShade); + @include euiFontSizeXS; + pointer-events: none; + position: absolute; + z-index: $euiZLevel9; + background-color: rgba(tintOrShade($euiColorFullShade, 25%, 80%), 0.9); + color: $euiColorGhost; + border-radius: $euiBorderRadius; + max-width: $euiSizeXL * 10; + overflow: hidden; + overflow-wrap: break-word; + transition: opacity $euiAnimSpeedNormal; + user-select: none; +} + +.elasticChartsAnnotation__header { + margin: 0; + background: rgba(shade($euiColorGhost, 20%), 0.9); + color: $euiColorFullShade; + padding: 0 8px; +} + +.elasticChartsAnnotation__details { + margin: 0; + padding: 0 8px; +} \ No newline at end of file diff --git a/src/components/_chart.scss b/src/components/_chart.scss index 100271b0f3..ff18c64763 100644 --- a/src/components/_chart.scss +++ b/src/components/_chart.scss @@ -2,3 +2,4 @@ @import 'tooltip'; @import 'crosshair'; @import 'highlighter'; +@import 'annotation'; diff --git a/src/components/annotation_tooltips.tsx b/src/components/annotation_tooltips.tsx new file mode 100644 index 0000000000..67197a91d3 --- /dev/null +++ b/src/components/annotation_tooltips.tsx @@ -0,0 +1,102 @@ +import { inject, observer } from 'mobx-react'; +import React from 'react'; +import { AnnotationTypes } from '../lib/series/specs'; +import { AnnotationId } from '../lib/utils/ids'; +import { AnnotationLineProps } from '../state/annotation_utils'; +import { ChartStore } from '../state/chart_state'; + +interface AnnotationTooltipProps { + chartStore?: ChartStore; +} + +class AnnotationTooltipComponent extends React.Component { + static displayName = 'AnnotationTooltip'; + + renderTooltip() { + const annotationTooltipState = this.props.chartStore!.annotationTooltipState.get(); + if (!annotationTooltipState || !annotationTooltipState.isVisible) { + return
; + } + + const transform = annotationTooltipState.transform; + const chartDimensions = this.props.chartStore!.chartDimensions; + + const style = { + transform, + top: chartDimensions.top, + left: chartDimensions.left, + }; + + return ( +
+

{annotationTooltipState.header}

+
+ {annotationTooltipState.details} +
+
+ ); + } + + renderAnnotationLineMarkers(annotationLines: AnnotationLineProps[], id: AnnotationId): JSX.Element[] { + const { chartDimensions } = this.props.chartStore!; + + const markers: JSX.Element[] = []; + + annotationLines.forEach((line: AnnotationLineProps, index: number) => { + if (!line.marker) { + return; + } + + const { transform, icon, color } = line.marker; + + const style = { + color, + transform, + top: chartDimensions.top, + left: chartDimensions.left, + }; + + const markerElement = ( +
+ {icon} +
+ ); + + markers.push(markerElement); + }); + + return markers; + } + + renderAnnotationMarkers(): JSX.Element[] { + const { annotationDimensions, annotationSpecs } = this.props.chartStore!; + const markers: JSX.Element[] = []; + + annotationDimensions.forEach((annotationLines: AnnotationLineProps[], id: AnnotationId) => { + const annotationSpec = annotationSpecs.get(id); + if (!annotationSpec) { + return; + } + + switch (annotationSpec.annotationType) { + case AnnotationTypes.Line: + const lineMarkers = this.renderAnnotationLineMarkers(annotationLines, id); + markers.push(...lineMarkers); + break; + } + }); + + return markers; + } + + render() { + return ( + + {this.renderAnnotationMarkers()} + {this.renderTooltip()} + + ); + } +} + +export const AnnotationTooltip = inject('chartStore')(observer(AnnotationTooltipComponent)); diff --git a/src/components/chart.tsx b/src/components/chart.tsx index 84940d142c..1fb633c7d5 100644 --- a/src/components/chart.tsx +++ b/src/components/chart.tsx @@ -3,6 +3,7 @@ import { Provider } from 'mobx-react'; import React, { CSSProperties, Fragment } from 'react'; import { SpecsParser } from '../specs/specs_parser'; import { ChartStore } from '../state/chart_state'; +import { AnnotationTooltip } from './annotation_tooltips'; import { ChartResizer } from './chart_resizer'; import { Crosshair } from './crosshair'; import { Highlighter } from './highlighter'; @@ -50,6 +51,7 @@ export class Chart extends React.Component { {renderer === 'svg' && } {renderer === 'canvas' && } + diff --git a/src/components/react_canvas/annotation.tsx b/src/components/react_canvas/annotation.tsx new file mode 100644 index 0000000000..a0ce0840b8 --- /dev/null +++ b/src/components/react_canvas/annotation.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Group, Line } from 'react-konva'; +import { AnnotationLineStyle } from '../../lib/themes/theme'; +import { Dimensions } from '../../lib/utils/dimensions'; +import { AnnotationLineProps } from '../../state/annotation_utils'; + +interface AnnotationProps { + chartDimensions: Dimensions; + debug: boolean; + lines: AnnotationLineProps[]; + lineStyle: AnnotationLineStyle; +} + +export class Annotation extends React.PureComponent { + render() { + return this.renderAnnotation(); + } + private renderAnnotationLine = (lineConfig: AnnotationLineProps, i: number) => { + const { line } = this.props.lineStyle; + const { position } = lineConfig; + + const lineProps = { + points: position, + ...line, + }; + + return ; + } + + private renderAnnotation = () => { + const { chartDimensions, lines } = this.props; + + return ( + + {lines.map(this.renderAnnotationLine)} + + ); + } +} diff --git a/src/components/react_canvas/reactive_chart.tsx b/src/components/react_canvas/reactive_chart.tsx index 5cdbbd1825..6ee4eebcc0 100644 --- a/src/components/react_canvas/reactive_chart.tsx +++ b/src/components/react_canvas/reactive_chart.tsx @@ -1,8 +1,12 @@ import { inject, observer } from 'mobx-react'; import React from 'react'; import { Layer, Rect, Stage } from 'react-konva'; +import { AnnotationLineStyle } from '../../lib/themes/theme'; +import { AnnotationId } from '../../lib/utils/ids'; +import { AnnotationDimensions } from '../../state/annotation_utils'; import { ChartStore, Point } from '../../state/chart_state'; import { BrushExtent } from '../../state/utils'; +import { Annotation } from './annotation'; import { AreaGeometries } from './area_geometries'; import { Axis } from './axis'; import { BarGeometries } from './bar_geometries'; @@ -167,6 +171,33 @@ class Chart extends React.Component { return gridComponents; } + renderAnnotations = () => { + const { annotationDimensions, annotationSpecs, chartDimensions, debug } = this.props.chartStore!; + + const annotationComponents: JSX.Element[] = []; + annotationDimensions.forEach((annotation: AnnotationDimensions, id: AnnotationId) => { + const spec = annotationSpecs.get(id); + if (!spec) { + return; + } + + // We merge custom style w/ the default on addAnnotationSpec, so this is guaranteed + // to be complete by the time we get to rendering + const lineStyle = spec.style as AnnotationLineStyle; + + annotationComponents.push( + , + ); + }); + return annotationComponents; + } + renderBrushTool = () => { const { brushing, brushStart, brushEnd } = this.state; const { chartDimensions, chartRotation, chartTransform } = this.props.chartStore!; @@ -242,15 +273,15 @@ class Chart extends React.Component { const clippings = debug ? {} : { - clipX: 0, - clipY: 0, - clipWidth: [90, -90].includes(chartRotation) - ? chartDimensions.height - : chartDimensions.width, - clipHeight: [90, -90].includes(chartRotation) - ? chartDimensions.width - : chartDimensions.height, - }; + clipX: 0, + clipY: 0, + clipWidth: [90, -90].includes(chartRotation) + ? chartDimensions.height + : chartDimensions.width, + clipHeight: [90, -90].includes(chartRotation) + ? chartDimensions.width + : chartDimensions.height, + }; let brushProps = {}; const isBrushEnabled = this.props.chartStore!.isBrushEnabled(); @@ -261,7 +292,7 @@ class Chart extends React.Component { }; } - const gridClippings = { + const layerClippings = { clipX: chartDimensions.left, clipY: chartDimensions.top, clipWidth: chartDimensions.width, @@ -297,7 +328,7 @@ class Chart extends React.Component { }} {...brushProps} > - + {this.renderGrids()} @@ -325,6 +356,10 @@ class Chart extends React.Component { {this.renderAxes()} + + + {this.renderAnnotations()} +
); diff --git a/src/lib/series/specs.ts b/src/lib/series/specs.ts index 89ce080139..8a7d78a403 100644 --- a/src/lib/series/specs.ts +++ b/src/lib/series/specs.ts @@ -1,6 +1,6 @@ -import { GridLineConfig } from '../themes/theme'; +import { AnnotationLineStyle, GridLineConfig } from '../themes/theme'; import { Accessor } from '../utils/accessor'; -import { AxisId, GroupId, SpecId } from '../utils/ids'; +import { AnnotationId, AxisId, GroupId, SpecId } from '../utils/ids'; import { ScaleContinuousType, ScaleType } from '../utils/scales/scales'; import { CurveType } from './curves'; import { DataSeriesColorsValues } from './series'; @@ -149,3 +149,53 @@ export enum Position { Left = 'left', Right = 'right', } + +export const AnnotationTypes = Object.freeze({ + Line: 'line' as AnnotationType, + Rectangle: 'rectangle' as AnnotationType, + Text: 'text' as AnnotationType, +}); + +export type AnnotationType = 'line' | 'rectangle' | 'text'; + +export const AnnotationDomainTypes = Object.freeze({ + XDomain: 'xDomain' as AnnotationDomainType, + YDomain: 'yDomain' as AnnotationDomainType, +}); + +export type AnnotationDomainType = 'xDomain' | 'yDomain'; +export interface AnnotationDatum { + dataValue: any; + details?: string; + header?: string; +} + +export interface LineAnnotationSpec { + /** The id of the annotation */ + annotationId: AnnotationId; + /** Annotation type: line, rectangle, text */ + annotationType: AnnotationType; + /** The ID of the axis group, generated via getGroupId method + * @default __global__ + */ + groupId: GroupId; // defaults to __global__; needed for yDomain position + /** Annotation domain type: AnnotationDomainTypes.XDomain or AnnotationDomainTypes.YDomain */ + domainType: AnnotationDomainType; + /** Data values defined with value, details, and header */ + dataValues: AnnotationDatum[]; + /** Custom line styles */ + style?: Partial; + /** Custom marker */ + marker?: JSX.Element; + /** + * Custom marker dimensions; will be computed internally + * Any user-supplied values will be overwritten + */ + markerDimensions?: { + width: number; + height: number; + }; +} + +// TODO: RectangleAnnotationSpec & TextAnnotationSpec +export type AnnotationSpec = LineAnnotationSpec; diff --git a/src/lib/themes/theme.test.ts b/src/lib/themes/theme.test.ts index c3ff65456f..62aa5158f8 100644 --- a/src/lib/themes/theme.test.ts +++ b/src/lib/themes/theme.test.ts @@ -7,9 +7,11 @@ import { BarSeriesStyle, ColorConfig, CrosshairStyle, + DEFAULT_ANNOTATION_LINE_STYLE, DEFAULT_GRID_LINE_CONFIG, LegendStyle, LineSeriesStyle, + mergeWithDefaultAnnotationLine, mergeWithDefaultGridLineConfig, mergeWithDefaultTheme, ScalesConfig, @@ -334,4 +336,44 @@ describe('Themes', () => { expect(mergeWithDefaultGridLineConfig(fullConfig)).toEqual(fullConfig); expect(mergeWithDefaultGridLineConfig({})).toEqual(DEFAULT_GRID_LINE_CONFIG); }); + + it('should merge custom and default annotation line configs', () => { + expect(mergeWithDefaultAnnotationLine()).toEqual(DEFAULT_ANNOTATION_LINE_STYLE); + + const customLineConfig = { + stroke: 'foo', + strokeWidth: 50, + opacity: 1, + }; + + const defaultLineConfig = { + stroke: '#000', + strokeWidth: 3, + opacity: 1, + }; + + const customDetailsConfig = { + fontSize: 50, + fontFamily: 'custom-font-family', + fontStyle: 'custom-font-style', + fill: 'custom-fill', + padding: 20, + }; + + const defaultDetailsConfig = { + fontSize: 10, + fontFamily: `'Open Sans', Helvetica, Arial, sans-serif`, + fontStyle: 'normal', + fill: 'gray', + padding: 0, + }; + + const expectedMergedCustomLineConfig = { line: customLineConfig, details: defaultDetailsConfig }; + const mergedCustomLineConfig = mergeWithDefaultAnnotationLine({ line: customLineConfig }); + expect(mergedCustomLineConfig).toEqual(expectedMergedCustomLineConfig); + + const expectedMergedCustomDetailsConfig = { line: defaultLineConfig, details: customDetailsConfig }; + const mergedCustomDetailsConfig = mergeWithDefaultAnnotationLine({ details: customDetailsConfig }); + expect(mergedCustomDetailsConfig).toEqual(expectedMergedCustomDetailsConfig); + }); }); diff --git a/src/lib/themes/theme.ts b/src/lib/themes/theme.ts index c8a21f431e..ec4a6fccfe 100644 --- a/src/lib/themes/theme.ts +++ b/src/lib/themes/theme.ts @@ -105,6 +105,12 @@ export interface CrosshairStyle { band: FillStyle & Visible; line: StrokeStyle & Visible; } + +export interface AnnotationLineStyle { + line: StrokeStyle & Opacity; + details: TextStyle; +} + export interface PartialTheme { chartMargins?: Margins; chartPaddings?: Margins; @@ -125,6 +131,21 @@ export const DEFAULT_GRID_LINE_CONFIG: GridLineConfig = { opacity: 1, }; +export const DEFAULT_ANNOTATION_LINE_STYLE: AnnotationLineStyle = { + line: { + stroke: '#000', + strokeWidth: 3, + opacity: 1, + }, + details: { + fontSize: 10, + fontFamily: `'Open Sans', Helvetica, Arial, sans-serif`, + fontStyle: 'normal', + fill: 'gray', + padding: 0, + }, +}; + export function mergeWithDefaultGridLineConfig(config: GridLineConfig): GridLineConfig { const strokeWidth = config.strokeWidth != null ? config.strokeWidth : DEFAULT_GRID_LINE_CONFIG.strokeWidth; @@ -138,6 +159,32 @@ export function mergeWithDefaultGridLineConfig(config: GridLineConfig): GridLine }; } +export function mergeWithDefaultAnnotationLine(config?: Partial): AnnotationLineStyle { + const defaultLine = DEFAULT_ANNOTATION_LINE_STYLE.line; + const defaultDetails = DEFAULT_ANNOTATION_LINE_STYLE.details; + const mergedConfig: AnnotationLineStyle = { ...DEFAULT_ANNOTATION_LINE_STYLE }; + + if (!config) { + return mergedConfig; + } + + if (config.line) { + mergedConfig.line = { + ...defaultLine, + ...config.line, + }; + } + + if (config.details) { + mergedConfig.details = { + ...defaultDetails, + ...config.details, + }; + } + + return mergedConfig; +} + export function mergeWithDefaultTheme( theme: PartialTheme, defaultTheme: Theme = LIGHT_THEME, diff --git a/src/lib/utils/ids.test.ts b/src/lib/utils/ids.test.ts index e5dcc1f4b7..ae3cbcec78 100644 --- a/src/lib/utils/ids.test.ts +++ b/src/lib/utils/ids.test.ts @@ -1,4 +1,4 @@ -import { AxisId, getAxisId, getGroupId, GroupId } from './ids'; +import { AnnotationId, AxisId, getAnnotationId, getAxisId, getGroupId, GroupId } from './ids'; describe('IDs', () => { test('ids should differ depending on entity', () => { @@ -18,4 +18,15 @@ describe('IDs', () => { expect(expectedAxisSeries).toEqual([...axisSeries]); expect(expectedGroupSeries).toEqual([...groupSeries]); }); + test('should be able to identify annotations', () => { + const annotationId1 = getAnnotationId('anno1'); + const annotationId2 = getAnnotationId('anno2'); + + const annotations = new Map(); + annotations.set(annotationId1, 'annotations 1'); + annotations.set(annotationId2, 'annotations 2'); + + const expectedAnnotations = [['anno1', 'annotations 1'], ['anno2', 'annotations 2']]; + expect(expectedAnnotations).toEqual([...annotations]); + }); }); diff --git a/src/lib/utils/ids.ts b/src/lib/utils/ids.ts index 83e1f54701..52a6f9d6c4 100644 --- a/src/lib/utils/ids.ts +++ b/src/lib/utils/ids.ts @@ -1,11 +1,14 @@ import { iso, Newtype } from 'newtype-ts'; -export interface GroupId extends Newtype<{ readonly GroupId: unique symbol }, string> {} -export interface AxisId extends Newtype<{ readonly AxisId: unique symbol }, string> {} -export interface SpecId extends Newtype<{ readonly SpecId: unique symbol }, string> {} +export interface GroupId extends Newtype<{ readonly GroupId: unique symbol }, string> { } +export interface AxisId extends Newtype<{ readonly AxisId: unique symbol }, string> { } +export interface SpecId extends Newtype<{ readonly SpecId: unique symbol }, string> { } +export interface AnnotationId extends Newtype<{ readonly AnnotationId: unique symbol }, string> { } + const groupIdIso = iso(); const axisIdIso = iso(); const specIdIso = iso(); +const annotationIdIso = iso(); export function getGroupId(id: string): GroupId { return groupIdIso.wrap(id); @@ -16,3 +19,6 @@ export function getAxisId(id: string): AxisId { export function getSpecId(id: string): SpecId { return specIdIso.wrap(id); } +export function getAnnotationId(id: string): AnnotationId { + return annotationIdIso.wrap(id); +} diff --git a/src/specs/index.ts b/src/specs/index.ts index 6832a094bb..e29a924a12 100644 --- a/src/specs/index.ts +++ b/src/specs/index.ts @@ -1,5 +1,6 @@ export { Axis } from './axis'; export { BasicSeries } from './basic_series'; +export { LineAnnotation } from './line_annotation'; export { LineSeries } from './line_series'; export { BarSeries } from './bar_series'; export { AreaSeries } from './area_series'; diff --git a/src/specs/line_annotation.tsx b/src/specs/line_annotation.tsx new file mode 100644 index 0000000000..61b36c66a1 --- /dev/null +++ b/src/specs/line_annotation.tsx @@ -0,0 +1,63 @@ +import { inject } from 'mobx-react'; +import React, { createRef, CSSProperties, PureComponent } from 'react'; +import { AnnotationTypes, LineAnnotationSpec } from '../lib/series/specs'; +import { DEFAULT_ANNOTATION_LINE_STYLE } from '../lib/themes/theme'; +import { getGroupId } from '../lib/utils/ids'; +import { SpecProps } from './specs_parser'; + +type LineAnnotationProps = SpecProps & LineAnnotationSpec; + +export class LineAnnotationSpecComponent extends PureComponent { + static defaultProps: Partial = { + groupId: getGroupId('__global__'), + annotationType: AnnotationTypes.Line, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + private markerRef = createRef(); + + componentDidMount() { + const { chartStore, children, ...config } = this.props; + if (this.markerRef.current) { + const { offsetWidth, offsetHeight } = this.markerRef.current; + config.markerDimensions = { + width: offsetWidth, + height: offsetHeight, + }; + } + chartStore!.addAnnotationSpec({ ...config }); + } + componentDidUpdate() { + const { chartStore, children, ...config } = this.props; + if (this.markerRef.current) { + const { offsetWidth, offsetHeight } = this.markerRef.current; + config.markerDimensions = { + width: offsetWidth, + height: offsetHeight, + }; + } + chartStore!.addAnnotationSpec({ ...config }); + } + componentWillUnmount() { + const { chartStore, annotationId } = this.props; + chartStore!.removeAnnotationSpec(annotationId); + } + render() { + if (!this.props.marker) { + return null; + } + + // We need to get the width & height of the marker passed into the spec + // so we render the marker offscreen if one has been defined & update the config + // with the width & height. + const offscreenStyle: CSSProperties = { + position: 'absolute', + left: -9999, + opacity: 0, + }; + + return (
{this.props.marker}
); + } +} + +export const LineAnnotation = inject('chartStore')(LineAnnotationSpecComponent); diff --git a/src/state/annotation_marker.test.tsx b/src/state/annotation_marker.test.tsx new file mode 100644 index 0000000000..a5e4243e17 --- /dev/null +++ b/src/state/annotation_marker.test.tsx @@ -0,0 +1,247 @@ +import * as React from 'react'; + +import { + AnnotationDomainType, + AnnotationDomainTypes, + AnnotationSpec, + AnnotationTypes, + Position, + Rotation, +} from '../lib/series/specs'; +import { DEFAULT_ANNOTATION_LINE_STYLE } from '../lib/themes/theme'; +import { Dimensions } from '../lib/utils/dimensions'; +import { getAnnotationId, getGroupId, GroupId } from '../lib/utils/ids'; +import { createContinuousScale, Scale, ScaleType } from '../lib/utils/scales/scales'; +import { + AnnotationLinePosition, + computeLineAnnotationDimensions, + DEFAULT_LINE_OVERFLOW, + isWithinLineBounds, +} from './annotation_utils'; +import { Point } from './chart_state'; + +describe('annotation marker', () => { + const groupId = getGroupId('foo-group'); + + const minRange = 0; + const maxRange = 100; + + const continuousData = [0, 10]; + const continuousScale = createContinuousScale(ScaleType.Linear, continuousData, minRange, maxRange); + + const chartDimensions: Dimensions = { + width: 10, + height: 20, + top: 5, + left: 15, + }; + + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = continuousScale; + + test('should compute line annotation dimensions with marker if defined (y domain)', () => { + const chartRotation: Rotation = 0; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + marker:
, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [DEFAULT_LINE_OVERFLOW, 20, 10, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [0, 20, 10, 20], + marker: { + icon:
, + transform: 'translate(calc(0px - 0%),calc(20px - 50%))', + color: '#000', + dimensions: { width: 0, height: 0 } + }, + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions with marker if defined (x domain)', () => { + const chartRotation: Rotation = 0; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + marker:
, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [20, -DEFAULT_LINE_OVERFLOW, 20, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [20, 0, 20, 20], + marker: { + icon:
, + transform: 'translate(calc(20px - 0%),calc(20px - 50%))', + color: '#000', + dimensions: { width: 0, height: 0 }, + }, + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute if a point is within an annotation line bounds (xDomain annotation)', () => { + const linePosition1: AnnotationLinePosition = [10, 0, 10, 20]; + const cursorPosition1: Point = { x: 0, y: 0 }; + const cursorPosition2: Point = { x: 10, y: 0 }; + + const offset: number = 0; + const horizontalChartRotation: Rotation = 0; + const verticalChartRotation: Rotation = 90; + const domainType: AnnotationDomainType = AnnotationDomainTypes.XDomain; + + const marker = { + icon:
, + transform: '', + color: 'custom-color', + dimensions: { width: 10, height: 10 }, + }; + + const bottomHorizontalRotationOutsideBounds = isWithinLineBounds( + Position.Bottom, + linePosition1, + cursorPosition1, + offset, + horizontalChartRotation, + domainType, + marker, + ); + + expect(bottomHorizontalRotationOutsideBounds).toBe(false); + + const bottomHorizontalRotationWithinBounds = isWithinLineBounds( + Position.Bottom, + linePosition1, + cursorPosition2, + offset, + horizontalChartRotation, + domainType, + marker, + ); + + expect(bottomHorizontalRotationWithinBounds).toBe(true); + + const topHorizontalRotationOutsideBounds = isWithinLineBounds( + Position.Top, + linePosition1, + cursorPosition1, + offset, + horizontalChartRotation, + domainType, + marker, + ); + + expect(topHorizontalRotationOutsideBounds).toBe(false); + + const verticalRotationOutsideBounds = isWithinLineBounds( + Position.Bottom, + linePosition1, + cursorPosition1, + offset, + verticalChartRotation, + domainType, + marker, + ); + + expect(verticalRotationOutsideBounds).toBe(true); + }); + + test('should compute if a point is within an annotation line bounds (yDomain annotation)', () => { + const linePosition1: AnnotationLinePosition = [10, 0, 10, 20]; + const cursorPosition1: Point = { x: 0, y: 0 }; + const cursorPosition2: Point = { x: 10, y: 0 }; + + const offset: number = 0; + const horizontalChartRotation: Rotation = 0; + const verticalChartRotation: Rotation = 90; + const domainType: AnnotationDomainType = AnnotationDomainTypes.YDomain; + + const marker = { + icon:
, + transform: '', + color: 'custom-color', + dimensions: { width: 10, height: 10 }, + }; + + const rightHorizontalRotationWithinBounds = isWithinLineBounds( + Position.Left, + linePosition1, + cursorPosition1, + offset, + horizontalChartRotation, + domainType, + marker, + ); + + expect(rightHorizontalRotationWithinBounds).toBe(true); + + const leftHorizontalRotationWithinBounds = isWithinLineBounds( + Position.Left, + linePosition1, + cursorPosition2, + offset, + horizontalChartRotation, + domainType, + marker, + ); + + expect(leftHorizontalRotationWithinBounds).toBe(true); + + const rightHorizontalRotationOutsideBounds = isWithinLineBounds( + Position.Right, + linePosition1, + cursorPosition1, + offset, + horizontalChartRotation, + domainType, + marker, + ); + + expect(rightHorizontalRotationOutsideBounds).toBe(false); + + const verticalRotationOutsideBounds = isWithinLineBounds( + Position.Left, + linePosition1, + cursorPosition1, + offset, + verticalChartRotation, + domainType, + marker, + ); + + expect(verticalRotationOutsideBounds).toBe(false); + }); +}); diff --git a/src/state/annotation_utils.test.ts b/src/state/annotation_utils.test.ts new file mode 100644 index 0000000000..8855f59866 --- /dev/null +++ b/src/state/annotation_utils.test.ts @@ -0,0 +1,1027 @@ +import { + AnnotationDomainType, + AnnotationDomainTypes, + AnnotationSpec, + AnnotationTypes, + AxisSpec, + Position, + Rotation, +} from '../lib/series/specs'; +import { DEFAULT_ANNOTATION_LINE_STYLE } from '../lib/themes/theme'; +import { Dimensions } from '../lib/utils/dimensions'; +import { AnnotationId, AxisId, getAnnotationId, getAxisId, getGroupId, GroupId } from '../lib/utils/ids'; +import { createContinuousScale, createOrdinalScale, Scale, ScaleType } from '../lib/utils/scales/scales'; +import { + AnnotationLinePosition, + AnnotationLineProps, + computeAnnotationDimensions, + computeAnnotationTooltipState, + computeLineAnnotationDimensions, + computeLineAnnotationTooltipState, + DEFAULT_LINE_OVERFLOW, + getAnnotationAxis, + getAnnotationLineTooltipPosition, + getAnnotationLineTooltipTransform, + getAnnotationLineTooltipXOffset, + getAnnotationLineTooltipYOffset, + isVerticalAnnotationLine, + isWithinLineBounds, + toTransformString, +} from './annotation_utils'; +import { Point } from './chart_state'; + +describe('annotation utils', () => { + const minRange = 0; + const maxRange = 100; + + const continuousData = [0, 10]; + const continuousScale = createContinuousScale(ScaleType.Linear, continuousData, minRange, maxRange); + + const ordinalData = ['a', 'b', 'c', 'd', 'a', 'b', 'c']; + const ordinalScale = createOrdinalScale(ordinalData, minRange, maxRange); + + const chartDimensions: Dimensions = { + width: 10, + height: 20, + top: 5, + left: 15, + }; + + const groupId = getGroupId('foo-group'); + + const axesSpecs: Map = new Map(); + const verticalAxisSpec: AxisSpec = { + id: getAxisId('vertical_axis'), + groupId, + hide: false, + showOverlappingTicks: false, + showOverlappingLabels: false, + position: Position.Left, + tickSize: 10, + tickPadding: 10, + tickFormat: (value: any) => value.toString(), + showGridLines: true, + }; + const horizontalAxisSpec: AxisSpec = { + id: getAxisId('horizontal_axis'), + groupId, + hide: false, + showOverlappingTicks: false, + showOverlappingLabels: false, + position: Position.Bottom, + tickSize: 10, + tickPadding: 10, + tickFormat: (value: any) => value.toString(), + showGridLines: true, + }; + + axesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); + + test('should compute annotation dimensions', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = ordinalScale; + + const annotations: Map = new Map(); + const annotationId = getAnnotationId('foo'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + annotations.set(annotationId, lineAnnotation); + + const dimensions = computeAnnotationDimensions( + annotations, + chartDimensions, + chartRotation, + yScales, + xScale, + axesSpecs, + ); + const expectedDimensions = new Map(); + expectedDimensions.set(annotationId, [{ + position: [DEFAULT_LINE_OVERFLOW, 20, 10, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [0, 20, 10, 20], + }]); + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should not compute annotation dimensions if a corresponding axis does not exist', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = ordinalScale; + + const annotations: Map = new Map(); + const annotationId = getAnnotationId('foo'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + annotations.set(annotationId, lineAnnotation); + + const dimensions = computeAnnotationDimensions( + annotations, + chartDimensions, + chartRotation, + yScales, + xScale, + new Map(), // empty axesSpecs + ); + const expectedDimensions = new Map(); + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for yDomain on a yScale (chartRotation 0, left axis)', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = ordinalScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [DEFAULT_LINE_OVERFLOW, 20, 10, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [0, 20, 10, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for yDomain on a yScale (chartRotation 0, right axis)', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = ordinalScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Right, + ); + const expectedDimensions = [{ + position: [0, 20, 10, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [0, 20, 10, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for yDomain on a yScale (chartRotation 90)', () => { + const chartRotation: Rotation = 90; + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = ordinalScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [20, 0, 20, 20 + DEFAULT_LINE_OVERFLOW], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [20, 0, 20, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should not compute line annotation dimensions for yDomain if no corresponding yScale', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + const xScale: Scale = ordinalScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + expect(dimensions).toEqual(null); + }); + + test('should compute line annotation dimensions for xDomain (chartRotation 0, ordinal scale)', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + const xScale: Scale = ordinalScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 'a', details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [12.5, -DEFAULT_LINE_OVERFLOW, 12.5, 20], + details: { detailsText: 'foo', headerText: 'a' }, + tooltipLinePosition: [12.5, 0, 12.5, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for xDomain (chartRotation 0, continuous scale, top axis)', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + const xScale: Scale = continuousScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Top, + ); + const expectedDimensions = [{ + position: [20, -DEFAULT_LINE_OVERFLOW, 20, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [20, 0, 20, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for xDomain (chartRotation 0, continuous scale, bottom axis)', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + const xScale: Scale = continuousScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Bottom, + ); + const expectedDimensions = [{ + position: [20, DEFAULT_LINE_OVERFLOW, 20, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [20, 0, 20, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for xDomain on a xScale (chartRotation 90, ordinal scale)', () => { + const chartRotation: Rotation = 90; + const yScales: Map = new Map(); + + const xScale: Scale = ordinalScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 'a', details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [-DEFAULT_LINE_OVERFLOW, 12.5, 10, 12.5], + details: { detailsText: 'foo', headerText: 'a' }, + tooltipLinePosition: [0, 12.5, 10, 12.5], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for xDomain on a xScale (chartRotation 90, continuous scale)', () => { + const chartRotation: Rotation = 90; + const yScales: Map = new Map(); + + const xScale: Scale = continuousScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [-DEFAULT_LINE_OVERFLOW, 20, 10, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [0, 20, 10, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for xDomain on a xScale (chartRotation -90, continuous scale)', + () => { + const chartRotation: Rotation = -90; + const yScales: Map = new Map(); + + const xScale: Scale = continuousScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Left, + ); + const expectedDimensions = [{ + position: [-DEFAULT_LINE_OVERFLOW, 0, 10, 0], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [0, 0, 10, 0], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for xDomain (chartRotation 180, continuous scale, top axis)', () => { + const chartRotation: Rotation = 180; + const yScales: Map = new Map(); + + const xScale: Scale = continuousScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Top, + ); + const expectedDimensions = [{ + position: [-10, -DEFAULT_LINE_OVERFLOW, -10, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [-10, 0, -10, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should compute line annotation dimensions for xDomain (chartRotation 180, continuous scale, bottom axis)', + () => { + const chartRotation: Rotation = 180; + const yScales: Map = new Map(); + const xScale: Scale = continuousScale; + + const annotationId = getAnnotationId('foo-line'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const dimensions = computeLineAnnotationDimensions( + lineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Bottom, + ); + const expectedDimensions = [{ + position: [-10, DEFAULT_LINE_OVERFLOW, -10, 20], + details: { detailsText: 'foo', headerText: '2' }, + tooltipLinePosition: [-10, DEFAULT_LINE_OVERFLOW, -10, 20], + }]; + expect(dimensions).toEqual(expectedDimensions); + }); + + test('should not compute annotation line values for values outside of domain', () => { + const chartRotation: Rotation = 0; + const yScales: Map = new Map(); + yScales.set(groupId, continuousScale); + + const xScale: Scale = ordinalScale; + + const annotationId = getAnnotationId('foo-line'); + const invalidXLineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: 'e', details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const emptyXDimensions = computeLineAnnotationDimensions( + invalidXLineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Right, + ); + + expect(emptyXDimensions).toEqual([]); + + const invalidStringXLineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: '', details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const invalidStringXDimensions = computeLineAnnotationDimensions( + invalidStringXLineAnnotation, + chartDimensions, + chartRotation, + yScales, + continuousScale, + Position.Right, + ); + + expect(invalidStringXDimensions).toEqual([]); + + const outOfBoundsXLineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.XDomain, + dataValues: [{ dataValue: -999, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const emptyOutOfBoundsXDimensions = computeLineAnnotationDimensions( + outOfBoundsXLineAnnotation, + chartDimensions, + chartRotation, + yScales, + continuousScale, + Position.Right, + ); + + expect(emptyOutOfBoundsXDimensions).toEqual([]); + + const invalidYLineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 'e', details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const emptyYDimensions = computeLineAnnotationDimensions( + invalidYLineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Right, + ); + + expect(emptyYDimensions).toEqual([]); + + const outOfBoundsYLineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: -999, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const emptyOutOfBoundsYDimensions = computeLineAnnotationDimensions( + outOfBoundsYLineAnnotation, + chartDimensions, + chartRotation, + yScales, + xScale, + Position.Right, + ); + + expect(emptyOutOfBoundsYDimensions).toEqual([]); + + const invalidStringYLineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: '', details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const invalidStringYDimensions = computeLineAnnotationDimensions( + invalidStringYLineAnnotation, + chartDimensions, + chartRotation, + yScales, + continuousScale, + Position.Right, + ); + + expect(invalidStringYDimensions).toEqual([]); + }); + + test('should compute if a point is within an annotation line bounds (xDomain annotation)', () => { + const linePosition1: AnnotationLinePosition = [10, 0, 10, 20]; + const cursorPosition1: Point = { x: 0, y: 0 }; + const cursorPosition2: Point = { x: 10, y: 0 }; + + const offset: number = 0; + const horizontalChartRotation: Rotation = 0; + const verticalChartRotation: Rotation = 90; + const domainType: AnnotationDomainType = AnnotationDomainTypes.XDomain; + + const horizontalRotationOutsideBounds = isWithinLineBounds( + Position.Bottom, + linePosition1, + cursorPosition1, + offset, + horizontalChartRotation, + domainType, + ); + + expect(horizontalRotationOutsideBounds).toBe(false); + + const horizontalRotationWithinBounds = isWithinLineBounds( + Position.Bottom, + linePosition1, + cursorPosition2, + offset, + horizontalChartRotation, + domainType, + ); + expect(horizontalRotationWithinBounds).toBe(true); + + const verticalRotationOutsideBounds = isWithinLineBounds( + Position.Bottom, + linePosition1, + cursorPosition1, + offset, + verticalChartRotation, + domainType, + ); + + expect(verticalRotationOutsideBounds).toBe(false); + + const verticalRotationWithinBounds = isWithinLineBounds( + Position.Bottom, + linePosition1, + cursorPosition2, + offset, + verticalChartRotation, + domainType, + ); + + expect(verticalRotationWithinBounds).toBe(true); + }); + test('should compute if a point is within an annotation line bounds (yDomain annotation)', () => { + const linePosition1: AnnotationLinePosition = [10, 0, 10, 20]; + const cursorPosition1: Point = { x: 0, y: 0 }; + const cursorPosition2: Point = { x: 10, y: 0 }; + + const offset: number = 0; + const horizontalChartRotation: Rotation = 0; + const verticalChartRotation: Rotation = 90; + const domainType: AnnotationDomainType = AnnotationDomainTypes.YDomain; + + const horizontalRotationOutsideBounds = isWithinLineBounds( + Position.Left, + linePosition1, + cursorPosition1, + offset, + horizontalChartRotation, + domainType, + ); + + expect(horizontalRotationOutsideBounds).toBe(false); + + const horizontalRotationWithinBounds = isWithinLineBounds( + Position.Left, + linePosition1, + cursorPosition2, + offset, + horizontalChartRotation, + domainType, + ); + expect(horizontalRotationWithinBounds).toBe(true); + + const verticalRotationOutsideBounds = isWithinLineBounds( + Position.Left, + linePosition1, + cursorPosition1, + offset, + verticalChartRotation, + domainType, + ); + + expect(verticalRotationOutsideBounds).toBe(false); + + const verticalRotationWithinBounds = isWithinLineBounds( + Position.Left, + linePosition1, + cursorPosition2, + offset, + verticalChartRotation, + domainType, + ); + + expect(verticalRotationWithinBounds).toBe(true); + }); + test('should determine if an annotation line is vertical dependent on domain type & chart rotation', () => { + const isHorizontal = true; + const isXDomain = true; + const xDomainHorizontalRotation = isVerticalAnnotationLine(isXDomain, isHorizontal); + expect(xDomainHorizontalRotation).toBe(true); + + const xDomainVerticalRotation = isVerticalAnnotationLine(isXDomain, !isHorizontal); + expect(xDomainVerticalRotation).toBe(false); + + const yDomainHorizontalRotation = isVerticalAnnotationLine(!isXDomain, isHorizontal); + expect(yDomainHorizontalRotation).toBe(false); + + const yDomainVerticalRotation = isVerticalAnnotationLine(isXDomain, !isHorizontal); + expect(yDomainVerticalRotation).toBe(false); + }); + test('should get the x offset for an annotation line tooltip', () => { + const bottomHorizontalRotationOffset = getAnnotationLineTooltipXOffset(0, Position.Bottom); + expect(bottomHorizontalRotationOffset).toBe(50); + + const topHorizontalRotationOffset = getAnnotationLineTooltipXOffset(0, Position.Top); + expect(topHorizontalRotationOffset).toBe(50); + + const bottomVerticalRotationOffset = getAnnotationLineTooltipXOffset(90, Position.Bottom); + expect(bottomVerticalRotationOffset).toBe(0); + + const topVerticalRotationOffset = getAnnotationLineTooltipXOffset(90, Position.Top); + expect(topVerticalRotationOffset).toBe(0); + + const leftHorizontalRotationOffset = getAnnotationLineTooltipXOffset(0, Position.Left); + expect(leftHorizontalRotationOffset).toBe(0); + + const rightHorizontalRotationOffset = getAnnotationLineTooltipXOffset(0, Position.Right); + expect(rightHorizontalRotationOffset).toBe(100); + + const leftVerticalRotationOffset = getAnnotationLineTooltipXOffset(90, Position.Left); + expect(leftVerticalRotationOffset).toBe(50); + + const rightVerticalRotationOffset = getAnnotationLineTooltipXOffset(90, Position.Right); + expect(rightVerticalRotationOffset).toBe(50); + }); + test('should get the y offset for an annotation line tooltip', () => { + const bottomHorizontalRotationOffset = getAnnotationLineTooltipYOffset(0, Position.Bottom); + expect(bottomHorizontalRotationOffset).toBe(100); + + const topHorizontalRotationOffset = getAnnotationLineTooltipYOffset(0, Position.Top); + expect(topHorizontalRotationOffset).toBe(0); + + const bottomVerticalRotationOffset = getAnnotationLineTooltipYOffset(90, Position.Bottom); + expect(bottomVerticalRotationOffset).toBe(50); + + const topVerticalRotationOffset = getAnnotationLineTooltipYOffset(90, Position.Top); + expect(topVerticalRotationOffset).toBe(50); + + const leftHorizontalRotationOffset = getAnnotationLineTooltipYOffset(0, Position.Left); + expect(leftHorizontalRotationOffset).toBe(50); + + const rightHorizontalRotationOffset = getAnnotationLineTooltipYOffset(0, Position.Right); + expect(rightHorizontalRotationOffset).toBe(50); + + const leftVerticalRotationOffset = getAnnotationLineTooltipYOffset(90, Position.Left); + expect(leftVerticalRotationOffset).toBe(100); + + const rightVerticalRotationOffset = getAnnotationLineTooltipYOffset(90, Position.Right); + expect(rightVerticalRotationOffset).toBe(100); + }); + test('should get annotation line tooltip position', () => { + const chartRotation: Rotation = 0; + const linePosition: AnnotationLinePosition = [1, 2, 3, 4]; + + const bottomLineTooltipPosition = getAnnotationLineTooltipPosition( + chartRotation, + linePosition, + Position.Bottom, + ); + expect(bottomLineTooltipPosition).toEqual({ xPosition: 1, yPosition: 4, xOffset: 50, yOffset: 100 }); + + const topLineTooltipPosition = getAnnotationLineTooltipPosition( + chartRotation, + linePosition, + Position.Top, + ); + expect(topLineTooltipPosition).toEqual({ xPosition: 1, yPosition: 2, xOffset: 50, yOffset: 0 }); + + const leftLineTooltipPosition = getAnnotationLineTooltipPosition( + chartRotation, + linePosition, + Position.Left, + ); + expect(leftLineTooltipPosition).toEqual({ xPosition: 1, yPosition: 4, xOffset: 0, yOffset: 50 }); + + const rightLineTooltipPosition = getAnnotationLineTooltipPosition( + chartRotation, + linePosition, + Position.Right, + ); + expect(rightLineTooltipPosition).toEqual({ xPosition: 3, yPosition: 4, xOffset: 100, yOffset: 50 }); + }); + test('should form the string for the position transform given a TransformPoint', () => { + const transformString = toTransformString({ xPosition: 1, yPosition: 4, xOffset: 50, yOffset: 100 }); + expect(transformString).toBe('translate(calc(1px - 50%),calc(4px - 100%))'); + }); + test('should get the transform for an annotation line tooltip', () => { + const chartRotation: Rotation = 0; + const linePosition: AnnotationLinePosition = [1, 2, 3, 4]; + + const lineTooltipTransform = getAnnotationLineTooltipTransform( + chartRotation, + linePosition, + Position.Bottom, + ); + expect(lineTooltipTransform).toBe('translate(calc(1px - 50%),calc(4px - 100%))'); + }); + test('should compute the tooltip state for an annotation line', () => { + const cursorPosition: Point = { x: 1, y: 2 }; + + const annotationLines: AnnotationLineProps[] = [{ + position: [1, 2, 3, 4], + details: {}, + tooltipLinePosition: [1, 2, 3, 4], + }]; + const lineStyle = DEFAULT_ANNOTATION_LINE_STYLE; + const chartRotation: Rotation = 0; + const localAxesSpecs = new Map(); + + // missing annotation axis (xDomain) + const missingTooltipState = computeLineAnnotationTooltipState( + cursorPosition, + annotationLines, + groupId, + AnnotationDomainTypes.XDomain, + lineStyle, + chartRotation, + localAxesSpecs, + ); + + const expectedMissingTooltipState = { + isVisible: false, + transform: '', + }; + + expect(missingTooltipState).toEqual(expectedMissingTooltipState); + + // add axis for xDomain annotation + localAxesSpecs.set(horizontalAxisSpec.id, horizontalAxisSpec); + + const xDomainTooltipState = computeLineAnnotationTooltipState( + cursorPosition, + annotationLines, + groupId, + AnnotationDomainTypes.XDomain, + lineStyle, + chartRotation, + localAxesSpecs, + ); + const expectedXDomainTooltipState = { + isVisible: true, + transform: 'translate(calc(1px - 50%),calc(4px - 100%))', + }; + + expect(xDomainTooltipState).toEqual(expectedXDomainTooltipState); + + // add axis for yDomain annotation + localAxesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); + + const yDomainTooltipState = computeLineAnnotationTooltipState( + cursorPosition, + annotationLines, + groupId, + AnnotationDomainTypes.YDomain, + lineStyle, + chartRotation, + localAxesSpecs, + ); + const expectedYDomainTooltipState = { + isVisible: true, + transform: 'translate(calc(1px - 0%),calc(4px - 50%))', + }; + + expect(yDomainTooltipState).toEqual(expectedYDomainTooltipState); + }); + + test('should compute the tooltip state for an annotation', () => { + const annotations: Map = new Map(); + const annotationId = getAnnotationId('foo'); + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: DEFAULT_ANNOTATION_LINE_STYLE, + }; + + const cursorPosition: Point = { x: 1, y: 2 }; + + const annotationLines: AnnotationLineProps[] = [{ + position: [1, 2, 3, 4], + details: {}, + tooltipLinePosition: [1, 2, 3, 4], + }]; + const chartRotation: Rotation = 0; + const localAxesSpecs: Map = new Map(); + + const annotationDimensions = new Map(); + annotationDimensions.set(annotationId, annotationLines); + + // missing annotations + const missingTooltipState = computeAnnotationTooltipState( + cursorPosition, + annotationDimensions, + annotations, + chartRotation, + localAxesSpecs, + ); + + expect(missingTooltipState).toBe(null); + + // add valid annotation axis + annotations.set(annotationId, lineAnnotation); + localAxesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); + + const tooltipState = computeAnnotationTooltipState( + cursorPosition, + annotationDimensions, + annotations, + chartRotation, + localAxesSpecs, + ); + + const expectedTooltipState = { + isVisible: true, + transform: 'translate(calc(1px - 0%),calc(4px - 50%))', + }; + + expect(tooltipState).toEqual(expectedTooltipState); + }); + + test('should get associated axis for an annotation', () => { + const localAxesSpecs = new Map(); + + const noAxis = getAnnotationAxis( + localAxesSpecs, + groupId, + AnnotationDomainTypes.XDomain, + ); + expect(noAxis).toBe(null); + + localAxesSpecs.set(horizontalAxisSpec.id, horizontalAxisSpec); + localAxesSpecs.set(verticalAxisSpec.id, verticalAxisSpec); + + const xAnnotationAxisPosition = getAnnotationAxis( + localAxesSpecs, + groupId, + AnnotationDomainTypes.XDomain, + ); + expect(xAnnotationAxisPosition).toEqual(Position.Bottom); + + const yAnnotationAxisPosition = getAnnotationAxis( + localAxesSpecs, + groupId, + AnnotationDomainTypes.YDomain, + ); + expect(yAnnotationAxisPosition).toEqual(Position.Left); + }); +}); diff --git a/src/state/annotation_utils.ts b/src/state/annotation_utils.ts new file mode 100644 index 0000000000..435f76c2e8 --- /dev/null +++ b/src/state/annotation_utils.ts @@ -0,0 +1,608 @@ +import { isHorizontal } from '../lib/axes/axis_utils'; +import { + AnnotationDatum, + AnnotationDomainType, + AnnotationDomainTypes, + AnnotationSpec, + AnnotationTypes, + AxisSpec, + Position, + Rotation, +} from '../lib/series/specs'; +import { AnnotationLineStyle } from '../lib/themes/theme'; +import { Dimensions } from '../lib/utils/dimensions'; +import { AnnotationId, AxisId, GroupId } from '../lib/utils/ids'; +import { Scale, ScaleType } from '../lib/utils/scales/scales'; +import { Point } from './chart_state'; +import { getAxesSpecForSpecId, isHorizontalRotation } from './utils'; + +export interface AnnotationTooltipState { + isVisible: boolean; + header?: string; + details?: string; + transform: string; +} +export interface AnnotationDetails { + headerText?: string; + detailsText?: string; +} + +export interface AnnotationMarker { + icon: JSX.Element; + transform: string; + dimensions: { width: number; height: number; }; + color: string; +} + +export type AnnotationLinePosition = [number, number, number, number]; + +export interface AnnotationLineProps { + position: AnnotationLinePosition; + tooltipLinePosition: AnnotationLinePosition; + details: AnnotationDetails; + marker?: AnnotationMarker; +} + +interface TransformPosition { + xPosition: number; + yPosition: number; + xOffset: number; + yOffset: number; +} + +// TODO: add AnnotationRectangleProps or AnnotationTextProps +export type AnnotationDimensions = AnnotationLineProps[]; + +export const DEFAULT_LINE_OVERFLOW = 0; + +export function computeYDomainLineAnnotationDimensions( + dataValues: AnnotationDatum[], + yScale: Scale, + chartRotation: Rotation, + lineOverflow: number, + axisPosition: Position, + chartDimensions: Dimensions, + lineColor: string, + marker?: JSX.Element, + markerDimensions?: { width: number; height: number; }, +): AnnotationLineProps[] { + const chartHeight = chartDimensions.height; + const chartWidth = chartDimensions.width; + const isHorizontalChartRotation = isHorizontalRotation(chartRotation); + const markerOffsets = markerDimensions || { width: 0, height: 0 }; + const lineProps: AnnotationLineProps[] = []; + + dataValues.forEach((datum: AnnotationDatum) => { + const { dataValue } = datum; + const details = { + detailsText: datum.details, + headerText: datum.header || dataValue.toString(), + }; + + // d3.scale will return 0 for '', rendering the line incorrectly at 0 + if (dataValue === '') { + return; + } + + const scaledYValue = yScale.scale(dataValue); + if (isNaN(scaledYValue)) { + return; + } + + const [domainStart, domainEnd] = yScale.domain; + if (domainStart > dataValue || domainEnd < dataValue) { + return; + } + + const yDomainPosition = scaledYValue; + + const leftHorizontalAxis: AnnotationLinePosition = + [0 - lineOverflow, yDomainPosition, chartWidth, yDomainPosition]; + const rightHorizontaAxis: AnnotationLinePosition = + [0, yDomainPosition, chartWidth + lineOverflow, yDomainPosition]; + + // Without overflow applied + const baseLinePosition: AnnotationLinePosition = isHorizontalChartRotation ? + [0, yDomainPosition, chartWidth, yDomainPosition] + : [yDomainPosition, 0, yDomainPosition, chartHeight]; + + const linePosition: AnnotationLinePosition = isHorizontalChartRotation ? + (axisPosition === Position.Left) ? leftHorizontalAxis : rightHorizontaAxis + : [yDomainPosition, 0, yDomainPosition, chartHeight + lineOverflow]; + + const markerPosition = [...linePosition] as AnnotationLinePosition; + + if (isHorizontalChartRotation) { + if (axisPosition === Position.Left) { + markerPosition[0] -= markerOffsets.width; + } else { + markerPosition[2] += markerOffsets.width; + } + } else { + markerPosition[3] += markerOffsets.height; + } + + const markerTransform = getAnnotationLineTooltipTransform(chartRotation, markerPosition, axisPosition); + const annotationMarker = marker ? + { icon: marker, transform: markerTransform, color: lineColor, dimensions: markerOffsets } + : undefined; + const lineProp = { + position: linePosition, + details, + marker: annotationMarker, + tooltipLinePosition: baseLinePosition, + }; + + lineProps.push(lineProp); + }); + + return lineProps; +} + +export function computeXDomainLineAnnotationDimensions( + dataValues: AnnotationDatum[], + xScale: Scale, + chartRotation: Rotation, + lineOverflow: number, + axisPosition: Position, + chartDimensions: Dimensions, + lineColor: string, + marker?: JSX.Element, + markerDimensions?: { width: number; height: number; }, +): AnnotationLineProps[] { + const chartHeight = chartDimensions.height; + const chartWidth = chartDimensions.width; + const markerOffsets = markerDimensions || { width: 0, height: 0 }; + const lineProps: AnnotationLineProps[] = []; + + dataValues.forEach((datum: AnnotationDatum) => { + const { dataValue } = datum; + const details = { + detailsText: datum.details, + headerText: datum.header || dataValue.toString(), + }; + + // TODO: make offset dependent on annotationSpec.alignment (left, center, right) + const offset = xScale.bandwidth / 2; + const isContinuous = xScale.type !== ScaleType.Ordinal; + + const scaledXValue = xScale.scale(dataValue); + + // d3.scale will return 0 for '', rendering the line incorrectly at 0 + if (isNaN(scaledXValue) || (isContinuous && dataValue === '')) { + return; + } + + if (isContinuous) { + const [domainStart, domainEnd] = xScale.domain; + + if (domainStart > dataValue || domainEnd < dataValue) { + return; + } + } + + const xDomainPosition = scaledXValue + offset; + + let linePosition: AnnotationLinePosition = [0, 0, 0, 0]; + let tooltipLinePosition: AnnotationLinePosition = [0, 0, 0, 0]; + let markerPosition: AnnotationLinePosition = [0, 0, 0, 0]; + + switch (chartRotation) { + case 0: { + const startY = (axisPosition === Position.Bottom) ? 0 : -lineOverflow; + const endY = (axisPosition === Position.Bottom) ? chartHeight + lineOverflow : chartHeight; + linePosition = [xDomainPosition, startY, xDomainPosition, endY]; + tooltipLinePosition = [xDomainPosition, 0, xDomainPosition, chartHeight]; + + const startMarkerY = (axisPosition === Position.Bottom) ? 0 : -lineOverflow - markerOffsets.height; + const endMarkerY = (axisPosition === Position.Bottom) ? + chartHeight + lineOverflow + markerOffsets.height : chartHeight; + markerPosition = [xDomainPosition, startMarkerY, xDomainPosition, endMarkerY]; + break; + } + case 90: { + linePosition = [-lineOverflow, xDomainPosition, chartWidth, xDomainPosition]; + tooltipLinePosition = [0, xDomainPosition, chartWidth, xDomainPosition]; + + const markerStartX = linePosition[0] - markerOffsets.width; + markerPosition = [markerStartX, xDomainPosition, chartWidth, xDomainPosition]; + break; + } + case -90: { + linePosition = [-lineOverflow, chartHeight - xDomainPosition, chartWidth, chartHeight - xDomainPosition]; + tooltipLinePosition = [0, chartHeight - xDomainPosition, chartWidth, chartHeight - xDomainPosition]; + + const markerStartX = linePosition[0] - markerOffsets.width; + markerPosition = [markerStartX, chartHeight - xDomainPosition, chartWidth, chartHeight - xDomainPosition]; + break; + } + case 180: { + const startY = (axisPosition === Position.Bottom) ? 0 : -lineOverflow; + const endY = (axisPosition === Position.Bottom) ? chartHeight + lineOverflow : chartHeight; + linePosition = [chartWidth - xDomainPosition, startY, chartWidth - xDomainPosition, endY]; + tooltipLinePosition = [chartWidth - xDomainPosition, 0, chartWidth - xDomainPosition, chartHeight]; + + const startMarkerY = (axisPosition === Position.Bottom) ? 0 : -lineOverflow - markerOffsets.height; + const endMarkerY = (axisPosition === Position.Bottom) ? + chartHeight + lineOverflow + markerOffsets.height : chartHeight; + markerPosition = [chartWidth - xDomainPosition, startMarkerY, chartWidth - xDomainPosition, endMarkerY]; + break; + } + } + + const markerTransform = getAnnotationLineTooltipTransform(chartRotation, markerPosition, axisPosition); + const annotationMarker = marker ? + { icon: marker, transform: markerTransform, color: lineColor, dimensions: markerOffsets } + : undefined; + const lineProp = { position: linePosition, details, marker: annotationMarker, tooltipLinePosition }; + lineProps.push(lineProp); + }); + + return lineProps; +} + +export function computeLineAnnotationDimensions( + annotationSpec: AnnotationSpec, + chartDimensions: Dimensions, + chartRotation: Rotation, + yScales: Map, + xScale: Scale, + axisPosition: Position, +): AnnotationLineProps[] | null { + const { domainType, dataValues, marker, markerDimensions } = annotationSpec; + + // TODO : make line overflow configurable via prop + const lineOverflow = DEFAULT_LINE_OVERFLOW; + + // this type is guaranteed as this has been merged with default + const lineStyle = annotationSpec.style as AnnotationLineStyle; + const lineColor = lineStyle.line.stroke; + + if (domainType === AnnotationDomainTypes.XDomain) { + return computeXDomainLineAnnotationDimensions( + dataValues, + xScale, + chartRotation, + lineOverflow, + axisPosition, + chartDimensions, + lineColor, + marker, + markerDimensions, + ); + } + + const groupId = annotationSpec.groupId; + const yScale = yScales.get(groupId); + if (!yScale) { + return null; + } + + return computeYDomainLineAnnotationDimensions( + dataValues, + yScale, + chartRotation, + lineOverflow, + axisPosition, + chartDimensions, + lineColor, + marker, + markerDimensions, + ); +} + +export function getAnnotationAxis( + axesSpecs: Map, + groupId: GroupId, + domainType: AnnotationDomainType, +): Position | null { + const { xAxis, yAxis } = getAxesSpecForSpecId(axesSpecs, groupId); + + const isXDomainAnnotation = isXDomain(domainType); + const annotationAxis = isXDomainAnnotation ? xAxis : yAxis; + + return annotationAxis ? annotationAxis.position : null; +} + +export function computeAnnotationDimensions( + annotations: Map, + chartDimensions: Dimensions, + chartRotation: Rotation, + yScales: Map, + xScale: Scale, + axesSpecs: Map, +): Map { + const annotationDimensions = new Map(); + + annotations.forEach((annotationSpec: AnnotationSpec, annotationId: AnnotationId) => { + switch (annotationSpec.annotationType) { + case AnnotationTypes.Line: + const { groupId, domainType } = annotationSpec; + const annotationAxisPosition = getAnnotationAxis(axesSpecs, groupId, domainType); + + if (!annotationAxisPosition) { + return; + } + + const dimensions = computeLineAnnotationDimensions( + annotationSpec, + chartDimensions, + chartRotation, + yScales, + xScale, + annotationAxisPosition, + ); + + if (dimensions) { + annotationDimensions.set(annotationId, dimensions); + } + break; + } + }); + + return annotationDimensions; +} + +export function isWithinLineBounds( + axisPosition: Position, + linePosition: AnnotationLinePosition, + cursorPosition: Point, + offset: number, + chartRotation: Rotation, + domainType: AnnotationDomainType, + marker?: AnnotationMarker, +): boolean { + const [startX, startY, endX, endY] = linePosition; + const isXDomainAnnotation = isXDomain(domainType); + + let isCursorWithinXBounds = false; + let isCursorWithinYBounds = false; + + const isHorizontalChartRotation = isHorizontalRotation(chartRotation); + + if (isXDomainAnnotation) { + isCursorWithinXBounds = isHorizontalChartRotation ? + cursorPosition.x >= startX - offset && cursorPosition.x <= endX + offset + : cursorPosition.x >= startX && cursorPosition.x <= endX; + isCursorWithinYBounds = isHorizontalChartRotation ? + cursorPosition.y >= startY && cursorPosition.y <= endY + : cursorPosition.y >= startY - offset && cursorPosition.y <= endY + offset; + } else { + isCursorWithinXBounds = isHorizontalChartRotation ? + cursorPosition.x >= startX && cursorPosition.x <= endX + : cursorPosition.x >= startX - offset && cursorPosition.x <= endX + offset; + isCursorWithinYBounds = isHorizontalChartRotation ? + cursorPosition.y >= startY - offset && cursorPosition.y <= endY + offset + : cursorPosition.y >= startY && cursorPosition.y <= endY; + } + + // If it's within cursor bounds, return true (no need to check marker bounds) + if (isCursorWithinXBounds && isCursorWithinYBounds) { + return true; + } + + if (!marker) { + return false; + } + + // Check if cursor within marker bounds + let isCursorWithinMarkerXBounds = false; + let isCursorWithinMarkerYBounds = false; + + const markerWidth = marker.dimensions.width; + const markerHeight = marker.dimensions.height; + + if (isXDomainAnnotation) { + const bottomAxisYBounds = + cursorPosition.y <= endY + markerHeight && cursorPosition.y >= endY; + + const topAxisYBounds = + cursorPosition.y >= startY - markerHeight && cursorPosition.y <= startY; + + isCursorWithinMarkerXBounds = isHorizontalChartRotation ? + cursorPosition.x <= endX + offset + markerWidth / 2 && cursorPosition.x >= startX - offset - markerWidth / 2 + : cursorPosition.x >= startX - markerWidth && cursorPosition.x <= startX; + isCursorWithinMarkerYBounds = isHorizontalChartRotation ? + (axisPosition === Position.Top ? topAxisYBounds : bottomAxisYBounds) + : cursorPosition.y >= startY - offset - markerHeight / 2 && cursorPosition.y <= endY + offset + markerHeight / 2; + } else { + const leftAxisXBounds = + cursorPosition.x >= startX - markerWidth && cursorPosition.x <= startX; + + const rightAxisXBounds = + cursorPosition.x <= endX + markerWidth && cursorPosition.x >= endX; + + isCursorWithinMarkerXBounds = isHorizontalChartRotation ? + (axisPosition === Position.Right ? rightAxisXBounds : leftAxisXBounds) + : cursorPosition.x <= endX + offset + markerWidth / 2 && cursorPosition.x >= startX - offset - markerWidth / 2; + isCursorWithinMarkerYBounds = isHorizontalChartRotation ? + cursorPosition.y >= startY - offset - markerHeight / 2 && cursorPosition.y <= endY + offset + markerHeight / 2 + : cursorPosition.y <= endY + markerHeight && cursorPosition.y >= endY; + } + + return isCursorWithinMarkerXBounds && isCursorWithinMarkerYBounds; +} + +export function isVerticalAnnotationLine( + isXDomainAnnotation: boolean, + isHorizontalChartRotation: boolean, +): boolean { + if (isXDomainAnnotation) { + return isHorizontalChartRotation; + } + + return !isHorizontalChartRotation; +} + +export function getAnnotationLineTooltipXOffset( + chartRotation: Rotation, + axisPosition: Position, +): number { + let xOffset = 0; + + const isHorizontalAxis = isHorizontal(axisPosition); + const isChartHorizontalRotation = isHorizontalRotation(chartRotation); + + if (isHorizontalAxis) { + xOffset = isChartHorizontalRotation ? 50 : 0; + } else { + xOffset = isChartHorizontalRotation ? (axisPosition === Position.Right ? 100 : 0) : 50; + } + + return xOffset; +} + +export function getAnnotationLineTooltipYOffset( + chartRotation: Rotation, + axisPosition: Position, +): number { + let yOffset = 0; + + const isHorizontalAxis = isHorizontal(axisPosition); + const isChartHorizontalRotation = isHorizontalRotation(chartRotation); + + if (isHorizontalAxis) { + yOffset = isChartHorizontalRotation ? (axisPosition === Position.Top ? 0 : 100) : 50; + } else { + yOffset = isChartHorizontalRotation ? 50 : 100; + } + + return yOffset; +} + +export function getAnnotationLineTooltipPosition( + chartRotation: Rotation, + linePosition: AnnotationLinePosition, + axisPosition: Position, +): TransformPosition { + const [startX, startY, endX, endY] = linePosition; + + const xPosition = (axisPosition === Position.Right) ? endX : startX; + const yPosition = (axisPosition === Position.Top) ? startY : endY; + + const xOffset = getAnnotationLineTooltipXOffset(chartRotation, axisPosition); + const yOffset = getAnnotationLineTooltipYOffset(chartRotation, axisPosition); + + return { xPosition, yPosition, xOffset, yOffset }; +} + +export function toTransformString(position: TransformPosition): string { + const { xPosition, yPosition, xOffset, yOffset } = position; + + const xTranslation = `calc(${xPosition}px - ${xOffset}%)`; + const yTranslation = `calc(${yPosition}px - ${yOffset}%)`; + + return `translate(${xTranslation},${yTranslation})`; +} + +export function getAnnotationLineTooltipTransform( + chartRotation: Rotation, + linePosition: AnnotationLinePosition, + axisPosition: Position, +): string { + const position = getAnnotationLineTooltipPosition( + chartRotation, + linePosition, + axisPosition, + ); + + return toTransformString(position); +} + +export function isXDomain(domainType: AnnotationDomainType): boolean { + return domainType === AnnotationDomainTypes.XDomain; +} + +export function computeLineAnnotationTooltipState( + cursorPosition: Point, + annotationLines: AnnotationLineProps[], + groupId: GroupId, + domainType: AnnotationDomainType, + style: AnnotationLineStyle, + chartRotation: Rotation, + axesSpecs: Map, +): AnnotationTooltipState { + + const annotationTooltipState: AnnotationTooltipState = { + isVisible: false, + transform: '', + }; + + const { xAxis, yAxis } = getAxesSpecForSpecId(axesSpecs, groupId); + const isXDomainAnnotation = isXDomain(domainType); + const annotationAxis = isXDomainAnnotation ? xAxis : yAxis; + + if (!annotationAxis) { + return annotationTooltipState; + } + + const axisPosition = annotationAxis.position; + + annotationLines.forEach((line: AnnotationLineProps) => { + const lineOffset = style.line.strokeWidth / 2; + const isWithinBounds = isWithinLineBounds( + axisPosition, + line.position, + cursorPosition, + lineOffset, + chartRotation, + domainType, + line.marker, + ); + + if (isWithinBounds) { + annotationTooltipState.isVisible = true; + + // Position tooltip based on axis position & lineOffset amount + annotationTooltipState.transform = getAnnotationLineTooltipTransform( + chartRotation, + line.tooltipLinePosition, + axisPosition, + ); + + if (line.details) { + annotationTooltipState.header = line.details.headerText; + annotationTooltipState.details = line.details.detailsText; + } + } + }); + + return annotationTooltipState; +} + +export function computeAnnotationTooltipState( + cursorPosition: Point, + annotationDimensions: Map, + annotationSpecs: Map, + chartRotation: Rotation, + axesSpecs: Map, +): AnnotationTooltipState | null { + for (const [annotationId, annotationDimension] of annotationDimensions) { + const spec = annotationSpecs.get(annotationId); + if (!spec) { + continue; + } + + const { annotationType } = spec; + switch (annotationType) { + case AnnotationTypes.Line: { + const groupId = spec.groupId; + const lineAnnotationTooltipState = computeLineAnnotationTooltipState( + cursorPosition, + annotationDimension, + groupId, + spec.domainType, + spec.style as AnnotationLineStyle, // this type is guaranteed as this has been merged with default + chartRotation, + axesSpecs, + ); + + if (lineAnnotationTooltipState.isVisible) { + return lineAnnotationTooltipState; + } + } + } + } + + return null; +} diff --git a/src/state/chart_state.test.ts b/src/state/chart_state.test.ts index 5fb1cf9118..ffb1e7a00c 100644 --- a/src/state/chart_state.test.ts +++ b/src/state/chart_state.test.ts @@ -1,7 +1,14 @@ import { GeometryValue, IndexedGeometry } from '../lib/series/rendering'; import { DataSeriesColorsValues } from '../lib/series/series'; -import { AxisSpec, BarSeriesSpec, Position } from '../lib/series/specs'; -import { getAxisId, getGroupId, getSpecId } from '../lib/utils/ids'; +import { + AnnotationDomainTypes, + AnnotationSpec, + AnnotationTypes, + AxisSpec, + BarSeriesSpec, + Position, +} from '../lib/series/specs'; +import { getAnnotationId, getAxisId, getGroupId, getSpecId } from '../lib/utils/ids'; import { TooltipType, TooltipValue } from '../lib/utils/interactions'; import { ScaleBand } from '../lib/utils/scales/scale_band'; import { ScaleContinuous } from '../lib/utils/scales/scale_continuous'; @@ -418,6 +425,45 @@ describe('Chart Store', () => { expect(store.axesSpecs.get(AXIS_ID)).toBe(undefined); }); + test('can add and remove an annotation spec', () => { + const annotationId = getAnnotationId('annotation'); + const groupId = getGroupId('group'); + + const customStyle = { + line: { + strokeWidth: 30, + stroke: '#f00000', + opacity: 0.32, + }, + details: { + fontSize: 90, + fontFamily: 'custom-font', + fontStyle: 'custom-style', + fill: 'custom-color', + padding: 20, + }, + }; + + const lineAnnotation: AnnotationSpec = { + annotationType: AnnotationTypes.Line, + annotationId, + domainType: AnnotationDomainTypes.YDomain, + dataValues: [{ dataValue: 2, details: 'foo' }], + groupId, + style: customStyle, + }; + + store.addAnnotationSpec(lineAnnotation); + + const expectedAnnotationSpecs = new Map(); + expectedAnnotationSpecs.set(annotationId, lineAnnotation); + + expect(store.annotationSpecs).toEqual(expectedAnnotationSpecs); + + store.removeAnnotationSpec(annotationId); + expect(store.annotationSpecs).toEqual(new Map()); + }); + test('only computes chart if parent dimensions are computed', () => { const localStore = new ChartStore(); @@ -627,4 +673,26 @@ describe('Chart Store', () => { expect(clickListener).toBeCalledTimes(2); expect(clickListener.mock.calls[1][0]).toEqual([geom1, geom2]); }); + test('can compute annotation tooltip state', () => { + const scale = new ScaleContinuous([0, 100], [0, 100], ScaleType.Linear); + + store.cursorPosition.x = -1; + store.cursorPosition.y = 0; + + expect(store.annotationTooltipState.get()).toBe(null); + + store.xScale = undefined; + expect(store.annotationTooltipState.get()).toBe(null); + + store.xScale = scale; + + store.yScales = undefined; + expect(store.annotationTooltipState.get()).toBe(null); + + store.yScales = new Map(); + store.yScales.set(GROUP_ID, scale); + + store.cursorPosition.x = 0; + expect(store.annotationTooltipState.get()).toBe(null); + }); }); diff --git a/src/state/chart_state.ts b/src/state/chart_state.ts index 9883f79eaf..537f78732c 100644 --- a/src/state/chart_state.ts +++ b/src/state/chart_state.ts @@ -28,6 +28,7 @@ import { RawDataSeries, } from '../lib/series/series'; import { + AnnotationSpec, AreaSeriesSpec, AxisSpec, BarSeriesSpec, @@ -40,10 +41,10 @@ import { } from '../lib/series/specs'; import { formatTooltip, formatXTooltipValue } from '../lib/series/tooltip'; import { LIGHT_THEME } from '../lib/themes/light_theme'; -import { Theme } from '../lib/themes/theme'; +import { mergeWithDefaultAnnotationLine, Theme } from '../lib/themes/theme'; import { computeChartDimensions, Dimensions } from '../lib/utils/dimensions'; import { Domain } from '../lib/utils/domain'; -import { AxisId, GroupId, SpecId } from '../lib/utils/ids'; +import { AnnotationId, AxisId, GroupId, SpecId } from '../lib/utils/ids'; import { areIndexedGeometryArraysEquals, getValidXPosition, @@ -55,6 +56,11 @@ import { } from '../lib/utils/interactions'; import { Scale, ScaleType } from '../lib/utils/scales/scales'; import { DEFAULT_TOOLTIP_SNAP, DEFAULT_TOOLTIP_TYPE } from '../specs/settings'; +import { + AnnotationDimensions, + computeAnnotationDimensions, + computeAnnotationTooltipState, +} from './annotation_utils'; import { getCursorBandPosition, getCursorLinePosition, @@ -134,6 +140,10 @@ export class ChartStore { axesTicks: Map = new Map(); // computed axesGridLinesPositions: Map = new Map(); // computed + annotationSpecs = new Map(); // read from jsx + + annotationDimensions = observable.map(new Map()); + seriesSpecs: Map = new Map(); // readed from jsx seriesDomainsAndData?: SeriesDomainsAndData; // computed @@ -154,6 +164,11 @@ export class ChartStore { tooltipSnap = observable.box(DEFAULT_TOOLTIP_SNAP); tooltipPosition = observable.object<{ transform: string }>({ transform: '' }); + /** cursorPosition is used by tooltip, so this is a way to expose the position for other uses */ + rawCursorPosition = observable.object<{ x: number; y: number }>({ x: -1, y: -1 }, undefined, { + deep: false, + }); + /** position of the cursor relative to the chart */ cursorPosition = observable.object<{ x: number; y: number }>({ x: -1, y: -1 }, undefined, { deep: false, @@ -213,6 +228,9 @@ export class ChartStore { * x and y values are relative to the container. */ setCursorPosition = action((x: number, y: number) => { + this.rawCursorPosition.x = x; + this.rawCursorPosition.y = y; + if (!this.seriesDomainsAndData || this.tooltipType.get() === TooltipType.None) { return; } @@ -383,6 +401,30 @@ export class ChartStore { } }); + annotationTooltipState = computed(() => { + // get positions relative to chart + const xPos = this.rawCursorPosition.x - this.chartDimensions.left; + const yPos = this.rawCursorPosition.y - this.chartDimensions.top; + + // only if we have a valid cursor position and the necessary scale + if (!this.xScale || !this.yScales) { + return null; + } + + const cursorPosition = { + x: xPos, + y: yPos, + }; + + return computeAnnotationTooltipState( + cursorPosition, + this.annotationDimensions, + this.annotationSpecs, + this.chartRotation, + this.axesSpecs, + ); + }); + isTooltipVisible = computed(() => { return ( !this.isBrushing.get() && @@ -654,6 +696,19 @@ export class ChartStore { this.axesSpecs.delete(axisId); } + addAnnotationSpec(annotationSpec: AnnotationSpec) { + const { style } = annotationSpec; + + // TODO: will need to check for annotationType when we introduce other types + const mergedLineStyle = mergeWithDefaultAnnotationLine(style); + annotationSpec.style = mergedLineStyle; + this.annotationSpecs.set(annotationSpec.annotationId, annotationSpec); + } + + removeAnnotationSpec(annotationId: AnnotationId) { + this.annotationSpecs.delete(annotationId); + } + computeChart() { this.initialized.set(false); // compute only if parent dimensions are computed @@ -793,6 +848,18 @@ export class ChartStore { this.axesVisibleTicks = axisTicksPositions.axisVisibleTicks; this.axesGridLinesPositions = axisTicksPositions.axisGridLinesPositions; + // annotation computations + const updatedAnnotationDimensions = computeAnnotationDimensions( + this.annotationSpecs, + this.chartDimensions, + this.chartRotation, + this.yScales, + this.xScale, + this.axesSpecs, + ); + + this.annotationDimensions.replace(updatedAnnotationDimensions); + this.canDataBeAnimated = isChartAnimatable(seriesGeometries.geometriesCounts, this.animateData); this.initialized.set(true); } diff --git a/stories/annotations.tsx b/stories/annotations.tsx new file mode 100644 index 0000000000..8c280df806 --- /dev/null +++ b/stories/annotations.tsx @@ -0,0 +1,292 @@ +import { EuiIcon } from '@elastic/eui'; +import { array, boolean, color, number, select } from '@storybook/addon-knobs'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { + Axis, + BarSeries, + Chart, + getSpecId, + LineAnnotation, + ScaleType, + Settings, + timeFormatter, +} from '../src'; +import { + AnnotationDatum, + AnnotationDomainTypes, + Position, +} from '../src/lib/series/specs'; +import { KIBANA_METRICS } from '../src/lib/series/utils/test_dataset_kibana'; +import { getAnnotationId, getAxisId } from '../src/lib/utils/ids'; + +const dateFormatter = timeFormatter('HH:mm:ss'); + +function generateAnnotationData(values: any[]): AnnotationDatum[] { + return values.map((value, index) => ({ dataValue: value, details: `detail-${index}` })); +} + +function generateTimeAnnotationData(values: any[]): AnnotationDatum[] { + return values.map((value, index) => ({ dataValue: value, details: `detail-${index}`, header: dateFormatter(value) })); +} + +storiesOf('Annotations', module) + .add('basic xDomain continuous', () => { + const data = array('data values', [2.5, 7.2]); + const dataValues = generateAnnotationData(data); + + const style = { + line: { + strokeWidth: 3, + stroke: '#f00', + opacity: 1, + }, + details: { + fontSize: 12, + fontFamily: 'Arial', + fontStyle: 'bold', + fill: 'gray', + padding: 0, + }, + }; + + const chartRotation = select('chartRotation', { + '0 deg': 0, + '90 deg': 90, + '-90 deg': -90, + '180 deg': 180, + }, 0); + + const isBottom = boolean('x domain axis is bottom', true); + const axisPosition = isBottom ? Position.Bottom : Position.Top; + + return ( + + + )} + /> + + + + + ); + }) + .add('basic xDomain ordinal', () => { + const dataValues = generateAnnotationData(array('annotation values', ['a', 'c'])); + + const chartRotation = select('chartRotation', { + '0 deg': 0, + '90 deg': 90, + '-90 deg': -90, + '180 deg': 180, + }, 0); + + return ( + + + )} + /> + + + + + + ); + }) + .add('basic yDomain', () => { + const data = array('data values', [3.5, 7.2]); + const dataValues = generateAnnotationData(data); + + const chartRotation = select('chartRotation', { + '0 deg': 0, + '90 deg': 90, + '-90 deg': -90, + '180 deg': 180, + }, 0); + + const isLeft = boolean('y-domain axis is Position.Left', true); + const axisTitle = isLeft ? 'y-domain axis (left)' : 'y-domain axis (right)'; + const axisPosition = isLeft ? Position.Left : Position.Right; + + return ( + + + )} + /> + + + + + ); + }) + .add('time series', () => { + const dataValues = + generateTimeAnnotationData([1551438150000, 1551438180000, 1551438390000, 1551438450000, 1551438480000]); + + const chartRotation = select('chartRotation', { + '0 deg': 0, + '90 deg': 90, + '-90 deg': -90, + '180 deg': 180, + }, 0); + + return ( + + + )} + /> + + + + + ); + }) + .add('styling', () => { + const data = [2.5, 7.2]; + const dataValues = generateAnnotationData(data); + + const dashWidth = number('dash line width', 1); + const dashGapWidth = number('dash gap width', 0); + + const style = { + line: { + strokeWidth: number('line stroke width', 3), + stroke: color('line & marker color', '#f00'), + dash: [dashWidth, dashGapWidth], + opacity: number('line opacity', 1, { + range: true, + min: 0, + max: 1, + step: 0.1, + }), + }, + }; + + const chartRotation = 0; + + const axisPosition = Position.Bottom; + + const marker = select('marker icon (examples from EUI)', { + alert: 'alert', + asterisk: 'asterisk', + questionInCircle: 'questionInCircle', + }, 'alert'); + + return ( + + + )} + /> + + + + + ); + });