From 1d937ce57b214d5d35121b795e70b0b3b311758f Mon Sep 17 00:00:00 2001 From: nickofthyme Date: Tue, 31 Mar 2020 11:10:12 -0500 Subject: [PATCH] feat(series): dot accessor --- .playground/playground.tsx | 109 +++++++++------- package.json | 2 + .../xy_chart/renderer/canvas/areas.ts | 22 ++-- .../xy_chart/renderer/canvas/lines.ts | 16 ++- .../xy_chart/renderer/canvas/points.ts | 30 +++-- .../xy_chart/renderer/canvas/renderers.ts | 24 +++- .../xy_chart/renderer/canvas/xy_chart.tsx | 4 + .../xy_chart/renderer/dom/highlighter.tsx | 4 +- .../xy_chart/rendering/rendering.ts | 114 +++++++++++------ .../selectors/get_elements_at_cursor_pos.ts | 10 +- .../state/selectors/get_geometries_index.ts | 4 +- .../selectors/get_geometries_index_keys.ts | 5 +- .../get_tooltip_values_highlighted_geoms.ts | 16 ++- src/chart_types/xy_chart/state/utils.ts | 63 ++++------ .../utils/indexed_geometry_linear_map.ts | 50 ++++++++ .../xy_chart/utils/indexed_geometry_map.ts | 93 ++++++++++++++ .../utils/indexed_geometry_spatial_map.ts | 112 +++++++++++++++++ .../xy_chart/utils/nonstacked_series_utils.ts | 3 +- src/chart_types/xy_chart/utils/series.ts | 24 +++- src/chart_types/xy_chart/utils/specs.ts | 4 +- .../xy_chart/utils/stacked_series_utils.ts | 5 +- src/components/tooltip/index.tsx | 74 ++++++----- src/mocks/utils.ts | 25 +++- src/renderers/canvas/index.ts | 13 +- src/scales/scale_band.ts | 31 +++-- src/specs/settings.tsx | 6 + src/utils/accessor.ts | 4 +- src/utils/data_generators/simple_noise.ts | 2 +- src/utils/geometry.ts | 1 + src/utils/themes/theme.ts | 6 + stories/mixed/7_dots.tsx | 118 ++++++++++++++++++ stories/mixed/mixed.stories.tsx | 1 + yarn.lock | 17 +++ 33 files changed, 794 insertions(+), 218 deletions(-) create mode 100644 src/chart_types/xy_chart/utils/indexed_geometry_linear_map.ts create mode 100644 src/chart_types/xy_chart/utils/indexed_geometry_map.ts create mode 100644 src/chart_types/xy_chart/utils/indexed_geometry_spatial_map.ts create mode 100644 stories/mixed/7_dots.tsx diff --git a/.playground/playground.tsx b/.playground/playground.tsx index 4c4796e5ca..938e94b382 100644 --- a/.playground/playground.tsx +++ b/.playground/playground.tsx @@ -17,57 +17,74 @@ * under the License. */ import React from 'react'; -import { - Chart, - ScaleType, - Position, - Axis, - LineSeries, - LineAnnotation, - RectAnnotation, - AnnotationDomainTypes, - LineAnnotationDatum, - RectAnnotationDatum, -} from '../src'; -import { SeededDataGenerator } from '../src/mocks/utils'; -export class Playground extends React.Component<{}, { isSunburstShown: boolean }> { - render() { - const dg = new SeededDataGenerator(); - const data = dg.generateGroupedSeries(10, 2).map((item) => ({ - ...item, - y1: item.y + 100, - })); - const lineDatum: LineAnnotationDatum[] = [{ dataValue: 321321 }]; - const rectDatum: RectAnnotationDatum[] = [{ coordinates: { x1: 100 } }]; +import { Delaunay } from 'd3-delaunay'; +import { getRandomNumberGenerator } from '../src/mocks/utils'; + +export class Playground extends React.Component<{}, { ready: boolean }> { + private readonly canvasRef: React.RefObject; + private ctx: CanvasRenderingContext2D | null; + private delaunay: Delaunay | null; + private points: number[][] = []; + + constructor(props: any) { + super(props); + this.canvasRef = React.createRef(); + this.ctx = null; + this.delaunay = null; + + this.state = { ready: false }; + } + + componentDidMount() { + if (!this.ctx) { + this.tryCanvasContext(); + this.setState({ ready: true }); + } + } + + tryCanvasContext() { + const canvas = this.canvasRef.current; + const ctx = canvas && canvas.getContext('2d'); + if (ctx) { + const rng = getRandomNumberGenerator(); + this.ctx = ctx; + + this.points = new Array(10).fill(1).map(() => [rng(0, 100), rng(0, 100)]); + console.table(this.points); + + this.delaunay = Delaunay.from(this.points); + const voronoi = this.delaunay.voronoi(); + + this.ctx.beginPath(); + voronoi.render(this.ctx); + this.ctx.lineWidth = 2; + this.ctx.strokeStyle = 'blue'; + this.ctx.stroke(); + } + } + + handleHover = (event: React.MouseEvent) => { + if (this.delaunay) { + const index = this.delaunay.find(event.nativeEvent.offsetX, event.nativeEvent.offsetY); + + console.log(this.points[index]); + } + }; + + render() { return ( <>
- - - - - - - - - +
); diff --git a/package.json b/package.json index 4b3985a6b4..ff5d139881 100644 --- a/package.json +++ b/package.json @@ -163,11 +163,13 @@ "webpack-dev-server": "^3.3.1" }, "dependencies": { + "@types/d3-delaunay": "^4.1.0", "@types/d3-shape": "^1.3.1", "classnames": "^2.2.6", "d3-array": "^1.2.4", "d3-collection": "^1.0.7", "d3-color": "^1.4.0", + "d3-delaunay": "^5.2.1", "d3-scale": "^1.0.7", "d3-shape": "^1.3.4", "eslint-plugin-unicorn": "^16.1.1", diff --git a/src/chart_types/xy_chart/renderer/canvas/areas.ts b/src/chart_types/xy_chart/renderer/canvas/areas.ts index db787fa6f7..60879227a0 100644 --- a/src/chart_types/xy_chart/renderer/canvas/areas.ts +++ b/src/chart_types/xy_chart/renderer/canvas/areas.ts @@ -37,6 +37,7 @@ interface AreaGeometriesProps { export function renderAreas(ctx: CanvasRenderingContext2D, props: AreaGeometriesProps) { withContext(ctx, (ctx) => { const { sharedStyle, highlightedLegendItem, areas, clippings } = props; + withClip(ctx, clippings, (ctx: CanvasRenderingContext2D) => { ctx.save(); @@ -58,16 +59,22 @@ export function renderAreas(ctx: CanvasRenderingContext2D, props: AreaGeometries ctx.clip(); ctx.restore(); }); - for (let i = 0; i < areas.length; i++) { - const glyph = areas[i]; - const { seriesPointStyle, seriesIdentifier } = glyph; + + areas.forEach((area) => { + const { seriesPointStyle, seriesIdentifier } = area; if (seriesPointStyle.visible) { const geometryStateStyle = getGeometryStateStyle(seriesIdentifier, highlightedLegendItem, sharedStyle); - withContext(ctx, () => { - renderPoints(ctx, glyph.points, seriesPointStyle, geometryStateStyle); - }); + withClip( + ctx, + clippings, + (ctx) => { + renderPoints(ctx, area.points, seriesPointStyle, geometryStateStyle); + }, + // TODO: add padding over clipping + area.points[0]?.value.dot !== null, + ); } - } + }); }); } @@ -83,6 +90,7 @@ function renderArea( const fill = buildAreaStyles(color, seriesAreaStyle, geometryStateStyle); renderAreaPath(ctx, transform.x, area, fill, clippedRanges, clippings); } + function renderAreaLines( ctx: CanvasRenderingContext2D, glyph: AreaGeometry, diff --git a/src/chart_types/xy_chart/renderer/canvas/lines.ts b/src/chart_types/xy_chart/renderer/canvas/lines.ts index 2cda5df803..d489e6e212 100644 --- a/src/chart_types/xy_chart/renderer/canvas/lines.ts +++ b/src/chart_types/xy_chart/renderer/canvas/lines.ts @@ -20,7 +20,7 @@ import { getGeometryStateStyle } from '../../rendering/rendering'; import { LineGeometry } from '../../../../utils/geometry'; import { SharedGeometryStateStyle } from '../../../../utils/themes/theme'; import { LegendItem } from '../../legend/legend'; -import { withContext } from '../../../../renderers/canvas'; +import { withContext, withClip } from '../../../../renderers/canvas'; import { renderPoints } from './points'; import { renderLinePaths } from './primitives/path'; import { Rect } from '../../../../geoms/types'; @@ -47,10 +47,16 @@ export function renderLines(ctx: CanvasRenderingContext2D, props: LineGeometries } if (seriesPointStyle.visible) { - withContext(ctx, (ctx) => { - const geometryStyle = getGeometryStateStyle(line.seriesIdentifier, highlightedLegendItem, sharedStyle); - renderPoints(ctx, line.points, line.seriesPointStyle, geometryStyle); - }); + withClip( + ctx, + clippings, + (ctx) => { + const geometryStyle = getGeometryStateStyle(line.seriesIdentifier, highlightedLegendItem, sharedStyle); + renderPoints(ctx, line.points, line.seriesPointStyle, geometryStyle); + }, + // TODO: add padding over clipping + line.points[0]?.value.dot !== null, + ); } }); }); diff --git a/src/chart_types/xy_chart/renderer/canvas/points.ts b/src/chart_types/xy_chart/renderer/canvas/points.ts index cd9f006c2e..d1dad63c18 100644 --- a/src/chart_types/xy_chart/renderer/canvas/points.ts +++ b/src/chart_types/xy_chart/renderer/canvas/points.ts @@ -19,7 +19,7 @@ import { PointGeometry } from '../../../../utils/geometry'; import { PointStyle, GeometryStateStyle } from '../../../../utils/themes/theme'; import { renderCircle } from './primitives/arc'; -import { Circle } from '../../../../geoms/types'; +import { Circle, Stroke, Fill } from '../../../../geoms/types'; import { buildPointStyles } from './styles/point'; export function renderPoints( @@ -28,16 +28,24 @@ export function renderPoints( themeStyle: PointStyle, geometryStateStyle: GeometryStateStyle, ) { - return points.map((point) => { - const { x, y, color, transform, styleOverrides } = point; - const { fill, stroke, radius } = buildPointStyles(color, themeStyle, geometryStateStyle, styleOverrides); + points + .map<[Circle, Fill, Stroke]>((point) => { + const { x, y, color, radius, transform, styleOverrides } = point; + const { fill, stroke, radius: radiusOverride } = buildPointStyles( + color, + themeStyle, + geometryStateStyle, + styleOverrides, + ); - const circle: Circle = { - x: x + transform.x, - y, - radius, - }; + const circle: Circle = { + x: x + transform.x, + y, + radius: radius || radiusOverride, + }; - renderCircle(ctx, circle, fill, stroke); - }); + return [circle, fill, stroke]; + }) + .sort(([{ radius: a }], [{ radius: b }]) => b - a) + .forEach((args) => renderCircle(ctx, ...args)); } diff --git a/src/chart_types/xy_chart/renderer/canvas/renderers.ts b/src/chart_types/xy_chart/renderer/canvas/renderers.ts index dc8a9b340f..5168ba8b8e 100644 --- a/src/chart_types/xy_chart/renderer/canvas/renderers.ts +++ b/src/chart_types/xy_chart/renderer/canvas/renderers.ts @@ -43,6 +43,7 @@ export function renderXYChartCanvas2d( chartTransform, chartRotation, geometries, + geometriesIndex, theme, highlightedLegendItem, annotationDimensions, @@ -160,18 +161,21 @@ export function renderXYChartCanvas2d( ); }); }, + // rendering debugger (ctx: CanvasRenderingContext2D) => { if (!debug) { return; } withContext(ctx, (ctx) => { + const { left, top, width, height } = chartDimensions; + renderDebugRect( ctx, { - x: chartDimensions.left, - y: chartDimensions.top, - width: chartDimensions.width, - height: chartDimensions.height, + x: left, + y: top, + width, + height, }, { color: stringToRGB('transparent'), @@ -182,6 +186,18 @@ export function renderXYChartCanvas2d( dash: [4, 4], }, ); + + const voronoi = geometriesIndex.voronoi([0, 0, width, height]); + + if (voronoi) { + ctx.beginPath(); + ctx.translate(left, top); + ctx.setLineDash([5, 5]); + voronoi.render(ctx); + ctx.lineWidth = 1; + ctx.strokeStyle = 'blue'; + ctx.stroke(); + } }); }, ]); diff --git a/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx b/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx index 9cf2210d1a..4ac7729b0f 100644 --- a/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx +++ b/src/chart_types/xy_chart/renderer/canvas/xy_chart.tsx @@ -47,12 +47,14 @@ import { renderXYChartCanvas2d } from './renderers'; import { isChartEmptySelector } from '../../state/selectors/is_chart_empty'; import { deepEqual } from '../../../../utils/fast_deep_equal'; import { Rotation } from '../../../../utils/commons'; +import { IndexedGeometryMap } from '../../utils/indexed_geometry_map'; export interface ReactiveChartStateProps { initialized: boolean; debug: boolean; isChartEmpty: boolean; geometries: Geometries; + geometriesIndex: IndexedGeometryMap; theme: Theme; chartContainerDimensions: Dimensions; chartRotation: Rotation; @@ -180,6 +182,7 @@ const DEFAULT_PROPS: ReactiveChartStateProps = { lines: [], points: [], }, + geometriesIndex: new IndexedGeometryMap(), theme: LIGHT_THEME, chartContainerDimensions: { width: 0, @@ -222,6 +225,7 @@ const mapStateToProps = (state: GlobalChartState): ReactiveChartStateProps => { isChartEmpty: isChartEmptySelector(state), debug: getSettingsSpecSelector(state).debug, geometries: computeSeriesGeometriesSelector(state).geometries, + geometriesIndex: computeSeriesGeometriesSelector(state).geometriesIndex, theme: getChartThemeSelector(state), chartContainerDimensions: getChartContainerDimensionsSelector(state), highlightedLegendItem: getHighlightedSeriesSelector(state), diff --git a/src/chart_types/xy_chart/renderer/dom/highlighter.tsx b/src/chart_types/xy_chart/renderer/dom/highlighter.tsx index f9242d15f9..ffb6175278 100644 --- a/src/chart_types/xy_chart/renderer/dom/highlighter.tsx +++ b/src/chart_types/xy_chart/renderer/dom/highlighter.tsx @@ -28,6 +28,7 @@ import { Rotation } from '../../../../utils/commons'; import { Transform } from '../../state/utils'; import { getChartRotationSelector } from '../../../../state/selectors/get_chart_rotation'; import { computeChartDimensionsSelector } from '../../state/selectors/compute_chart_dimensions'; +import { DEFAULT_HIGHLIGHT_PADDING } from '../../rendering/rendering'; interface HighlighterProps { initialized: boolean; @@ -64,10 +65,11 @@ class HighlighterComponent extends React.Component { key={i} cx={x + geom.transform.x} cy={y} - r={geom.radius} + r={geom.radius + DEFAULT_HIGHLIGHT_PADDING} stroke={color} strokeWidth={4} fill="transparent" + clipPath={geom.value.dot !== null ? `url(#${clipPathId})` : undefined} /> ); } diff --git a/src/chart_types/xy_chart/rendering/rendering.ts b/src/chart_types/xy_chart/rendering/rendering.ts index f70f76d46e..026a6e89a0 100644 --- a/src/chart_types/xy_chart/rendering/rendering.ts +++ b/src/chart_types/xy_chart/rendering/rendering.ts @@ -31,7 +31,6 @@ import { CurveType, getCurveFactory } from '../../../utils/curves'; import { DataSeriesDatum, DataSeries, XYChartSeriesIdentifier } from '../utils/series'; import { DisplayValueSpec, PointStyleAccessor, BarStyleAccessor } from '../utils/specs'; import { - IndexedGeometry, PointGeometry, BarGeometry, AreaGeometry, @@ -42,20 +41,11 @@ import { } from '../../../utils/geometry'; import { mergePartial, Color } from '../../../utils/commons'; import { LegendItem } from '../legend/legend'; +import { IndexedGeometryMap, GeometryType } from '../utils/indexed_geometry_map'; +import { getDistance } from '../state/utils'; +import { PointBuffer } from '../../../specs'; -export function mutableIndexedGeometryMapUpsert( - mutableGeometriesIndex: Map, - key: any, - geometry: IndexedGeometry | IndexedGeometry[], -) { - const existing = mutableGeometriesIndex.get(key); - const upsertGeometry: IndexedGeometry[] = Array.isArray(geometry) ? geometry : [geometry]; - if (existing === undefined) { - mutableGeometriesIndex.set(key, upsertGeometry); - } else { - mutableGeometriesIndex.set(key, [...upsertGeometry, ...existing]); - } -} +export const DEFAULT_HIGHLIGHT_PADDING = 10; export function getPointStyleOverrides( datum: DataSeriesDatum, @@ -104,22 +94,60 @@ export function getBarStyleOverrides( }); } +/** + * Get radius function form ratio and min/max dot sixe + * + * @todo add continuous/non-stepped function + * + * @param {Datum[]} radii + * @param {number} lineWidth + * @param {number=50} radiusRatio - 1 to 100 + */ +function getRadiusFn(data: DataSeriesDatum[], lineWidth: number, radiusRatio: number = 50) { + if (data.length === 0) { + return () => 0; + } + const { min, max } = data.reduce( + (acc, { dot }) => + dot === null + ? acc + : { + min: Math.min(acc.min, dot / 2), + max: Math.max(acc.max, dot / 2), + }, + { min: Infinity, max: -Infinity }, + ); + const radiusStep = (max - min || max * 100) / Math.pow(radiusRatio, 2); + return function getRadius(dot: number | null, defaultRadius = 0): number { + if (dot === null) { + return defaultRadius; + } + const circleRadius = (dot / 2 - min) / radiusStep; + const baseMagicNumber = 2; + const base = circleRadius ? Math.sqrt(circleRadius + baseMagicNumber) + lineWidth : lineWidth; + return base; + }; +} + function renderPoints( shift: number, dataSeries: DataSeries, xScale: Scale, yScale: Scale, color: Color, + lineWidth: number, hasY0Accessors: boolean, styleAccessor?: PointStyleAccessor, + radiusRatio?: number, ): { pointGeometries: PointGeometry[]; - indexedGeometries: Map; + indexedGeometryMap: IndexedGeometryMap; } { - const indexedGeometries: Map = new Map(); + const indexedGeometryMap = new IndexedGeometryMap(); const isLogScale = isLogarithmicScale(yScale); + const getRadius = getRadiusFn(dataSeries.data, lineWidth, radiusRatio); const pointGeometries = dataSeries.data.reduce((acc, datum) => { - const { x: xValue, y0, y1, initialY0, initialY1, filled } = datum; + const { x: xValue, y0, y1, initialY0, initialY1, filled, dot } = datum; // don't create the point if not within the xScale domain or it that point was filled if (!xScale.isValueInDomain(xValue) || (filled && filled.y1 !== undefined)) { return acc; @@ -134,7 +162,7 @@ function renderPoints( return; } let y; - let radius = 10; + let radius = getRadius(dot); // we fix 0 and negative values at y = 0 if (yDatum === null || (isLogScale && yDatum <= 0)) { y = yScale.range[0]; @@ -159,6 +187,7 @@ function renderPoints( value: { x: xValue, y: originalY, + dot, accessor: hasY0Accessors && index === 0 ? BandedAccessorType.Y0 : BandedAccessorType.Y1, }, transform: { @@ -168,7 +197,8 @@ function renderPoints( seriesIdentifier, styleOverrides, }; - mutableIndexedGeometryMapUpsert(indexedGeometries, xValue, pointGeometry); + const geometryType = dot === null ? GeometryType.linear : GeometryType.spatial; + indexedGeometryMap.set(pointGeometry, geometryType); // use the geometry only if the yDatum in contained in the current yScale domain const isHidden = yDatum === null || (isLogScale && yDatum <= 0); if (!isHidden && yScale.isValueInDomain(yDatum)) { @@ -179,7 +209,7 @@ function renderPoints( }, [] as PointGeometry[]); return { pointGeometries, - indexedGeometries, + indexedGeometryMap, }; } @@ -195,9 +225,9 @@ export function renderBars( minBarHeight?: number, ): { barGeometries: BarGeometry[]; - indexedGeometries: Map; + indexedGeometryMap: IndexedGeometryMap; } { - const indexedGeometries: Map = new Map(); + const indexedGeometryMap = new IndexedGeometryMap(); const barGeometries: BarGeometry[] = []; const bboxCalculator = new CanvasTextBBoxCalculator(); @@ -306,12 +336,13 @@ export function renderBars( value: { x: datum.x, y: initialY1, + dot: null, accessor: BandedAccessorType.Y1, }, seriesIdentifier, seriesStyle, }; - mutableIndexedGeometryMapUpsert(indexedGeometries, datum.x, barGeometry); + indexedGeometryMap.set(barGeometry); barGeometries.push(barGeometry); }); @@ -319,7 +350,7 @@ export function renderBars( return { barGeometries, - indexedGeometries, + indexedGeometryMap, }; } @@ -335,9 +366,10 @@ export function renderLine( seriesStyle: LineSeriesStyle, pointStyleAccessor?: PointStyleAccessor, hasFit?: boolean, + radiusRatio?: number, ): { lineGeometry: LineGeometry; - indexedGeometries: Map; + indexedGeometryMap: IndexedGeometryMap; } { const isLogScale = isLogarithmicScale(yScale); @@ -361,14 +393,16 @@ export function renderLine( const y = 0; const x = shift; - const { pointGeometries, indexedGeometries } = renderPoints( + const { pointGeometries, indexedGeometryMap } = renderPoints( shift - xScaleOffset, dataSeries, xScale, yScale, color, + seriesStyle.line.strokeWidth, hasY0Accessors, pointStyleAccessor, + radiusRatio, ); const clippedRanges = hasFit && !hasY0Accessors ? getClippedRanges(dataSeries.data, xScale, xScaleOffset) : []; @@ -393,7 +427,7 @@ export function renderLine( }; return { lineGeometry, - indexedGeometries, + indexedGeometryMap, }; } @@ -425,9 +459,10 @@ export function renderArea( isStacked = false, pointStyleAccessor?: PointStyleAccessor, hasFit?: boolean, + radiusRatio?: number, ): { areaGeometry: AreaGeometry; - indexedGeometries: Map; + indexedGeometryMap: IndexedGeometryMap; } { const isLogScale = isLogarithmicScale(yScale); const pathGenerator = area() @@ -466,14 +501,16 @@ export function renderArea( } } - const { pointGeometries, indexedGeometries } = renderPoints( + const { pointGeometries, indexedGeometryMap } = renderPoints( shift - xScaleOffset, dataSeries, xScale, yScale, color, + seriesStyle.line.strokeWidth, hasY0Accessors, pointStyleAccessor, + radiusRatio, ); const areaGeometry: AreaGeometry = { @@ -500,7 +537,7 @@ export function renderArea( }; return { areaGeometry, - indexedGeometries, + indexedGeometryMap, }; } @@ -568,16 +605,23 @@ export function isPointOnGeometry( xCoordinate: number, yCoordinate: number, indexedGeometry: BarGeometry | PointGeometry, + buffer: PointBuffer = DEFAULT_HIGHLIGHT_PADDING, ) { const { x, y } = indexedGeometry; if (isPointGeometry(indexedGeometry)) { const { radius, transform } = indexedGeometry; - return ( - yCoordinate >= y - radius && - yCoordinate <= y + radius && - xCoordinate >= x + transform.x - radius && - xCoordinate <= x + transform.x + radius + const distance = getDistance( + { + x: xCoordinate, + y: yCoordinate, + }, + { + x: x + transform.x, + y, + }, ); + const radiusBuffer = typeof buffer === 'number' ? buffer : buffer(radius); + return distance <= radius + radiusBuffer; } const { width, height } = indexedGeometry; return yCoordinate >= y && yCoordinate <= y + height && xCoordinate >= x && xCoordinate <= x + width; diff --git a/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts b/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts index f6b1150cd1..aa902eb214 100644 --- a/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts +++ b/src/chart_types/xy_chart/state/selectors/get_elements_at_cursor_pos.ts @@ -30,6 +30,7 @@ import { Dimensions } from '../../../../utils/dimensions'; import { GlobalChartState } from '../../../../state/chart_state'; import { isValidPointerOverEvent } from '../../../../utils/events'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { IndexedGeometryMap } from '../../utils/indexed_geometry_map'; const getExternalPointerEventStateSelector = (state: GlobalChartState) => state.externalEvents.pointer; @@ -49,7 +50,7 @@ function getElementAtCursorPosition( orientedProjectedPoinerPosition: Point, scales: ComputedScales, geometriesIndexKeys: any, - geometriesIndex: Map, + geometriesIndex: IndexedGeometryMap, externalPointerEvent: PointerEvent | null, { chartDimensions, @@ -63,12 +64,13 @@ function getElementAtCursorPosition( if (x == null || x > chartDimensions.width + chartDimensions.left) { return []; } - return geometriesIndex.get(externalPointerEvent.value) || []; + // TODO: Handle external event with spatial points + return geometriesIndex.find(externalPointerEvent.value, { x: -1, y: -1 }, true); } const xValue = scales.xScale.invertWithStep(orientedProjectedPoinerPosition.x, geometriesIndexKeys); if (!xValue) { return []; } - // get the elements on at this cursor position - return geometriesIndex.get(xValue.value) || []; + // get the elements at cursor position + return geometriesIndex.find(xValue?.value, orientedProjectedPoinerPosition, true); } diff --git a/src/chart_types/xy_chart/state/selectors/get_geometries_index.ts b/src/chart_types/xy_chart/state/selectors/get_geometries_index.ts index 9ae4d151ee..2017d03b0d 100644 --- a/src/chart_types/xy_chart/state/selectors/get_geometries_index.ts +++ b/src/chart_types/xy_chart/state/selectors/get_geometries_index.ts @@ -17,13 +17,13 @@ * under the License. */ import createCachedSelector from 're-reselect'; -import { IndexedGeometry } from '../../../../utils/geometry'; import { computeSeriesGeometriesSelector } from './compute_series_geometries'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; +import { IndexedGeometryMap } from '../../utils/indexed_geometry_map'; export const getGeometriesIndexSelector = createCachedSelector( [computeSeriesGeometriesSelector], - (geometries): Map => { + (geometries): IndexedGeometryMap => { return geometries.geometriesIndex; }, )(getChartIdSelector); diff --git a/src/chart_types/xy_chart/state/selectors/get_geometries_index_keys.ts b/src/chart_types/xy_chart/state/selectors/get_geometries_index_keys.ts index b112c41dac..c304a2dd47 100644 --- a/src/chart_types/xy_chart/state/selectors/get_geometries_index_keys.ts +++ b/src/chart_types/xy_chart/state/selectors/get_geometries_index_keys.ts @@ -23,7 +23,8 @@ import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; export const getGeometriesIndexKeysSelector = createCachedSelector( [computeSeriesGeometriesSelector], - (seriesGeometries): any[] => { - return [...seriesGeometries.geometriesIndex.keys()].sort(compareByValueAsc); + (seriesGeometries): number[] => { + // TODO: find why is this only for numbers not strings + return seriesGeometries.geometriesIndex.keys().sort(compareByValueAsc); }, )(getChartIdSelector); diff --git a/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts b/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts index 506a802e6d..bfae330034 100644 --- a/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts +++ b/src/chart_types/xy_chart/state/selectors/get_tooltip_values_highlighted_geoms.ts @@ -39,12 +39,14 @@ import { TooltipType, TooltipValueFormatter, isFollowTooltipType, + SettingsSpec, } from '../../../../specs'; import { isValidPointerOverEvent } from '../../../../utils/events'; import { getChartRotationSelector } from '../../../../state/selectors/get_chart_rotation'; import { getChartIdSelector } from '../../../../state/selectors/get_chart_id'; import { hasSingleSeriesSelector } from './has_single_series'; import { TooltipInfo } from '../../../../components/tooltip/types'; +import { getSettingsSpecSelector } from '../../../../state/selectors/get_settings_specs'; const EMPTY_VALUES = Object.freeze({ tooltip: { @@ -65,6 +67,7 @@ export const getTooltipInfoAndGeometriesSelector = createCachedSelector( [ getSeriesSpecsSelector, getAxisSpecsSelector, + getSettingsSpecSelector, getProjectedPointerPositionSelector, getOrientedProjectedPointerPositionSelector, getChartRotationSelector, @@ -75,20 +78,21 @@ export const getTooltipInfoAndGeometriesSelector = createCachedSelector( getExternalPointerEventStateSelector, getTooltipHeaderFormatterSelector, ], - getTooltipAndHighlightFromXValue, + getTooltipAndHighlightFromValue, )((state: GlobalChartState) => { return state.chartId; }); -function getTooltipAndHighlightFromXValue( +function getTooltipAndHighlightFromValue( seriesSpecs: BasicSeriesSpec[], axesSpecs: AxisSpec[], + settings: SettingsSpec, projectedPointerPosition: Point, orientedProjectedPointerPosition: Point, chartRotation: Rotation, hasSingleSeries: boolean, scales: ComputedScales, - xMatchingGeoms: IndexedGeometry[], + matchingGeoms: IndexedGeometry[], tooltipType: TooltipType = TooltipType.VerticalCursor, externalPointerEvent: PointerEvent | null, tooltipHeaderFormatter?: TooltipValueFormatter, @@ -108,14 +112,14 @@ function getTooltipAndHighlightFromXValue( return EMPTY_VALUES; } - if (xMatchingGeoms.length === 0) { + if (matchingGeoms.length === 0) { return EMPTY_VALUES; } // build the tooltip value list let header: TooltipValue | null = null; const highlightedGeometries: IndexedGeometry[] = []; - const values = xMatchingGeoms + const values = matchingGeoms .filter(({ value: { y } }) => y !== null) .reduce((acc, indexedGeometry) => { const { @@ -139,7 +143,7 @@ function getTooltipAndHighlightFromXValue( let isHighlighted = false; if ( (!externalPointerEvent || isPointerOutEvent(externalPointerEvent)) && - isPointOnGeometry(x, y, indexedGeometry) + isPointOnGeometry(x, y, indexedGeometry, settings.pointBuffer) ) { isHighlighted = true; highlightedGeometries.push(indexedGeometry); diff --git a/src/chart_types/xy_chart/state/utils.ts b/src/chart_types/xy_chart/state/utils.ts index a6d23e07d7..d1527fa677 100644 --- a/src/chart_types/xy_chart/state/utils.ts +++ b/src/chart_types/xy_chart/state/utils.ts @@ -20,7 +20,7 @@ import { isVerticalAxis } from '../utils/axis_utils'; import { CurveType } from '../../../utils/curves'; import { mergeXDomain, XDomain } from '../domains/x_domain'; import { mergeYDomain, YDomain } from '../domains/y_domain'; -import { mutableIndexedGeometryMapUpsert, renderArea, renderBars, renderLine } from '../rendering/rendering'; +import { renderArea, renderBars, renderLine } from '../rendering/rendering'; import { computeXScale, computeYScales, countBarsInCluster } from '../utils/scales'; import { DataSeries, @@ -56,9 +56,11 @@ import { Dimensions } from '../../../utils/dimensions'; import { Domain } from '../../../utils/domain'; import { GroupId, SpecId } from '../../../utils/ids'; import { Scale } from '../../../scales'; -import { PointGeometry, BarGeometry, AreaGeometry, LineGeometry, IndexedGeometry } from '../../../utils/geometry'; +import { PointGeometry, BarGeometry, AreaGeometry, LineGeometry } from '../../../utils/geometry'; import { LegendItem } from '../legend/legend'; import { Spec } from '../../../specs'; +import { IndexedGeometryMap } from '../utils/indexed_geometry_map'; +import { Point } from '../../../utils/point'; const MAX_ANIMATABLE_BARS = 300; const MAX_ANIMATABLE_LINES_AREA_POINTS = 600; @@ -98,7 +100,7 @@ export interface Geometries { export interface ComputedGeometries { scales: ComputedScales; geometries: Geometries; - geometriesIndex: Map; + geometriesIndex: IndexedGeometryMap; geometriesCounts: GeometriesCounts; } @@ -261,6 +263,7 @@ export function computeSeriesDomains( }; updatedSeriesCollection.set(key, updatedColorSet); }); + return { xDomain, yDomain, @@ -307,8 +310,7 @@ export function computeSeriesGeometries( const areas: AreaGeometry[] = []; const bars: BarGeometry[] = []; const lines: LineGeometry[] = []; - let stackedGeometriesIndex: Map = new Map(); - let nonStackedGeometriesIndex: Map = new Map(); + const geometriesIndex = new IndexedGeometryMap(); let orderIndex = 0; const geometriesCounts = { points: 0, @@ -344,7 +346,7 @@ export function computeSeriesGeometries( lines.push(...geometries.lines); bars.push(...geometries.bars); points.push(...geometries.points); - stackedGeometriesIndex = mergeGeometriesIndexes(stackedGeometriesIndex, geometries.geometriesIndex); + geometriesIndex.merge(geometries.indexedGeometryMap); // update counts geometriesCounts.points += geometries.geometriesCounts.points; geometriesCounts.bars += geometries.geometriesCounts.bars; @@ -379,7 +381,7 @@ export function computeSeriesGeometries( bars.push(...geometries.bars); points.push(...geometries.points); - nonStackedGeometriesIndex = mergeGeometriesIndexes(nonStackedGeometriesIndex, geometries.geometriesIndex); + geometriesIndex.merge(geometries.indexedGeometryMap); // update counts geometriesCounts.points += geometries.geometriesCounts.points; geometriesCounts.bars += geometries.geometriesCounts.bars; @@ -388,7 +390,6 @@ export function computeSeriesGeometries( geometriesCounts.lines += geometries.geometriesCounts.lines; geometriesCounts.linePoints += geometries.geometriesCounts.linePoints; }); - const geometriesIndex = mergeGeometriesIndexes(stackedGeometriesIndex, nonStackedGeometriesIndex); return { scales: { xScale, @@ -474,7 +475,7 @@ function renderGeometries( bars: BarGeometry[]; areas: AreaGeometry[]; lines: LineGeometry[]; - geometriesIndex: Map; + indexedGeometryMap: IndexedGeometryMap; geometriesCounts: GeometriesCounts; } { const len = dataSeries.length; @@ -483,10 +484,7 @@ function renderGeometries( const bars: BarGeometry[] = []; const areas: AreaGeometry[] = []; const lines: LineGeometry[] = []; - const pointGeometriesIndex: Map = new Map(); - let barGeometriesIndex: Map = new Map(); - let lineGeometriesIndex: Map = new Map(); - let areaGeometriesIndex: Map = new Map(); + const indexedGeometryMap = new IndexedGeometryMap(); const geometriesCounts = { points: 0, bars: 0, @@ -532,7 +530,7 @@ function renderGeometries( spec.styleAccessor, spec.minBarHeight, ); - barGeometriesIndex = mergeGeometriesIndexes(barGeometriesIndex, renderedBars.indexedGeometries); + indexedGeometryMap.merge(renderedBars.indexedGeometryMap); bars.push(...renderedBars.barGeometries); geometriesCounts.bars += renderedBars.barGeometries.length; barIndexOffset += 1; @@ -557,8 +555,9 @@ function renderGeometries( lineSeriesStyle, spec.pointStyleAccessor, Boolean(spec.fit && ((spec.fit as FitConfig).type || spec.fit) !== Fit.None), + chartTheme.radiusRatio, ); - lineGeometriesIndex = mergeGeometriesIndexes(lineGeometriesIndex, renderedLines.indexedGeometries); + indexedGeometryMap.merge(renderedLines.indexedGeometryMap); lines.push(renderedLines.lineGeometry); geometriesCounts.linePoints += renderedLines.lineGeometry.points.length; geometriesCounts.lines += 1; @@ -571,7 +570,6 @@ function renderGeometries( const renderedAreas = renderArea( // move the point on half of the bandwidth if we have mixed bars/lines (xScale.bandwidth * areaShift) / 2, - ds, xScale, yScale, @@ -583,25 +581,21 @@ function renderGeometries( isStacked, spec.pointStyleAccessor, Boolean(spec.fit && ((spec.fit as FitConfig).type || spec.fit) !== Fit.None), + chartTheme.radiusRatio, ); - areaGeometriesIndex = mergeGeometriesIndexes(areaGeometriesIndex, renderedAreas.indexedGeometries); + indexedGeometryMap.merge(renderedAreas.indexedGeometryMap); areas.push(renderedAreas.areaGeometry); geometriesCounts.areasPoints += renderedAreas.areaGeometry.points.length; geometriesCounts.areas += 1; } } - const geometriesIndex = mergeGeometriesIndexes( - pointGeometriesIndex, - lineGeometriesIndex, - areaGeometriesIndex, - barGeometriesIndex, - ); + return { points, bars, areas, lines, - geometriesIndex, + indexedGeometryMap, geometriesCounts, }; } @@ -657,23 +651,6 @@ export function computeChartTransform(chartDimensions: Dimensions, chartRotation } } -/** - * Merge multiple geometry indexes maps together. - * @param iterables a set of maps to be merged - * @returns a new Map where each element with the same key are concatenated on a single - * IndexedGemoetry array for that key - */ -export function mergeGeometriesIndexes(...iterables: Map[]) { - const geometriesIndex: Map = new Map(); - - for (const iterable of iterables) { - for (const item of iterable) { - mutableIndexedGeometryMapUpsert(geometriesIndex, item[0], item[1]); - } - } - return geometriesIndex; -} - export function isHorizontalRotation(chartRotation: Rotation) { return chartRotation === 0 || chartRotation === 180; } @@ -710,3 +687,7 @@ export function isAllSeriesDeselected(legendItems: Map): boo } return true; } + +export function getDistance(a: Point, b: Point) { + return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2); +} diff --git a/src/chart_types/xy_chart/utils/indexed_geometry_linear_map.ts b/src/chart_types/xy_chart/utils/indexed_geometry_linear_map.ts new file mode 100644 index 0000000000..05a00a7a14 --- /dev/null +++ b/src/chart_types/xy_chart/utils/indexed_geometry_linear_map.ts @@ -0,0 +1,50 @@ +/* + * 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. */ + +import { IndexedGeometry } from '../../../utils/geometry'; + +export class IndexedGeometryLinearMap { + private map = new Map(); + + set(geometry: IndexedGeometry) { + const { x } = geometry.value; + const existing = this.map.get(x); + const upsertGeometry: IndexedGeometry[] = Array.isArray(geometry) ? geometry : [geometry]; + if (existing === undefined) { + this.map.set(x, upsertGeometry); + } else { + this.map.set(x, [...upsertGeometry, ...existing]); + } + } + + getMergeData() { + return [...this.map.values()]; + } + + keys(): Array { + return [...this.map.keys()].filter((key): key is number => typeof key === 'number'); + } + + find(x: number | string | null): IndexedGeometry[] { + if (x === null) { + return []; + } + + return this.map.get(x) ?? []; + } +} diff --git a/src/chart_types/xy_chart/utils/indexed_geometry_map.ts b/src/chart_types/xy_chart/utils/indexed_geometry_map.ts new file mode 100644 index 0000000000..0c08f67f1f --- /dev/null +++ b/src/chart_types/xy_chart/utils/indexed_geometry_map.ts @@ -0,0 +1,93 @@ +/* + * 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. */ + +import { Delaunay } from 'd3-delaunay'; +import { $Values } from 'utility-types'; + +import { IndexedGeometry, isPointGeometry } from '../../../utils/geometry'; +import { Point } from '../../../utils/point'; +import { IndexedGeometryLinearMap } from './indexed_geometry_linear_map'; +import { IndexedGeometrySpatialMap } from './indexed_geometry_spatial_map'; + +export const GeometryType = Object.freeze({ + linear: 'linear' as 'linear', + spatial: 'spatial' as 'spatial', +}); +export type GeometryType = $Values; + +export class IndexedGeometryMap { + private linearMap = new IndexedGeometryLinearMap(); + private spatialMap = new IndexedGeometrySpatialMap(); + + /** + * Renterns voronoi instance to render voronoi grid + * + * @param bounds + */ + voronoi(bounds?: Delaunay.Bounds) { + return this.spatialMap.voronoi(bounds); + } + + keys(): Array { + return [...this.linearMap.keys(), ...this.spatialMap.keys()]; + } + + set(geometry: IndexedGeometry, type: GeometryType = GeometryType.linear) { + if (type === GeometryType.spatial) { + if (!isPointGeometry(geometry)) { + throw new Error('Spatial geometry must be PointGeometry'); + } + this.spatialMap.set([geometry]); + } else { + this.linearMap.set(geometry); + } + } + + find(x: number | string | null, point: Point, neighbors = true): IndexedGeometry[] { + if (x === null) { + return []; + } + + return [...this.linearMap.find(x), ...this.spatialMap.find(x, point, neighbors)]; + } + + getMergeData() { + return { + spatialGeometries: this.spatialMap.getMergeData(), + linearGeometries: this.linearMap.getMergeData(), + }; + } + + /** + * Merge multiple indexedMaps into base indexedMaps + * @param indexedMaps + */ + merge(...indexedMaps: IndexedGeometryMap[]) { + for (const indexedMap of indexedMaps) { + const { spatialGeometries, linearGeometries } = indexedMap.getMergeData(); + this.spatialMap.set(spatialGeometries); + linearGeometries.forEach((geometry) => { + if (Array.isArray(geometry)) { + geometry.forEach((geometry) => this.linearMap.set(geometry)); + } else { + this.linearMap.set(geometry); + } + }); + } + } +} diff --git a/src/chart_types/xy_chart/utils/indexed_geometry_spatial_map.ts b/src/chart_types/xy_chart/utils/indexed_geometry_spatial_map.ts new file mode 100644 index 0000000000..b6bb3f7bcb --- /dev/null +++ b/src/chart_types/xy_chart/utils/indexed_geometry_spatial_map.ts @@ -0,0 +1,112 @@ +/* + * 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. */ + +import { Delaunay } from 'd3-delaunay'; + +import { IndexedGeometry, PointGeometry } from '../../../utils/geometry'; +import { Point } from '../../../utils/point'; +import { getDistance } from '../state/utils'; + +export type IndexedGeometrySpatialMapPoint = [number, number]; + +export class IndexedGeometrySpatialMap { + private map: Delaunay | null = null; + private points: IndexedGeometrySpatialMapPoint[] = []; + private pointGeometries: PointGeometry[] = []; + private searchStartIndex: number = 0; + private maxRadius = -Infinity; + + constructor(points: PointGeometry[] = []) { + this.set(points); + } + + isSpatial() { + return this.pointGeometries.length > 0; + } + + set(points: PointGeometry[]) { + // TODO: handle coincident points + this.maxRadius = Math.max(this.maxRadius, ...points.map(({ radius }) => radius)); + this.pointGeometries.push(...points); + this.points.push( + ...points.map(({ x, y }) => [x, y]), + ); + + if (this.points.length > 0) { + // TODO: handle write/read init + this.map = Delaunay.from(this.points); + } + } + + voronoi = (bounds?: Delaunay.Bounds) => { + return this.map?.voronoi(bounds); + }; + + getMergeData() { + return [...this.pointGeometries]; + } + + keys(): Array { + return this.pointGeometries.map(({ value: { x } }) => x); + } + + find(x: number | string | null, point: Point): IndexedGeometry[] { + if (x === null) { + return []; + } + + const elements = []; + if (this.map !== null) { + const index = this.map.find(point.x, point.y, this.searchStartIndex); + + // Set next starting search index for faster lookup + this.searchStartIndex = index; + elements.push(...this.getRadialNeighbors(index, point, new Set([index]))); + elements.push(...[this.pointGeometries[index] ?? []]); + } + + return elements; + } + + /** + * Gets surrounding points whose radius could be within the active cursor position + * + * @param selectedIndex + * @param point + * @param visitedIndices + */ + private getRadialNeighbors(selectedIndex: number, point: Point, visitedIndices: Set): IndexedGeometry[] { + if (this.map === null) { + return []; + } + + return [...this.map.neighbors(selectedIndex)] + .filter((i) => !visitedIndices.has(i)) + .flatMap((i) => { + visitedIndices.add(i); + const geometry = this.pointGeometries[i]; + + if (getDistance(geometry, point) < this.maxRadius) { + // Gets neighbors based on relation to maxRadius + return [geometry, ...this.getRadialNeighbors(i, point, visitedIndices)]; + } + + return []; + }); + } +} diff --git a/src/chart_types/xy_chart/utils/nonstacked_series_utils.ts b/src/chart_types/xy_chart/utils/nonstacked_series_utils.ts index efcbbc2a85..289c021ba0 100644 --- a/src/chart_types/xy_chart/utils/nonstacked_series_utils.ts +++ b/src/chart_types/xy_chart/utils/nonstacked_series_utils.ts @@ -57,7 +57,7 @@ export const formatNonStackedDataValues = (dataSeries: RawDataSeries, scaleToExt }; for (let i = 0; i < len; i++) { const data = dataSeries.data[i]; - const { x, y1, datum } = data; + const { x, y1, dot, datum } = data; let y0: number | null; if (y1 === null) { y0 = null; @@ -75,6 +75,7 @@ export const formatNonStackedDataValues = (dataSeries: RawDataSeries, scaleToExt y0, initialY1: y1, initialY0: data.y0 == null || y1 === null ? null : data.y0, + dot, datum, }; formattedValues.data.push(formattedValue); diff --git a/src/chart_types/xy_chart/utils/series.ts b/src/chart_types/xy_chart/utils/series.ts index 36c632731a..e7e79aafbd 100644 --- a/src/chart_types/xy_chart/utils/series.ts +++ b/src/chart_types/xy_chart/utils/series.ts @@ -44,8 +44,10 @@ export interface RawDataSeriesDatum { x: number | string; /** the main y metric */ y1: number | null; - /** the optional y0 metric, used for bars or area with a lower bound */ + /** the optional y0 metric, used for bars and area with a lower bound */ y0?: number | null; + /** the optional dot metric, used for lines and area series */ + dot: number | null; /** the datum */ datum?: T; } @@ -61,6 +63,8 @@ export interface DataSeriesDatum { initialY1: number | null; /** initial y0 value, non stacked */ initialY0: number | null; + /** the optional dot metric, used for lines and area series */ + dot: number | null; /** initial datum */ datum?: T; /** the list of filled values because missing or nulls */ @@ -128,8 +132,12 @@ export function splitSeries({ xAccessor, yAccessors, y0Accessors, + dotAccessor, splitSeriesAccessors = [], -}: Pick): { +}: Pick< + BasicSeriesSpec, + 'id' | 'data' | 'xAccessor' | 'yAccessors' | 'y0Accessors' | 'splitSeriesAccessors' | 'dotAccessor' +>): { rawDataSeries: RawDataSeries[]; colorsValues: Set; xValues: Set; @@ -143,7 +151,7 @@ export function splitSeries({ const splitAccessors = getSplitAccessors(datum, splitSeriesAccessors); if (isMultipleY) { yAccessors.forEach((accessor, index) => { - const cleanedDatum = cleanDatum(datum, xAccessor, accessor, y0Accessors && y0Accessors[index]); + const cleanedDatum = cleanDatum(datum, xAccessor, accessor, y0Accessors && y0Accessors[index], dotAccessor); if (cleanedDatum !== null && cleanedDatum.x !== null && cleanedDatum.x !== undefined) { xValues.add(cleanedDatum.x); @@ -152,7 +160,7 @@ export function splitSeries({ } }); } else { - const cleanedDatum = cleanDatum(datum, xAccessor, yAccessors[0], y0Accessors && y0Accessors[0]); + const cleanedDatum = cleanDatum(datum, xAccessor, yAccessors[0], y0Accessors && y0Accessors[0], dotAccessor); if (cleanedDatum !== null && cleanedDatum.x !== null && cleanedDatum.x !== undefined) { xValues.add(cleanedDatum.x); const seriesKey = updateSeriesMap(series, splitAccessors, yAccessors[0], cleanedDatum, specId); @@ -240,17 +248,21 @@ export function cleanDatum( xAccessor: Accessor | AccessorFn, yAccessor: Accessor, y0Accessor?: Accessor, + dotAccessor?: Accessor | AccessorFn, ): RawDataSeriesDatum | null { if (typeof datum !== 'object' || datum === null) { return null; } + const x = getAccessorValue(datum, xAccessor); + if (typeof x !== 'string' && typeof x !== 'number') { return null; } - const y1 = castToNumber(datum[yAccessor as keyof typeof datum]); - const cleanedDatum: RawDataSeriesDatum = { x, y1, datum, y0: null }; + const dot = dotAccessor === undefined ? null : getAccessorValue(datum, dotAccessor); + const y1 = castToNumber(datum[yAccessor]); + const cleanedDatum: RawDataSeriesDatum = { x, y1, datum, y0: null, dot }; if (y0Accessor) { cleanedDatum.y0 = castToNumber(datum[y0Accessor as keyof typeof datum]); } diff --git a/src/chart_types/xy_chart/utils/specs.ts b/src/chart_types/xy_chart/utils/specs.ts index dd91b2f78f..7d53559fd9 100644 --- a/src/chart_types/xy_chart/utils/specs.ts +++ b/src/chart_types/xy_chart/utils/specs.ts @@ -26,8 +26,8 @@ import { PointStyle, RectAnnotationStyle, } from '../../../utils/themes/theme'; -import { Accessor, AccessorFormat, AccessorFn } from '../../../utils/accessor'; import { RecursivePartial, Color, Position, Datum } from '../../../utils/commons'; +import { Accessor, AccessorFormat, AccessorFn } from '../../../utils/accessor'; import { AxisId, GroupId } from '../../../utils/ids'; import { ScaleContinuousType, ScaleType } from '../../../scales'; import { CurveType } from '../../../utils/curves'; @@ -338,6 +338,8 @@ export interface SeriesAccessors { splitSeriesAccessors?: Accessor[]; /** An array of fields thats indicates the stack membership */ stackAccessors?: Accessor[]; + /** Field name of dot size metric on `Datum` */ + dotAccessor: Accessor | AccessorFn; } export interface SeriesScales { diff --git a/src/chart_types/xy_chart/utils/stacked_series_utils.ts b/src/chart_types/xy_chart/utils/stacked_series_utils.ts index 48c1131f08..5564b2471b 100644 --- a/src/chart_types/xy_chart/utils/stacked_series_utils.ts +++ b/src/chart_types/xy_chart/utils/stacked_series_utils.ts @@ -144,6 +144,7 @@ export function formatStackedDataSeriesValues( x, // filling as 0 value y1: 0, + dot: null, }, stackedValues, seriesIndex, @@ -177,7 +178,7 @@ function getStackedFormattedSeriesDatum( isPercentageMode = false, filled?: FilledValues, ): DataSeriesDatum | undefined { - const { x, datum } = data; + const { x, dot, datum } = data; const stack = stackedValues.get(x); if (!stack) { return; @@ -204,6 +205,7 @@ function getStackedFormattedSeriesDatum( y0: computedY0, initialY1: y1, initialY0, + dot, datum, ...(filled && { filled }), }; @@ -230,6 +232,7 @@ function getStackedFormattedSeriesDatum( y0: stackedY0, initialY1: y1, initialY0, + dot, datum, ...(filled && { filled }), }; diff --git a/src/components/tooltip/index.tsx b/src/components/tooltip/index.tsx index b0119c14b0..faee820e24 100644 --- a/src/components/tooltip/index.tsx +++ b/src/components/tooltip/index.tsx @@ -99,41 +99,57 @@ class TooltipComponent extends React.Component { return
{formatter ? formatter(headerData) : headerData.value}
; } - render() { - const { isVisible, info, headerFormatter, getChartContainerRef } = this.props; - const chartContainerRef = getChartContainerRef(); - if (!this.portalNode || chartContainerRef.current === null || !isVisible || !info) { - return null; - } - const tooltipComponent = ( + renderTooltip = (info: TooltipInfo) => { + const { headerFormatter } = this.props; + + return (
{this.renderHeader(info.header, headerFormatter)}
- {info.values.map(({ seriesIdentifier, valueAccessor, label, value, color, isHighlighted, isVisible }) => { - if (!isVisible) { - return null; - } - const classes = classNames('echTooltip__item', { - /* eslint @typescript-eslint/camelcase:0 */ - echTooltip__rowHighlighted: isHighlighted, - }); - return ( -
- {label} - {value} -
- ); - })} + {info.values.map( + ({ seriesIdentifier, valueAccessor, label, value, color, isHighlighted, isVisible }, index) => { + if (!isVisible) { + return null; + } + const classes = classNames('echTooltip__item', { + /* eslint @typescript-eslint/camelcase:0 */ + echTooltip__rowHighlighted: isHighlighted, + }); + return ( +
+ {label} + {value} +
+ ); + }, + )}
); - return createPortal(tooltipComponent, this.portalNode); + }; + + isComplexTooltip(info: TooltipInfo) { + // TODO: fix after changes to tooltip info + // check for multiple x values in info array + return info.values.every(({ value }) => value); + } + + render() { + const { isVisible, info, getChartContainerRef } = this.props; + const chartContainerRef = getChartContainerRef(); + + if (!this.portalNode || chartContainerRef.current === null || !isVisible || !info) { + return null; + } + + return createPortal(this.renderTooltip(info), this.portalNode); } } diff --git a/src/mocks/utils.ts b/src/mocks/utils.ts index 7441a55cb2..b298a41082 100644 --- a/src/mocks/utils.ts +++ b/src/mocks/utils.ts @@ -31,7 +31,30 @@ export const forcedType = (obj: Partial): T => { return obj as T; }; -export const getRandomNumberGenerator = (seed = process.env.RNG_SEED) => seedrandom(seed); +/** + * Return rng function with optional `min`, `max` and `fractionDigits` params + * + * @param string process.env.RNG_SEED + */ +export const getRandomNumberGenerator = (seed = process.env.RNG_SEED) => { + const rng = seedrandom(seed); + + /** + * Random number generator + * + * @param {} min=0 + * @param {} max=1 + * @param {} fractionDigits=0 + */ + return function randomNumberGenerator(min = 0, max = 1, fractionDigits = 0) { + const num = rng() * (max - min) + min; + + if (fractionDigits === 0) return Math.floor(num); + + const factor = 10 ** fractionDigits; + return Math.round((num + Number.EPSILON) * factor) / factor; + }; +}; export class SeededDataGenerator extends DataGenerator { constructor(frequency = 500) { diff --git a/src/renderers/canvas/index.ts b/src/renderers/canvas/index.ts index 22f1356207..096546b114 100644 --- a/src/renderers/canvas/index.ts +++ b/src/renderers/canvas/index.ts @@ -56,14 +56,17 @@ export function renderLayers(ctx: CanvasRenderingContext2D, layers: Array<(ctx: export function withClip( ctx: CanvasRenderingContext2D, - clip: { x: number; y: number; width: number; height: number }, + clipppings: Rect, fun: (ctx: CanvasRenderingContext2D) => void, + shouldClip = true, ) { withContext(ctx, (ctx) => { - const { x, y, width, height } = clip; - ctx.beginPath(); - ctx.rect(x, y, width, height); - ctx.clip(); + if (shouldClip) { + const { x, y, width, height } = clipppings; + ctx.beginPath(); + ctx.rect(x, y, width, height); + ctx.clip(); + } withContext(ctx, (ctx) => { fun(ctx); }); diff --git a/src/scales/scale_band.ts b/src/scales/scale_band.ts index df406291ed..612e3a5846 100644 --- a/src/scales/scale_band.ts +++ b/src/scales/scale_band.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { scaleBand, scaleQuantize, ScaleQuantize } from 'd3-scale'; +import { scaleBand, scaleQuantize, ScaleQuantize, ScaleBand as D3ScaleBand } from 'd3-scale'; import { clamp } from '../utils/commons'; import { ScaleType, Scale } from '.'; @@ -31,7 +31,7 @@ export class ScaleBand implements Scale { readonly invertedScale: ScaleQuantize; readonly minInterval: number; readonly barsPadding: number; - private readonly d3Scale: any; + private readonly d3Scale: D3ScaleBand; constructor( domain: any[], @@ -45,7 +45,7 @@ export class ScaleBand implements Scale { barsPadding = 0, ) { this.type = ScaleType.Ordinal; - this.d3Scale = scaleBand(); + this.d3Scale = scaleBand(); this.d3Scale.domain(domain); this.d3Scale.range(range); const safeBarPadding = clamp(barsPadding, 0, 1); @@ -68,28 +68,45 @@ export class ScaleBand implements Scale { this.minInterval = 0; } - scale(value: any) { - return this.d3Scale(value); + scale(value: string | number) { + const scaleValue = this.d3Scale(value); + + if (typeof scaleValue !== 'number') { + throw new Error(`The value (${value}) was not scalable`); + } + + return scaleValue; } - pureScale(value: any) { - return this.d3Scale(value); + + pureScale(value: string | number) { + const scaleValue = this.d3Scale(value); + + if (typeof scaleValue !== 'number') { + throw new Error(`The value (${value}) was not scalable`); + } + + return scaleValue; } ticks() { return this.domain; } + invert(value: any) { return this.invertedScale(value); } + invertWithStep(value: any) { return { value: this.invertedScale(value), withinBandwidth: true, }; } + isSingleValue() { return this.domain.length < 2; } + isValueInDomain(value: any) { return this.domain.includes(value); } diff --git a/src/specs/settings.tsx b/src/specs/settings.tsx index 11d9e4ea80..34fcd0df2a 100644 --- a/src/specs/settings.tsx +++ b/src/specs/settings.tsx @@ -155,6 +155,11 @@ export interface LegendColorPickerProps { } export type LegendColorPicker = ComponentType; +/** + * Buffer between cursor and point to trigger interaction + */ +export type PointBuffer = number | ((radius: number) => number); + export interface SettingsSpec extends Spec { /** * Partial theme to be merged with base @@ -195,6 +200,7 @@ export interface SettingsSpec extends Spec { onElementClick?: ElementClickListener; onElementOver?: ElementOverListener; onElementOut?: BasicListener; + pointBuffer?: PointBuffer; onBrushEnd?: BrushEndListener; onLegendItemOver?: LegendItemListener; onLegendItemOut?: BasicListener; diff --git a/src/utils/accessor.ts b/src/utils/accessor.ts index 0aa7dbd0b3..7fb1249dc3 100644 --- a/src/utils/accessor.ts +++ b/src/utils/accessor.ts @@ -18,8 +18,8 @@ import { Datum } from './commons'; -type UnaryAccessorFn = (datum: Datum) => any; -type BinaryAccessorFn = (datum: Datum, index: number) => any; +type UnaryAccessorFn = (datum: Datum) => Return; +type BinaryAccessorFn = (datum: Datum, index: number) => Return; export type AccessorFn = UnaryAccessorFn; export type IndexedAccessorFn = UnaryAccessorFn | BinaryAccessorFn; diff --git a/src/utils/data_generators/simple_noise.ts b/src/utils/data_generators/simple_noise.ts index 1203ca0b07..c19e6ede59 100644 --- a/src/utils/data_generators/simple_noise.ts +++ b/src/utils/data_generators/simple_noise.ts @@ -34,7 +34,7 @@ export class Simple1DNoise { } getValue(x: number) { - const r = new Array(this.maxVertices).fill(0).map(this.getRandomNumber); + const r = new Array(this.maxVertices).fill(0).map(() => this.getRandomNumber()); const scaledX = x * this.scale; const xFloor = Math.floor(scaledX); const t = scaledX - xFloor; diff --git a/src/utils/geometry.ts b/src/utils/geometry.ts index c43042fd6d..b55e6dd224 100644 --- a/src/utils/geometry.ts +++ b/src/utils/geometry.ts @@ -34,6 +34,7 @@ export type BandedAccessorType = $Values; export interface GeometryValue { y: any; x: any; + dot: number | null; accessor: BandedAccessorType; } diff --git a/src/utils/themes/theme.ts b/src/utils/themes/theme.ts index b448750267..09fbd35d02 100644 --- a/src/utils/themes/theme.ts +++ b/src/utils/themes/theme.ts @@ -175,6 +175,12 @@ export interface Theme { colors: ColorConfig; legend: LegendStyle; crosshair: CrosshairStyle; + /** + * Used to scale radius with `dotAccessor` + * + * value from 1 to 100 + */ + radiusRatio?: number; } export type PartialTheme = RecursivePartial; diff --git a/stories/mixed/7_dots.tsx b/stories/mixed/7_dots.tsx new file mode 100644 index 0000000000..9bf4d633cf --- /dev/null +++ b/stories/mixed/7_dots.tsx @@ -0,0 +1,118 @@ +/* + * 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. */ + +import React from 'react'; +import { select, number, boolean } from '@storybook/addon-knobs'; + +import { + AreaSeries, + Axis, + Chart, + LineSeries, + Position, + ScaleType, + Settings, + SeriesTypes, + TooltipType, +} from '../../src'; +import { getRandomNumberGenerator } from '../../src/mocks/utils'; +import { action } from '@storybook/addon-actions'; + +const getRandomNumber = getRandomNumberGenerator(); +const data = new Array(100).fill(0).map((_, x) => ({ + x, + y: getRandomNumber(0, 100), + z: getRandomNumber(0, 50), +})); + +export const example = () => { + const onElementListeners = { + onElementClick: action('onElementClick'), + onElementOver: action('onElementOver'), + onElementOut: action('onElementOut'), + }; + const chartType = select( + 'Chart type', + { + Area: SeriesTypes.Area, + Line: SeriesTypes.Line, + Bubble: 'bubble', + }, + 'bubble', + ); + const radiusRatio = number('radiusRatio', 50, { + range: true, + min: 1, + max: 100, + step: 1, + }); + const size = number('data size', 20, { + range: true, + min: 10, + max: 100, + step: 10, + }); + + return ( + + 20 / r} + {...onElementListeners} + /> + + Number(d).toFixed(2)} /> + + {chartType === SeriesTypes.Area ? ( + + ) : ( + + )} + + ); +}; diff --git a/stories/mixed/mixed.stories.tsx b/stories/mixed/mixed.stories.tsx index 17220ee97c..249643b1f5 100644 --- a/stories/mixed/mixed.stories.tsx +++ b/stories/mixed/mixed.stories.tsx @@ -31,3 +31,4 @@ export { example as areasAndBars } from './3_areas_and_bars'; export { example as testBarLinesLinear } from './4_test_bar'; export { example as testBarLinesTime } from './5_test_bar_time'; export { example as fittingFunctionsNonStackedSeries } from './6_fitting'; +export { example as dotAccessor } from './7_dots'; diff --git a/yarn.lock b/yarn.lock index f69e306c98..dc91a5d54e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4455,6 +4455,11 @@ resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-1.2.2.tgz#80cf7cfff7401587b8f89307ba36fe4a576bc7cf" integrity sha512-6pBxzJ8ZP3dYEQ4YjQ+NVbQaOflfgXq/JbDiS99oLobM2o72uAST4q6yPxHv6FOTCRC/n35ktuo8pvw/S4M7sw== +"@types/d3-delaunay@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-4.1.0.tgz#7b4ac0644d1fcf97c9335a59751ffc01a58a5c24" + integrity sha512-KWZn+XNDLTtoR3zMpAI2go4PbRcazlOsOmlrmZAwYOgV7lYRx469ii78LLswRDI9J6jJmrlCl6Zo9ccoWFfrIw== + "@types/d3-path@*": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.8.tgz#48e6945a8ff43ee0a1ce85c8cfa2337de85c7c79" @@ -7789,6 +7794,13 @@ d3-color@1, d3-color@^1.4.0: resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.0.tgz#89c45a995ed773b13314f06460df26d60ba0ecaf" integrity sha512-TzNPeJy2+iEepfiL92LAAB7fvnp/dV2YwANPVHdDWmYMm23qIJBYww3qT8I8C1wXrmrg4UWs7BKc2tKIgyjzHg== +d3-delaunay@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.2.1.tgz#0c4b280eb00194986ac4a3df9c81d32bf216cb36" + integrity sha512-ZZdeJl6cKRyqYVFYK+/meXvWIrAvZsZTD7WSxl4OPXCmuXNgDyACAClAJHD63zL25TA+IJGURUNO7rFseNFCYw== + dependencies: + delaunator "4" + d3-format@1: version "1.4.1" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.1.tgz#c45f74b17c5a290c072a4ba7039dd19662cd5ce6" @@ -8057,6 +8069,11 @@ del@^5.1.0: rimraf "^3.0.0" slash "^3.0.0" +delaunator@4: + version "4.0.1" + resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-4.0.1.tgz#3d779687f57919a7a418f8ab947d3bddb6846957" + integrity sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"