diff --git a/CHANGELOG.md b/CHANGELOG.md index c9abc72155d..9849fbc73b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) +- Added beta version of `EuiXYChart` and associated components ([#309](https://github.com/elastic/eui/pull/309)) + **Bug fixes** - Fixed some IE11 flex box bugs and documented others (modal overflowing, image shrinking, and flex group wrapping) ([#973](https://github.com/elastic/eui/pull/973)) diff --git a/package.json b/package.json index 877eaa5eeab..bc33138dd53 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "react-datepicker": "v1.4.1", "react-input-autosize": "^2.2.1", "react-virtualized": "^9.18.5", + "react-vis": "^1.10.1", + "serve": "^6.3.1", "tabbable": "^1.1.0", "uuid": "^3.1.0" }, diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index f32ea419b91..ecccf285268 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -210,6 +210,24 @@ import { ToolTipExample } import { ToggleExample } from './views/toggle/toggle_example'; +import { XYChartExample } + from './views/xy_chart/xy_chart_example'; + +import { XYChartAxisExample } + from './views/xy_chart_axis/xy_axis_example'; + +import { XYChartBarExample } + from './views/xy_chart_bar/bar_example'; + +import { XYChartHistogramExample } + from './views/xy_chart_histogram/histogram_example'; + +import { XYChartAreaExample } + from './views/xy_chart_area/area_example'; + +import { XYChartLineExample } + from './views/xy_chart_line/line_example'; + import { Changelog } from './views/package/changelog'; @@ -340,7 +358,19 @@ const navigation = [{ FilterGroupExample, SearchBarExample, ].map(example => createExample(example)), -}, { +}, +{ + name: 'XY Charts (Beta)', + items: [ + XYChartExample, + XYChartAxisExample, + XYChartLineExample, + XYChartAreaExample, + XYChartBarExample, + XYChartHistogramExample, + ].map(example => createExample(example)), +}, +{ name: 'Utilities', items: [ AccessibilityExample, diff --git a/src-docs/src/views/xy_chart/complex.js b/src-docs/src/views/xy_chart/complex.js new file mode 100644 index 00000000000..a85bafe87d2 --- /dev/null +++ b/src-docs/src/views/xy_chart/complex.js @@ -0,0 +1,87 @@ +import React, { Fragment, Component } from 'react'; + +import { + EuiText, + EuiCodeBlock, + EuiSpacer, + EuiXYChart, + EuiBarSeries, + EuiAreaSeries, + EuiLineSeries, +} from '../../../../src/components'; + +const barSeries = []; +for (let i = 0; i < 2; i++) { + const data = new Array(20).fill(0).map((d, i) => ({ x: i, y: Number((Math.random() * 4).toFixed(2)) })); + barSeries.push(data); +} +const lineData = new Array(20).fill(0).map((d, i) => ({ x: i, y: Number((Math.random() * 4).toFixed(2)) })); +const areaData = new Array(20).fill(0).map((d, i) => ({ x: i, y: Number((Math.random() * 4).toFixed(2)) })); + +export default class ComplexDemo extends Component { + state = { + json: 'Please drag your mouse to select an area or click on an element' + } + handleSelectionBrushEnd = (area) => { + this.setState(() => ({ + eventName: 'onSelectionBrushEnd', + json: JSON.stringify(area, null, 2), + })); + } + handleOnValueClick = (data) => { + this.setState(() => ({ + eventName: 'onValueClick', + json: JSON.stringify(data, null, 2), + })); + } + handleOnSeriesClick = (series) => () => { + this.setState(() => ({ + eventName: 'onSeriesClick', + json: JSON.stringify({ name: series }), + })); + } + render() { + const { eventName, json } = this.state; + return ( + + + + {barSeries + .map((data, index) => ( + + ))} + + + + { eventName && ( + +

Event: { eventName }

+
+ )} + + { json } + +
+ ); + } +} diff --git a/src-docs/src/views/xy_chart/crosshair_sync.js b/src-docs/src/views/xy_chart/crosshair_sync.js new file mode 100644 index 00000000000..445b7bf7bf0 --- /dev/null +++ b/src-docs/src/views/xy_chart/crosshair_sync.js @@ -0,0 +1,42 @@ +import React from 'react'; + +import { EuiSpacer, EuiXYChart, EuiBarSeries } from '../../../../src/components'; + +// eslint-disable-next-line +export class ExampleCrosshair extends React.Component { + state = { + crosshairValue: 2, + }; + _updateCrosshairLocation = crosshairValue => { + this.setState({ crosshairValue }); + }; + render() { + return ( +
+ + + + + + + +
+ ); + } +} diff --git a/src-docs/src/views/xy_chart/empty.js b/src-docs/src/views/xy_chart/empty.js new file mode 100644 index 00000000000..e9bde5dc9f8 --- /dev/null +++ b/src-docs/src/views/xy_chart/empty.js @@ -0,0 +1,5 @@ +import React from 'react'; + +import { EuiXYChart } from '../../../../src/components'; + +export default () => ; diff --git a/src-docs/src/views/xy_chart/horizontal.js b/src-docs/src/views/xy_chart/horizontal.js new file mode 100644 index 00000000000..baca430ac0b --- /dev/null +++ b/src-docs/src/views/xy_chart/horizontal.js @@ -0,0 +1,39 @@ +import React from 'react'; + +import { + EuiXYChart, + EuiAreaSeries, + EuiLineSeries, + EuiXYChartUtils, +} from '../../../../src/components'; +const { ORIENTATION } = EuiXYChartUtils; + +const data = new Array(80).fill(0).map((d, i) => { + const data = { + y: i, + y0: i, + x: Number((Math.random() * 4 + 4).toFixed(2)), + x0: 0, + }; + return data; +}); + +export default function () { + return ( + + + + + ); +} diff --git a/src-docs/src/views/xy_chart/multi_axis.js b/src-docs/src/views/xy_chart/multi_axis.js new file mode 100644 index 00000000000..e8bdb688cd8 --- /dev/null +++ b/src-docs/src/views/xy_chart/multi_axis.js @@ -0,0 +1,75 @@ +import React from 'react'; + +import { + EuiXYChart, + EuiLineSeries, + EuiXAxis, + EuiYAxis, + EuiXYChartAxisUtils, +} from '../../../../src/components'; +import { VISUALIZATION_COLORS } from '../../../../src/services'; + + + +const DATA_A = [{ x: 'A', y: 0 }, { x: 'B', y: 1 }, { x: 'C', y: 2 }, { x: 'D', y: 1 }, { x: 'E', y: 2 }]; +const DATA_B = [{ x: 'A', y: 100 }, { x: 'B', y: 100 }, { x: 'C', y: 150 }, { x: 'D', y: 55 }, { x: 'E', y: 95 }]; +const DATA_C = [{ x: 'A', y: 30 }, { x: 'B', y: 45 }, { x: 'C', y: 67 }, { x: 'D', y: 22 }, { x: 'E', y: 44 }]; + +const DATA_A_DOMAIN = [-0.5, 3]; +const DATA_B_DOMAIN = [0, 200]; +const DATA_C_DOMAIN = [15, 80]; + +export default () => ( + + + + + + + + + +); diff --git a/src-docs/src/views/xy_chart/xy_chart_example.js b/src-docs/src/views/xy_chart/xy_chart_example.js new file mode 100644 index 00000000000..f6805db4932 --- /dev/null +++ b/src-docs/src/views/xy_chart/xy_chart_example.js @@ -0,0 +1,153 @@ +import React, { Fragment } from 'react'; +import { GuideSectionTypes } from '../../components'; +import { EuiCode, EuiXYChart, EuiCallOut, EuiSpacer } from '../../../../src/components'; +import ComplexChartExampleCode from './complex'; +import EmptyExampleCode from './empty'; +import MultiAxisChartExampleCode from './multi_axis'; +import { ExampleCrosshair } from './crosshair_sync'; + +export const XYChartExample = { + title: 'General', + intro: ( + + +

+ This component is still in Beta. We consider it to be reasonably stable, and welcome you to implement it, + but please be aware that breaking changes can come at any time with this component as such changes on beta + components does not necessitate a major version bump. +

+
+ + +
+ ), + sections: [ + { + title: 'Complex example', + text: ( +
+

+ Use EuiXYChart to display line, bar, area, and stream charts. Note + that charts are composed with EuiLineSeries, EuiAreaSeries,{' '} + EuiBar, and EuiStream being child components. +

+
+ ), + props: { EuiXYChart }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./complex'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Empty Chart', + text: ( +
+

When no data is provided to EuiXYChart, an empty state is displayed

+
+ ), + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./empty'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Keep cross-hair in sync', + text: ( +
+

+ When displayed side-by-side with other charts, we need to be able to keep them in sync +

+
+ ), + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./crosshair_sync'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Multi Axis', + text: ( +
+

If just displaying values is enough, then you can let the chart auto label axis

+
+ ), + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./multi_axis'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + // TODO include the following example when AreasSeries PR (create vertical areachart) + // will be merged into react-vis and orientation prop semantic will be solved. + // { + // title: 'Horizontal chart', + // text: ( + //
+ //

If just displaying values is enough, then you can let the chart auto label axis

+ //
+ // ), + // source: [ + // { + // type: GuideSectionTypes.JS, + // code: require('!!raw-loader!./horizontal'), + // }, + // { + // type: GuideSectionTypes.HTML, + // code: 'This component can only be used from React', + // }, + // ], + // demo: ( + //
+ // + //
+ // ), + // }, + ], +}; diff --git a/src-docs/src/views/xy_chart_area/area.js b/src-docs/src/views/xy_chart_area/area.js new file mode 100644 index 00000000000..2296360091d --- /dev/null +++ b/src-docs/src/views/xy_chart_area/area.js @@ -0,0 +1,11 @@ +import React from 'react'; + +import { EuiXYChart, EuiAreaSeries } from '../../../../src/components'; + +const DATA_A = [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 1 }, { x: 5, y: 2 }]; + +export default () => ( + + + +); diff --git a/src-docs/src/views/xy_chart_area/area_example.js b/src-docs/src/views/xy_chart_area/area_example.js new file mode 100644 index 00000000000..707484a1687 --- /dev/null +++ b/src-docs/src/views/xy_chart_area/area_example.js @@ -0,0 +1,138 @@ +import React, { Fragment } from 'react'; +import { GuideSectionTypes } from '../../components'; +import AreaSeriesExample from './area'; +import StackedAreaSeriesExample from './stacked_area'; +import CurvedAreaExample from './curved_area'; +import RangeAreaExample from './range_area'; +import { EuiCode, EuiAreaSeries, EuiLink, EuiCallOut, EuiSpacer } from '../../../../src/components'; + +export const XYChartAreaExample = { + title: 'Area chart', + intro: ( + + +

+ This component is still in Beta. We consider it to be reasonably stable, and welcome you to implement it, + but please be aware that breaking changes can come at any time with this component as such changes on beta + components does not necessitate a major version bump. +

+
+ + +
+ ), + sections: [ + { + title: 'Area Series', + text: ( +
+

+ Use EuiAreaSeries to display area charts. +

+
+ ), + props: { EuiAreaSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./area'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Stacked Area Series', + text: ( +
+

+ Use multiple EuiAreaSeries to display stacked area charts specifying the{' '} + stackBy:y prop on the EuiXYChart + to enable stacking. +

+
+ ), + props: { EuiAreaSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./stacked_area'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Curved Area Series', + text: ( +
+

+ Use the curve prop to change the curve representation. Visit{' '} + + d3-shape#curves + + for available values (the bundle curve does not work with area chart). +

+
+ ), + props: { EuiAreaSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./curved_area'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Range area chart', + text: ( +

+ Each point in the chart is specified by two y values y0 (lower value) and + y (upper value) to display a range area chart. +

+ ), + props: { EuiAreaSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./range_area'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + ], +}; diff --git a/src-docs/src/views/xy_chart_area/curved_area.js b/src-docs/src/views/xy_chart_area/curved_area.js new file mode 100644 index 00000000000..dd6d439dd2d --- /dev/null +++ b/src-docs/src/views/xy_chart_area/curved_area.js @@ -0,0 +1,61 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiForm, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiXYChart, + EuiAreaSeries, +} from '../../../../src/components'; + +const DATA_A = [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: -1 }, { x: 5, y: 2 }]; +const DATA_B = [{ x: 0, y: 3 }, { x: 1, y: 2 }, { x: 2, y: 4 }, { x: 3, y: 1 }, { x: 5, y: 3 }]; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [ + { value: 'linear', text: 'Linear' }, + { value: 'curveCardinal', text: 'Curve Cardinal' }, + { value: 'curveNatural', text: 'Curve Natural' }, + { value: 'curveMonotoneX', text: 'Curve Monotone X' }, + { value: 'curveMonotoneY', text: 'Curve Monotone Y' }, + { value: 'curveBasis', text: 'Curve Basis' }, + { value: 'curveCatmullRom', text: 'Curve Catmull Rom' }, + { value: 'curveStep', text: 'Curve Step' }, + { value: 'curveStepAfter', text: 'Curve Step After' }, + { value: 'curveStepBefore', text: 'Curve Step Before' }, + ]; + + this.state = { + value: this.options[0].value, + }; + } + + onChange = e => { + this.setState({ + value: e.target.value, + }); + }; + + render() { + return ( + + + + + + + + + + + + + + + ); + } +} diff --git a/src-docs/src/views/xy_chart_area/range_area.js b/src-docs/src/views/xy_chart_area/range_area.js new file mode 100644 index 00000000000..04c01037014 --- /dev/null +++ b/src-docs/src/views/xy_chart_area/range_area.js @@ -0,0 +1,13 @@ +import React from 'react'; + +import { EuiXYChart, EuiAreaSeries, EuiLineSeries } from '../../../../src/components'; + +const LINE_DATA = new Array(100).fill(0).map((d, i) => ({ x: i, y: Math.random() * 2 + 8 })); +const AREA_DATA = LINE_DATA.map(({ x, y })=> ({ x, y0: y - Math.random() - 2, y: y + Math.random() + 2 })); + +export default () => ( + + + + +); diff --git a/src-docs/src/views/xy_chart_area/stacked_area.js b/src-docs/src/views/xy_chart_area/stacked_area.js new file mode 100644 index 00000000000..51286d6ca67 --- /dev/null +++ b/src-docs/src/views/xy_chart_area/stacked_area.js @@ -0,0 +1,14 @@ +import React from 'react'; + +import { EuiXYChart, EuiAreaSeries } from '../../../../src/components'; + +const dataA = [{ x: 0, y: 3 }, { x: 1, y: 2 }, { x: 2, y: 1 }, { x: 3, y: 2 }, { x: 4, y: 3 }]; + +const dataB = [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 4 }, { x: 3, y: 1 }, { x: 4, y: 1 }]; + +export default () => ( + + + + +); diff --git a/src-docs/src/views/xy_chart_axis/annotations.js b/src-docs/src/views/xy_chart_axis/annotations.js new file mode 100644 index 00000000000..7dbb66ff211 --- /dev/null +++ b/src-docs/src/views/xy_chart_axis/annotations.js @@ -0,0 +1,51 @@ +import React from 'react'; + +import { + EuiXYChart, + EuiLineSeries, + EuiLineAnnotation, + EuiXYChartUtils, + EuiXYChartAxisUtils, +} from '../../../../src/components'; + +const DATA_A = [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: -1 }, + { x: 4, y: null }, + { x: 5, y: 2 }, +]; + +export default () => ( + + + + + + + + + +); diff --git a/src-docs/src/views/xy_chart_axis/simple_axis.js b/src-docs/src/views/xy_chart_axis/simple_axis.js new file mode 100644 index 00000000000..f9e688f343b --- /dev/null +++ b/src-docs/src/views/xy_chart_axis/simple_axis.js @@ -0,0 +1,39 @@ +import React from 'react'; + +import { + EuiLineSeries, + EuiXAxis, + EuiYAxis, + EuiXYChart, + EuiXYChartAxisUtils, + EuiXYChartTextUtils, +} from '../../../../src/components'; + +const DATA = [{ x: 0, y: 5 }, { x: 1, y: 3 }, { x: 2, y: 2 }, { x: 3, y: 3 }]; + +function xAxisTickFormatter(value) { + return EuiXYChartTextUtils.labelWordWrap(`Axis value is ${value}`, 10); +} + +export default () => ( + + + + + + + +); diff --git a/src-docs/src/views/xy_chart_axis/xy_axis_example.js b/src-docs/src/views/xy_chart_axis/xy_axis_example.js new file mode 100644 index 00000000000..f26a8e251fb --- /dev/null +++ b/src-docs/src/views/xy_chart_axis/xy_axis_example.js @@ -0,0 +1,82 @@ +import React, { Fragment } from 'react'; +import { GuideSectionTypes } from '../../components'; +import { EuiCode, EuiXAxis, EuiYAxis, EuiLineAnnotation, EuiCallOut, EuiSpacer } from '../../../../src/components'; +import SimpleAxisExampleCode from './simple_axis'; +import AnnotationExampleCode from './annotations'; + +export const XYChartAxisExample = { + title: 'Axis', + intro: ( + + +

+ This component is still in Beta. We consider it to be reasonably stable, and welcome you to implement it, + but please be aware that breaking changes can come at any time with this component as such changes on beta + components does not necessitate a major version bump. +

+
+ + +
+ ), + sections: [ + { + title: 'Complex Axis example', + text: ( +
+

+ EuiYAxis and EuiXAxis can be used instead of the{' '} + EuiDefaultAxis to allow higher axis customization. See the JS example + to check the available properties. +

+
+ ), + props: { EuiXAxis, EuiYAxis }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./simple_axis'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Annotations', + text: ( +
+

+ EuiLineAnnotation can be used to add annotation lines with optional text + on the chart. +

+
+ ), + props: { EuiLineAnnotation }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./simple_axis'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + ], +}; diff --git a/src-docs/src/views/xy_chart_bar/bar_example.js b/src-docs/src/views/xy_chart_bar/bar_example.js new file mode 100644 index 00000000000..938f7ddd63e --- /dev/null +++ b/src-docs/src/views/xy_chart_bar/bar_example.js @@ -0,0 +1,190 @@ +import React, { Fragment } from 'react'; +import { GuideSectionTypes } from '../../components'; +import VerticalBarSeriesExample from './vertical_bar_series'; +import HorizontalBarSeriesExample from './horizontal_bar_series'; +import StackedVerticalBarSeriesExample from './stacked_vertical_bar_series'; +import StackedHorizontalBarSeriesExample from './stacked_horizontal_bar_series'; +import TimeSeriesExample from './time_series'; + +import { EuiBadge, EuiCallOut, EuiSpacer, EuiLink, EuiCode, EuiBarSeries } from '../../../../src/components'; + +export const XYChartBarExample = { + title: 'Bar charts', + intro: ( + + +

+ This component is still in Beta. We consider it to be reasonably stable, and welcome you to implement it, + but please be aware that breaking changes can come at any time with this component as such changes on beta + components does not necessitate a major version bump. +

+
+ + +

+ You can use EuiXYChart with EuiBarSeries to + displaying bar charts. +

+ +

+ The EuiXYChart component pass the orientation prop to every component child + to accomodate vertical and horizontal use cases. + The default orientation is vertical. +

+ +

+ You should specify EuiXYChart prop xType="ordinal" + to specify the X Axis scale type since you are creating a Bar Chart (read the quote below).
+ You can use barchart also with other X axis scale types, but this can lead to misinterpretation + of your charts (basically because the bar width doesn't represent a real measure like in the histograms). +

+ +

+ You can configure the Y-Axis scale type yType of EuiXYChart + with the following scales linear,log, + time, time-utc. +

+ + + +

+ A bar chart or bar graph is a chart or graph that presents categorical data with rectangular + bars with heights or lengths proportional to the values that they represent. + The bars can be plotted vertically or horizontally. [...] +

+

+ A bar graph shows comparisons among discrete categories. One axis of the chart shows the specific + categories being compared, and the other axis represents a measured value. + Some bar graphs present bars clustered in groups of more than one, + showing the values of more than one measured variable. +

+ Wikipedia +
+
+ +
+ ), + sections: [ + { + title: 'Vertical Bar Chart', + text: ( +

+ You can create out-of-the-box vertical bar charts just adding a EuiBarSeries + component into your EuiXYChart. +

+ ), + props: { EuiBarSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./vertical_bar_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + { + title: 'Stacked Vertical Bar Chart', + text: ( +

+ To display a vertical stacked bar charts specify stackBy="y". + If stackBy is not specified, bars are clustered together depending on + the X value. +

+ ), + props: { EuiBarSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./stacked_vertical_bar_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + { + title: 'Horizontal Bar Chart', + text: ( +

+ + experimental + To display an horizontal bar chart specify orientation="horizontal". + Since you are rotating the chart, you also have to invert x and y + values in your data. The y becomes your ordinal/categorial axis and the + x becomes your measure/value axis. +

+ ), + props: { EuiBarSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./horizontal_bar_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + { + title: 'Stacked Horizontal Bar Chart', + text: ( +

+ + experimental + To display an horizontal stacked bar charts specify stackBy="x" + together with orientation="horizontal". + If stackBy is not specified, bars are clustered together depending on + the Y value. +

+ ), + props: { EuiBarSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./stacked_horizontal_bar_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + + { + title: 'Time Series', + text: ( +

+ Use EuiXYChart with xType='time' + to display a time series bar chart. +

+ ), + props: { EuiBarSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./time_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + ], +}; diff --git a/src-docs/src/views/xy_chart_bar/horizontal_bar_series.js b/src-docs/src/views/xy_chart_bar/horizontal_bar_series.js new file mode 100644 index 00000000000..990d5206278 --- /dev/null +++ b/src-docs/src/views/xy_chart_bar/horizontal_bar_series.js @@ -0,0 +1,22 @@ +import React from 'react'; + +import { EuiXYChart, EuiBarSeries, EuiXYChartUtils } from '../../../../src/components'; + +const { SCALE, ORIENTATION } = EuiXYChartUtils; +const data = [ + { x: 3, y: 'A' }, + { x: 1, y: 'B' }, + { x: 5, y: 'C' }, + { x: 2, y: 'D' }, + { x: 1, y: 'E' }, +]; +export default () => ( + + + +); diff --git a/src-docs/src/views/xy_chart_bar/stacked_horizontal_bar_series.js b/src-docs/src/views/xy_chart_bar/stacked_horizontal_bar_series.js new file mode 100644 index 00000000000..1622eadf6e3 --- /dev/null +++ b/src-docs/src/views/xy_chart_bar/stacked_horizontal_bar_series.js @@ -0,0 +1,64 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiSpacer, + EuiButton, + EuiXYChart, + EuiBarSeries, + EuiXYChartUtils, +} from '../../../../src/components'; + +const { ORIENTATION, SCALE } = EuiXYChartUtils; + +const dataA = [ + { x: 1, y: 'A' }, + { x: 2, y: 'B' }, + { x: 3, y: 'C' }, + { x: 4, y: 'D' }, + { x: 5, y: 'E' }, +]; +const dataB = [ + { x: 3, y: 'A' }, + { x: 2, y: 'B' }, + { x: 1, y: 'C' }, + { x: 2, y: 'D' }, + { x: 3, y: 'E' }, +]; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + stacked: true, + }; + } + + onSwitchStacked = () => { + this.setState({ + stacked: !this.state.stacked, + }); + }; + + render() { + const { stacked } = this.state; + return ( + + + Toggle stacked + + + + + + + + ); + } +} diff --git a/src-docs/src/views/xy_chart_bar/stacked_vertical_bar_series.js b/src-docs/src/views/xy_chart_bar/stacked_vertical_bar_series.js new file mode 100644 index 00000000000..03fdf021bf5 --- /dev/null +++ b/src-docs/src/views/xy_chart_bar/stacked_vertical_bar_series.js @@ -0,0 +1,47 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiSpacer, + EuiButton, + EuiXYChart, + EuiBarSeries, + EuiXYChartUtils, +} from '../../../../src/components'; + +const { SCALE } = EuiXYChartUtils; + +const dataA = [{ x: 0, y: 5 }, { x: 1, y: 4 }, { x: 2, y: 3 }, { x: 3, y: 2 }, { x: 4, y: 1 }]; + +const dataB = [{ x: 0, y: 1 }, { x: 1, y: 2 }, { x: 2, y: 3 }, { x: 3, y: 4 }, { x: 4, y: 5 }]; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + stacked: true, + }; + } + + onSwitchStacked = () => { + this.setState({ + stacked: !this.state.stacked, + }); + }; + + render() { + const { stacked } = this.state; + return ( + + + Toggle stacked + + + + + + + + ); + } +} diff --git a/src-docs/src/views/xy_chart_bar/time_series.js b/src-docs/src/views/xy_chart_bar/time_series.js new file mode 100644 index 00000000000..2b13f1defdc --- /dev/null +++ b/src-docs/src/views/xy_chart_bar/time_series.js @@ -0,0 +1,60 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiButton, + EuiSpacer, + EuiXYChart, + EuiLineSeries, + EuiBarSeries, + EuiXYChartUtils, +} from '../../../../src/components'; + +const { SCALE } = EuiXYChartUtils; +const timestamp = Date.now(); +const ONE_HOUR = 3600000; + +function randomizeData(size = 24, max = 15) { + return new Array(size) + .fill(0) + .map((d, i) => ({ + x0: ONE_HOUR * i, + x: ONE_HOUR * (i + 1), + y: Math.floor(Math.random() * max), + })) + .map(el => ({ + x: el.x + timestamp, + y: el.y, + })); +} +function buildData(series) { + const max = Math.ceil(Math.random() * 100000); + return new Array(series).fill(0).map(() => randomizeData(10, max)); +} +export default class Example extends Component { + state = { + series: 4, + data: buildData(4), + }; + handleRandomize = () => { + this.setState({ + data: buildData(this.state.series), + }); + }; + render() { + const { data } = this.state; + return ( + + Randomize data + + + {data.map((d, i) => ( + + ))} + {data.map((d, i) => ( + + ))} + + + ); + } +} diff --git a/src-docs/src/views/xy_chart_bar/vertical_bar_series.js b/src-docs/src/views/xy_chart_bar/vertical_bar_series.js new file mode 100644 index 00000000000..818258b1b18 --- /dev/null +++ b/src-docs/src/views/xy_chart_bar/vertical_bar_series.js @@ -0,0 +1,23 @@ +import React from 'react'; + +import { EuiXYChart, EuiBarSeries, EuiXYChartUtils } from '../../../../src/components'; +const { SCALE } = EuiXYChartUtils; +const data = [ + { x: 'A', y: 3 }, + { x: 'B', y: 1 }, + { x: 'C', y: 5 }, + { x: 'D', y: 2 }, + { x: 'E', y: 1 }, +]; + +export default () => ( + + { + console.log({ singleBarData }); + }} + /> + +); diff --git a/src-docs/src/views/xy_chart_histogram/histogram_example.js b/src-docs/src/views/xy_chart_histogram/histogram_example.js new file mode 100644 index 00000000000..950f9326ab5 --- /dev/null +++ b/src-docs/src/views/xy_chart_histogram/histogram_example.js @@ -0,0 +1,202 @@ +import React, { Fragment } from 'react'; +import { GuideSectionTypes } from '../../components'; +import VerticalRectSeriesExample from './vertical_rect_series'; +import StackedVerticalRectSeriesExample from './stacked_vertical_rect_series'; +import HorizontalRectSeriesExample from './horizontal_rect_series'; +import StackedHorizontalRectSeriesExample from './stacked_horizontal_rect_series'; +import TimeHistogramSeriesExample from './time_histogram_series'; + +import { + EuiBadge, + EuiSpacer, + EuiCode, + EuiCallOut, + EuiLink, + EuiHistogramSeries, +} from '../../../../src/components'; + +export const XYChartHistogramExample = { + title: 'Histograms', + intro: ( + + +

+ This component is still in Beta. We consider it to be reasonably stable, and welcome you to implement it, + but please be aware that breaking changes can come at any time with this component as such changes on beta + components does not necessitate a major version bump. +

+
+ + +

+ You can use EuiXYChart with EuiHistogramSeries to + displaying histogram charts. +

+ +

+ The EuiXYChart component pass the orientation prop to every component child + to accomodate vertical and horizontal use cases. + The default orientation is vertical. +

+ +

+ You can specify the EuiXYChart prop xType and + yType to one of the following scales: linear,log, + time, time-utc. + The use of ordinal and category is not supported. +

+ + + +

+ A histogram is an accurate representation of the distribution of numerical data. [...] +

+

+ To construct a histogram, the first step is to bin the range of values—that is, + divide the entire range of values into a series of intervals—and then count how many values fall into each interval. + The bins are usually specified as consecutive, non-overlapping intervals of a variable. + The bins (intervals) must be adjacent, and are often (but are not required to be) of equal size +

+ Wikipedia +
+
+ +
+ ), + sections: [ + { + title: 'Vertical Histogram', + text: ( +

+ You can create out-of-the-box vertical histograms just adding a EuiHistogramSeries + component into your EuiXYChart. +

+ ), + props: { EuiHistogramSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./vertical_rect_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + { + title: 'Stacked Vertical Histogram', + text: ( + +

+ Use EuiXYChart with EuiHistogramSeries for + displaying stacked vertical histograms. +

+

+ Specify stackBy="x" to stack bars together. +

+ +
+ ), + props: { EuiHistogramSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./stacked_vertical_rect_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + { + title: 'Horizontal Histogram', + text: ( +

+ + experimental + You can create horizontal histograms specifing orientation="horizontal". + Since you are rotating the histogram, you also have to invert your data. +

+ ), + props: { EuiHistogramSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./vertical_rect_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + { + title: 'Stacked Horizontal Histogram', + text: ( + +

+ + experimental + To display an horizontal stacked histograms specify stackBy="x" + together with orientation="horizontal". +

+ +
+ ), + props: { EuiHistogramSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./stacked_horizontal_rect_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + { + title: 'Time Series Histogram version', + text: ( +

+ Use EuiXYChart with xType='time' + to display a time series histogram. +

+ ), + props: { EuiHistogramSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./time_histogram_series'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: (), + }, + ], +}; diff --git a/src-docs/src/views/xy_chart_histogram/horizontal_rect_series.js b/src-docs/src/views/xy_chart_histogram/horizontal_rect_series.js new file mode 100644 index 00000000000..e0d81ba0f06 --- /dev/null +++ b/src-docs/src/views/xy_chart_histogram/horizontal_rect_series.js @@ -0,0 +1,16 @@ +import React from 'react'; + +import { EuiXYChart, EuiHistogramSeries, EuiXYChartUtils } from '../../../../src/components'; + +const data = [ + { x: 3, y: 0, y0: 1 }, + { x: 1, y: 1, y0: 2 }, + { x: 5, y: 2, y0: 3 }, + { x: 2, y: 3, y0: 4 }, + { x: 1, y: 4, y0: 5 }, +]; +export default () => ( + + + +); diff --git a/src-docs/src/views/xy_chart_histogram/stacked_horizontal_rect_series.js b/src-docs/src/views/xy_chart_histogram/stacked_horizontal_rect_series.js new file mode 100644 index 00000000000..65b14e455a5 --- /dev/null +++ b/src-docs/src/views/xy_chart_histogram/stacked_horizontal_rect_series.js @@ -0,0 +1,31 @@ +import React from 'react'; + +import { EuiXYChart, EuiHistogramSeries, EuiXYChartUtils } from '../../../../src/components'; + +const dataA = [ + { y: 0, y0: 1, x: 1 }, + { y: 1, y0: 2, x: 2 }, + { y: 2, y0: 3, x: 3 }, + { y: 3, y0: 4, x: 4 }, + { y: 4, y0: 5, x: 5 }, +]; + +const dataB = [ + { y: 0, y0: 1, x: 5 }, + { y: 1, y0: 2, x: 4 }, + { y: 2, y0: 3, x: 3 }, + { y: 3, y0: 4, x: 2 }, + { y: 4, y0: 5, x: 1 }, +]; + +export default () => ( + + + + +); diff --git a/src-docs/src/views/xy_chart_histogram/stacked_vertical_rect_series.js b/src-docs/src/views/xy_chart_histogram/stacked_vertical_rect_series.js new file mode 100644 index 00000000000..4e1ea5e958f --- /dev/null +++ b/src-docs/src/views/xy_chart_histogram/stacked_vertical_rect_series.js @@ -0,0 +1,26 @@ +import React from 'react'; + +import { EuiXYChart, EuiHistogramSeries } from '../../../../src/components'; + +const dataA = [ + { x0: 0, x: 1, y: 1 }, + { x0: 1, x: 2, y: 2 }, + { x0: 2, x: 3, y: 1 }, + { x0: 3, x: 4, y: 1 }, + { x0: 4, x: 5, y: 1 }, +]; + +const dataB = [ + { x0: 0, x: 1, y: 2 }, + { x0: 1, x: 2, y: 1 }, + { x0: 2, x: 3, y: 2 }, + { x0: 3, x: 4, y: 2 }, + { x0: 4, x: 5, y: 2 }, +]; + +export default () => ( + + + + +); diff --git a/src-docs/src/views/xy_chart_histogram/time_histogram_series.js b/src-docs/src/views/xy_chart_histogram/time_histogram_series.js new file mode 100644 index 00000000000..ef678a344b3 --- /dev/null +++ b/src-docs/src/views/xy_chart_histogram/time_histogram_series.js @@ -0,0 +1,56 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiButton, + EuiSpacer, + EuiXYChart, + EuiHistogramSeries, + EuiXYChartUtils, +} from '../../../../src/components'; +const { SCALE } = EuiXYChartUtils; +const timestamp = Date.now(); +const ONE_HOUR = 3600000; + + +function randomizeData(size = 24, max = 15) { + return new Array(size) + .fill(0) + .map((d, i) => ({ + x0: ONE_HOUR * i, + x: ONE_HOUR * (i + 1), + y: Math.floor(Math.random() * max), + })) + .map(el => ({ + x0: el.x0 + timestamp, + x: el.x + timestamp, + y: el.y, + })); +} +function buildData(series) { + const max = Math.ceil(Math.random() * 100000000); + return new Array(series).fill(0).map(() => randomizeData(100, max)); +} +export default class Example extends Component { + state = { + series: 4, + data: buildData(4), + }; + handleRandomize = () => { + this.setState({ + data: buildData(this.state.series), + }); + }; + render() { + const { data } = this.state; + return ( + + Randomize data + + + + {data.map((d, i) => )} + + + ); + } +} diff --git a/src-docs/src/views/xy_chart_histogram/vertical_rect_series.js b/src-docs/src/views/xy_chart_histogram/vertical_rect_series.js new file mode 100644 index 00000000000..ab1ca994e4e --- /dev/null +++ b/src-docs/src/views/xy_chart_histogram/vertical_rect_series.js @@ -0,0 +1,17 @@ +import React from 'react'; + +import { EuiXYChart, EuiHistogramSeries } from '../../../../src/components'; + +const data = [ + { x0: 0, x: 1, y: 1 }, + { x0: 1, x: 2, y: 3 }, + { x0: 2, x: 3, y: 2 }, + { x0: 3, x: 4, y: 0.5 }, + { x0: 4, x: 5, y: 5 }, +]; + +export default () => ( + + + +); diff --git a/src-docs/src/views/xy_chart_line/curved_line.js b/src-docs/src/views/xy_chart_line/curved_line.js new file mode 100644 index 00000000000..22c64c12e31 --- /dev/null +++ b/src-docs/src/views/xy_chart_line/curved_line.js @@ -0,0 +1,75 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiForm, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiXYChart, + EuiLineSeries, + EuiXYChartUtils, +} from '../../../../src/components'; + +const { + LINEAR, + CURVE_CARDINAL, + CURVE_NATURAL, + CURVE_MONOTONE_X, + CURVE_MONOTONE_Y, + CURVE_BASIS, + CURVE_BUNDLE, + CURVE_CATMULL_ROM, + CURVE_STEP, + CURVE_STEP_AFTER, + CURVE_STEP_BEFORE, +} = EuiXYChartUtils.CURVE; + +const DATA_A = [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: -1 }, { x: 5, y: 2 }]; + +export default class extends Component { + constructor(props) { + super(props); + + this.options = [ + { value: LINEAR, text: 'Linear' }, + { value: CURVE_CARDINAL, text: 'Curve Cardinal' }, + { value: CURVE_NATURAL, text: 'Curve Natural' }, + { value: CURVE_MONOTONE_X, text: 'Curve Monotone X' }, + { value: CURVE_MONOTONE_Y, text: 'Curve Monotone Y' }, + { value: CURVE_BASIS, text: 'Curve Basis' }, + { value: CURVE_BUNDLE, text: 'Curve Bundle' }, + { value: CURVE_CATMULL_ROM, text: 'Curve Catmull Rom' }, + { value: CURVE_STEP, text: 'Curve Step' }, + { value: CURVE_STEP_AFTER, text: 'Curve Step After' }, + { value: CURVE_STEP_BEFORE, text: 'Curve Step Before' }, + ]; + + this.state = { + value: this.options[0].value, + }; + } + + onChange = e => { + this.setState({ + value: e.target.value, + }); + }; + + render() { + return ( + + + + + + + + + + + + + + ); + } +} diff --git a/src-docs/src/views/xy_chart_line/custom_domain_line.js b/src-docs/src/views/xy_chart_line/custom_domain_line.js new file mode 100644 index 00000000000..157203bd138 --- /dev/null +++ b/src-docs/src/views/xy_chart_line/custom_domain_line.js @@ -0,0 +1,14 @@ +import React from 'react'; + +import { EuiXYChart, EuiLineSeries } from '../../../../src/components'; + +const X_DOMAIN = [-1, 6]; +const Y_DOMAIN = [0, 3]; + +const DATA_A = [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 1 }, { x: 5, y: 2 }]; + +export default () => ( + + + +); diff --git a/src-docs/src/views/xy_chart_line/custom_style_line.js b/src-docs/src/views/xy_chart_line/custom_style_line.js new file mode 100644 index 00000000000..4bf72ee4d3a --- /dev/null +++ b/src-docs/src/views/xy_chart_line/custom_style_line.js @@ -0,0 +1,109 @@ +import React, { Component, Fragment } from 'react'; + +import { + EuiForm, + EuiFormRow, + EuiRange, + EuiSpacer, + EuiXYChart, + EuiLineSeries, + EuiCheckboxGroup, +} from '../../../../src/components'; + +import makeId from '../../../../src/components/form/form_row/make_id'; + +const DATA_A = [{ x: 0, y: 1 }, { x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: -1 }, { x: 5, y: 2 }]; + +export default class extends Component { + constructor(props) { + super(props); + + this.state = { + lineMarkSize: '4', + lineSize: '2', + lineProps: [ + { + id: `showLineMarks`, + label: 'Show Line Marks', + }, + ], + linePropsIdToSelectedMap: { + showLineMarks: true, + }, + }; + } + + onLinePropsChange = optionId => { + const newLinePropsIdToSelectedMap = { + ...this.state.linePropsIdToSelectedMap, + ...{ + [optionId]: !this.state.linePropsIdToSelectedMap[optionId], + }, + }; + + this.setState({ + linePropsIdToSelectedMap: newLinePropsIdToSelectedMap, + }); + }; + + onChangeLineSize = e => { + this.setState({ + lineSize: e.target.value, + }); + }; + + onChangeLineMarkSize = e => { + this.setState({ + lineMarkSize: e.target.value, + }); + }; + + render() { + const { + linePropsIdToSelectedMap: { showLineMarks }, + lineSize, + lineMarkSize, + } = this.state; + return ( + + + + + + + + + + + + + + + + + + ); + } +} diff --git a/src-docs/src/views/xy_chart_line/line.js b/src-docs/src/views/xy_chart_line/line.js new file mode 100644 index 00000000000..5b341a418a4 --- /dev/null +++ b/src-docs/src/views/xy_chart_line/line.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import { + EuiXYChart, + EuiLineSeries, +} from '../../../../src/components'; + +const DATA_A = [ + { x: 0, y: 1 }, + { x: 1, y: 1 }, + { x: 2, y: 2 }, + { x: 3, y: -1 }, + { x: 4, y: null }, + { x: 5, y: 2 }, +]; + +export default () => ( + + + +); diff --git a/src-docs/src/views/xy_chart_line/line_example.js b/src-docs/src/views/xy_chart_line/line_example.js new file mode 100644 index 00000000000..c06772487a5 --- /dev/null +++ b/src-docs/src/views/xy_chart_line/line_example.js @@ -0,0 +1,179 @@ +import React, { Fragment } from 'react'; +import { GuideSectionTypes } from '../../components'; +import LineChartExample from './line'; +import CustomDomainLineChartExample from './custom_domain_line'; +import MultiLineChartExample from './multi_line'; +import CurvedLineChartExample from './curved_line'; +import CustomStyleLineChartExample from './custom_style_line'; +import { EuiCode, EuiLineSeries, EuiLink, EuiCallOut, EuiSpacer } from '../../../../src/components'; + +export const XYChartLineExample = { + title: 'Line chart', + intro: ( + + +

+ This component is still in Beta. We consider it to be reasonably stable, and welcome you to implement it, + but please be aware that breaking changes can come at any time with this component as such changes on beta + components does not necessitate a major version bump. +

+
+ + +
+ ), + sections: [ + { + title: 'Line chart', + text: ( +
+

+ Use EuiLineSeries to display line charts. The chart domain will cover the + whole extent and doesn't add any padding. +

+
+ ), + props: { EuiLineSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./line'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Custom domain line chart', + text: ( +
+

+ Use EuiLineSeries to display line charts. Specify{' '} + xDomain and/or yDomain + props to use custom domains. +

+
+ ), + props: { EuiLineSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./custom_domain_line'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Multi Line chart', + text: ( +
+

+ Use multiple EuiLineSeries to display a milti-line chart. +

+
+ ), + props: { EuiLineSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./multi_line'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Curved Line chart', + text: ( +
+

+ Use the curve prop to change the curve representation. Visit{' '} + + d3-shape#curves + + for all possible values. +

+
+ ), + props: { EuiLineSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./curved_line'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + { + title: 'Custom style Line chart', + text: ( +
+

Use the following props to change the style of the Line Chart

+
    +
  • + lineSize to change the size/width of the line. +
  • +
  • + lineMarkSize to change the size/radius of marks. +
  • +
  • + showLine to show/hide the line. +
  • +
  • + showLineMarks to show/hide the line marks. +
  • +
+
+ ), + props: { EuiLineSeries }, + source: [ + { + type: GuideSectionTypes.JS, + code: require('!!raw-loader!./custom_style_line'), + }, + { + type: GuideSectionTypes.HTML, + code: 'This component can only be used from React', + }, + ], + demo: ( +
+ +
+ ), + }, + ], +}; diff --git a/src-docs/src/views/xy_chart_line/multi_line.js b/src-docs/src/views/xy_chart_line/multi_line.js new file mode 100644 index 00000000000..4a2bed0e284 --- /dev/null +++ b/src-docs/src/views/xy_chart_line/multi_line.js @@ -0,0 +1,13 @@ +import React from 'react'; + +import { EuiXYChart, EuiLineSeries } from '../../../../src/components'; + +const DATA_A = [{ x: 0, y: 1 }, { x: 1.5, y: 1 }, { x: 2, y: 2 }, { x: 3, y: -1 }, { x: 5, y: 2 }]; +const DATA_B = [{ x: 0, y: 3 }, { x: 1, y: 4 }, { x: 2, y: 1 }, { x: 3, y: 2 }, { x: 5, y: 5 }]; + +export default () => ( + + + + +); diff --git a/src/components/index.js b/src/components/index.js index 2c41840fd58..8a6e790e120 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -308,6 +308,27 @@ export { EuiToolTip, } from './tool_tip'; +export { + EuiXYChart, + EuiXYChartUtils, + EuiXYChartAxisUtils, + EuiXYChartTextUtils, + EuiLineSeries, + EuiAreaSeries, + EuiBarSeries, + EuiHistogramSeries, + EuiVerticalBarSeries, + EuiHorizontalBarSeries, + EuiVerticalRectSeries, + EuiHorizontalRectSeries, + EuiDefaultAxis, + EuiXAxis, + EuiYAxis, + EuiCrosshairX, + EuiCrosshairY, + EuiLineAnnotation, +} from './xy_chart'; + export { EuiHideFor, EuiShowFor, diff --git a/src/components/index.scss b/src/components/index.scss index 941b3be2524..6338c413808 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -52,3 +52,5 @@ @import 'toggle/index'; @import 'tool_tip/index'; @import 'text/index'; +@import 'xy_chart/index'; + diff --git a/src/components/xy_chart/__snapshots__/selection_brush.test.js.snap b/src/components/xy_chart/__snapshots__/selection_brush.test.js.snap new file mode 100644 index 00000000000..544da598cfb --- /dev/null +++ b/src/components/xy_chart/__snapshots__/selection_brush.test.js.snap @@ -0,0 +1,448 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiSelectionBrush renders an horizontal selection brush 1`] = ` + + + + + +`; + +exports[`EuiSelectionBrush renders an vertical selection brush 1`] = ` + + + + + +`; + +exports[`EuiSelectionBrush renders free form selection brush 1`] = ` + + + + + +`; diff --git a/src/components/xy_chart/__snapshots__/xy_chart.test.js.snap b/src/components/xy_chart/__snapshots__/xy_chart.test.js.snap new file mode 100644 index 00000000000..8fd924184da --- /dev/null +++ b/src/components/xy_chart/__snapshots__/xy_chart.test.js.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiXYChart renders an empty chart 1`] = ` +
+
+ + + +
+ +

+ Chart not available +

+
+
+

+ ~~Empty Chart~~ +

+
+
+ +
+
+`; diff --git a/src/components/xy_chart/_index.scss b/src/components/xy_chart/_index.scss new file mode 100644 index 00000000000..1d4238e3d62 --- /dev/null +++ b/src/components/xy_chart/_index.scss @@ -0,0 +1,11 @@ +/* react-vis scss styles copied and pasted from react-vis lib */ +@import "styles/react_vis/plot"; +@import "styles/react_vis/legends"; +@import "styles/react_vis/radial-chart"; +@import "styles/react_vis/treemap"; + +@import "series/index"; +@import "axis/index"; +@import "legend"; +@import "line_annotation"; +@import "xy_chart"; diff --git a/src/components/xy_chart/_legend.scss b/src/components/xy_chart/_legend.scss new file mode 100644 index 00000000000..b95838f0152 --- /dev/null +++ b/src/components/xy_chart/_legend.scss @@ -0,0 +1,58 @@ +.euiLegendTitle { + @include euiTitle; + @include euiFontSizeL; +} + +.euiLegendContainer { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.euiLegendContent { + white-space: nowrap; + color: gray; + display: flex; +} + +.euiLegendTruncatedLabel { + display: inline-block; +} +.euiLegendSeriesValue { + margin-left: 5px; + display: inline-block; + color: black; +} +.euiLegendMoreSeriesContainer { + @include euiFontSizeS; + color: gray; +} + +.euiLegendItemContainer { + display: flex; + align-items: center; + color: gray; + cursor: pointer; + user-select: none; + margin-right: 4px; + opacity: 1; + @include euiFontSizeM; + &:last-of-type { + margin-right: 0; + } +} + +.euiLegendItemIndicator { + border-radius: 100%; + width: 8px; + height: 8px; + margin-right: 4px; +} + +.euiTitle--small { + @include euiFontSizeM; +} + +.euiTitle--large { + @include euiFontSizeXL; +} diff --git a/src/components/xy_chart/_line_annotation.scss b/src/components/xy_chart/_line_annotation.scss new file mode 100644 index 00000000000..85cb88d24c7 --- /dev/null +++ b/src/components/xy_chart/_line_annotation.scss @@ -0,0 +1,12 @@ +.euiLineAnnotations__line { + stroke: red; + stroke-width: 2px; + opacity: 0.3; +} +.euiLineAnnotations__text { + font-size: $euiFontSizeXS; + fill: red; + stroke-width: 0; + opacity: 0.3; + alignment-baseline: text-after-edge; +} diff --git a/src/components/xy_chart/_xy_chart.scss b/src/components/xy_chart/_xy_chart.scss new file mode 100644 index 00000000000..121a7a16e9c --- /dev/null +++ b/src/components/xy_chart/_xy_chart.scss @@ -0,0 +1,3 @@ +.rv-xy-plot__inner { + overflow: visible; // TODO fix when adding automatic margin into svg +} diff --git a/src/components/xy_chart/axis/__snapshots__/default_axis.test.js.snap b/src/components/xy_chart/axis/__snapshots__/default_axis.test.js.snap new file mode 100644 index 00000000000..cb03e4c7653 --- /dev/null +++ b/src/components/xy_chart/axis/__snapshots__/default_axis.test.js.snap @@ -0,0 +1,950 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiDefaultAxis render default axis 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 1.0 + + + + + + 1.2 + + + + + + 1.4 + + + + + + 1.6 + + + + + + 1.8 + + + + + + 2.0 + + + + + +
+
+
+`; + +exports[`EuiDefaultAxis render rotated 90deg default axis 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 1.0 + + + + + + 1.2 + + + + + + 1.4 + + + + + + 1.6 + + + + + + 1.8 + + + + + + 2.0 + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/axis/__snapshots__/horizontal_grid.test.js.snap b/src/components/xy_chart/axis/__snapshots__/horizontal_grid.test.js.snap new file mode 100644 index 00000000000..3eff90dbffe --- /dev/null +++ b/src/components/xy_chart/axis/__snapshots__/horizontal_grid.test.js.snap @@ -0,0 +1,84 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiHorizontalGrid render the horizontal grid 1`] = ` +
+
+
+ + + + + + + + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/axis/__snapshots__/vertical_grid.test.js.snap b/src/components/xy_chart/axis/__snapshots__/vertical_grid.test.js.snap new file mode 100644 index 00000000000..a000fa14321 --- /dev/null +++ b/src/components/xy_chart/axis/__snapshots__/vertical_grid.test.js.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiVerticalGrid render the vertical grid 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/axis/__snapshots__/x_axis.test.js.snap b/src/components/xy_chart/axis/__snapshots__/x_axis.test.js.snap new file mode 100644 index 00000000000..89b45cb7c12 --- /dev/null +++ b/src/components/xy_chart/axis/__snapshots__/x_axis.test.js.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiXAxis render the x axis 1`] = ` +
+
+
+ + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/axis/__snapshots__/y_axis.test.js.snap b/src/components/xy_chart/axis/__snapshots__/y_axis.test.js.snap new file mode 100644 index 00000000000..39b1139a3e8 --- /dev/null +++ b/src/components/xy_chart/axis/__snapshots__/y_axis.test.js.snap @@ -0,0 +1,174 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiYAxis render the y axis 1`] = ` +
+
+
+ + + + + + + + 1.0 + + + + + + 1.2 + + + + + + 1.4 + + + + + + 1.6 + + + + + + 1.8 + + + + + + 2.0 + + + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/axis/_grids.scss b/src/components/xy_chart/axis/_grids.scss new file mode 100644 index 00000000000..f924182f3c8 --- /dev/null +++ b/src/components/xy_chart/axis/_grids.scss @@ -0,0 +1,12 @@ +// NOTE: the current implementation of react-vis doesn't supports +// adding classes to grid lines but only a style prop is supported. +// We can overwrite here the original react-vis class or overwrite +// togheter with all other scss properties. + +.rv-xy-plot__grid-lines__line { + + stroke-dasharray: 5 5; + stroke-opacity: 0.3; + // support a clean mouse over when grids is above elements + pointer-events: none; +} diff --git a/src/components/xy_chart/axis/_index.scss b/src/components/xy_chart/axis/_index.scss new file mode 100644 index 00000000000..fb806d52f24 --- /dev/null +++ b/src/components/xy_chart/axis/_index.scss @@ -0,0 +1 @@ +@import "grids"; diff --git a/src/components/xy_chart/axis/default_axis.js b/src/components/xy_chart/axis/default_axis.js new file mode 100644 index 00000000000..77c074e807a --- /dev/null +++ b/src/components/xy_chart/axis/default_axis.js @@ -0,0 +1,53 @@ +import React, { Fragment, PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { EuiXAxis } from './x_axis'; +import { EuiYAxis } from './y_axis'; +import { EuiHorizontalGrid } from './horizontal_grid'; +import { EuiVerticalGrid } from './vertical_grid'; +import { ORIENTATION } from '../utils/chart_utils'; + + +/** + * The Default Axis component, with X and Y axis on the bottom and left position respectively, + * and horiznontal or vertical grid depending on the orientation prop. + */ +export class EuiDefaultAxis extends PureComponent { + render() { + const { showGridLines, orientation, xOnZero, yOnZero, ...rest } = this.props; + + return ( + + {showGridLines && + orientation === ORIENTATION.VERTICAL && } + + {showGridLines && + orientation === ORIENTATION.HORIZONTAL && } + + + + + ); + } +} + +EuiDefaultAxis.displayName = 'EuiDefaultAxis'; + +EuiDefaultAxis.propTypes = { + /** The orientation of the chart, used to determine the correct orientation of grids */ + orientation: PropTypes.string, + /** Show/Hide the background grids */ + showGridLines: PropTypes.bool, + /** Specify if the x axis lay on 0, otherwise lyed on min x */ + xOnZero: PropTypes.bool, + /** Specify if the y axis lay on 0, otherwise layd on min y */ + yOnZero: PropTypes.bool, +}; + +EuiDefaultAxis.defaultProps = { + orientation: ORIENTATION.VERTICAL, + showGridLines: true, + xOnZero: false, + yOnZero: false, +}; + +EuiDefaultAxis.requiresSVG = true; diff --git a/src/components/xy_chart/axis/default_axis.test.js b/src/components/xy_chart/axis/default_axis.test.js new file mode 100644 index 00000000000..3ecf82f1ff2 --- /dev/null +++ b/src/components/xy_chart/axis/default_axis.test.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiLineSeries } from '../series/line_series'; +import { EuiDefaultAxis, EuiXAxis, EuiYAxis, EuiVerticalGrid, EuiHorizontalGrid } from './'; +import { requiredProps } from '../../../test/required_props'; +import { ORIENTATION } from '../utils/chart_utils'; + +describe('EuiDefaultAxis', () => { + test('render default axis', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const component = mount( + + + + ); + expect(component.find(EuiDefaultAxis)).toHaveLength(1); + expect(component.find(EuiXAxis)).toHaveLength(1); + expect(component.find(EuiYAxis)).toHaveLength(1); + expect(component.find(EuiHorizontalGrid)).toHaveLength(1); + expect(component.find(EuiVerticalGrid)).toHaveLength(0); + expect(component.render()).toMatchSnapshot(); + }); + test('render rotated 90deg default axis', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const component = mount( + + + + ); + expect(component.find(EuiDefaultAxis)).toHaveLength(1); + expect(component.find(EuiVerticalGrid)).toHaveLength(1); + expect(component.find(EuiHorizontalGrid)).toHaveLength(0); + expect(component.render()).toMatchSnapshot(); + }); +}); diff --git a/src/components/xy_chart/axis/horizontal_grid.js b/src/components/xy_chart/axis/horizontal_grid.js new file mode 100644 index 00000000000..29b6856b157 --- /dev/null +++ b/src/components/xy_chart/axis/horizontal_grid.js @@ -0,0 +1,19 @@ +import React, { PureComponent } from 'react'; +import { HorizontalGridLines } from 'react-vis'; + +/** + * Horizontal grid lines aligned with y axis ticks + */ +export class EuiHorizontalGrid extends PureComponent { + render() { + return ( + + ); + } +} + +EuiHorizontalGrid.displayName = 'EuiHorizontalGrid'; + +EuiHorizontalGrid.requiresSVG = true; diff --git a/src/components/xy_chart/axis/horizontal_grid.test.js b/src/components/xy_chart/axis/horizontal_grid.test.js new file mode 100644 index 00000000000..a9b3f1eee63 --- /dev/null +++ b/src/components/xy_chart/axis/horizontal_grid.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiLineSeries } from '../series/line_series'; +import { EuiHorizontalGrid } from './'; +import { requiredProps } from '../../../test/required_props'; + +describe('EuiHorizontalGrid', () => { + test('render the horizontal grid', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const width = 600; + const component = mount( + + + + + ); + const horizontalGridComponent = component.find(EuiHorizontalGrid); + expect(horizontalGridComponent).toHaveLength(1); + const firstLineProps = horizontalGridComponent.find('line').at(0).props(); + expect(firstLineProps.y1).toEqual(firstLineProps.y2); + expect(firstLineProps.x1).toEqual(0); + expect(firstLineProps.x2).toEqual(width - 50); // right + left default xychart margin + expect(component.render()).toMatchSnapshot(); + }); +}); diff --git a/src/components/xy_chart/axis/index.js b/src/components/xy_chart/axis/index.js new file mode 100644 index 00000000000..ea6034fb57d --- /dev/null +++ b/src/components/xy_chart/axis/index.js @@ -0,0 +1,5 @@ +export { EuiDefaultAxis } from './default_axis'; +export { EuiXAxis } from './x_axis'; +export { EuiYAxis } from './y_axis'; +export { EuiHorizontalGrid } from './horizontal_grid'; +export { EuiVerticalGrid } from './vertical_grid'; diff --git a/src/components/xy_chart/axis/vertical_grid.js b/src/components/xy_chart/axis/vertical_grid.js new file mode 100644 index 00000000000..657e519c9af --- /dev/null +++ b/src/components/xy_chart/axis/vertical_grid.js @@ -0,0 +1,19 @@ +import React, { PureComponent } from 'react'; +import { VerticalGridLines } from 'react-vis'; + +/** + * Vertical grid lines aligned with x axis ticks + */ +export class EuiVerticalGrid extends PureComponent { + render() { + return ( + + ); + } +} + +EuiVerticalGrid.displayName = 'EuiVerticalGrid'; + +EuiVerticalGrid.requiresSVG = true; diff --git a/src/components/xy_chart/axis/vertical_grid.test.js b/src/components/xy_chart/axis/vertical_grid.test.js new file mode 100644 index 00000000000..a4a21e18c03 --- /dev/null +++ b/src/components/xy_chart/axis/vertical_grid.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiLineSeries } from '../series/line_series'; +import { EuiVerticalGrid } from './'; +import { requiredProps } from '../../../test/required_props'; + +describe('EuiVerticalGrid', () => { + test('render the vertical grid', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const height = 200; + const component = mount( + + + + + ); + const verticalGridComponent = component.find(EuiVerticalGrid); + expect(verticalGridComponent).toHaveLength(1); + const firstLineProps = verticalGridComponent.find('line').at(0).props(); + expect(firstLineProps.x1).toEqual(firstLineProps.x2); + expect(firstLineProps.y1).toEqual(0); + expect(firstLineProps.y2).toEqual(height - 50); // top + bottom default xychart margin + expect(component.render()).toMatchSnapshot(); + }); +}); diff --git a/src/components/xy_chart/axis/x_axis.js b/src/components/xy_chart/axis/x_axis.js new file mode 100644 index 00000000000..27220605934 --- /dev/null +++ b/src/components/xy_chart/axis/x_axis.js @@ -0,0 +1,67 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { XAxis } from 'react-vis'; +import { EuiXYChartAxisUtils } from '../utils/axis_utils'; + +const { TITLE_POSITION, ORIENTATION } = EuiXYChartAxisUtils; + +export class EuiXAxis extends PureComponent { + render() { + const { + title, + titlePosition, + orientation, + tickSize, + tickLabelAngle, + tickFormat, + tickValues, + onZero, + ...rest + } = this.props; + return ( + + ); + } +} + +EuiXAxis.displayName = 'EuiXAxis'; + +EuiXAxis.propTypes = { + /** The axis title */ + title: PropTypes.string, + /** The axis title position */ + titlePosition: PropTypes.oneOf([TITLE_POSITION.START, TITLE_POSITION.MIDDLE, TITLE_POSITION.END]), + /** The axis orientation */ + orientation: PropTypes.oneOf([ORIENTATION.TOP, ORIENTATION.BOTTOM]), + /** Fix the axis at zero value */ + onZero: PropTypes.bool, + /** An array of ticks values */ + ticks: PropTypes.array, + /** The height of the ticks in pixels */ + tickSize: PropTypes.number, + /** TODO */ + tickValues: PropTypes.array, + /** A formatter function in the form of function(value, index, scale, tickTotal) */ + tickFormat: PropTypes.func, + /** the rotation angle in degree of the tick label */ + tickLabelAngle: PropTypes.number, +}; + +EuiXAxis.defaultProps = { + onZero: false, + titlePosition: TITLE_POSITION.MIDDLE, + orientation: ORIENTATION.BOTTOM, + tickSize: 0, +}; + +EuiXAxis.requiresSVG = true; diff --git a/src/components/xy_chart/axis/x_axis.test.js b/src/components/xy_chart/axis/x_axis.test.js new file mode 100644 index 00000000000..e46e74b9245 --- /dev/null +++ b/src/components/xy_chart/axis/x_axis.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiLineSeries } from '../series/line_series'; +import { EuiXAxis } from './'; +import { requiredProps } from '../../../test/required_props'; + +describe('EuiXAxis', () => { + test('render the x axis', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const height = 200; + const component = mount( + + + + + ); + expect(component.find(EuiXAxis)).toHaveLength(1); + expect(component.render()).toMatchSnapshot(); + }); +}); diff --git a/src/components/xy_chart/axis/y_axis.js b/src/components/xy_chart/axis/y_axis.js new file mode 100644 index 00000000000..45761adf694 --- /dev/null +++ b/src/components/xy_chart/axis/y_axis.js @@ -0,0 +1,67 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { YAxis } from 'react-vis'; +import { EuiXYChartAxisUtils } from '../utils/axis_utils'; + +const { TITLE_POSITION, ORIENTATION } = EuiXYChartAxisUtils; + +export class EuiYAxis extends PureComponent { + render() { + const { + title, + titlePosition, + orientation, + tickSize, + tickLabelAngle, + tickFormat, + tickValues, + onZero, + ...rest + } = this.props; + return ( + + ); + } +} + +EuiYAxis.displayName = 'EuiYAxis'; + +EuiYAxis.propTypes = { + /** The axis title */ + title: PropTypes.string, + /** The axis title position */ + titlePosition: PropTypes.oneOf([TITLE_POSITION.START, TITLE_POSITION.MIDDLE, TITLE_POSITION.END]), + /** The axis orientation */ + orientation: PropTypes.oneOf([ORIENTATION.LEFT, ORIENTATION.RIGHT]), + /** Fix the axis at zero value */ + onZero: PropTypes.bool, + /** An array of ticks values */ + ticks: PropTypes.array, + /** The height of the ticks in pixels */ + tickSize: PropTypes.number, + /** TODO */ + tickValues: PropTypes.array, + /** A formatter function in the form of function(value, index, scale, tickTotal) */ + tickFormat: PropTypes.func, + /** the rotation angle in degree of the tick label */ + tickLabelAngle: PropTypes.number, +}; + +EuiYAxis.defaultProps = { + onZero: false, + titlePosition: TITLE_POSITION.MIDDLE, + orientation: ORIENTATION.LEFT, + tickSize: 0, +}; + +EuiYAxis.requiresSVG = true; diff --git a/src/components/xy_chart/axis/y_axis.test.js b/src/components/xy_chart/axis/y_axis.test.js new file mode 100644 index 00000000000..7767298012d --- /dev/null +++ b/src/components/xy_chart/axis/y_axis.test.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiLineSeries } from '../series/line_series'; +import { EuiYAxis } from './'; +import { requiredProps } from '../../../test/required_props'; + +describe('EuiYAxis', () => { + test('render the y axis', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const height = 200; + const component = mount( + + + + + ); + expect(component.find(EuiYAxis)).toHaveLength(1); + expect(component.render()).toMatchSnapshot(); + }); +}); diff --git a/src/components/xy_chart/crosshairs/__snapshots__/crosshair_x.test.js.snap b/src/components/xy_chart/crosshairs/__snapshots__/crosshair_x.test.js.snap new file mode 100644 index 00000000000..18d0fe9b84a --- /dev/null +++ b/src/components/xy_chart/crosshairs/__snapshots__/crosshair_x.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCrosshairX render the X crosshair 1`] = ` +
+
+
+ + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/crosshairs/__snapshots__/crosshair_y.test.js.snap b/src/components/xy_chart/crosshairs/__snapshots__/crosshair_y.test.js.snap new file mode 100644 index 00000000000..7757dd6fb05 --- /dev/null +++ b/src/components/xy_chart/crosshairs/__snapshots__/crosshair_y.test.js.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiCrosshairY render the Y crosshair 1`] = ` +
+
+
+ + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/crosshairs/crosshair_x.js b/src/components/xy_chart/crosshairs/crosshair_x.js new file mode 100644 index 00000000000..6423a136d88 --- /dev/null +++ b/src/components/xy_chart/crosshairs/crosshair_x.js @@ -0,0 +1,205 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { AbstractSeries, Crosshair } from 'react-vis'; +import { SCALE } from '../utils/chart_utils'; +/** + * The Crosshair used by the XYChart as main tooltip mechanism along X axis (vertical). + */ +export class EuiCrosshairX extends AbstractSeries { + state = { + values: [], + } + + static get requiresSVG() { + return false; + } + + static get isCanvas() { + return false; + } + + static getDerivedStateFromProps(props) { + const { crosshairValue, _allData } = props; + + if (crosshairValue !== undefined) { + return { + values: EuiCrosshairX._computeDataFromXValue(_allData, crosshairValue), + }; + } + return null; + } + + static _computeDataFromXValue(dataSeries, crosshairValue) { + const filteredAndFlattenDataByX = dataSeries + .filter(series => series) // get only cleaned data series + .map((series, seriesIndex) => { + return series + .filter(dataPoint => dataPoint.x === crosshairValue) + .map(dataPoint => ({ ...dataPoint, originalValues: { ...dataPoint }, seriesIndex })); + }) + .reduce((acc, val) => acc.concat(val), []); + return filteredAndFlattenDataByX; + } + + onParentMouseMove(event) { + this._handleNearestX(event); + } + + onParentMouseLeave() { + if (this.props.onCrosshairUpdate) { + this.props.onCrosshairUpdate(null); + } + this.setState({ + values: [] + }); + } + + _formatXValue = (x) => { + const { xType } = this.props; + if (xType === SCALE.TIME || xType === SCALE.TIME_UTC) { + return new Date(x).toISOString(); // TODO add a props for time formatting + } else { + return x; + } + } + + _titleFormat = (dataPoints = []) => { + if (dataPoints.length > 0) { + const [ firstDataPoint ] = dataPoints; + const { originalValues } = firstDataPoint; + const value = (typeof originalValues.x0 === 'number') + ? `${this._formatXValue(originalValues.x0)} to ${this._formatXValue(originalValues.x)}` + : this._formatXValue(originalValues.x); + return { + title: 'X Value', + value, + }; + } + } + + _itemsFormat = (dataPoints) => { + const { seriesNames } = this.props; + + return dataPoints.map(d => { + return { + title: seriesNames[d.seriesIndex], + value: d.y, + }; + }); + } + + _handleNearestX(event) { + const cleanedDataSeries = this.props._allData.filter(dataSeries => dataSeries); + if (cleanedDataSeries.length === 0) { + return; + } + const containerCoordiante = super._getXYCoordinateInContainer(event); + this._findNearestXData(cleanedDataSeries, containerCoordiante.x); + } + + /** + * _findNearestXData - Find the nearest set of data in all existing series. + * + * @param {type} dataSeries an array of dataseries + * @param {type} mouseXContainerCoords the x coordinate of the mouse on the chart container + * @protected + */ + _findNearestXData(dataSeries, mouseXContainerCoords) { + const xScaleFn = super._getAttributeFunctor('x'); + // keeping a global min distance to filter only elements with the same distance + let globalMinDistance = Number.POSITIVE_INFINITY; + + const nearestXData = dataSeries + .map((data, seriesIndex) => { + let minDistance = Number.POSITIVE_INFINITY; + let value = null; + // TODO to increase the performance, it's better to use a search algorithm like bisect + // starting from the assumption that we will always have the same length for + // for each series and we can assume that the scale x index can reflect more or less + // the position of the mouse inside the array. + data.forEach((item) => { + let itemXCoords; + const xCoord = xScaleFn(item); + // check the right item coordinate if we use x0 and x value (e.g. on histograms) + if (typeof item.x0 === 'number') { + // we need to compute the scaled x0 using the xScale attribute functor + // we don't have access of the x0 attribute functor + const x0Coord = xScaleFn({ x: item.x0 }); + itemXCoords = (xCoord - x0Coord) / 2 + x0Coord; + } else { + itemXCoords = xCoord; + } + const newDistance = Math.abs(mouseXContainerCoords - itemXCoords); + if (newDistance < minDistance) { + minDistance = newDistance; + value = item; + } + globalMinDistance = Math.min(globalMinDistance, minDistance); + }); + + if (!value) { + return; + } + + return { + minDistance, + value, + seriesIndex, + }; + }) + .filter(d => d); + + // filter and map nearest X data per dataseries to get only the nearet onces + const values = nearestXData + .filter(value => value.minDistance === globalMinDistance) + .map(value => { + // check if we are on histograms and we need to show the right x and y values + const d = value.value; + const x = typeof d.x0 === 'number' + ? (d.x - d.x0) / 2 + d.x0 + : d.x; + const y = typeof d.y0 === 'number' + ? (d.y - d.y0) + : d.y; + return { x, y, originalValues: d, seriesIndex: value.seriesIndex }; + }); + const { onCrosshairUpdate } = this.props; + if (onCrosshairUpdate) { + onCrosshairUpdate(values[0].x); + } + + this.setState(() => ({ + values, + })); + } + + render() { + const { values } = this.state; + return ( + + ); + } +} + +EuiCrosshairX.displayName = 'EuiCrosshairX'; + +EuiCrosshairX.propTypes = { + /** + * The crosshair value used to display this crosshair (doesn't depend on mouse position) + */ + crosshairValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + /** + * The ordered array of series names + */ + seriesNames: PropTypes.arrayOf(PropTypes.string).isRequired, +}; +EuiCrosshairX.defaultProps = {}; diff --git a/src/components/xy_chart/crosshairs/crosshair_x.test.js b/src/components/xy_chart/crosshairs/crosshair_x.test.js new file mode 100644 index 00000000000..4a8246a039d --- /dev/null +++ b/src/components/xy_chart/crosshairs/crosshair_x.test.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiVerticalBarSeries } from '../series/vertical_bar_series'; +import { EuiCrosshairX } from './'; +import { requiredProps } from '../../../test/required_props'; +import { Crosshair } from 'react-vis'; +describe('EuiCrosshairX', () => { + test('render the X crosshair', () => { + const data = [ { x: 0, y: 1.5 }, { x: 1, y: 2 }]; + const component = mount( + + + + + ); + expect(component.find(EuiCrosshairX)).toHaveLength(1); + // check if the Crosshair component is empty + expect(component.find(Crosshair).children()).toHaveLength(0); + expect(component.render()).toMatchSnapshot(); + expect(component.find('rect')).toHaveLength(2); + + component.find('rect').at(0).simulate('mousemove', { nativeEvent: { clientX: 50, clientY: 100 } }); + expect(component.find(Crosshair).children()).toHaveLength(1); + const crosshair = component.find('.rv-crosshair'); + expect(crosshair).toHaveLength(1); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__title__value').text()).toBe('0'); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__item__value').text()).toBe('1.5'); + + component.find('rect').at(0).simulate('mousemove', { nativeEvent: { clientX: 351, clientY: 100 } }); + expect(crosshair).toHaveLength(1); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__title__value').text()).toBe('1'); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__item__value').text()).toBe('2'); + }); +}); diff --git a/src/components/xy_chart/crosshairs/crosshair_y.js b/src/components/xy_chart/crosshairs/crosshair_y.js new file mode 100644 index 00000000000..108cd320751 --- /dev/null +++ b/src/components/xy_chart/crosshairs/crosshair_y.js @@ -0,0 +1,386 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Copyright (c) 2016 - 2017 Uber Technologies, Inc. + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { AbstractSeries, ScaleUtils } from 'react-vis'; +import { SCALE } from '../utils/chart_utils'; + +/** + * Format title by detault. + * @param {Array} values List of values. + * @returns {*} Formatted value or undefined. + */ +function defaultTitleFormat(values) { + const value = getFirstNonEmptyValue(values); + if (value) { + return { + title: 'x', + value: value.x + }; + } +} + +/** + * Format items by default. + * @param {Array} values Array of values. + * @returns {*} Formatted list of items. + */ +function defaultItemsFormat(values) { + return values.map((v, i) => { + if (v) { + return { value: v.y, title: i }; + } + }); +} + +/** + * Get the first non-empty item from an array. + * @param {Array} values Array of values. + * @returns {*} First non-empty value or undefined. + */ +function getFirstNonEmptyValue(values) { + return (values || []).find(v => Boolean(v)); +} + +export class CrosshairY extends PureComponent { + + static get propTypes() { + return { + className: PropTypes.string, + values: PropTypes.array, + series: PropTypes.object, + innerWidth: PropTypes.number, + innerHeight: PropTypes.number, + marginLeft: PropTypes.number, + marginTop: PropTypes.number, + orientation: PropTypes.oneOf(['left', 'right']), + itemsFormat: PropTypes.func, + titleFormat: PropTypes.func, + style: PropTypes.shape({ + line: PropTypes.object, + title: PropTypes.object, + box: PropTypes.object + }) + }; + } + + static get defaultProps() { + return { + titleFormat: defaultTitleFormat, + itemsFormat: defaultItemsFormat, + style: { + line: {}, + title: {}, + box: {} + } + }; + } + + /** + * Render crosshair title. + * @returns {*} Container with the crosshair title. + * @private + */ + _renderCrosshairTitle() { + const { values, titleFormat, style } = this.props; + const titleItem = titleFormat(values); + if (!titleItem) { + return null; + } + return ( +
+ {titleItem.title} + {': '} + {titleItem.value} +
+ ); + } + + /** + * Render crosshair items (title + value for each series). + * @returns {*} Array of React classes with the crosshair values. + * @private + */ + _renderCrosshairItems() { + const { values, itemsFormat } = this.props; + const items = itemsFormat(values); + if (!items) { + return null; + } + return items.filter(i => i).map(function renderValue(item, i) { + return ( +
+ {item.title} + {': '} + {item.value} +
+ ); + }); + } + + render() { + const { + children, + className, + values, + marginTop, + marginLeft, + innerWidth, + style } = this.props; + const value = getFirstNonEmptyValue(values); + if (!value) { + return null; + } + const y = ScaleUtils.getAttributeFunctor(this.props, 'y'); + const innerTop = y(value); + + const left = marginLeft; + const top = marginTop + innerTop; + const innerClassName = `rv-crosshair__inner rv-crosshair__inner--left`; + return ( +
+ +
+ +
+ {children ? + children : +
+
+ {this._renderCrosshairTitle()} + {this._renderCrosshairItems()} +
+
+ } +
+
+ ); + } +} + +CrosshairY.displayName = 'CrosshairY'; + +/** + * The Crosshair used by the XYChart as main tooltip mechanism along Y axis (horizontal). + */ +export class EuiCrosshairY extends AbstractSeries { + state = { + values: [], + } + + static get requiresSVG() { + return false; + } + + static get isCanvas() { + return false; + } + + static getDerivedStateFromProps(props) { + const { crosshairValue, _allData } = props; + + if (crosshairValue !== undefined) { + return { + values: EuiCrosshairY._computeDataFromYValue(_allData, crosshairValue), + }; + } + return null; + } + + static _computeDataFromYValue(dataSeries, crosshairValue) { + const filteredAndFlattenDataByY = dataSeries + .filter(series => series) // get only cleaned data series + .map((series, seriesIndex) => { + return series + .filter(dataPoint => dataPoint.y === crosshairValue) + .map(dataPoint => ({ ...dataPoint, originalValues: { ...dataPoint }, seriesIndex })); + }) + .reduce((acc, val) => acc.concat(val), []); + return filteredAndFlattenDataByY; + } + + onParentMouseMove(event) { + this._handleNearestY(event); + } + + onParentMouseLeave() { + if (this.props.onCrosshairUpdate) { + this.props.onCrosshairUpdate(null); + } + this.setState({ + values: [] + }); + } + _formatYValue = (y) => { + const { yType } = this.props; + if (yType === SCALE.TIME || yType === SCALE.TIME_UTC) { + return new Date(y).toISOString(); // TODO add a props for time formatting + } else { + return y; + } + } + + _titleFormat = (dataPoints = []) => { + if (dataPoints.length > 0) { + const [ firstDataPoint ] = dataPoints; + const { originalValues } = firstDataPoint; + const value = (typeof originalValues.y0 === 'number') + ? `${this._formatYValue(originalValues.y0)} to ${this._formatYValue(originalValues.y)}` + : this._formatYValue(originalValues.y); + return { + title: 'Y Value', + value, + }; + } + } + + _itemsFormat = (dataPoints) => { + const { seriesNames } = this.props; + return dataPoints.map(d => { + return { + title: seriesNames[d.seriesIndex], + value: d.x, + }; + }); + } + + _handleNearestY(event) { + const cleanedDataSeries = this.props._allData.filter(dataSeries => dataSeries); + if (cleanedDataSeries.length === 0) { + return; + } + const containerCoordiante = super._getXYCoordinateInContainer(event); + this._findNearestYData(cleanedDataSeries, containerCoordiante.y); + } + + /** + * _findNearestYData - Find the nearest set of data in all existing series. + * + * @param {type} dataSeries an array of dataseries + * @param {type} mouseYContainerCoords the y coordinate of the mouse on the chart container + * @protected + */ + _findNearestYData(dataSeries, mouseYContainerCoords) { + const yScaleFn = super._getAttributeFunctor('y'); + // keeping a global min distance to filter only elements with the same distance + let globalMinDistance = Number.POSITIVE_INFINITY; + + const nearestYData = dataSeries + .map((data, seriesIndex) => { + let minDistance = Number.POSITIVE_INFINITY; + let value = null; + // TODO to increase the performance, it's better to use a search algorithm like bisect + // starting from the assumption that we will always have the same length for + // for each series and we can assume that the scale y index can reflect more or less + // the position of the mouse inside the array. + data.forEach((item) => { + let itemYCoords; + const yCoord = yScaleFn(item); + // check the right item coordinate if we use x0 and x value (e.g. on histograms) + if (typeof item.y0 === 'number') { + // we need to compute the scaled y0 using the xScale attribute functor + // we don't have access of the y0 attribute functor + const y0Coord = yScaleFn({ y: item.y0 }); + itemYCoords = (yCoord - y0Coord) / 2 + y0Coord; + } else { + itemYCoords = yCoord; + } + const newDistance = Math.abs(mouseYContainerCoords - itemYCoords); + if (newDistance < minDistance) { + minDistance = newDistance; + value = item; + } + globalMinDistance = Math.min(globalMinDistance, minDistance); + }); + + if (!value) { + return; + } + + return { + minDistance, + value, + seriesIndex, + }; + }) + .filter(d => d); + + // filter and map nearest X data per dataseries to get only the nearet onces + const values = nearestYData + .filter(value => value.minDistance === globalMinDistance) + .map(value => { + // check if we are on histograms and we need to show the right x and y values + const d = value.value; + const y = typeof d.y0 === 'number' + ? (d.y - d.y0) / 2 + d.y0 + : d.y; + const x = typeof d.x0 === 'number' + ? (d.x - d.x0) + : d.x; + return { x, y, originalValues: d, seriesIndex: value.seriesIndex }; + }); + const { onCrosshairUpdate } = this.props; + if (onCrosshairUpdate) { + onCrosshairUpdate(values[0].y); + } + + this.setState(() => ({ + values, + })); + } + + render() { + const { values } = this.state; + return ( + + ); + } +} + +EuiCrosshairY.displayName = 'EuiCrosshairY'; + +EuiCrosshairY.propTypes = { + /** + * The crosshair value used to display this crosshair (doesn't depend on mouse position) + */ + crosshairValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + /** + * The ordered array of series names + */ + seriesNames: PropTypes.arrayOf(PropTypes.string).isRequired, +}; +EuiCrosshairY.defaultProps = {}; diff --git a/src/components/xy_chart/crosshairs/crosshair_y.test.js b/src/components/xy_chart/crosshairs/crosshair_y.test.js new file mode 100644 index 00000000000..6a580544dd2 --- /dev/null +++ b/src/components/xy_chart/crosshairs/crosshair_y.test.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiHorizontalBarSeries } from '../series/horizontal_bar_series'; +import { EuiCrosshairY } from './'; +import { CrosshairY } from './crosshair_y'; +import { requiredProps } from '../../../test/required_props'; + +describe('EuiCrosshairY', () => { + test('render the Y crosshair', () => { + const data = [ { x: 1.5, y: 0 }, { x: 2, y: 1 }]; + const component = mount( + + + + + ); + expect(component.find(EuiCrosshairY)).toHaveLength(1); + // check if the Crosshair component is empty + expect(component.find(CrosshairY).children()).toHaveLength(0); + expect(component.render()).toMatchSnapshot(); + expect(component.find('rect')).toHaveLength(2); + + component.find('rect').at(0).simulate('mousemove', { nativeEvent: { clientX: 100, clientY: 0 } }); + expect(component.find(CrosshairY).children()).toHaveLength(1); + const crosshair = component.find('.rv-crosshair'); + expect(crosshair).toHaveLength(1); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__title__value').text()).toBe('1'); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__item__value').text()).toBe('2'); + + component.find('rect').at(0).simulate('mousemove', { nativeEvent: { clientX: 301, clientY: 100 } }); + expect(crosshair).toHaveLength(1); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__title__value').text()).toBe('0'); + expect(crosshair.find('.rv-crosshair__inner__content .rv-crosshair__item__value').text()).toBe('1.5'); + }); +}); diff --git a/src/components/xy_chart/crosshairs/index.js b/src/components/xy_chart/crosshairs/index.js new file mode 100644 index 00000000000..7eb5f347a4c --- /dev/null +++ b/src/components/xy_chart/crosshairs/index.js @@ -0,0 +1,2 @@ +export { EuiCrosshairX } from './crosshair_x'; +export { EuiCrosshairY } from './crosshair_y'; diff --git a/src/components/xy_chart/index.js b/src/components/xy_chart/index.js new file mode 100644 index 00000000000..7f2a702d816 --- /dev/null +++ b/src/components/xy_chart/index.js @@ -0,0 +1,14 @@ +export { EuiXYChart } from './xy_chart'; +export { EuiLineAnnotation } from './line_annotation'; + +// XY chart data series +export * from './series'; + +// XY chart axis components +export * from './axis'; + +// XY chart utility classes +export * from './utils'; + +// XY chart crosshairs +export * from './crosshairs'; diff --git a/src/components/xy_chart/legend.js b/src/components/xy_chart/legend.js new file mode 100644 index 00000000000..c31ed79e95f --- /dev/null +++ b/src/components/xy_chart/legend.js @@ -0,0 +1,37 @@ +import React from 'react'; +import LegendItem from './legend_item'; + +const Title = ({ children }) =>
{children}
; +const Container = ({ children }) =>
{children}
; +const LegendContent = ({ children }) =>
{children}
; +const TruncatedLabel = ({ children }) =>
{children}
; +const SeriesValue = ({ children }) =>
{children}
; +const MoreSeriesContainer = ({ children }) =>
{children}
; + +function MoreSeries({ hiddenSeries }) { + if (hiddenSeries <= 0) { + return null; + } + + return (+{hiddenSeries}); +} + +export default function Legends({ chartTitle, truncateLegends, series, hiddenSeries, clickLegend, seriesVisibility }) { + return ( +
+ {chartTitle} + + {series.filter(serie => !serie.isEmpty).map((serie, i) => { + const text = ( + + {truncateLegends ? {serie.title} : serie.title} + {serie.legendValue && {serie.legendValue}} + + ); + return clickLegend(i)} disabled={seriesVisibility[i]} text={text} color={serie.color} />; + })} + + +
+ ); +} diff --git a/src/components/xy_chart/legend_item.js b/src/components/xy_chart/legend_item.js new file mode 100644 index 00000000000..6dd5acf0e1f --- /dev/null +++ b/src/components/xy_chart/legend_item.js @@ -0,0 +1,36 @@ +import React, { PureComponent } from 'react'; + +const Container = ({ children, disabled }) => ( +
+ {children} +
+); + +const Indicator = ({ children, color }) => ( + + {children} + +); + +export default class Legend extends PureComponent { + render() { + const { onClick, color, text, fontSize, radius, disabled = false, className } = this.props; + + return ( + + + {text} + + ); + } +} diff --git a/src/components/xy_chart/line_annotation.js b/src/components/xy_chart/line_annotation.js new file mode 100644 index 00000000000..1a6e0fe07cc --- /dev/null +++ b/src/components/xy_chart/line_annotation.js @@ -0,0 +1,125 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { AbstractSeries, ScaleUtils } from 'react-vis'; +import { EuiXYChartUtils } from './utils/chart_utils'; +import { EuiXYChartAxisUtils } from './utils/axis_utils'; +const { HORIZONTAL, VERTICAL } = EuiXYChartUtils.ORIENTATION; +const { START, MIDDLE, END } = EuiXYChartAxisUtils.TITLE_POSITION; + +/** + * Draw simple line annotation into the chart. Currently it's a work in progress + * but will be extented to add text and tooltips if required. + * The basic usage is for displaying the current time marker. + */ +export class EuiLineAnnotation extends AbstractSeries { + /** + * Get attribute functor. + * @param {string} attr Attribute name + * @returns {*} Functor. + * @protected + */ + _getAttributeFunctor(attr) { + return ScaleUtils.getAttributeFunctor(this.props, attr); + } + /** + * Get the attribute value if it is available. + * @param {string} attr Attribute name. + * @returns {*} Attribute value if available, fallback value or undefined + * otherwise. + * @protected + */ + _getAttributeValue(attr) { + return ScaleUtils.getAttributeValue(this.props, attr); + } + _getTextXY(textPosition, min, max) { + switch (textPosition) { + case END: + return min; + case START: + return max; + case MIDDLE: + return Math.abs((max - min) / 2); + } + } + render() { + const { + data, + orientation, + textPosition, + innerHeight, + innerWidth, + marginLeft, + marginTop, + } = this.props; + const axis = orientation === HORIZONTAL ? 'y' : 'x'; + const scale = this._getAttributeFunctor(axis); + + return ( + + + {data.map((d, i) => { + const { value } = d; + const position = scale({ [axis]: value }); + return ( + + ); + })} + + + {data.filter(d => d.text).map((d, i) => { + const { value } = d; + let x = 0; + let y = 0; + let rotation = 0; + if (orientation === VERTICAL) { + x = scale({ [axis]: value }); + y = this._getTextXY(textPosition, 0, innerHeight); + rotation = '-90'; + } else { + x = this._getTextXY(textPosition, innerWidth, 0); + y = scale({ [axis]: value }); + } + + return ( + + {d.text} + + ); + })} + + + ); + } +} +EuiLineAnnotation.displayName = 'EuiLineAnnotation'; +EuiLineAnnotation.propTypes = { + /** An annotation data Array<{value: string|number, text: string}> */ + data: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + text: PropTypes.string, + }) + ).isRequired, + /** The orientation of the annotation. */ + orientation: PropTypes.oneOf([HORIZONTAL, VERTICAL]), + textPosition: PropTypes.oneOf([START, MIDDLE, END]), +}; + +EuiLineAnnotation.defaultProps = { + orientation: VERTICAL, + textPosition: START, +}; diff --git a/src/components/xy_chart/selection_brush.js b/src/components/xy_chart/selection_brush.js new file mode 100644 index 00000000000..63c1f0d0b7c --- /dev/null +++ b/src/components/xy_chart/selection_brush.js @@ -0,0 +1,216 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ScaleUtils, AbstractSeries } from 'react-vis'; +import { ORIENTATION, SCALE } from './utils/chart_utils'; +const { HORIZONTAL, VERTICAL, BOTH } = ORIENTATION; + +const DEFAULT_AREAS = { + areaSize: 0, + drawArea: { + x0: 0, + x1: 0, + y0: 0, + y1: 0, + }, + rectArea: { + x: 0, + y: 0, + width: 0, + height: 0, + } +}; + +export class EuiSelectionBrush extends AbstractSeries { + state = { + drawing: false, + ...DEFAULT_AREAS, + } + + onParentMouseDown(e) { + this._startDrawing(e); + } + + onParentMouseMove(e) { + this._brushing(e); + } + + onParentMouseUp() { + this._stopDrawing(); + } + + onParentMouseLeave() { + this._stopDrawing(); + } + + _getDrawArea(offsetX, offsetY, isStartingPoint) { + const { orientation, marginTop, marginLeft, innerHeight, innerWidth } = this.props; + const yLocation = offsetY - marginTop; + const xLocation = offsetX - marginLeft; + let x0; + let y0; + if (isStartingPoint) { + x0 = orientation === VERTICAL ? 0 : xLocation; + y0 = orientation === HORIZONTAL ? 0 : yLocation; + } else { + x0 = this.state.drawArea.x0; + y0 = this.state.drawArea.y0; + } + const x1 = orientation === VERTICAL ? innerWidth : xLocation; + const y1 = orientation === HORIZONTAL ? innerHeight : yLocation; + const areaSize = Math.abs(x0 - x1) * Math.abs(y0 - y1); + return { + areaSize, + drawArea: { + x0, + x1, + y0, + y1, + }, + rectArea: { + x: x0 < x1 ? x0 : x1, + y: y0 < y1 ? y0 : y1, + width: x0 < x1 ? (x1 - x0) : (x0 - x1), + height: y0 < y1 ? (y1 - y0) : (y0 - y1), + } + }; + } + + _getScaledValue(scale, scaleType, value0, value1) { + switch(scaleType) { + case SCALE.ORDINAL: + return [0, 0]; + default: + return [ + scale.invert(value0 < value1 ? value0 : value1), + scale.invert(value0 < value1 ? value1 : value0), + ]; + break; + + } + } + + _startDrawing = (e) => { + const { onBrushStart } = this.props; + const { offsetX, offsetY } = e.nativeEvent; + const drawAndRectAreas = this._getDrawArea(offsetX, offsetY, true); + this.setState(() => ({ + drawing: true, + ...drawAndRectAreas, + })); + + if (onBrushStart) { + onBrushStart(drawAndRectAreas); + } + } + + _brushing = (e) => { + const { onBrushing } = this.props; + const { drawing } = this.state; + const { offsetX, offsetY } = e.nativeEvent; + if (drawing) { + const drawAndRectAreas = this._getDrawArea(offsetX, offsetY); + this.setState(() => ({ + ...drawAndRectAreas + })); + + if (onBrushing) { + onBrushing(drawAndRectAreas); + } + } else { + this.setState(() => ({ + drawing: false, + ...DEFAULT_AREAS, + })); + } + } + + _stopDrawing = () => { + // Quickly short-circuit if the user isn't drawing in our component + const { drawing } = this.state; + if (!drawing) { + return; + } + + // Clear the draw area + this.setState(() => ({ + drawing: false, + ...DEFAULT_AREAS, + })); + + + // Don't invoke the callback if the selected area was < 25 square px. + // This is a click not a select + const { areaSize } = this.state; + if (areaSize < 25) { + return; + } + const { drawArea } = this.state; + const { x0, y0, x1, y1 } = drawArea; + const { xType, yType, onBrushEnd } = this.props; + const xScale = ScaleUtils.getAttributeScale(this.props, 'x'); + const yScale = ScaleUtils.getAttributeScale(this.props, 'y'); + + const xValues = this._getScaledValue(xScale, xType, x0, x1); + const yValues = this._getScaledValue(yScale, yType, y0, y1); + + // Compute the corresponding domain drawn + const domainArea = { + startX: xValues[0], + endX: xValues[1], + startY: yValues[1], + endY: yValues[0], + }; + + if (onBrushEnd) { + onBrushEnd({ + domainArea, + drawArea, + }); + } + } + + render() { + const { marginLeft, marginTop, color, opacity } = this.props; + const { rectArea: { x, y, width, height } } = this.state; + return ( + + + + ); + } +} + +EuiSelectionBrush.displayName = 'EuiSelectionBrush'; + +EuiSelectionBrush.propTypes = { + /** Specify the brush orientation */ + orientation: PropTypes.oneOf([ HORIZONTAL, VERTICAL, BOTH ]), + /** Callback on brush start event. */ + onBrushStart: PropTypes.func, + /** Callback on every mouse move event. */ + onBrushing: PropTypes.func, + /** Callback on brush end event. */ + onBrushEnd: PropTypes.func.isRequired, + /** The color of the brush rectangle */ + color: PropTypes.string, + /** The opacity of the brush rectangle*/ + opacity: PropTypes.number, +}; + +EuiSelectionBrush.defaultProps = { + orientation: HORIZONTAL, + color: 'black', + opacity: 0.2, +}; diff --git a/src/components/xy_chart/selection_brush.test.js b/src/components/xy_chart/selection_brush.test.js new file mode 100644 index 00000000000..dd1bd5ce4b7 --- /dev/null +++ b/src/components/xy_chart/selection_brush.test.js @@ -0,0 +1,235 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from './xy_chart'; +import { EuiSelectionBrush } from './selection_brush'; +import { EuiVerticalBarSeries } from './series'; +import { ORIENTATION, SCALE } from './utils/chart_utils'; + +const NOOP = () => {}; +const DEFAULT_MARGINS = { + left: 40, + right: 10, + top: 10, + bottom: 40 +}; + +describe('EuiSelectionBrush', () => { + + test(`renders an horizontal selection brush`, () => { + const data = [{ x: 0, y: 2 }, { x: 1, y: 4 }]; + const component = mount( + + + + ); + + let selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.exists()).toBe(true); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousedown', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 100, offsetY: DEFAULT_MARGINS.top + 100 } + }); + selectionBrush = component.find(EuiSelectionBrush); + + expect(selectionBrush).toMatchSnapshot(); + expect(selectionBrush.find('rect').at(0).props().x).toBe(50); + expect(selectionBrush.find('rect').at(0).props().y).toBe(0); + expect(selectionBrush.find('rect').at(0).props().width).toBe(50); + expect(selectionBrush.find('rect').at(0).props().height).toBe(200 - DEFAULT_MARGINS.top - DEFAULT_MARGINS.bottom); + component.find('svg').at(0).simulate('mouseup', { nativeEvent: { offsetX: 100, offsetY: 100 } }); + selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.find('rect').at(0).props().x).toBe(0); + expect(selectionBrush.find('rect').at(0).props().y).toBe(0); + expect(selectionBrush.find('rect').at(0).props().width).toBe(0); + expect(selectionBrush.find('rect').at(0).props().height).toBe(0); + + }); + test(`renders an vertical selection brush`, () => { + const data = [{ x: 0, y: 2 }, { x: 1, y: 4 }]; + const component = mount( + + + + ); + let selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.exists()).toBe(true); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousedown', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 100, offsetY: DEFAULT_MARGINS.top + 100 + } }); + selectionBrush = component.find(EuiSelectionBrush); + + expect(selectionBrush).toMatchSnapshot(); + expect(selectionBrush.find('rect').at(0).props().x).toBe(0); + expect(selectionBrush.find('rect').at(0).props().y).toBe(50); + expect(selectionBrush.find('rect').at(0).props().width).toBe(600 - DEFAULT_MARGINS.left - DEFAULT_MARGINS.right); + expect(selectionBrush.find('rect').at(0).props().height).toBe(50); + component.find('svg').at(0).simulate('mouseup', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 100, offsetY: DEFAULT_MARGINS.top + 100 } + }); + selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.find('rect').at(0).props().x).toBe(0); + expect(selectionBrush.find('rect').at(0).props().y).toBe(0); + expect(selectionBrush.find('rect').at(0).props().width).toBe(0); + expect(selectionBrush.find('rect').at(0).props().height).toBe(0); + }); + test(`renders free form selection brush`, () => { + const data = [{ x: 0, y: 2 }, { x: 1, y: 4 }]; + const component = mount( + + + + ); + let selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.exists()).toBe(true); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousedown', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 100, offsetY: DEFAULT_MARGINS.top + 100 } + }); + selectionBrush = component.find(EuiSelectionBrush); + + expect(selectionBrush).toMatchSnapshot(); + expect(selectionBrush.find('rect').at(0).props().x).toBe(50); + expect(selectionBrush.find('rect').at(0).props().y).toBe(50); + expect(selectionBrush.find('rect').at(0).props().width).toBe(50); + expect(selectionBrush.find('rect').at(0).props().height).toBe(50); + component.find('svg').at(0).simulate('mouseup', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 100, offsetY: DEFAULT_MARGINS.top + 100 } + }); + selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.find('rect').at(0).props().x).toBe(0); + expect(selectionBrush.find('rect').at(0).props().y).toBe(0); + expect(selectionBrush.find('rect').at(0).props().width).toBe(0); + expect(selectionBrush.find('rect').at(0).props().height).toBe(0); + }); + test(`get onSelectionBrushEnd call on linear x scale`, () => { + const data = [{ x: 0, y: 2 }, { x: 1, y: 4 }]; + const onSelectionBrushEndMock = jest.fn(); + const component = mount( + + + + ); + let selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.exists()).toBe(true); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousedown', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 50, offsetY: DEFAULT_MARGINS.top + 50 } + }); + component.find('svg').at(0).simulate('mousemove', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 100, offsetY: DEFAULT_MARGINS.top + 100 } + }); + component.find('svg').at(0).simulate('mouseup', { + nativeEvent: { offsetX: DEFAULT_MARGINS.left + 100, offsetY: DEFAULT_MARGINS.top + 100 } + }); + selectionBrush = component.find(EuiSelectionBrush); + expect(onSelectionBrushEndMock.mock.calls.length).toBe(1); + const expectedBrush = { + domainArea: { + startX: -0.5, + endX: 1.5, + startY: 2, + endY: 3, + }, + drawArea: { + x0: 0, + x1: 600, + y0: 50, + y1: 100, + } + }; + expect(onSelectionBrushEndMock.mock.calls[0][0]).toEqual(expectedBrush); + }); + test.skip(`get onSelectionBrushEnd call on ordinal x scale`, () => { + const data = [{ x: 0, y: 2 }, { x: 1, y: 4 }]; + const onSelectionBrushEndMock = jest.fn(); + const component = mount( + + + + ); + let selectionBrush = component.find(EuiSelectionBrush); + expect(selectionBrush.exists()).toBe(true); + component.find('svg').at(0).simulate('mousemove', { nativeEvent: { offsetX: 50, offsetY: 50 } }); + component.find('svg').at(0).simulate('mousedown', { nativeEvent: { offsetX: 50, offsetY: 50 } }); + component.find('svg').at(0).simulate('mousemove', { nativeEvent: { offsetX: 100, offsetY: 100 } }); + component.find('svg').at(0).simulate('mouseup', { nativeEvent: { offsetX: 100, offsetY: 100 } }); + selectionBrush = component.find(EuiSelectionBrush); + expect(onSelectionBrushEndMock.mock.calls.length).toBe(1); + const expectedBrush = { + // TODO update the domain in respect to ordinal scale + domainArea: { + startX: -0.5, + endX: 1.5, + startY: 2, + endY: 3, + }, + drawArea: { + x0: 0, + x1: 600, + y0: 50, + y1: 100, + } + }; + expect(onSelectionBrushEndMock.mock.calls[0][0]).toEqual(expectedBrush); + }); +}); diff --git a/src/components/xy_chart/series/__snapshots__/area_series.test.js.snap b/src/components/xy_chart/series/__snapshots__/area_series.test.js.snap new file mode 100644 index 00000000000..35737f402a1 --- /dev/null +++ b/src/components/xy_chart/series/__snapshots__/area_series.test.js.snap @@ -0,0 +1,3621 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiAreaSeries all props are rendered 1`] = ` + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + 5 + + + + + + 6 + + + + + + 7 + + + + + + 8 + + + + + + 9 + + + + + + 10 + + + + + + + + + + + + + + + + +
+
+
+
+
+
+`; diff --git a/src/components/xy_chart/series/__snapshots__/horizontal_bar_series.test.js.snap b/src/components/xy_chart/series/__snapshots__/horizontal_bar_series.test.js.snap new file mode 100644 index 00000000000..f2c56c35169 --- /dev/null +++ b/src/components/xy_chart/series/__snapshots__/horizontal_bar_series.test.js.snap @@ -0,0 +1,1143 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiHorizontalBarSeries all props are rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 0 + + + + + + 5 + + + + + + 10 + + + + + + 15 + + + + + + 20 + + + + + +
+
+
+`; + +exports[`EuiHorizontalBarSeries is rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 0 + + + + + + 5 + + + + + + 10 + + + + + + 15 + + + + + + 20 + + + + + +
+
+
+`; + +exports[`EuiHorizontalBarSeries renders stacked bar chart 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + 1 + + + + + + + + + + + 2 + + + + + + 4 + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/series/__snapshots__/horizontal_rect_series.test.js.snap b/src/components/xy_chart/series/__snapshots__/horizontal_rect_series.test.js.snap new file mode 100644 index 00000000000..d01d678fa8f --- /dev/null +++ b/src/components/xy_chart/series/__snapshots__/horizontal_rect_series.test.js.snap @@ -0,0 +1,1224 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiHorizontalRectSeries all props are rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + +
+
+
+`; + +exports[`EuiHorizontalRectSeries is rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + +
+
+
+`; + +exports[`EuiHorizontalRectSeries renders stacked bar chart 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + 1 + + + + + + + + + + + 3 + + + + + + 4 + + + + + + 5 + + + + + + 6 + + + + + + 7 + + + + + + 8 + + + + + + 9 + + + + + + 10 + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/series/__snapshots__/line_series.test.js.snap b/src/components/xy_chart/series/__snapshots__/line_series.test.js.snap new file mode 100644 index 00000000000..724bbed719e --- /dev/null +++ b/src/components/xy_chart/series/__snapshots__/line_series.test.js.snap @@ -0,0 +1,8189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiLineSeries all props are rendered 1`] = ` + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + + + + + + + + + + + + +
+
+
+
+
+
+`; + +exports[`EuiLineSeries is rendered 1`] = ` + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + + + + + + + + + + + + +
+
+
+
+
+
+`; diff --git a/src/components/xy_chart/series/__snapshots__/vertical_bar_series.test.js.snap b/src/components/xy_chart/series/__snapshots__/vertical_bar_series.test.js.snap new file mode 100644 index 00000000000..37e8ac83ea6 --- /dev/null +++ b/src/components/xy_chart/series/__snapshots__/vertical_bar_series.test.js.snap @@ -0,0 +1,1292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiVerticalBarSeries all props are rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + -0.4 + + + + + + -0.2 + + + + + + 0.0 + + + + + + 0.2 + + + + + + 0.4 + + + + + + 0.6 + + + + + + 0.8 + + + + + + 1.0 + + + + + + 1.2 + + + + + + 1.4 + + + + + + + + + + + 0 + + + + + + 2 + + + + + + 4 + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + +
+
+
+`; + +exports[`EuiVerticalBarSeries is rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + -0.4 + + + + + + -0.2 + + + + + + 0.0 + + + + + + 0.2 + + + + + + 0.4 + + + + + + 0.6 + + + + + + 0.8 + + + + + + 1.0 + + + + + + 1.2 + + + + + + 1.4 + + + + + + + + + + + 0 + + + + + + 2 + + + + + + 4 + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + +
+
+
+`; + +exports[`EuiVerticalBarSeries renders stacked bar chart 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + 1 + + + + + + + + + + + 0 + + + + + + 2 + + + + + + 4 + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/series/__snapshots__/vertical_rect_series.test.js.snap b/src/components/xy_chart/series/__snapshots__/vertical_rect_series.test.js.snap new file mode 100644 index 00000000000..0c59f31c9dd --- /dev/null +++ b/src/components/xy_chart/series/__snapshots__/vertical_rect_series.test.js.snap @@ -0,0 +1,1332 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiVerticalRectSeries all props are rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 0 + + + + + + 2 + + + + + + 4 + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + +
+
+
+`; + +exports[`EuiVerticalRectSeries is rendered 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + 0.0 + + + + + + 0.1 + + + + + + 0.2 + + + + + + 0.3 + + + + + + 0.4 + + + + + + 0.5 + + + + + + 0.6 + + + + + + 0.7 + + + + + + 0.8 + + + + + + 0.9 + + + + + + 1.0 + + + + + + + + + + + 0 + + + + + + 2 + + + + + + 4 + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + + 12 + + + + + + 14 + + + + + +
+
+
+`; + +exports[`EuiVerticalRectSeries renders stacked vertical histogram 1`] = ` +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + 1 + + + + + + + + + + + 0 + + + + + + 2 + + + + + + 4 + + + + + + 6 + + + + + + 8 + + + + + + 10 + + + + + +
+
+
+`; diff --git a/src/components/xy_chart/series/_area_series.scss b/src/components/xy_chart/series/_area_series.scss new file mode 100644 index 00000000000..868f7c3a090 --- /dev/null +++ b/src/components/xy_chart/series/_area_series.scss @@ -0,0 +1,3 @@ +.euiAreaSeries { + stroke-width: 0; +} diff --git a/src/components/xy_chart/series/_bar_series.scss b/src/components/xy_chart/series/_bar_series.scss new file mode 100644 index 00000000000..edebd5d9f3f --- /dev/null +++ b/src/components/xy_chart/series/_bar_series.scss @@ -0,0 +1,27 @@ +// NOTE: opacity, stroke and fill are style by code inside react-vis +// we can overwrite and add !important for force svg styled component +// or overwrite style by code. + +.euiBarSeries { + rect { + stroke-width: 1; + stroke: white !important; + rx: 2; + ry: 2; + } +} + +.euiBarSeries--highDataVolume { + rect { + stroke-width: 0; + rx: 0; + ry: 0; + } +} +.euiBarSeries--hoverEnabled { + rect{ + &:hover { + cursor: pointer; + } + } +} diff --git a/src/components/xy_chart/series/_histogram_series.scss b/src/components/xy_chart/series/_histogram_series.scss new file mode 100644 index 00000000000..9118a97865b --- /dev/null +++ b/src/components/xy_chart/series/_histogram_series.scss @@ -0,0 +1,27 @@ +// NOTE: opacity, stroke and fill are style by code inside react-vis +// we can overwrite and add !important for force svg styled component +// or overwrite style by code. + +.euiHistogramSeries { + rect { + stroke-width: 1; + stroke: white !important; + rx: 2; + ry: 2; + } +} + +.euiHistogramSeries--highDataVolume { + rect { + stroke-width: 0; + rx: 0; + ry: 0; + } +} +.euiHistogramSeries--hoverEnabled { + rect{ + &:hover { + cursor: pointer; + } + } +} diff --git a/src/components/xy_chart/series/_index.scss b/src/components/xy_chart/series/_index.scss new file mode 100644 index 00000000000..d497bbbf741 --- /dev/null +++ b/src/components/xy_chart/series/_index.scss @@ -0,0 +1,3 @@ +@import "area_series"; +@import "bar_series"; +@import "histogram_series"; diff --git a/src/components/xy_chart/series/area_series.js b/src/components/xy_chart/series/area_series.js new file mode 100644 index 00000000000..d4986137b54 --- /dev/null +++ b/src/components/xy_chart/series/area_series.js @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { AreaSeries, AbstractSeries } from 'react-vis'; +import { CURVE } from '../utils/chart_utils'; + +import { VisualizationColorType } from '../utils/visualization_color_type'; + +// TODO: needs to send a PR to react-vis for incorporate these changes into AreaSeries class for vertical +// area chart visualizations. +// class ExtendedAreaSeries extends AreaSeries { +// _renderArea(data, x, y0, y, curve, getNull) { +// const x0 = this._getAttr0Functor('x'); +// let area = d3Area(); +// if (curve !== null) { +// if (typeof curve === 'string' && curves[curve]) { +// area = area.curve(curves[curve]); +// } else if (typeof curve === 'function') { +// area = area.curve(curve); +// } +// } +// console.log(Object.getPrototypeOf(this)) +// area = area.defined(getNull); +// area = area +// .x1(x) +// .x0(x0) // this is required for displaying vertical area charts. +// .y0(y0) +// .y1(y); +// return area(data); +// } +// } + +export class EuiAreaSeries extends AbstractSeries { + state = { + isMouseOverSeries: false, + } + + _onSeriesMouseOver = () => { + this.setState(() => ({ isMouseOverSeries: true })); + } + + _onSeriesMouseOut = () => { + this.setState(() => ({ isMouseOverSeries: false })); + } + + render() { + const { isMouseOverSeries } = this.state; + const { name, data, curve, color, onSeriesClick, ...rest } = this.props; + return ( + + ); + } +} +EuiAreaSeries.displayName = 'EuiAreaSeries'; +EuiAreaSeries.propTypes = { + /** The name used to define the data in tooltips and legends */ + name: PropTypes.string.isRequired, + /** Array<{x: string|number, y: string|number}> */ + data: PropTypes.arrayOf( + PropTypes.shape({ + x: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + y: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }) + ).isRequired, + /** An EUI visualization color, the default value is enforced by EuiXYChart */ + color: VisualizationColorType, + curve: PropTypes.oneOf(Object.values(CURVE)), + onSeriesClick: PropTypes.func, +}; + +EuiAreaSeries.defaultProps = { + curve: CURVE.LINEAR, +}; diff --git a/src/components/xy_chart/series/area_series.test.js b/src/components/xy_chart/series/area_series.test.js new file mode 100644 index 00000000000..4b63f09b514 --- /dev/null +++ b/src/components/xy_chart/series/area_series.test.js @@ -0,0 +1,123 @@ +import React from 'react'; +import { mount, render } from 'enzyme'; +import { patchRandom, unpatchRandom } from '../../../test/patch_random'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiAreaSeries } from './area_series'; +import { CURVE } from '../utils/chart_utils'; +import { benchmarkFunction } from '../../../test/time_execution'; +import { VISUALIZATION_COLORS } from '../../../services'; + +beforeEach(patchRandom); +afterEach(unpatchRandom); + +const AREA_SERIES_PROPS = { + name: 'name', + data: [{ x: 0, y: 5 }, { x: 1, y: 10 }], + color: VISUALIZATION_COLORS[0], + curve: CURVE.NATURAL, + onSeriesClick: jest.fn(), +}; + +describe('EuiAreaSeries', () => { + test('all props are rendered', () => { + const component = mount( + + + + ); + + expect(component).toMatchSnapshot(); + }); + + test('call onSeriesClick', () => { + const data = [{ x: 0, y: 5 }, { x: 1, y: 3 }]; + const onSeriesClick = jest.fn(); + const component = mount( + + + + ); + component.find('path').at(0).simulate('click'); + expect(onSeriesClick.mock.calls).toHaveLength(1); + }); + + describe('performance', () => { + it.skip('renders 1000 items in under 1 second', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [250, '250'], + [500, '500'], + [750, '750'], + [1000, '1000'] + ]; + const data = []; + + for (let i = 0; i < 1000; i++) { + data.push({ x: i, y: Math.random() }); + } + + function renderChart() { + render( + + + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~150ms on a MacBookPro + expect(runtime).toBeLessThan(1000); + }); + + it.skip('renders 30 lines of 500 items in under 3 seconds', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [125, '125'], + [250, '240'], + [375, '375'], + [500, '500'] + ]; + + const linesData = []; + for (let i = 0; i < 30; i++) { + const data = []; + + for (let i = 0; i < 500; i++) { + data.push({ x: i, y: Math.random() }); + } + + linesData.push(data); + } + + function renderChart() { + render( + + {linesData.map((data, index) => ( + + ))} + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~2150 on a MacBookPro + expect(runtime).toBeLessThan(3000); + }); + }); +}); diff --git a/src/components/xy_chart/series/bar_series.js b/src/components/xy_chart/series/bar_series.js new file mode 100644 index 00000000000..e9bfa306dcc --- /dev/null +++ b/src/components/xy_chart/series/bar_series.js @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { VerticalBarSeries, HorizontalBarSeries, AbstractSeries } from 'react-vis'; +import { ORIENTATION } from '../utils/chart_utils'; +import classNames from 'classnames'; + +import { VisualizationColorType } from '../utils/visualization_color_type'; + +export class EuiBarSeries extends AbstractSeries { + state = { + isMouseOverValue: false, + } + static getParentConfig(attr, props) { + const { _orientation } = props; + return _orientation === ORIENTATION.HORIZONTAL + ? HorizontalBarSeries.getParentConfig(attr) + : VerticalBarSeries.getParentConfig(attr); + } + _onValueMouseOver = () => { + this.setState(() => ({ isMouseOverValue: true })); + } + + _onValueMouseOut = () => { + this.setState(() => ({ isMouseOverValue: false })); + } + render() { + const { _orientation, name, data, color, onValueClick, ...rest } = this.props; + const { isMouseOverValue } = this.state; + const isHighDataVolume = data.length > 80 ? true : false; + const classes = classNames( + 'euiBarSeries', + isHighDataVolume && 'euiBarSeries--highDataVolume', + isMouseOverValue && onValueClick && 'euiBarSeries--hoverEnabled', + ); + const BarSeriesComponent = _orientation === ORIENTATION.HORIZONTAL ? HorizontalBarSeries : VerticalBarSeries; + return ( + + ); + } +} + +EuiBarSeries.displayName = 'EuiBarSeries'; + +EuiBarSeries.propTypes = { + /** + * The name used to define the data in tooltips and legends + */ + name: PropTypes.string.isRequired, + /** + * Array<{x: string|number, y: string|number}> depending on XYChart xType scale and yType scale + */ + data: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + y: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + })).isRequired, + /** + * An EUI visualization color, the default value is passed through EuiXYChart + */ + color: VisualizationColorType, + /** + * @private passed via XYChart + */ + // _orientation: PropTypes.string, + + /** + * Callback when clicking on a bar. Returns { x, y } object. + */ + onValueClick: PropTypes.func, +}; + +EuiBarSeries.defaultProps = {}; diff --git a/src/components/xy_chart/series/histogram_series.js b/src/components/xy_chart/series/histogram_series.js new file mode 100644 index 00000000000..a18a1e4cc0a --- /dev/null +++ b/src/components/xy_chart/series/histogram_series.js @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { VerticalRectSeries, HorizontalRectSeries, AbstractSeries } from 'react-vis'; +import { ORIENTATION } from '../utils/chart_utils'; +import { VISUALIZATION_COLORS } from '../../../services'; +import classNames from 'classnames'; + +export class EuiHistogramSeries extends AbstractSeries { + state = { + isMouseOverValue: false, + } + static getParentConfig(attr, props) { + const { _orientation } = props; + return _orientation === ORIENTATION.HORIZONTAL + ? HorizontalRectSeries.getParentConfig(attr) + : VerticalRectSeries.getParentConfig(attr); + } + _onValueMouseOver = () => { + this.setState(() => ({ isMouseOverValue: true })); + } + + _onValueMouseOut = () => { + this.setState(() => ({ isMouseOverValue: false })); + } + render() { + const { _orientation, name, data, color, onValueClick, ...rest } = this.props; + const { isMouseOverValue } = this.state; + const isHighDataVolume = data.length > 80 ? true : false; + const classes = classNames( + 'euiHistogramSeries', + isHighDataVolume && 'euiHistogramSeries--highDataVolume', + isMouseOverValue && onValueClick && 'euiHistogramSeries--hoverEnabled', + ); + const HistogramSeriesComponent = _orientation === ORIENTATION.HORIZONTAL ? HorizontalRectSeries : VerticalRectSeries; + return ( + + ); + } +} + +EuiHistogramSeries.displayName = 'EuiHistogramSeries'; + +EuiHistogramSeries.propTypes = { + /** The name used to define the data in tooltips and legends */ + name: PropTypes.string.isRequired, + /** Array<{x: number, y: string|number}> */ + data: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + y: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + })).isRequired, + /** An EUI visualization color, the default value is enforced by EuiXYChart */ + color: PropTypes.oneOf(VISUALIZATION_COLORS), + + /** + * @private passed via XYChart + */ + // _orientation: PropTypes.string, + /** + * Callback when clicking on a bar. Returns { x, y } object. + */ + onValueClick: PropTypes.func, + +}; + +EuiHistogramSeries.defaultProps = {}; diff --git a/src/components/xy_chart/series/horizontal_bar_series.js b/src/components/xy_chart/series/horizontal_bar_series.js new file mode 100644 index 00000000000..a52702d0cb0 --- /dev/null +++ b/src/components/xy_chart/series/horizontal_bar_series.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { HorizontalBarSeries } from 'react-vis'; +import classNames from 'classnames'; + +import { VisualizationColorType } from '../utils/visualization_color_type'; + +export class EuiHorizontalBarSeries extends HorizontalBarSeries { + state = { + isMouseOverValue: false, + } + + _onValueMouseOver = () => { + this.setState(() => ({ isMouseOverValue: true })); + } + + _onValueMouseOut = () => { + this.setState(() => ({ isMouseOverValue: false })); + } + + render() { + const { isMouseOverValue } = this.state; + const { name, data, color, onValueClick, ...rest } = this.props; + const isHighDataVolume = data.length > 80 ? true : false; + const classes = classNames( + 'euiBarSeries', + isHighDataVolume && 'euiBarSeries--highDataVolume', + isMouseOverValue && onValueClick && 'euiBarSeries--hoverEnabled', + ); + return ( + + ); + } +} + +EuiHorizontalBarSeries.displayName = 'EuiHorizontalBarSeries'; + +EuiHorizontalBarSeries.propTypes = { + /** The name used to define the data in tooltips and legends */ + name: PropTypes.string.isRequired, + /** Array<{x: number, y: string|number}> */ + data: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.number, + y: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + })).isRequired, + /** An EUI visualization color, the default value is enforced by EuiXYChart */ + color: VisualizationColorType, + /** + * Callback when clicking on a bar. Returns { x, y } object. + */ + onValueClick: PropTypes.func +}; + +EuiHorizontalBarSeries.defaultProps = {}; diff --git a/src/components/xy_chart/series/horizontal_bar_series.test.js b/src/components/xy_chart/series/horizontal_bar_series.test.js new file mode 100644 index 00000000000..a4634ec7156 --- /dev/null +++ b/src/components/xy_chart/series/horizontal_bar_series.test.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, mount } from 'enzyme'; +import { patchRandom, unpatchRandom } from '../../../test/patch_random'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiHorizontalBarSeries } from './horizontal_bar_series'; +import { VISUALIZATION_COLORS } from '../../../services'; + +beforeEach(patchRandom); +afterEach(unpatchRandom); + +describe('EuiHorizontalBarSeries', () => { + test('is rendered', () => { + const component = mount( + + + + ); + + expect(component.find('.rv-xy-plot__series')).toHaveLength(1); + + const rects = component.find('.rv-xy-plot__series--bar rect'); + expect(rects).toHaveLength(2); + + const firstRectProps = rects.at(0).props(); + expect(firstRectProps.x).toBeDefined(); + expect(firstRectProps.y).toBeDefined(); + expect(firstRectProps.width).toBeDefined(); + expect(firstRectProps.height).toBeDefined(); + + const secondRectProps = rects.at(1).props(); + expect(secondRectProps.x).toBeDefined(); + expect(secondRectProps.y).toBeDefined(); + expect(secondRectProps.width).toBeDefined(); + expect(secondRectProps.height).toBeDefined(); + + expect(component.render()).toMatchSnapshot(); + }); + + test('call onValueClick', () => { + const data = [{ x: 0, y: 5 }, { x: 1, y: 3 }]; + const onValueClick = jest.fn(); + const component = mount( + + + + ); + component.find('rect').at(0).simulate('click'); + expect(onValueClick.mock.calls).toHaveLength(1); + expect(onValueClick.mock.calls[0][0]).toEqual(data[0]); + }); + + test('all props are rendered', () => { + const component = render( + + {}} + /> + + ); + + expect(component).toMatchSnapshot(); + }); + + test('renders stacked bar chart', () => { + const component = render( + + {}} + /> + {}} + /> + + ); + expect(component.find('.rv-xy-plot__series')).toHaveLength(2); + expect(component.find('.rv-xy-plot__series--bar rect')).toHaveLength(4); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/xy_chart/series/horizontal_rect_series.js b/src/components/xy_chart/series/horizontal_rect_series.js new file mode 100644 index 00000000000..237a7ea27da --- /dev/null +++ b/src/components/xy_chart/series/horizontal_rect_series.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { HorizontalRectSeries } from 'react-vis'; +import classNames from 'classnames'; + +import { VisualizationColorType } from '../utils/visualization_color_type'; + +export class EuiHorizontalRectSeries extends HorizontalRectSeries { + state = { + isMouseOverValue: false, + } + + _onValueMouseOver = () => { + this.setState(() => ({ isMouseOverValue: true })); + } + + _onValueMouseOut = () => { + this.setState(() => ({ isMouseOverValue: false })); + } + + render() { + const { isMouseOverValue } = this.state; + const { name, data, color, onValueClick, ...rest } = this.props; + const isHighDataVolume = data.length > 80 ? true : false; + const classes = classNames( + 'euiHistogramSeries', + isHighDataVolume && 'euiHistogramSeries--highDataVolume', + isMouseOverValue && onValueClick && 'euiHistogramSeries--hoverEnabled', + ); + return ( + + ); + } +} + +EuiHorizontalRectSeries.displayName = 'EuiHorizontalRectSeries'; + +EuiHorizontalRectSeries.propTypes = { + /** The name used to define the data in tooltips and legends */ + name: PropTypes.string.isRequired, + /** Array<{x: number, y: number, y0: number}> */ + data: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.number, + y: PropTypes.number, + y0: PropTypes.number, + })).isRequired, + /** An EUI visualization color, the default value is enforced by EuiXYChart */ + color: VisualizationColorType, + /** + * Callback when clicking on a bar. Returns { x, y } object. + */ + onValueClick: PropTypes.func +}; + +EuiHorizontalRectSeries.defaultProps = {}; diff --git a/src/components/xy_chart/series/horizontal_rect_series.test.js b/src/components/xy_chart/series/horizontal_rect_series.test.js new file mode 100644 index 00000000000..fdc5df469f8 --- /dev/null +++ b/src/components/xy_chart/series/horizontal_rect_series.test.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, mount } from 'enzyme'; +import { patchRandom, unpatchRandom } from '../../../test/patch_random'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiHorizontalRectSeries } from './horizontal_rect_series'; +import { VISUALIZATION_COLORS } from '../../../services'; + +beforeEach(patchRandom); +afterEach(unpatchRandom); + +describe('EuiHorizontalRectSeries', () => { + test('is rendered', () => { + const component = mount( + + + + ); + + expect(component.find('.rv-xy-plot__series')).toHaveLength(1); + + const rects = component.find('.rv-xy-plot__series--rect rect'); + expect(rects).toHaveLength(2); + + const firstRectProps = rects.at(0).props(); + expect(firstRectProps.x).toBeDefined(); + expect(firstRectProps.y).toBeDefined(); + expect(firstRectProps.width).toBeDefined(); + expect(firstRectProps.height).toBeDefined(); + + const secondRectProps = rects.at(1).props(); + expect(secondRectProps.x).toBeDefined(); + expect(secondRectProps.y).toBeDefined(); + expect(secondRectProps.width).toBeDefined(); + expect(secondRectProps.height).toBeDefined(); + + expect(component.render()).toMatchSnapshot(); + }); + + test('all props are rendered', () => { + const component = render( + + {}} + /> + + ); + + expect(component).toMatchSnapshot(); + }); + + test('call onValueClick', () => { + const data = [{ x: 0, y: 5 }, { x: 1, y: 3 }]; + const onValueClick = jest.fn(); + const component = mount( + + + + ); + component.find('rect').at(0).simulate('click'); + expect(onValueClick.mock.calls).toHaveLength(1); + expect(onValueClick.mock.calls[0][0]).toEqual(data[0]); + }); + + test('renders stacked bar chart', () => { + const component = render( + + {}} + /> + {}} + /> + + ); + expect(component.find('.rv-xy-plot__series')).toHaveLength(2); + expect(component.find('.rv-xy-plot__series--rect rect')).toHaveLength(4); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/src/components/xy_chart/series/index.js b/src/components/xy_chart/series/index.js new file mode 100644 index 00000000000..4eb1e6c4f76 --- /dev/null +++ b/src/components/xy_chart/series/index.js @@ -0,0 +1,8 @@ +export { EuiLineSeries } from './line_series'; +export { EuiAreaSeries } from './area_series'; +export { EuiBarSeries } from './bar_series'; +export { EuiHistogramSeries } from './histogram_series'; +export { EuiVerticalBarSeries } from './vertical_bar_series'; +export { EuiHorizontalBarSeries } from './horizontal_bar_series'; +export { EuiVerticalRectSeries } from './vertical_rect_series'; +export { EuiHorizontalRectSeries } from './horizontal_rect_series'; diff --git a/src/components/xy_chart/series/line_series.js b/src/components/xy_chart/series/line_series.js new file mode 100644 index 00000000000..047cf185d6c --- /dev/null +++ b/src/components/xy_chart/series/line_series.js @@ -0,0 +1,101 @@ +import React, { Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { LineSeries, MarkSeries, AbstractSeries } from 'react-vis'; +import { CURVE } from '../utils/chart_utils'; +import { VisualizationColorType } from '../utils/visualization_color_type'; + +export class EuiLineSeries extends AbstractSeries { + render() { + const { + data, + name, + curve, + onSeriesClick, + onValueClick, + showLineMarks, + lineSize, + lineMarkColor, + lineMarkSize, + color, + ...rest + } = this.props; + + return ( + + + + + {showLineMarks && ( + + )} + + ); + } +} + +EuiLineSeries.displayName = 'EuiLineSeries'; + +EuiLineSeries.propTypes = { + /** The name used to define the data in tooltips and legends */ + name: PropTypes.string.isRequired, + /** Array<{x: string|number, y: string|number}> */ + data: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + y: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + })).isRequired, + /** An EUI visualization color, the default value is enforced by EuiXYChart */ + color: VisualizationColorType, + curve: PropTypes.oneOf(Object.values(CURVE)), + showLineMarks: PropTypes.bool, + lineSize: PropTypes.number, + lineMarkColor: PropTypes.string, + lineMarkSize: PropTypes.number, + onSeriesClick: PropTypes.func, + onValueClick: PropTypes.func +}; + +EuiLineSeries.defaultProps = { + curve: CURVE.LINEAR, + showLineMarks: false, + lineSize: 1, + lineMarkSize: 0 +}; diff --git a/src/components/xy_chart/series/line_series.test.js b/src/components/xy_chart/series/line_series.test.js new file mode 100644 index 00000000000..6fc92066b53 --- /dev/null +++ b/src/components/xy_chart/series/line_series.test.js @@ -0,0 +1,144 @@ +import React from 'react'; +import { mount, render } from 'enzyme'; +import { patchRandom, unpatchRandom } from '../../../test/patch_random'; +import { benchmarkFunction } from '../../../test/time_execution'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiLineSeries } from './line_series'; +import { VISUALIZATION_COLORS } from '../../../services'; + +beforeEach(patchRandom); +afterEach(unpatchRandom); + +describe('EuiLineSeries', () => { + test('is rendered', () => { + const component = mount( + + + + ); + + expect(component).toMatchSnapshot(); + }); + + test('all props are rendered', () => { + const component = mount( + + {}} + onValueClick={() => {}} + /> + + ); + + expect(component).toMatchSnapshot(); + }); + + test('call onValueClick and onSeriesClick', () => { + const data = [{ x: 0, y: 5 }, { x: 1, y: 3 }]; + const onValueClick = jest.fn(); + const onSeriesClick = jest.fn(); + const component = mount( + + + + ); + component.find('path').at(0).simulate('click'); + expect(onSeriesClick.mock.calls).toHaveLength(1); + component.find('circle').at(0).simulate('click'); + expect(onValueClick.mock.calls).toHaveLength(1); + expect(onValueClick.mock.calls[0][0]).toEqual(data[0]); + // check if onSeriesClick is fired after clicking on marker + expect(onSeriesClick.mock.calls).toHaveLength(1); + }); + + + describe('performance', () => { + it.skip('renders 1000 items in under 1 second', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [250, '250'], + [500, '500'], + [750, '750'], + [1000, '1000'] + ]; + const data = []; + + for (let i = 0; i < 1000; i++) { + data.push({ x: i, y: Math.random() }); + } + + function renderChart() { + render( + + + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~120ms on a MacBookPro + expect(runtime).toBeLessThan(1000); + }); + + it.skip('renders 30 lines of 500 items in under 3 seconds', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [125, '125'], + [250, '240'], + [375, '375'], + [500, '500'] + ]; + + const linesData = []; + for (let i = 0; i < 30; i++) { + const data = []; + + for (let i = 0; i < 500; i++) { + data.push({ x: i, y: Math.random() }); + } + + linesData.push(data); + } + + function renderChart() { + render( + + {linesData.map((data, index) => ( + + ))} + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~1700ms on a MacBookPro + expect(runtime).toBeLessThan(3000); + }); + }); +}); diff --git a/src/components/xy_chart/series/vertical_bar_series.js b/src/components/xy_chart/series/vertical_bar_series.js new file mode 100644 index 00000000000..eaaf1faab39 --- /dev/null +++ b/src/components/xy_chart/series/vertical_bar_series.js @@ -0,0 +1,66 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { VerticalBarSeries } from 'react-vis'; +import classNames from 'classnames'; + +import { VisualizationColorType } from '../utils/visualization_color_type'; + +export class EuiVerticalBarSeries extends VerticalBarSeries { + state = { + isMouseOverValue: false, + } + + _onValueMouseOver = () => { + this.setState(() => ({ isMouseOverValue: true })); + } + + _onValueMouseOut = () => { + this.setState(() => ({ isMouseOverValue: false })); + } + + render() { + const { isMouseOverValue } = this.state; + const { name, data, color, onValueClick, ...rest } = this.props; + const isHighDataVolume = data.length > 80 ? true : false; + const classes = classNames( + 'euiBarSeries', + isHighDataVolume && 'euiBarSeries--highDataVolume', + isMouseOverValue && onValueClick && 'euiBarSeries--hoverEnabled', + ); + return ( + + ); + } +} + +EuiVerticalBarSeries.displayName = 'EuiVerticalBarSeries'; + +EuiVerticalBarSeries.propTypes = { + /** The name used to define the data in tooltips and legends */ + name: PropTypes.string.isRequired, + /** Array<{x: string|number, y: number}> */ + data: PropTypes.arrayOf(PropTypes.shape({ + x: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]), + y: PropTypes.number, + })).isRequired, + /** An EUI visualization color, the default value is enforced by EuiXYChart */ + color: VisualizationColorType, + /** + * Callback when clicking on a bar. Returns { x, y } object. + */ + onValueClick: PropTypes.func, +}; + +EuiVerticalBarSeries.defaultProps = {}; diff --git a/src/components/xy_chart/series/vertical_bar_series.test.js b/src/components/xy_chart/series/vertical_bar_series.test.js new file mode 100644 index 00000000000..534741454a9 --- /dev/null +++ b/src/components/xy_chart/series/vertical_bar_series.test.js @@ -0,0 +1,172 @@ +import React from 'react'; +import { render, mount } from 'enzyme'; +import { patchRandom, unpatchRandom } from '../../../test/patch_random'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiVerticalBarSeries } from './vertical_bar_series'; +import { benchmarkFunction } from '../../../test/time_execution'; +import { VISUALIZATION_COLORS } from '../../../services'; + +beforeEach(patchRandom); +afterEach(unpatchRandom); + +describe('EuiVerticalBarSeries', () => { + test('is rendered', () => { + const component = mount( + + + + ); + + expect(component.find('.rv-xy-plot__series')).toHaveLength(1); + + const rects = component.find('.rv-xy-plot__series--bar rect'); + expect(rects).toHaveLength(2); + + const firstRectProps = rects.at(0).props(); + expect(firstRectProps.x).toBeDefined(); + expect(firstRectProps.y).toBeDefined(); + expect(firstRectProps.width).toBeDefined(); + expect(firstRectProps.height).toBeDefined(); + + const secondRectProps = rects.at(1).props(); + expect(secondRectProps.x).toBeDefined(); + expect(secondRectProps.y).toBeDefined(); + expect(secondRectProps.width).toBeDefined(); + expect(secondRectProps.height).toBeDefined(); + + expect(component.render()).toMatchSnapshot(); + }); + + test('all props are rendered', () => { + const component = render( + + {}} + /> + + ); + + expect(component).toMatchSnapshot(); + }); + + test('renders stacked bar chart', () => { + const component = render( + + + + + ); + expect(component.find('.rv-xy-plot__series')).toHaveLength(2); + expect(component.find('.rv-xy-plot__series--bar rect')).toHaveLength(4); + expect(component).toMatchSnapshot(); + }); + test('call onValueClick', () => { + const data = [{ x: 0, y: 5 }, { x: 1, y: 3 }]; + const onValueClick = jest.fn(); + const component = mount( + + + + ); + component.find('rect').at(0).simulate('click'); + expect(onValueClick.mock.calls).toHaveLength(1); + expect(onValueClick.mock.calls[0][0]).toEqual(data[0]); + }); + + describe.skip('performance', () => { + it('renders 1000 items in under 0.5 seconds', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [250, '250'], + [500, '500'], + [750, '750'], + [1000, '1000'] + ]; + const data = []; + + for (let i = 0; i < 1000; i++) { + data.push({ x: i, y: Math.random() }); + } + + function renderChart() { + render( + + + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~9ms on a MacBookPro + expect(runtime).toBeLessThan(500); + }); + + it('renders 30 lines of 500 items in under 3 seconds', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [125, '125'], + [250, '240'], + [375, '375'], + [500, '500'] + ]; + + const linesData = []; + for (let i = 0; i < 30; i++) { + const data = []; + + for (let i = 0; i < 500; i++) { + data.push({ x: i, y: Math.random() }); + } + + linesData.push(data); + } + + function renderChart() { + render( + + {linesData.map((data, index) => ( + + ))} + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~1750 on a MacBookPro + expect(runtime).toBeLessThan(3000); + }); + }); +}); diff --git a/src/components/xy_chart/series/vertical_rect_series.js b/src/components/xy_chart/series/vertical_rect_series.js new file mode 100644 index 00000000000..b1bc0e392a0 --- /dev/null +++ b/src/components/xy_chart/series/vertical_rect_series.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { VerticalRectSeries } from 'react-vis'; +import classNames from 'classnames'; + +import { VisualizationColorType } from '../utils/visualization_color_type'; + +export class EuiVerticalRectSeries extends VerticalRectSeries { + state = { + isMouseOverValue: false, + } + + _onValueMouseOver = () => { + this.setState(() => ({ isMouseOverValue: true })); + } + + _onValueMouseOut = () => { + this.setState(() => ({ isMouseOverValue: false })); + } + + render() { + const { isMouseOverValue } = this.state; + const { name, data, color, onValueClick, ...rest } = this.props; + const isHighDataVolume = data.length > 80 ? true : false; + const classes = classNames( + 'euiHistogramSeries', + isHighDataVolume && 'euiHistogramSeries--highDataVolume', + isMouseOverValue && onValueClick && 'euiHistogramSeries--hoverEnabled', + ); + return ( + + ); + } +} + +EuiVerticalRectSeries.displayName = 'EuiVerticalRectSeries'; + +EuiVerticalRectSeries.propTypes = { + /** The name used to define the data in tooltips and legends */ + name: PropTypes.string.isRequired, + /** Array<{x0: number, x: number, y: number}> */ + data: PropTypes.arrayOf(PropTypes.shape({ + x0: PropTypes.number, + x: PropTypes.number, + y: PropTypes.number, + })).isRequired, + /** An EUI visualization color, the default value is enforced by EuiXYChart */ + color: VisualizationColorType, + /** + * Callback when clicking on a bar. Returns { x, y } object. + */ + onValueClick: PropTypes.func +}; + +EuiVerticalRectSeries.defaultProps = {}; diff --git a/src/components/xy_chart/series/vertical_rect_series.test.js b/src/components/xy_chart/series/vertical_rect_series.test.js new file mode 100644 index 00000000000..29e965dd697 --- /dev/null +++ b/src/components/xy_chart/series/vertical_rect_series.test.js @@ -0,0 +1,176 @@ +import React from 'react'; +import { render, mount } from 'enzyme'; +import { patchRandom, unpatchRandom } from '../../../test/patch_random'; +import { requiredProps } from '../../../test/required_props'; + +import { EuiXYChart } from '../xy_chart'; +import { EuiVerticalRectSeries } from './vertical_rect_series'; +import { benchmarkFunction } from '../../../test/time_execution'; +import { VISUALIZATION_COLORS } from '../../../services'; + +beforeEach(patchRandom); +afterEach(unpatchRandom); + +describe('EuiVerticalRectSeries', () => { + test('is rendered', () => { + const component = mount( + + + + ); + + expect(component.find('.rv-xy-plot__series')).toHaveLength(1); + + const rects = component.find('.rv-xy-plot__series--rect rect'); + expect(rects).toHaveLength(2); + + const firstRectProps = rects.at(0).props(); + expect(firstRectProps.x).toBeDefined(); + expect(firstRectProps.y).toBeDefined(); + expect(firstRectProps.width).toBeDefined(); + expect(firstRectProps.height).toBeDefined(); + + const secondRectProps = rects.at(1).props(); + expect(secondRectProps.x).toBeDefined(); + expect(secondRectProps.y).toBeDefined(); + expect(secondRectProps.width).toBeDefined(); + expect(secondRectProps.height).toBeDefined(); + + expect(component.render()).toMatchSnapshot(); + }); + + test('all props are rendered', () => { + const component = render( + + {}} + /> + + ); + + expect(component).toMatchSnapshot(); + }); + + test('call onValueClick', () => { + const data = [{ x: 0, y: 5 }, { x: 1, y: 3 }]; + const onValueClick = jest.fn(); + const component = mount( + + + + ); + component.find('rect').at(0).simulate('click'); + expect(onValueClick.mock.calls).toHaveLength(1); + expect(onValueClick.mock.calls[0][0]).toEqual(data[0]); + }); + + test('renders stacked vertical histogram', () => { + const component = render( + + {}} + /> + {}} + /> + + ); + expect(component.find('.rv-xy-plot__series')).toHaveLength(2); + expect(component.find('.rv-xy-plot__series--rect rect')).toHaveLength(4); + + expect(component).toMatchSnapshot(); + }); + + describe('performance', () => { + it.skip('renders 1000 items in under 0.5 seconds', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [250, '250'], + [500, '500'], + [750, '750'], + [1000, '1000'] + ]; + const data = []; + + for (let i = 0; i < 1000; i++) { + data.push({ x: i, y: Math.random() }); + } + + function renderChart() { + render( + + + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~9ms on a MacBookPro + expect(runtime).toBeLessThan(500); + }); + + it.skip('renders 30 lines of 500 items in under 3 seconds', () => { + const yTicks = [[0, 'zero'], [1, 'one']]; + const xTicks = [ + [0, '0'], + [125, '125'], + [250, '240'], + [375, '375'], + [500, '500'] + ]; + + const linesData = []; + for (let i = 0; i < 30; i++) { + const data = []; + + for (let i = 0; i < 500; i++) { + data.push({ x: i, y: Math.random() }); + } + + linesData.push(data); + } + + function renderChart() { + render( + + {linesData.map((data, index) => ( + + ))} + + ); + } + + const runtime = benchmarkFunction(renderChart); + // as of 2018-05-011 / git 00cfbb94d2fcb08aeeed2bb8f4ed0b94eb08307b + // this is ~1750 on a MacBookPro + expect(runtime).toBeLessThan(3000); + }); + }); +}); diff --git a/src/components/xy_chart/status-text.js b/src/components/xy_chart/status-text.js new file mode 100644 index 00000000000..45ba7da8121 --- /dev/null +++ b/src/components/xy_chart/status-text.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { EuiText } from '../text'; +import { EuiIcon } from '../icon'; + +function StatusText({ width, height, text }) { + return ( +
+
+
+
+ {text && {text}} +
+
+ ); +} + +StatusText.propTypes = { + text: PropTypes.string +}; + +export default StatusText; diff --git a/src/components/xy_chart/styles/react_vis/legends.scss b/src/components/xy_chart/styles/react_vis/legends.scss new file mode 100644 index 00000000000..45e8674dcab --- /dev/null +++ b/src/components/xy_chart/styles/react_vis/legends.scss @@ -0,0 +1,134 @@ +$rv-legend-enabled-color: #3a3a48; +$rv-legend-disabled-color: #b8b8b8; + +.rv-discrete-color-legend { + box-sizing: border-box; + overflow-y: auto; + font-size: 12px; + + &.horizontal { + white-space: nowrap; + } +} + +.rv-discrete-color-legend-item { + color: $rv-legend-enabled-color; + border-radius: 1px; + padding: 9px 10px; + + &.horizontal { + display: inline-block; + + .rv-discrete-color-legend-item__title { + margin-left: 0; + display: block; + } + } +} + +.rv-discrete-color-legend-item__color { + background: #dcdcdc; + display: inline-block; + height: 2px; + vertical-align: middle; + width: 14px; +} + +.rv-discrete-color-legend-item__title { + margin-left: 10px; +} + +.rv-discrete-color-legend-item.disabled { + color: $rv-legend-disabled-color; +} + +.rv-discrete-color-legend-item.clickable { + cursor: pointer; + + &:hover { + background: #f9f9f9; + } +} + +.rv-search-wrapper { + display: flex; + flex-direction: column; +} + +.rv-search-wrapper__form { + flex: 0; +} + +.rv-search-wrapper__form__input { + width: 100%; + color: #a6a6a5; + border: 1px solid #e5e5e4; + padding: 7px 10px; + font-size: 12px; + box-sizing: border-box; + border-radius: 2px; + margin: 0 0 9px; + outline: 0; +} + +.rv-search-wrapper__contents { + flex: 1; + overflow: auto; +} + +.rv-continuous-color-legend { + font-size: 12px; + + .rv-gradient { + height: 4px; + border-radius: 2px; + margin-bottom: 5px; + } +} + +.rv-continuous-size-legend { + font-size: 12px; + + .rv-bubbles { + text-align: justify; + overflow: hidden; + margin-bottom: 5px; + width: 100%; + } + + .rv-bubble { + background: #d8d9dc; + display: inline-block; + vertical-align: bottom; + } + + .rv-spacer { + display: inline-block; + font-size: 0; + line-height: 0; + width: 100%; + } +} + +.rv-legend-titles { + height: 16px; + position: relative; +} + +.rv-legend-titles__left, +.rv-legend-titles__right, +.rv-legend-titles__center { + position: absolute; + white-space: nowrap; + overflow: hidden; +} + +.rv-legend-titles__center { + display: block; + text-align: center; + width: 100%; +} + +.rv-legend-titles__right { + right: 0; +} diff --git a/src/components/xy_chart/styles/react_vis/plot.scss b/src/components/xy_chart/styles/react_vis/plot.scss new file mode 100644 index 00000000000..8d1f75ca255 --- /dev/null +++ b/src/components/xy_chart/styles/react_vis/plot.scss @@ -0,0 +1,128 @@ +$rv-xy-plot-axis-font-color: #6b6b76; +$rv-xy-plot-axis-line-color: #e6e6e9; +$rv-xy-plot-axis-font-size: 11px; +$rv-xy-plot-tooltip-background: #3a3a48; +$rv-xy-plot-tooltip-color: #fff; +$rv-xy-plot-tooltip-font-size: 12px; +$rv-xy-plot-tooltip-border-radius: 4px; +$rv-xy-plot-tooltip-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +$rv-xy-plot-tooltip-padding: 7px 10px; + +.rv-xy-plot { + color: #c3c3c3; + position: relative; + + canvas { + pointer-events: none; + } + + .rv-xy-canvas { + pointer-events: none; + position: absolute; + } +} + +.rv-xy-plot__inner { + display: block; +} + +.rv-xy-plot__axis__line { + fill: none; + stroke-width: 2px; + stroke: $rv-xy-plot-axis-line-color; +} + +.rv-xy-plot__axis__tick__line { + stroke: $rv-xy-plot-axis-line-color; +} + +.rv-xy-plot__axis__tick__text { + fill: $rv-xy-plot-axis-font-color; + font-size: $rv-xy-plot-axis-font-size; +} + +.rv-xy-plot__axis__title { + text { + fill: $rv-xy-plot-axis-font-color; + font-size: $rv-xy-plot-axis-font-size; + } +} + +.rv-xy-plot__grid-lines__line { + stroke: $rv-xy-plot-axis-line-color; +} + +.rv-xy-plot__circular-grid-lines__line { + fill-opacity: 0; + stroke: $rv-xy-plot-axis-line-color; +} + +.rv-xy-plot__series, +.rv-xy-plot__series path { + pointer-events: all; +} + +.rv-xy-plot__series--line { + fill: none; + stroke: #000; + stroke-width: 2px; +} + +.rv-crosshair { + position: absolute; + font-size: 11px; + pointer-events: none; +} + +.rv-crosshair__line { + background: #47d3d9; + width: 1px; +} + +.rv-crosshair__inner { + position: absolute; + text-align: left; + top: 0; +} + +.rv-crosshair__inner__content { + border-radius: $rv-xy-plot-tooltip-border-radius; + background: $rv-xy-plot-tooltip-background; + color: $rv-xy-plot-tooltip-color; + font-size: $rv-xy-plot-tooltip-font-size; + padding: $rv-xy-plot-tooltip-padding; + box-shadow: $rv-xy-plot-tooltip-shadow; +} + +.rv-crosshair__inner--left { + right: 4px; +} + +.rv-crosshair__inner--right { + left: 4px; +} + +.rv-crosshair__title { + font-weight: bold; + white-space: nowrap; +} + +.rv-crosshair__item { + white-space: nowrap; +} + +.rv-hint { + position: absolute; + pointer-events: none; +} + +.rv-hint__content { + border-radius: $rv-xy-plot-tooltip-border-radius; + padding: $rv-xy-plot-tooltip-padding; + font-size: $rv-xy-plot-tooltip-font-size; + background: $rv-xy-plot-tooltip-background; + box-shadow: $rv-xy-plot-tooltip-shadow; + color: $rv-xy-plot-tooltip-color; + text-align: left; + white-space: nowrap; +} diff --git a/src/components/xy_chart/styles/react_vis/radial-chart.scss b/src/components/xy_chart/styles/react_vis/radial-chart.scss new file mode 100644 index 00000000000..854a57d4b67 --- /dev/null +++ b/src/components/xy_chart/styles/react_vis/radial-chart.scss @@ -0,0 +1,6 @@ +.rv-radial-chart { + + .rv-xy-plot__series--label { + pointer-events: none; + } +} diff --git a/src/components/xy_chart/styles/react_vis/treemap.scss b/src/components/xy_chart/styles/react_vis/treemap.scss new file mode 100644 index 00000000000..4626d66ea79 --- /dev/null +++ b/src/components/xy_chart/styles/react_vis/treemap.scss @@ -0,0 +1,22 @@ +.rv-treemap { + font-size: 12px; + position: relative; +} + +.rv-treemap__leaf { + overflow: hidden; + position: absolute; +} + +.rv-treemap__leaf--circle { + align-items: center; + border-radius: 100%; + display: flex; + justify-content: center; +} + +.rv-treemap__leaf__content { + overflow: hidden; + padding: 10px; + text-overflow: ellipsis; +} diff --git a/src/components/xy_chart/tooltip.js b/src/components/xy_chart/tooltip.js new file mode 100644 index 00000000000..5414c83afdd --- /dev/null +++ b/src/components/xy_chart/tooltip.js @@ -0,0 +1,85 @@ +import React from 'react'; +import _ from 'lodash'; +import { Hint } from 'react-vis'; +import PropTypes from 'prop-types'; +// import { +// colors, +// unit, +// units, +// px, +// borderRadius, +// fontSize, +// fontSizes +// } from '../../variables'; +// import Legend from '../../Legend/Legend'; + +// const TooltipElm = styled.div` +// margin: 0 ${px(unit)}; +// transform: translateY(-50%); +// border: 1px solid ${colors.gray4}; +// background: ${colors.white}; +// border-radius: ${borderRadius}; +// font-size: ${fontSize}; +// color: ${colors.black}; +// `; + +// const Header = styled.div` +// background: ${colors.gray5}; +// border-bottom: 1px solid ${colors.gray4}; +// border-radius: ${borderRadius} ${borderRadius} 0 0; +// padding: ${px(units.half)}; +// color: ${colors.gray3}; +// `; + +// const Legends = styled.div` +// display: flex; +// flex-direction: column; +// padding: ${px(units.half)}; +// padding: ${px(units.quarter)} ${px(unit)} ${px(units.quarter)} +// ${px(units.half)}; +// font-size: ${fontSizes.small}; +// `; + +// const LegendContainer = styled.div` +// display: flex; +// align-items: center; +// margin: ${px(units.quarter)} 0; +// justify-content: space-between; +// `; + +// const LegendGray = styled(Legend)` +// color: ${colors.gray3}; +// `; + +// const Value = styled.div` +// color: ${colors.gray2}; +// font-size: ${fontSize}; +// `; + +// +//
{header || moment(x).format('MMMM Do YYYY, HH:mm')}
+// +// {tooltipPoints.map((point, i) => ( +// +// +// {point.value} +// +// ))} +// +//
; + +export default function Tooltip({ tooltipPoints, x, y, ...props }) { + if (_.isEmpty(tooltipPoints)) { + return null; + } + return ; +} + +Tooltip.propTypes = { + header: PropTypes.string, + tooltipPoints: PropTypes.array.isRequired, + x: PropTypes.number, + y: PropTypes.number +}; + +Tooltip.defaultProps = {}; diff --git a/src/components/xy_chart/utils/axis_utils.js b/src/components/xy_chart/utils/axis_utils.js new file mode 100644 index 00000000000..abe375135bc --- /dev/null +++ b/src/components/xy_chart/utils/axis_utils.js @@ -0,0 +1,28 @@ +import { AxisUtils } from 'react-vis'; + +/** + * Axis orientation. Can be top, bottom, left, right. + * See react-vis AxisUtils.ORIENTATION for docs. + */ +export const ORIENTATION = { + TOP: AxisUtils.ORIENTATION.TOP, + LEFT: AxisUtils.ORIENTATION.LEFT, + RIGHT: AxisUtils.ORIENTATION.RIGHT, + BOTTOM: AxisUtils.ORIENTATION.BOTTOM, + HORIZONTAL: AxisUtils.ORIENTATION.HORIZONTAL, + VERTICAL: AxisUtils.ORIENTATION.VERTICAL, +}; + +/** + * The title position along the axis. + */ +export const TITLE_POSITION = { + MIDDLE: 'middle', + START: 'start', + END: 'end', +}; + +export const EuiXYChartAxisUtils = { + TITLE_POSITION, + ORIENTATION, +}; diff --git a/src/components/xy_chart/utils/chart_utils.js b/src/components/xy_chart/utils/chart_utils.js new file mode 100644 index 00000000000..95669496496 --- /dev/null +++ b/src/components/xy_chart/utils/chart_utils.js @@ -0,0 +1,63 @@ + +/** + * Used to describe orientation. + */ +export const ORIENTATION = { + /** The main measure/value is along Y axis. Standard chart orientation. */ + VERTICAL: 'vertical', + /** The main measure/value is along X axis. Rotated 90 deg. */ + HORIZONTAL: 'horizontal', + /** Along both axis axis */ + BOTH: 'both', +}; + + +/** + * Type of scales used in charts. + */ +export const SCALE = { + /** Continuous scale, that works with numbers. + * Similar to [d3.scaleLinear](https://github.com/d3/d3-scale/blob/master/README.md#scaleLinear). */ + LINEAR: 'linear', + /** Ordinal scale, works with numbers and strings. + * Similar to [d3.scaleOrdinal](https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales).*/ + ORDINAL: 'ordinal', + /** Categorical scale, each new value gets the next value from the range. + * Similar to d3.scale.category\[Number\], but works with other values besides colors. */ + CATEGORY: 'category', + /** Time scale. Similar to [d3.scaleTime](https://github.com/d3/d3-scale/blob/master/README.md#time-scales). */ + TIME: 'time', + /** Time UTC scale. Similar to [d3.scaleUtc](https://github.com/d3/d3-scale/blob/master/README.md#scaleUtc).*/ + TIME_UTC: 'time-utc', + /** Log scale. Similar to [d3.scaleLog](https://github.com/d3/d3-scale/blob/master/README.md#log-scales). */ + LOG: 'log', + /** Returns exactly the value that was given to it. + * Similar to [d3.scaleIdentity](https://github.com/d3/d3-scale#scaleIdentity), except that it does NOT coerce data into numbers. + * This is useful for precisely specifying properties in the data, eg color can be specified directly on the data. */ + LITERAL: 'literal' +}; + + +/** + * Differnet types of curves that can be used on lines and areas series. + * See [d3-shape#curves](https://github.com/d3/d3-shape#curves) + */ +export const CURVE = { + LINEAR: 'linear', + CURVE_CARDINAL: 'curveCardinal', + CURVE_NATURAL: 'curveNatural', + CURVE_MONOTONE_X: 'curveMonotoneX', + CURVE_MONOTONE_Y: 'curveMonotoneY', + CURVE_BASIS: 'curveBasis', + CURVE_BUNDLE: 'curveBundle', + CURVE_CATMULL_ROM: 'curveCatmullRom', + CURVE_STEP: 'curveStep', + CURVE_STEP_AFTER: 'curveStepAfter', + CURVE_STEP_BEFORE: 'curveStepBefore', +}; + +export const EuiXYChartUtils = { + ORIENTATION, + SCALE, + CURVE, +}; diff --git a/src/components/xy_chart/utils/index.js b/src/components/xy_chart/utils/index.js new file mode 100644 index 00000000000..43f4f75eb29 --- /dev/null +++ b/src/components/xy_chart/utils/index.js @@ -0,0 +1,3 @@ +export { EuiXYChartUtils } from './chart_utils'; +export { EuiXYChartAxisUtils } from './axis_utils'; +export { EuiXYChartTextUtils } from './text_utils'; diff --git a/src/components/xy_chart/utils/series_utils.js b/src/components/xy_chart/utils/series_utils.js new file mode 100644 index 00000000000..5e4d5b761e7 --- /dev/null +++ b/src/components/xy_chart/utils/series_utils.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { AbstractSeries } from 'react-vis'; + +/** + * Check if the component is series or not. + * @param {React.Component} child Component. + * @returns {boolean} True if the child is series, false otherwise. + */ +export function isSeriesChild(child) { + const { prototype } = child.type; + return prototype instanceof AbstractSeries; +} + +/** + * Get all series from the 'children' object of the component. + * @param {Object} children Children. + * @returns {Array} Array of children. + */ +export function getSeriesChildren(children) { + return React.Children.toArray(children).filter(child => + child && isSeriesChild(child)); +} + +export function rotateDataSeries(data) { + return data.map(d => { + return { + x: d.y, + y: d.x, + x0: d.y0, + y0: d.x0, + }; + }); +} diff --git a/src/components/xy_chart/utils/text_utils.js b/src/components/xy_chart/utils/text_utils.js new file mode 100644 index 00000000000..bd2de7cbc08 --- /dev/null +++ b/src/components/xy_chart/utils/text_utils.js @@ -0,0 +1,34 @@ +import React, { Fragment } from 'react'; + +/** + * Word wrapper that takes a long text and wrap words into lines of the same length. + * and return a SVG component composed by tspan tags. + * source: https://j11y.io/snippets/wordwrap-for-javascript/ + * @param {Array of Strings} texts - an array of splitted text, one per line + * @return {Object} Return a Fragment of SVG tspan elements to be used inside axis label formatter. + */ +function labelWordWrap(text, width) { + const pieces = wordWrap(text, width); + return ( + + {pieces.map((piece, i) => { + return ( + + {piece} + + ); + })} + + ); +} + +function wordWrap(text, width = 75, cut = false) { + if (!text) { + return text; + } + const regex = `.{1,${ width }}(s|$)${ cut ? `|.{${ width }}|.+$` : '|S+?(s|$)'}`; + return text.match(RegExp(regex, 'g')); +} +export const EuiXYChartTextUtils = { + labelWordWrap, +}; diff --git a/src/components/xy_chart/utils/visualization_color_type.js b/src/components/xy_chart/utils/visualization_color_type.js new file mode 100644 index 00000000000..2ce053adc33 --- /dev/null +++ b/src/components/xy_chart/utils/visualization_color_type.js @@ -0,0 +1,16 @@ +import { VISUALIZATION_COLORS } from '../../../services'; + +export function VisualizationColorType(props, propName) { + const color = props[propName]; + if (color === undefined) { + return; + } + // TODO upgrade this to check all possible color string formats + // using libs like colorjs + if (!(typeof color === 'string' || color instanceof String) || !color.startsWith('#')) { + return new Error('Color must be a valid hex color string in the form #RRGGBB'); + } + if (!VISUALIZATION_COLORS.includes(color.toUpperCase())) { + console.warn('Prefer safe EUI Visualization Colors.'); + } +} diff --git a/src/components/xy_chart/xy_chart.js b/src/components/xy_chart/xy_chart.js new file mode 100644 index 00000000000..a594f38bde2 --- /dev/null +++ b/src/components/xy_chart/xy_chart.js @@ -0,0 +1,322 @@ +import React, { PureComponent, Fragment } from 'react'; +import { XYPlot, AbstractSeries, makeVisFlexible } from 'react-vis'; + +import PropTypes from 'prop-types'; +import { EuiEmptyPrompt } from '../empty_prompt'; +import { EuiSelectionBrush } from './selection_brush'; +import { EuiDefaultAxis } from './axis/default_axis'; +import { EuiCrosshairX } from './crosshairs/crosshair_x'; +import { EuiCrosshairY } from './crosshairs/crosshair_y'; +import { VISUALIZATION_COLORS } from '../../services'; +import { getSeriesChildren } from './utils/series_utils'; +import { ORIENTATION, SCALE } from './utils/chart_utils'; +const { HORIZONTAL, VERTICAL, BOTH } = ORIENTATION; +const { LINEAR, ORDINAL, CATEGORY, TIME, TIME_UTC, LOG, LITERAL } = SCALE; + +const DEFAULT_MARGINS = { + left: 40, + right: 10, + top: 10, + bottom: 40 +}; + +/** + * The extended version of the react-vis XYPlot with the mouseLeave and mouseUp handlers. + * TODO: send a PR to react-vis for incorporate these two changes directly into XYPlot class. + */ +class XYExtendedPlot extends XYPlot { + /** + * Trigger onMouseLeave handler if it was passed in props. + * @param {Event} event Native event. + * @private + */ + _mouseLeaveHandler(event) { + const { onMouseLeave, children } = this.props; + if (onMouseLeave) { + super.onMouseLeave(event); + } + const seriesChildren = getSeriesChildren(children); + seriesChildren.forEach((child, index) => { + const component = this[`series${index}`]; + if (component && component.onParentMouseLeave) { + component.onParentMouseLeave(event); + } + }); + } + + /** + * Trigger onMouseUp handler if it was passed in props. + * @param {Event} event Native event. + * @private + */ + _mouseUpHandler = (event) => { + const { onMouseUp, children } = this.props; + if (onMouseUp) { + super.onMouseUp(event); + } + const seriesChildren = getSeriesChildren(children); + seriesChildren.forEach((child, index) => { + const component = this[`series${index}`]; + if (component && component.onParentMouseUp) { + component.onParentMouseUp(event); + } + }); + } + + render() { + const { + className, + dontCheckIfEmpty, + style, + width, + height, + } = this.props; + + if (!dontCheckIfEmpty && this._isPlotEmpty()) { + return ( +
+ ); + } + const components = this._getClonedChildComponents(); + + return ( +
+ + {components.filter(c => c && c.type.requiresSVG)} + + {this.renderCanvasComponents(components, this.props)} + {components.filter(c => c && !c.type.requiresSVG && !c.type.isCanvas)} +
+ ); + } +} + + +/** + * The root component of any XY chart. + * It renders an react-vis XYPlot including default axis and a valid crosshair. + * You can also enable the Selection Brush. + */ +class XYChart extends PureComponent { + state = { + mouseOver: false, + }; + colorIterator = 0; + _xyPlotRef = React.createRef(); + + + /** + * Checks if the plot is empty, looking at existing series and data props. + */ + _isEmptyPlot(children) { + return React.Children + .toArray(children) + .filter(this._isAbstractSeries) + .filter(child => { + return child.props.data && child.props.data.length > 0; + }) + .length === 0; + } + + /** + * Checks if a react child is an AbstractSeries + */ + _isAbstractSeries(child) { + const { prototype } = child.type; + // Avoid applying chart props to non series children + return prototype instanceof AbstractSeries; + } + + + /** + * Render children adding a valid EUI visualization color if the color prop is not specified. + */ + _renderChildren(children) { + let colorIterator = 0; + + return React.Children.map(children, (child, i) => { + // Avoid applying color props to non series children + if (!this._isAbstractSeries(child)) { + return child; + } + + const props = { + id: `chart-${i}`, + }; + if (!child.props.color) { + props.color = VISUALIZATION_COLORS[colorIterator % VISUALIZATION_COLORS.length]; + colorIterator++; + } + props._orientation = this.props.orientation; + + return React.cloneElement(child, props); + }); + } + _getSeriesNames = (children) => { + return React.Children.toArray(children) + .filter(this._isAbstractSeries) + .map(({ props: { name } }) => (name)); + } + + render() { + const { + children, + width, + height, + xType, + yType, + stackBy, + statusText, + xDomain, + yDomain, + yPadding, + xPadding, + animateData, + showDefaultAxis, + showCrosshair, + enableSelectionBrush, + selectionBrushOrientation, + onSelectionBrushEnd, + orientation, + crosshairValue, + onCrosshairUpdate, + ...rest + } = this.props; + + if (this._isEmptyPlot(children)) { + return ( + Chart not available} + body={ + +

{ statusText }

+
+ } + /> + ); + } + + const Crosshair = orientation === HORIZONTAL ? EuiCrosshairY : EuiCrosshairX; + const seriesNames = this._getSeriesNames(children); + return ( +
+ + {this._renderChildren(children)} + {showDefaultAxis && } + {showCrosshair && ( + + )} + + {enableSelectionBrush && ( + + )} + +
+ ); + } +} +XYChart.displayName = 'EuiXYChart'; + +XYChart.propTypes = { + /** The initial width of the chart. */ + width: PropTypes.number.isRequired, + /** The initial height of the chart. */ + height: PropTypes.number.isRequired, + /** **experimental** The orientation of the chart. */ + orientation: PropTypes.oneOf([HORIZONTAL, VERTICAL]), + /** If the chart animates on data changes. */ + animateData: PropTypes.bool, + /** TODO */ + stackBy: PropTypes.string, + /** The main x axis scale type. See https://github.com/uber/react-vis/blob/master/docs/scales-and-data.md */ + xType: PropTypes.oneOf([LINEAR, ORDINAL, CATEGORY, TIME, TIME_UTC, LOG, LITERAL]), + /** The main y axis scale type. See https://github.com/uber/react-vis/blob/master/docs/scales-and-data.md*/ + yType: PropTypes.oneOf([LINEAR, ORDINAL, CATEGORY, TIME, TIME_UTC, LOG, LITERAL]), + /** Manually specify the domain of x axis. */ + xDomain: PropTypes.array, + /** Manually specify the domain of y axis. */ + yDomain: PropTypes.array, + /** The horizontal padding between the chart borders and chart elements. */ + xPadding: PropTypes.number, + /** The vertical padding between the chart borders and chart elements. */ + yPadding: PropTypes.number, + /** Add an additional status text above the graph status message*/ + statusText: PropTypes.string, + /** Shows the crosshair tooltip on mouse move.*/ + showCrosshair: PropTypes.bool, + /** Specify the axis value where to display crosshair based on chart orientation value. */ + crosshairValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + /** Callback when the crosshair position is updated. */ + onCrosshairUpdate: PropTypes.func, + /** Show the default X and Y axis. */ + showDefaultAxis: PropTypes.bool, + /** Enable the brush tool */ + enableSelectionBrush: PropTypes.bool, + /** Specify the brush orientation */ + selectionBrushOrientation: PropTypes.oneOf([HORIZONTAL, VERTICAL, BOTH]), + /** Callback on brush end event with { begin, end } object returned. */ + onSelectionBrushEnd: PropTypes.func, +}; + +XYChart.defaultProps = { + animateData: true, + xType: 'linear', + yType: 'linear', + yPadding: 0, + xPadding: 0, + orientation: VERTICAL, + showCrosshair: true, + showDefaultAxis: true, + enableSelectionBrush: false, + selectionBrushOrientation: HORIZONTAL, +}; + +export const EuiXYChart = makeVisFlexible(XYChart); diff --git a/src/components/xy_chart/xy_chart.test.js b/src/components/xy_chart/xy_chart.test.js new file mode 100644 index 00000000000..2dbbfba3d75 --- /dev/null +++ b/src/components/xy_chart/xy_chart.test.js @@ -0,0 +1,137 @@ +import React from 'react'; +import { mount } from 'enzyme'; + +import { EuiXYChart } from './xy_chart'; +import { EuiLineSeries } from './series/line_series'; +import { EuiDefaultAxis } from './axis'; +import { EuiCrosshairX, EuiCrosshairY } from './crosshairs/'; +import { requiredProps } from '../../test/required_props'; +import { VISUALIZATION_COLORS } from '../../services'; + +const NOOP = f => f; + +export const XYCHART_PROPS = { + width: 1, + height: 1, + orientation: 'vertical', + animateData: true, + stackBy: 'y', + xType: 'linear', + yType: 'linear', + xDomain: [0, 1], + yDomain: [0, 1], + xPadding: 0, + yPadding: 0, + statusText: '', + showCrosshair: true, + crosshairValue: 0, + onCrosshairUpdate: NOOP, + showDefaultAxis: true, + enableSelectionBrush: true, + selectionBrushOrientation: 'vertical', + onSelectionBrushEnd: NOOP, +}; + +describe('EuiXYChart', () => { + + test(`renders all props`, () => { + const wrapper = mount(); + const wrapperProps = wrapper.props(); + + expect(wrapper.find(EuiXYChart).length).toBe(1); + Object.keys(XYCHART_PROPS).forEach(propName => { + expect(wrapperProps[propName]).toBe(XYCHART_PROPS[propName]); + }); + }); + + test('renders an empty chart', () => { + const EMPTY_CHART_MESSAGE = '~~Empty Chart~~'; + const component = mount( + + ); + + expect(component.render().find('.euiText').text()).toBe(EMPTY_CHART_MESSAGE); + expect(component.find(EuiDefaultAxis)).toHaveLength(0); + expect(component.find(EuiCrosshairX)).toHaveLength(0); + expect(component.find(EuiCrosshairY)).toHaveLength(0); + expect(component.render()).toMatchSnapshot(); + }); + + test('renders right default colors', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const series = new Array(VISUALIZATION_COLORS.length * 2) + .fill(0) + .map((color, i) => { + return { name: `series-${i}`, data }; + }); + const component = mount( + + { + series.map((d, i) => ()) + } + + ); + + expect(component.find(EuiLineSeries)).toHaveLength(VISUALIZATION_COLORS.length * 2); + VISUALIZATION_COLORS.forEach((color, i) => { + expect(component.find(EuiLineSeries).at(i).props().color).toBe(color); + expect(component.find(EuiLineSeries).at(i + VISUALIZATION_COLORS.length).props().color).toBe(color); + }); + }); + + test('renders default colors together with existing series colors', () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const AVAILABLE_COLORS = VISUALIZATION_COLORS.length; + const series = new Array(AVAILABLE_COLORS * 2) + .fill(0) + .map((color, i) => { + return { name: `series-${i}`, data }; + }); + series.splice(1, 0, { name: `series-colored`, data, color: VISUALIZATION_COLORS[5] }); + + const component = mount( + + { + series.map((d, i) => ()) + } + + ); + const lineComponents = component.find(EuiLineSeries); + expect(lineComponents).toHaveLength(AVAILABLE_COLORS * 2 + 1); + // check before + expect(lineComponents.at(0).props().color).toBe(VISUALIZATION_COLORS[0]); + // check if the inserted element maintain its own color + expect(lineComponents.at(1).props().color).toBe(VISUALIZATION_COLORS[5]); + // check if the skip maintain the color assignment + expect(lineComponents.at(2).props().color).toBe(VISUALIZATION_COLORS[1]); + expect(lineComponents.at(AVAILABLE_COLORS + 1).props().color).toBe(VISUALIZATION_COLORS[0]); + }); + + test(`Check wrong EUI color warning`, () => { + const data = [ { x: 0, y: 1 }, { x: 1, y: 2 }]; + const original = console.warn; + const mock = jest.fn(); + console.warn = mock; + mount( + + + ); + expect(console.warn.mock.calls[0][0]).toBe('Prefer safe EUI Visualization Colors.'); + console.warn.mockClear(); + console.warn = original; + + }); +}); diff --git a/src/services/color/index.js b/src/services/color/index.js index 750249aabcb..0c4908fe99b 100644 --- a/src/services/color/index.js +++ b/src/services/color/index.js @@ -2,4 +2,4 @@ export { isColorDark } from './is_color_dark'; export { hexToRgb } from './hex_to_rgb'; export { rgbToHex } from './rgb_to_hex'; export { calculateContrast, calculateLuminance } from './luminance_and_contrast'; -export { VISUALIZATION_COLORS } from './visualization_colors'; +export { VISUALIZATION_COLORS, DEFAULT_VISUALIZATION_COLOR } from './visualization_colors'; diff --git a/src/services/color/visualization_colors.js b/src/services/color/visualization_colors.js index b628f4b7c22..0ca8ab243ce 100644 --- a/src/services/color/visualization_colors.js +++ b/src/services/color/visualization_colors.js @@ -14,3 +14,5 @@ export const VISUALIZATION_COLORS = [ '#461A0A', '#920000', ]; + +export const DEFAULT_VISUALIZATION_COLOR = VISUALIZATION_COLORS[1]; diff --git a/src/services/index.js b/src/services/index.js index eb52457c528..d44ee26f8ad 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -22,6 +22,7 @@ export { hexToRgb, rgbToHex, VISUALIZATION_COLORS, + DEFAULT_VISUALIZATION_COLOR, } from './color'; export { diff --git a/src/test/patch_random.js b/src/test/patch_random.js new file mode 100644 index 00000000000..de18b8023de --- /dev/null +++ b/src/test/patch_random.js @@ -0,0 +1,10 @@ +const _mathRandom = Math.random; + +export function patchRandom() { + let x = 0; + Math.random = () => x += 0.00001; +} + +export function unpatchRandom() { + Math.random = _mathRandom; +} diff --git a/src/test/time_execution.js b/src/test/time_execution.js new file mode 100644 index 00000000000..582e03d3997 --- /dev/null +++ b/src/test/time_execution.js @@ -0,0 +1,21 @@ +export function timeExecution(fn) { + const start = process.hrtime(); + fn(); + const [seconds, nanoseconds] = process.hrtime(start); + const milliseconds = (seconds * 1000) + (nanoseconds / 1000000); + return milliseconds; +} + +export function benchmarkFunction(fn, warmupRuns = 3, benchmarkRuns = 3) { + // warmup v8 optimizations, cache, etc + for (let i = 0; i < warmupRuns; i++) { + fn(); + } + + const runTimes = []; + for (let i = 0; i < benchmarkRuns; i++) { + runTimes.push(timeExecution(fn)); + } + + return Math.min.apply(null, runTimes); +} diff --git a/yarn.lock b/yarn.lock index a071a18366f..1f432462fda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -125,6 +125,10 @@ acorn@^5.0.0, acorn@^5.2.1, acorn@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.3.0.tgz#7446d39459c54fb49a80e6ee6478149b940ec822" +address@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/address/-/address-1.0.3.tgz#b5f50631f8d6cec8bd20c963963afb55e06cbce9" + adm-zip@0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.2.1.tgz#e801cedeb5bd9a4e98d699c5c0f4239e2731dcbf" @@ -265,6 +269,10 @@ aproba@^1.0.3: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" +arch@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" + archiver-utils@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-1.3.0.tgz#e50b4c09c70bf3d680e32ff1b7994e9f9d895174" @@ -296,12 +304,25 @@ are-we-there-yet@~1.1.2: delegates "^1.0.0" readable-stream "^2.0.6" +arg@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/arg/-/arg-2.0.0.tgz#c06e7ff69ab05b3a4a03ebe0407fac4cba657545" + argparse@^1.0.7: version "1.0.9" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.9.tgz#73d83bc263f86e97f8cc4f6bae1b0e90a7d22c86" dependencies: sprintf-js "~1.0.2" +args@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/args/-/args-4.0.0.tgz#5ca24cdba43d4b17111c56616f5f2e9d91933954" + dependencies: + camelcase "5.0.0" + chalk "2.3.2" + leven "2.1.0" + mri "1.1.0" + aria-query@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-0.7.0.tgz#4af10a1e61573ddea0cf3b99b51c52c05b424d24" @@ -1221,6 +1242,12 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +basic-auth@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.0.tgz#015db3f353e02e56377755f962742e8981e7bbba" + dependencies: + safe-buffer "5.1.1" + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -1339,7 +1366,7 @@ boom@5.x.x: dependencies: hoek "4.x.x" -boxen@^1.2.1: +boxen@1.3.0, boxen@^1.2.1: version "1.3.0" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" dependencies: @@ -1554,6 +1581,10 @@ camelcase-keys@^2.0.0: camelcase "^2.0.0" map-obj "^1.0.0" +camelcase@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42" + camelcase@^1.0.2: version "1.2.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39" @@ -1621,6 +1652,30 @@ chai@^4.1.2: pathval "^1.0.0" type-detect "^4.0.0" +chalk@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.2.tgz#250dc96b07491bfd601e648d66ddf5f60c7a5c65" + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.0.tgz#a060a297a6b57e15b61ca63ce84995daa0fe6e52" + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@2.4.1, chalk@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" @@ -1639,14 +1694,6 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0: escape-string-regexp "^1.0.5" supports-color "^4.0.0" -chalk@^2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - chardet@^0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2" @@ -1802,6 +1849,13 @@ cli-width@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" +clipboardy@1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/clipboardy/-/clipboardy-1.2.3.tgz#0526361bf78724c1f20be248d428e365433c07ef" + dependencies: + arch "^2.1.0" + execa "^0.8.0" + cliui@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1" @@ -1984,6 +2038,12 @@ compressible@~2.0.11: dependencies: mime-db ">= 1.30.0 < 2" +compressible@~2.0.13: + version "2.0.14" + resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.14.tgz#326c5f507fbb055f54116782b969a81b67a29da7" + dependencies: + mime-db ">= 1.34.0 < 2" + compression@^1.5.2: version "1.7.1" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.1.tgz#eff2603efc2e22cf86f35d2eb93589f9875373db" @@ -1996,6 +2056,18 @@ compression@^1.5.2: safe-buffer "5.1.1" vary "~1.1.2" +compression@^1.6.2: + version "1.7.2" + resolved "http://registry.npmjs.org/compression/-/compression-1.7.2.tgz#aaffbcd6aaf854b44ebb280353d5ad1651f59a69" + dependencies: + accepts "~1.3.4" + bytes "3.0.0" + compressible "~2.0.13" + debug "2.6.9" + on-headers "~1.0.1" + safe-buffer "5.1.1" + vary "~1.1.2" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2069,7 +2141,7 @@ content-type-parser@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/content-type-parser/-/content-type-parser-1.0.2.tgz#caabe80623e63638b2502fd4c7f12ff4ce2352e7" -content-type@~1.0.4: +content-type@1.0.4, content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" @@ -2367,6 +2439,88 @@ cycle@1.0.x: version "1.0.3" resolved "https://registry.yarnpkg.com/cycle/-/cycle-1.0.3.tgz#21e80b2be8580f98b468f379430662b046c34ad2" +d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.1.tgz#d1ca33de2f6ac31efadb8e050a021d7e2396d5dc" + +d3-collection@1, d3-collection@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/d3-collection/-/d3-collection-1.0.4.tgz#342dfd12837c90974f33f1cc0a785aea570dcdc2" + +d3-color@1, d3-color@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.2.0.tgz#d1ea19db5859c86854586276ec892cf93148459a" + +d3-contour@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/d3-contour/-/d3-contour-1.3.0.tgz#cfb99098c48c46edd77e15ce123162f9e333e846" + dependencies: + d3-array "^1.1.1" + +d3-format@1, d3-format@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.3.0.tgz#a3ac44269a2011cdb87c7b5693040c18cddfff11" + +d3-geo@^1.6.4: + version "1.10.0" + resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.10.0.tgz#2972d18014f1e38fc1f8bb6d545377bdfb00c9ab" + dependencies: + d3-array "1" + +d3-hierarchy@^1.1.4: + version "1.1.6" + resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.6.tgz#842c1372090f870b7ea013ebae5c0c8d9f56229c" + +d3-interpolate@1, d3-interpolate@^1.1.4: + version "1.2.0" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.2.0.tgz#40d81bd8e959ff021c5ea7545bc79b8d22331c41" + dependencies: + d3-color "1" + +d3-path@1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.5.tgz#241eb1849bd9e9e8021c0d0a799f8a0e8e441764" + +d3-sankey@^0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/d3-sankey/-/d3-sankey-0.7.1.tgz#d229832268fc69a7fec84803e96c2256a614c521" + dependencies: + d3-array "1" + d3-collection "1" + d3-shape "^1.2.0" + +d3-scale@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-1.0.7.tgz#fa90324b3ea8a776422bd0472afab0b252a0945d" + dependencies: + d3-array "^1.2.0" + d3-collection "1" + d3-color "1" + d3-format "1" + d3-interpolate "1" + d3-time "1" + d3-time-format "2" + +d3-shape@^1.1.0, d3-shape@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.2.0.tgz#45d01538f064bafd05ea3d6d2cb748fd8c41f777" + dependencies: + d3-path "1" + +d3-time-format@2: + version "2.1.1" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.1.tgz#85b7cdfbc9ffca187f14d3c456ffda268081bb31" + dependencies: + d3-time "1" + +d3-time@1: + version "1.0.8" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.0.8.tgz#dbd2d6007bf416fe67a76d17947b784bffea1e84" + +d3-voronoi@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.2.tgz#1687667e8f13a2d158c80c1480c5a29cb0d8973c" + d@1: version "1.0.0" resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f" @@ -2377,7 +2531,7 @@ damerau-levenshtein@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.4.tgz#03191c432cb6eea168bb77f3a55ffdccb8978514" -dargs@^5.1.0: +dargs@5.1.0, dargs@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/dargs/-/dargs-5.1.0.tgz#ec7ea50c78564cd36c9d5ec18f66329fade27829" @@ -2395,7 +2549,7 @@ dateformat@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.2.tgz#9a4df4bff158ac2f34bc637abdb15471607e1659" -debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.6, debug@^2.6.8, debug@^2.6.9: +debug@2, debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.6, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" dependencies: @@ -2515,7 +2669,7 @@ depd@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" -depd@~1.1.1: +depd@~1.1.1, depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" @@ -2556,6 +2710,13 @@ detect-node@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.3.tgz#a2033c09cc8e158d37748fbde7507832bd6ce127" +detect-port@1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.2.3.tgz#15bf49820d02deb84bfee0a74876b32d791bf610" + dependencies: + address "^1.0.1" + debug "^2.6.0" + diff@3.5.0, diff@^3.1.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -2757,7 +2918,7 @@ emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" -encodeurl@~1.0.1: +encodeurl@~1.0.1, encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -2908,10 +3069,6 @@ es6-promise@^3.0.2: version "3.3.1" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" -es6-promise@^4.0.3: - version "4.2.4" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.4.tgz#dc4221c2b16518760bd8c39a52d8f356fc00ed29" - es6-set@~0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1" @@ -3260,6 +3417,18 @@ execa@^0.7.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +execa@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + execall@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/execall/-/execall-1.0.0.tgz#73d0904e395b3cab0658b08d09ec25307f29bb73" @@ -3523,6 +3692,10 @@ fileset@^2.0.2: glob "^7.0.3" minimatch "^3.0.3" +filesize@3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.6.1.tgz#090bb3ee01b6f801a8a8be99d31710b3422bb317" + fill-range@^2.1.0: version "2.2.3" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.3.tgz#50b77dfd7e469bc7492470963699fe7a8485a723" @@ -3695,6 +3868,14 @@ from@~0: version "0.1.7" resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" +fs-extra@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-6.0.1.tgz#8abc128f7946e310135ddc93b98bddb410e7a34b" + dependencies: + graceful-fs "^4.1.2" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs-extra@^0.30.0: version "0.30.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.30.0.tgz#f233ffcc08d4da7d432daa449776989db1df93f0" @@ -3705,14 +3886,6 @@ fs-extra@^0.30.0: path-is-absolute "^1.0.0" rimraf "^2.2.8" -fs-extra@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-1.0.0.tgz#cd3ce5f7e7cb6145883fcae3191e9877f8587950" - dependencies: - graceful-fs "^4.1.2" - jsonfile "^2.1.0" - klaw "^1.0.0" - fs-extra@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-3.0.1.tgz#3794f378c58b342ea7dbbb23095109c4b3b62291" @@ -3945,7 +4118,7 @@ global-dirs@^0.1.0: dependencies: ini "^1.3.4" -global@~4.3.0: +global@^4.3.1, global@~4.3.0: version "4.3.2" resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" dependencies: @@ -4106,7 +4279,7 @@ handle-thing@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4" -handlebars@^4.0.3: +handlebars@4.0.11, handlebars@^4.0.3: version "4.0.11" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.11.tgz#630a35dfe0294bc281edae6ffc5d329fc7982dcc" dependencies: @@ -4236,13 +4409,6 @@ hash.js@^1.0.0, hash.js@^1.0.3: inherits "^2.0.3" minimalistic-assert "^1.0.0" -hasha@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/hasha/-/hasha-2.2.0.tgz#78d7cbfc1e6d66303fe79837365984517b2f6ee1" - dependencies: - is-stream "^1.0.1" - pinkie-promise "^2.0.0" - hawk@3.1.3, hawk@~3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/hawk/-/hawk-3.1.3.tgz#078444bd7c1640b0fe540d2c9b73d59678e8e1c4" @@ -4290,6 +4456,10 @@ hoek@2.x.x: version "2.16.3" resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed" +hoek@4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb" + hoek@4.x.x: version "4.2.0" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" @@ -4674,7 +4844,7 @@ ip-regex@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-1.0.3.tgz#dc589076f659f419c222039a33316f1c7387effd" -ip@^1.1.0, ip@^1.1.5: +ip@1.1.5, ip@^1.1.0, ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" @@ -4979,7 +5149,7 @@ is-scoped@^1.0.0: dependencies: scoped-regex "^1.0.0" -is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@1.1.0, is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -5675,7 +5845,7 @@ left-pad@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.2.0.tgz#d30a73c6b8201d8f7d8e7956ba9616087a68e0ee" -leven@^2.1.0: +leven@2.1.0, leven@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" @@ -6087,6 +6257,22 @@ methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" +micro-compress@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/micro-compress/-/micro-compress-1.0.0.tgz#53f5a80b4ad0320ca165a559b6e3df145d4f704f" + dependencies: + compression "^1.6.2" + +micro@9.3.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/micro/-/micro-9.3.1.tgz#0c37eba0171554b1beccda5215ff8ea4e7aa59d6" + dependencies: + arg "2.0.0" + chalk "2.4.0" + content-type "1.0.4" + is-stream "1.1.0" + raw-body "2.3.2" + micromatch@^2.1.5, micromatch@^2.3.11: version "2.3.11" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565" @@ -6134,10 +6320,24 @@ miller-rabin@^4.0.0: version "1.32.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.32.0.tgz#485b3848b01a3cda5f968b4882c0771e58e09414" +"mime-db@>= 1.34.0 < 2": + version "1.34.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.34.0.tgz#452d0ecff5c30346a6dc1e64b1eaee0d3719ff9a" + mime-db@~1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01" +mime-db@~1.33.0: + version "1.33.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" + +mime-types@2.1.18: + version "2.1.18" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" + dependencies: + mime-db "~1.33.0" + mime-types@^2.1.12, mime-types@~2.1.15, mime-types@~2.1.16, mime-types@~2.1.17, mime-types@~2.1.7: version "2.1.17" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a" @@ -6266,6 +6466,10 @@ moment@^2.20.1: version "2.20.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd" +mri@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.0.tgz#5c0a3f29c8ccffbbb1ec941dcec09d71fa32f36a" + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -6498,6 +6702,10 @@ node-status-codes@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f" +node-version@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/node-version/-/node-version-1.1.3.tgz#1081c87cce6d2dbbd61d0e51e28c287782678496" + nodeclient-spectre@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/nodeclient-spectre/-/nodeclient-spectre-1.0.3.tgz#831ce6151cc897174843f9289db76002260aa1b3" @@ -6797,6 +7005,16 @@ onetime@^2.0.0: dependencies: mimic-fn "^1.0.0" +openssl-self-signed-certificate@1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/openssl-self-signed-certificate/-/openssl-self-signed-certificate-1.1.6.tgz#9d3a4776b1a57e9847350392114ad2f915a83dd4" + +opn@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/opn/-/opn-5.3.0.tgz#64871565c863875f052cfdf53d3e3cb5adb53b1c" + dependencies: + is-wsl "^1.1.0" + opn@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/opn/-/opn-4.0.2.tgz#7abc22e644dff63b0a96d5ab7f2790c0f01abc95" @@ -7096,7 +7314,7 @@ path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" -path-is-inside@^1.0.1, path-is-inside@^1.0.2: +path-is-inside@1.0.2, path-is-inside@^1.0.1, path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" @@ -7122,6 +7340,12 @@ path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-type@3.0.0, path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + dependencies: + pify "^3.0.0" + path-type@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -7136,12 +7360,6 @@ path-type@^2.0.0: dependencies: pify "^2.0.0" -path-type@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" - dependencies: - pify "^3.0.0" - pathval@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" @@ -7178,20 +7396,6 @@ performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" -phantomjs-prebuilt@^2.1.16: - version "2.1.16" - resolved "https://registry.yarnpkg.com/phantomjs-prebuilt/-/phantomjs-prebuilt-2.1.16.tgz#efd212a4a3966d3647684ea8ba788549be2aefef" - dependencies: - es6-promise "^4.0.3" - extract-zip "^1.6.5" - fs-extra "^1.0.0" - hasha "^2.2.0" - kew "^0.7.0" - progress "^1.1.8" - request "^2.81.0" - request-progress "^2.0.1" - which "^1.2.10" - pify@^2.0.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -7657,10 +7861,6 @@ progress@2.0.0, progress@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f" -progress@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" - promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -7797,7 +7997,7 @@ querystringify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-1.0.0.tgz#6286242112c5b712fa654e526652bf6a13ff05cb" -raf@^3.4.0: +raf@^3.1.0, raf@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.0.tgz#a28876881b4bc2ca9117d4138163ddb80f781575" dependencies: @@ -7919,6 +8119,14 @@ react-input-autosize@^2.2.1: dependencies: prop-types "^15.5.8" +react-motion@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316" + dependencies: + performance-now "^0.2.0" + prop-types "^15.5.8" + raf "^3.1.0" + react-onclickoutside@^6.7.1: version "6.7.1" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz#6a5b5b8b4eae6b776259712c89c8a2b36b17be93" @@ -7984,6 +8192,28 @@ react-virtualized@^9.18.5: loose-envify "^1.3.0" prop-types "^15.6.0" +react-vis@^1.10.1: + version "1.10.1" + resolved "https://registry.yarnpkg.com/react-vis/-/react-vis-1.10.1.tgz#e9b49474001186666b4094cfa8b0395f74b22df6" + dependencies: + d3-array "^1.2.0" + d3-collection "^1.0.3" + d3-color "^1.0.3" + d3-contour "^1.1.0" + d3-format "^1.2.0" + d3-geo "^1.6.4" + d3-hierarchy "^1.1.4" + d3-interpolate "^1.1.4" + d3-sankey "^0.7.1" + d3-scale "^1.0.5" + d3-shape "^1.1.0" + d3-voronoi "^1.1.2" + deep-equal "^1.0.1" + global "^4.3.1" + hoek "4.2.1" + prop-types "^15.5.8" + react-motion "^0.5.2" + react@^16.3.0: version "16.3.2" resolved "https://registry.yarnpkg.com/react/-/react-16.3.2.tgz#fdc8420398533a1e58872f59091b272ce2f91ea9" @@ -8237,6 +8467,13 @@ regexpu-core@^2.0.0: regjsgen "^0.2.0" regjsparser "^0.1.4" +registry-auth-token@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20" + dependencies: + rc "^1.1.6" + safe-buffer "^5.0.1" + registry-auth-token@^3.0.1: version "3.3.1" resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.1.tgz#fb0d3289ee0d9ada2cbb52af5dfe66cb070d3006" @@ -8244,7 +8481,7 @@ registry-auth-token@^3.0.1: rc "^1.1.6" safe-buffer "^5.0.1" -registry-url@^3.0.3: +registry-url@3.1.0, registry-url@^3.0.3: version "3.1.0" resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942" dependencies: @@ -8300,12 +8537,6 @@ replace-ext@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" -request-progress@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-2.0.1.tgz#5d36bb57961c673aa5b788dbc8141fdf23b44e08" - dependencies: - throttleit "^1.0.0" - request-promise-core@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.1.tgz#3eee00b2c5aa83239cfb04c5700da36f81cd08b6" @@ -8774,6 +9005,24 @@ send@0.16.1: range-parser "~1.2.0" statuses "~1.3.1" +send@0.16.2: + version "0.16.2" + resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1" + dependencies: + debug "2.6.9" + depd "~1.1.2" + destroy "~1.0.4" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "~1.6.2" + mime "1.4.1" + ms "2.0.0" + on-finished "~2.3.0" + range-parser "~1.2.0" + statuses "~1.4.0" + serializerr@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/serializerr/-/serializerr-1.0.3.tgz#12d4c5aa1c3ffb8f6d1dc5f395aa9455569c3f91" @@ -8801,6 +9050,33 @@ serve-static@1.13.1: parseurl "~1.3.2" send "0.16.1" +serve@^6.3.1: + version "6.5.8" + resolved "https://registry.yarnpkg.com/serve/-/serve-6.5.8.tgz#fd7ad6b9c10ba12084053030cc1a8b636c0a10a7" + dependencies: + args "4.0.0" + basic-auth "2.0.0" + bluebird "3.5.1" + boxen "1.3.0" + chalk "2.4.1" + clipboardy "1.2.3" + dargs "5.1.0" + detect-port "1.2.3" + filesize "3.6.1" + fs-extra "6.0.1" + handlebars "4.0.11" + ip "1.1.5" + micro "9.3.1" + micro-compress "1.0.0" + mime-types "2.1.18" + node-version "1.1.3" + openssl-self-signed-certificate "1.1.6" + opn "5.3.0" + path-is-inside "1.0.2" + path-type "3.0.0" + send "0.16.2" + update-check "1.5.1" + set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -9155,7 +9431,7 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"statuses@>= 1.3.1 < 2": +"statuses@>= 1.3.1 < 2", statuses@~1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" @@ -9500,10 +9776,6 @@ throat@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" -throttleit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" - through2@^2.0.0, through2@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" @@ -9792,6 +10064,13 @@ unzip-response@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" +update-check@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/update-check/-/update-check-1.5.1.tgz#24fc52266273cb8684d2f1bf9687c0e52dcf709f" + dependencies: + registry-auth-token "3.3.2" + registry-url "3.1.0" + update-notifier@^2.1.0: version "2.3.0" resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.3.0.tgz#4e8827a6bb915140ab093559d7014e3ebb837451" @@ -10128,7 +10407,6 @@ wdio-visual-regression-service@silne30/wdio-visual-regression-service#Add_Filena lodash "^4.13.1" node-resemble-js "0.0.5" nodeclient-spectre "^1.0.3" - phantomjs-prebuilt "^2.1.16" platform "^1.3.1" wdio-screenshot "^0.6.0"