diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-log-y-axis-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-log-y-axis-visually-looks-correct-1-snap.png index 2f2da18472..0d3c9e2b39 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-log-y-axis-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-log-y-axis-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-and-positive-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-and-positive-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..36bba60335 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-and-positive-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-and-positive-visually-looks-correct-2-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-and-positive-visually-looks-correct-2-snap.png new file mode 100644 index 0000000000..36bba60335 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-and-positive-visually-looks-correct-2-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-band-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-band-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..2ea43bfc0a Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-band-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-values-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-values-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..e6159375f1 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-area-chart-with-negative-values-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-negative-values-with-log-scale-1-snap.png b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-negative-values-with-log-scale-1-snap.png new file mode 100644 index 0000000000..cb29697295 Binary files /dev/null and b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-negative-values-with-log-scale-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-negative-values-when-hiding-positive-one-1-snap.png b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-negative-values-when-hiding-positive-one-1-snap.png new file mode 100644 index 0000000000..4ce636d9eb Binary files /dev/null and b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-negative-values-when-hiding-positive-one-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-positive-domain-mixed-polarity-domain-1-snap.png b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-positive-domain-mixed-polarity-domain-1-snap.png new file mode 100644 index 0000000000..5a5f41d12a Binary files /dev/null and b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-positive-domain-mixed-polarity-domain-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-positive-values-when-hiding-negative-one-1-snap.png b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-positive-values-when-hiding-negative-one-1-snap.png new file mode 100644 index 0000000000..41da2502eb Binary files /dev/null and b/integration/tests/__image_snapshots__/area-stories-test-ts-area-series-stories-negative-log-areas-snows-only-positive-values-when-hiding-negative-one-1-snap.png differ diff --git a/integration/tests/area_stories.test.ts b/integration/tests/area_stories.test.ts index f81de8b5e9..c3ba29873e 100644 --- a/integration/tests/area_stories.test.ts +++ b/integration/tests/area_stories.test.ts @@ -63,4 +63,32 @@ describe('Area series stories', () => { ); }); }); + describe('Negative log Areas', () => { + it('snows negative values with log scale', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/area-chart--with-negative-values&knob-Y scale=log', + ); + }); + it('snows only positive domain mixed polarity domain', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/area-chart--with-negative-and-positive&knob-Y scale=log', + ); + }); + + it('snows only positive values when hiding negative one', async () => { + const action = async () => await page.click('.echLegendItem:nth-child(2) .echLegendItem__label'); + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/area-chart--with-negative-and-positive&knob-Y scale=log', + { action }, + ); + }); + + it('snows only negative values when hiding positive one', async () => { + const action = async () => await page.click('.echLegendItem:nth-child(1) .echLegendItem__label'); + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/area-chart--with-negative-and-positive&knob-Y scale=log', + { action }, + ); + }); + }); }); diff --git a/src/chart_types/xy_chart/renderer/canvas/areas.ts b/src/chart_types/xy_chart/renderer/canvas/areas.ts index 91e24a7f2c..ce60777095 100644 --- a/src/chart_types/xy_chart/renderer/canvas/areas.ts +++ b/src/chart_types/xy_chart/renderer/canvas/areas.ts @@ -24,7 +24,7 @@ import { Rotation } from '../../../../utils/commons'; import { Dimensions } from '../../../../utils/dimensions'; import { AreaGeometry, PerPanel } from '../../../../utils/geometry'; import { SharedGeometryStateStyle } from '../../../../utils/themes/theme'; -import { getGeometryStateStyle } from '../../rendering/rendering'; +import { getGeometryStateStyle } from '../../rendering/utils'; import { renderPoints } from './points'; import { renderLinePaths, renderAreaPath } from './primitives/path'; import { buildAreaStyles } from './styles/area'; diff --git a/src/chart_types/xy_chart/renderer/canvas/bars.ts b/src/chart_types/xy_chart/renderer/canvas/bars.ts index 750b668422..488ddcb8f5 100644 --- a/src/chart_types/xy_chart/renderer/canvas/bars.ts +++ b/src/chart_types/xy_chart/renderer/canvas/bars.ts @@ -24,7 +24,7 @@ import { Rotation } from '../../../../utils/commons'; import { Dimensions } from '../../../../utils/dimensions'; import { BarGeometry, PerPanel } from '../../../../utils/geometry'; import { SharedGeometryStateStyle } from '../../../../utils/themes/theme'; -import { getGeometryStateStyle } from '../../rendering/rendering'; +import { getGeometryStateStyle } from '../../rendering/utils'; import { renderRect } from './primitives/rect'; import { buildBarStyles } from './styles/bar'; import { withPanelTransform } from './utils/panel_transform'; diff --git a/src/chart_types/xy_chart/renderer/canvas/bubbles.ts b/src/chart_types/xy_chart/renderer/canvas/bubbles.ts index fe3ea4d0c8..e00d30238b 100644 --- a/src/chart_types/xy_chart/renderer/canvas/bubbles.ts +++ b/src/chart_types/xy_chart/renderer/canvas/bubbles.ts @@ -25,7 +25,7 @@ import { Rotation } from '../../../../utils/commons'; import { Dimensions } from '../../../../utils/dimensions'; import { BubbleGeometry, PerPanel, PointGeometry } from '../../../../utils/geometry'; import { SharedGeometryStateStyle, GeometryStateStyle, PointStyle } from '../../../../utils/themes/theme'; -import { getGeometryStateStyle } from '../../rendering/rendering'; +import { getGeometryStateStyle } from '../../rendering/utils'; import { renderPointGroup } from './points'; interface BubbleGeometriesDataProps { diff --git a/src/chart_types/xy_chart/renderer/canvas/lines.ts b/src/chart_types/xy_chart/renderer/canvas/lines.ts index db631a2b8f..7a95809a50 100644 --- a/src/chart_types/xy_chart/renderer/canvas/lines.ts +++ b/src/chart_types/xy_chart/renderer/canvas/lines.ts @@ -24,7 +24,7 @@ import { Rotation } from '../../../../utils/commons'; import { Dimensions } from '../../../../utils/dimensions'; import { LineGeometry, PerPanel } from '../../../../utils/geometry'; import { SharedGeometryStateStyle } from '../../../../utils/themes/theme'; -import { getGeometryStateStyle } from '../../rendering/rendering'; +import { getGeometryStateStyle } from '../../rendering/utils'; import { renderPoints } from './points'; import { renderLinePaths } from './primitives/path'; import { buildLineStyles } from './styles/line'; diff --git a/src/chart_types/xy_chart/rendering/area.ts b/src/chart_types/xy_chart/rendering/area.ts new file mode 100644 index 0000000000..f87d9d68ff --- /dev/null +++ b/src/chart_types/xy_chart/rendering/area.ts @@ -0,0 +1,152 @@ +/* + * 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 { area } from 'd3-shape'; + +import { Scale } from '../../../scales'; +import { Color } from '../../../utils/commons'; +import { CurveType, getCurveFactory } from '../../../utils/curves'; +import { Dimensions } from '../../../utils/dimensions'; +import { AreaGeometry } from '../../../utils/geometry'; +import { AreaSeriesStyle } from '../../../utils/themes/theme'; +import { IndexedGeometryMap } from '../utils/indexed_geometry_map'; +import { DataSeries, DataSeriesDatum } from '../utils/series'; +import { PointStyleAccessor } from '../utils/specs'; +import { renderPoints } from './points'; +import { + getClippedRanges, + getY0ScaledValueOrThrow, + getY1ScaledValueOrThrow, + isYValueDefined, + MarkSizeOptions, +} from './utils'; + +/** @internal */ +export function renderArea( + shift: number, + dataSeries: DataSeries, + xScale: Scale, + yScale: Scale, + panel: Dimensions, + color: Color, + curve: CurveType, + hasY0Accessors: boolean, + xScaleOffset: number, + seriesStyle: AreaSeriesStyle, + markSizeOptions: MarkSizeOptions, + isStacked = false, + pointStyleAccessor?: PointStyleAccessor, + hasFit?: boolean, +): { + areaGeometry: AreaGeometry; + indexedGeometryMap: IndexedGeometryMap; +} { + const y1Fn = getY1ScaledValueOrThrow(yScale); + const y0Fn = getY0ScaledValueOrThrow(yScale); + const definedFn = isYValueDefined(yScale, xScale); + const pathGenerator = area() + .x(({ x }) => xScale.scaleOrThrow(x) - xScaleOffset) + .y1(y1Fn) + .y0(y0Fn) + .defined((datum) => { + return definedFn(datum) && (hasY0Accessors ? definedFn(datum, 'y0') : true); + }) + .curve(getCurveFactory(curve)); + + const clippedRanges = getClippedRanges(dataSeries.data, xScale, xScaleOffset); + + let y1Line: string | null; + + try { + y1Line = pathGenerator.lineY1()(dataSeries.data); + } catch { + // When values are not scalable + y1Line = null; + } + + const lines: string[] = []; + if (y1Line) { + lines.push(y1Line); + } + if (hasY0Accessors) { + let y0Line: string | null; + + try { + y0Line = pathGenerator.lineY0()(dataSeries.data); + } catch { + // When values are not scalable + y0Line = null; + } + if (y0Line) { + lines.push(y0Line); + } + } + + const { pointGeometries, indexedGeometryMap } = renderPoints( + shift - xScaleOffset, + dataSeries, + xScale, + yScale, + panel, + color, + seriesStyle.line, + hasY0Accessors, + markSizeOptions, + pointStyleAccessor, + false, + ); + + let areaPath: string; + + try { + areaPath = pathGenerator(dataSeries.data) || ''; + } catch { + // When values are not scalable + areaPath = ''; + } + + const areaGeometry: AreaGeometry = { + area: areaPath, + lines, + points: pointGeometries, + color, + transform: { + y: 0, + x: shift, + }, + seriesIdentifier: { + key: dataSeries.key, + specId: dataSeries.specId, + yAccessor: dataSeries.yAccessor, + splitAccessors: dataSeries.splitAccessors, + seriesKeys: dataSeries.seriesKeys, + smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, + smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, + }, + seriesAreaStyle: seriesStyle.area, + seriesAreaLineStyle: seriesStyle.line, + seriesPointStyle: seriesStyle.point, + isStacked, + clippedRanges, + hideClippedRanges: !hasFit, + }; + return { + areaGeometry, + indexedGeometryMap, + }; +} diff --git a/src/chart_types/xy_chart/rendering/bars.ts b/src/chart_types/xy_chart/rendering/bars.ts new file mode 100644 index 0000000000..11d7101bd8 --- /dev/null +++ b/src/chart_types/xy_chart/rendering/bars.ts @@ -0,0 +1,293 @@ +/* + * 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 { Scale } from '../../../scales'; +import { ScaleType } from '../../../scales/constants'; +import { CanvasTextBBoxCalculator } from '../../../utils/bbox/canvas_text_bbox_calculator'; +import { Color, mergePartial } from '../../../utils/commons'; +import { Dimensions } from '../../../utils/dimensions'; +import { BandedAccessorType, BarGeometry } from '../../../utils/geometry'; +import { BarSeriesStyle, DisplayValueStyle } from '../../../utils/themes/theme'; +import { IndexedGeometryMap } from '../utils/indexed_geometry_map'; +import { DataSeries, DataSeriesDatum, XYChartSeriesIdentifier } from '../utils/series'; +import { BarStyleAccessor, DisplayValueSpec, StackMode } from '../utils/specs'; + +/** @internal */ +export function renderBars( + orderIndex: number, + dataSeries: DataSeries, + xScale: Scale, + yScale: Scale, + panel: Dimensions, + color: Color, + sharedSeriesStyle: BarSeriesStyle, + displayValueSettings?: DisplayValueSpec, + styleAccessor?: BarStyleAccessor, + minBarHeight?: number, + stackMode?: StackMode, + chartRotation?: number, +): { + barGeometries: BarGeometry[]; + indexedGeometryMap: IndexedGeometryMap; +} { + const indexedGeometryMap = new IndexedGeometryMap(); + const barGeometries: BarGeometry[] = []; + + const bboxCalculator = new CanvasTextBBoxCalculator(); + + // default padding to 1 for now + const padding = 1; + const { fontSize, fontFamily } = sharedSeriesStyle.displayValue; + const absMinHeight = minBarHeight && Math.abs(minBarHeight); + + dataSeries.data.forEach((datum) => { + const { y0, y1, initialY1, filled } = datum; + // don't create a bar if the initialY1 value is null. + if (y1 === null || initialY1 === null || (filled && filled.y1 !== undefined)) { + return; + } + // don't create a bar if not within the xScale domain + if (!xScale.isValueInDomain(datum.x)) { + return; + } + + let y: number | null; + let y0Scaled; + if (yScale.type === ScaleType.Log) { + y = y1 === 0 || y1 === null ? yScale.range[0] : yScale.scale(y1); + if (yScale.isInverted) { + y0Scaled = y0 === 0 || y0 === null ? yScale.range[1] : yScale.scale(y0); + } else { + y0Scaled = y0 === 0 || y0 === null ? yScale.range[0] : yScale.scale(y0); + } + } else { + y = yScale.scale(y1); + if (yScale.isInverted) { + // use always zero as baseline if y0 is null + y0Scaled = y0 === null ? yScale.scale(0) : yScale.scale(y0); + } else { + y0Scaled = y0 === null ? yScale.scale(0) : yScale.scale(y0); + } + } + + if (y === null || y0Scaled === null) { + return; + } + let height = y0Scaled - y; + + // handle minBarHeight adjustment + if (absMinHeight !== undefined && height !== 0 && Math.abs(height) < absMinHeight) { + const heightDelta = absMinHeight - Math.abs(height); + if (height < 0) { + height = -absMinHeight; + y += heightDelta; + } else { + height = absMinHeight; + y -= heightDelta; + } + } + + const xScaled = xScale.scale(datum.x); + + if (xScaled === null) { + return; + } + + const x = xScaled + xScale.bandwidth * orderIndex; + const width = xScale.bandwidth; + const originalY1Value = stackMode === StackMode.Percentage ? y1 - (y0 ?? 0) : initialY1; + const formattedDisplayValue = + displayValueSettings && displayValueSettings.valueFormatter + ? displayValueSettings.valueFormatter(originalY1Value) + : undefined; + + // only show displayValue for even bars if showOverlappingValue + const displayValueText = + displayValueSettings && displayValueSettings.isAlternatingValueLabel && barGeometries.length % 2 + ? undefined + : formattedDisplayValue; + + const { displayValueWidth, fixedFontScale } = computeBoxWidth( + displayValueText || '', + { padding, fontSize, fontFamily, bboxCalculator, width }, + displayValueSettings, + ); + + const isHorizontalRotation = chartRotation == null || [0, 180].includes(chartRotation); + // Take 70% of space for the label text + const fontSizeFactor = 0.7; + // Pick the right side of the label's box to use as factor reference + const referenceWidth = Math.max(isHorizontalRotation ? displayValueWidth : fixedFontScale, 1); + + const textScalingFactor = getFinalFontScalingFactor( + (width * fontSizeFactor) / referenceWidth, + fixedFontScale, + fontSize, + ); + + const hideClippedValue = displayValueSettings ? displayValueSettings.hideClippedValue : undefined; + // Based on rotation scale the width of the text box + const bboxWidthFactor = isHorizontalRotation ? textScalingFactor : 1; + + const displayValue = + displayValueSettings && displayValueSettings.showValueLabel + ? { + fontScale: textScalingFactor, + fontSize: fixedFontScale, + text: displayValueText, + width: bboxWidthFactor * displayValueWidth, + height: textScalingFactor * fixedFontScale, + hideClippedValue, + isValueContainedInElement: displayValueSettings.isValueContainedInElement, + } + : undefined; + + const seriesIdentifier: XYChartSeriesIdentifier = { + key: dataSeries.key, + specId: dataSeries.specId, + yAccessor: dataSeries.yAccessor, + splitAccessors: dataSeries.splitAccessors, + seriesKeys: dataSeries.seriesKeys, + smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, + smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, + }; + + const seriesStyle = getBarStyleOverrides(datum, seriesIdentifier, sharedSeriesStyle, styleAccessor); + + const barGeometry: BarGeometry = { + displayValue, + x, + y, + transform: { + x: 0, + y: 0, + }, + width, + height, + color, + value: { + x: datum.x, + y: originalY1Value, + mark: null, + accessor: BandedAccessorType.Y1, + datum: datum.datum, + }, + seriesIdentifier, + seriesStyle, + panel, + }; + indexedGeometryMap.set(barGeometry); + barGeometries.push(barGeometry); + }); + + bboxCalculator.destroy(); + + return { + barGeometries, + indexedGeometryMap, + }; +} + +/** + * Workout the text box size and fixedFontSize based on a collection of options + * @internal + */ +function computeBoxWidth( + text: string, + { + padding, + fontSize, + fontFamily, + bboxCalculator, + width, + }: { + padding: number; + fontSize: number | { min: number; max: number }; + fontFamily: string; + bboxCalculator: CanvasTextBBoxCalculator; + width: number; + }, + displayValueSettings: DisplayValueSpec | undefined, +): { fixedFontScale: number; displayValueWidth: number } { + const fixedFontScale = Math.max(typeof fontSize === 'number' ? fontSize : fontSize.min, 1); + + const computedDisplayValueWidth = bboxCalculator.compute(text || '', padding, fixedFontScale, fontFamily).width; + if (typeof fontSize !== 'number') { + return { + fixedFontScale, + displayValueWidth: computedDisplayValueWidth, + }; + } + return { + fixedFontScale, + displayValueWidth: + displayValueSettings && displayValueSettings.isValueContainedInElement ? width : computedDisplayValueWidth, + }; +} + +/** + * Returns a safe scaling factor for label text for fixed or range size inputs + * @internal + */ +function getFinalFontScalingFactor( + scale: number, + fixedFontSize: number, + limits: DisplayValueStyle['fontSize'], +): number { + if (typeof limits === 'number') { + // it's a fixed size, so it's always ok + return 1; + } + const finalFontSize = scale * fixedFontSize; + if (finalFontSize > limits.max) { + return limits.max / fixedFontSize; + } + if (finalFontSize < limits.min) { + // it's technically 1, but keep it generic in case the fixedFontSize changes + return limits.min / fixedFontSize; + } + return scale; +} + +/** @internal */ +export function getBarStyleOverrides( + datum: DataSeriesDatum, + seriesIdentifier: XYChartSeriesIdentifier, + seriesStyle: BarSeriesStyle, + styleAccessor?: BarStyleAccessor, +): BarSeriesStyle { + const styleOverride = styleAccessor && styleAccessor(datum, seriesIdentifier); + + if (!styleOverride) { + return seriesStyle; + } + + if (typeof styleOverride === 'string') { + return { + ...seriesStyle, + rect: { + ...seriesStyle.rect, + fill: styleOverride, + }, + }; + } + + return mergePartial(seriesStyle, styleOverride, { + mergeOptionalPartialValues: true, + }); +} diff --git a/src/chart_types/xy_chart/rendering/bubble.ts b/src/chart_types/xy_chart/rendering/bubble.ts new file mode 100644 index 0000000000..5285990ce2 --- /dev/null +++ b/src/chart_types/xy_chart/rendering/bubble.ts @@ -0,0 +1,80 @@ +/* + * 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 { Scale } from '../../../scales'; +import { Color } from '../../../utils/commons'; +import { Dimensions } from '../../../utils/dimensions'; +import { BubbleGeometry } from '../../../utils/geometry'; +import { BubbleSeriesStyle } from '../../../utils/themes/theme'; +import { IndexedGeometryMap } from '../utils/indexed_geometry_map'; +import { DataSeries } from '../utils/series'; +import { PointStyleAccessor } from '../utils/specs'; +import { renderPoints } from './points'; +import { MarkSizeOptions } from './utils'; + +/** @internal */ +export function renderBubble( + shift: number, + dataSeries: DataSeries, + xScale: Scale, + yScale: Scale, + color: Color, + panel: Dimensions, + hasY0Accessors: boolean, + xScaleOffset: number, + seriesStyle: BubbleSeriesStyle, + markSizeOptions: MarkSizeOptions, + isMixedChart: boolean, + pointStyleAccessor?: PointStyleAccessor, +): { + bubbleGeometry: BubbleGeometry; + indexedGeometryMap: IndexedGeometryMap; +} { + const { pointGeometries, indexedGeometryMap } = renderPoints( + shift - xScaleOffset, + dataSeries, + xScale, + yScale, + panel, + color, + seriesStyle.point, + hasY0Accessors, + markSizeOptions, + pointStyleAccessor, + !isMixedChart, + ); + + const bubbleGeometry = { + points: pointGeometries, + color, + seriesIdentifier: { + key: dataSeries.key, + specId: dataSeries.specId, + yAccessor: dataSeries.yAccessor, + splitAccessors: dataSeries.splitAccessors, + seriesKeys: dataSeries.seriesKeys, + smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, + smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, + }, + seriesPointStyle: seriesStyle.point, + }; + return { + bubbleGeometry, + indexedGeometryMap, + }; +} diff --git a/src/chart_types/xy_chart/rendering/line.ts b/src/chart_types/xy_chart/rendering/line.ts new file mode 100644 index 0000000000..5e186c7f98 --- /dev/null +++ b/src/chart_types/xy_chart/rendering/line.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 { line } from 'd3-shape'; + +import { Scale } from '../../../scales'; +import { Color } from '../../../utils/commons'; +import { CurveType, getCurveFactory } from '../../../utils/curves'; +import { Dimensions } from '../../../utils/dimensions'; +import { LineGeometry } from '../../../utils/geometry'; +import { LineSeriesStyle } from '../../../utils/themes/theme'; +import { IndexedGeometryMap } from '../utils/indexed_geometry_map'; +import { DataSeries, DataSeriesDatum } from '../utils/series'; +import { PointStyleAccessor } from '../utils/specs'; +import { renderPoints } from './points'; +import { getClippedRanges, getY1ScaledValueOrThrow, isYValueDefined, MarkSizeOptions } from './utils'; + +/** @internal */ +export function renderLine( + shift: number, + dataSeries: DataSeries, + xScale: Scale, + yScale: Scale, + panel: Dimensions, + color: Color, + curve: CurveType, + hasY0Accessors: boolean, + xScaleOffset: number, + seriesStyle: LineSeriesStyle, + markSizeOptions: MarkSizeOptions, + pointStyleAccessor?: PointStyleAccessor, + hasFit?: boolean, +): { + lineGeometry: LineGeometry; + indexedGeometryMap: IndexedGeometryMap; +} { + const y1Fn = getY1ScaledValueOrThrow(yScale); + const definedFn = isYValueDefined(yScale, xScale); + + const pathGenerator = line() + .x(({ x }) => xScale.scaleOrThrow(x) - xScaleOffset) + .y(y1Fn) + .defined((datum) => { + return definedFn(datum); + }) + .curve(getCurveFactory(curve)); + + const { pointGeometries, indexedGeometryMap } = renderPoints( + shift - xScaleOffset, + dataSeries, + xScale, + yScale, + panel, + color, + seriesStyle.line, + hasY0Accessors, + markSizeOptions, + pointStyleAccessor, + ); + + const clippedRanges = getClippedRanges(dataSeries.data, xScale, xScaleOffset); + let linePath: string; + + try { + linePath = pathGenerator(dataSeries.data) || ''; + } catch { + // When values are not scalable + linePath = ''; + } + + const lineGeometry = { + line: linePath, + points: pointGeometries, + color, + transform: { + x: shift, + y: 0, + }, + seriesIdentifier: { + key: dataSeries.key, + specId: dataSeries.specId, + yAccessor: dataSeries.yAccessor, + splitAccessors: dataSeries.splitAccessors, + seriesKeys: dataSeries.seriesKeys, + smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, + smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, + }, + seriesLineStyle: seriesStyle.line, + seriesPointStyle: seriesStyle.point, + clippedRanges, + hideClippedRanges: !hasFit, + }; + return { + lineGeometry, + indexedGeometryMap, + }; +} diff --git a/src/chart_types/xy_chart/rendering/points.ts b/src/chart_types/xy_chart/rendering/points.ts new file mode 100644 index 0000000000..7f156931d2 --- /dev/null +++ b/src/chart_types/xy_chart/rendering/points.ts @@ -0,0 +1,230 @@ +/* + * 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 { Scale } from '../../../scales'; +import { Color } from '../../../utils/commons'; +import { Dimensions } from '../../../utils/dimensions'; +import { BandedAccessorType, PointGeometry } from '../../../utils/geometry'; +import { LineStyle, PointStyle } from '../../../utils/themes/theme'; +import { GeometryType, IndexedGeometryMap } from '../utils/indexed_geometry_map'; +import { DataSeries, DataSeriesDatum, FilledValues, XYChartSeriesIdentifier } from '../utils/series'; +import { PointStyleAccessor, StackMode } from '../utils/specs'; +import { + getY0ScaledValueOrThrow, + getY1ScaledValueOrThrow, + isDatumFilled, + isYValueDefined, + MarkSizeOptions, +} from './utils'; + +/** @internal */ +export function renderPoints( + shift: number, + dataSeries: DataSeries, + xScale: Scale, + yScale: Scale, + panel: Dimensions, + color: Color, + lineStyle: LineStyle, + hasY0Accessors: boolean, + markSizeOptions: MarkSizeOptions, + styleAccessor?: PointStyleAccessor, + spatial = false, +): { + pointGeometries: PointGeometry[]; + indexedGeometryMap: IndexedGeometryMap; +} { + const indexedGeometryMap = new IndexedGeometryMap(); + const getRadius = markSizeOptions.enabled + ? getRadiusFn(dataSeries.data, lineStyle.strokeWidth, markSizeOptions.ratio) + : () => 0; + const geometryType = spatial ? GeometryType.spatial : GeometryType.linear; + + const y1Fn = getY1ScaledValueOrThrow(yScale); + const y0Fn = getY0ScaledValueOrThrow(yScale); + const yDefined = isYValueDefined(yScale, xScale); + + const pointGeometries = dataSeries.data.reduce((acc, datum) => { + const { x: xValue, mark } = datum; + // don't create the point if not within the xScale domain + if (!xScale.isValueInDomain(xValue)) { + return acc; + } + // don't create the point if it that point was filled + if (isDatumFilled(datum)) { + return acc; + } + const x = xScale.scale(xValue); + + if (x === null) { + return acc; + } + + const points: PointGeometry[] = []; + const yDatumKeyNames: Array> = hasY0Accessors ? ['y0', 'y1'] : ['y1']; + + yDatumKeyNames.forEach((yDatumKeyName, index) => { + // skip rendering point if y1 is null + const radius = getRadius(mark); + let y: number | null; + try { + y = yDatumKeyName === 'y1' ? y1Fn(datum) : y0Fn(datum); + if (y === null) { + return; + } + } catch { + return; + } + + const originalY = getDatumYValue(datum, index === 0, hasY0Accessors, dataSeries.stackMode); + const seriesIdentifier: XYChartSeriesIdentifier = { + key: dataSeries.key, + specId: dataSeries.specId, + yAccessor: dataSeries.yAccessor, + splitAccessors: dataSeries.splitAccessors, + seriesKeys: dataSeries.seriesKeys, + smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, + smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, + }; + const styleOverrides = getPointStyleOverrides(datum, seriesIdentifier, styleAccessor); + const pointGeometry: PointGeometry = { + radius, + x, + y, + color, + value: { + x: xValue, + y: originalY, + mark, + accessor: hasY0Accessors && index === 0 ? BandedAccessorType.Y0 : BandedAccessorType.Y1, + datum: datum.datum, + }, + transform: { + x: shift, + y: 0, + }, + seriesIdentifier, + styleOverrides, + panel, + }; + indexedGeometryMap.set(pointGeometry, geometryType); + // use the geometry only if the yDatum in contained in the current yScale domain + if (yDefined(datum, yDatumKeyName)) { + points.push(pointGeometry); + } + }); + return [...acc, ...points]; + }, [] as PointGeometry[]); + return { + pointGeometries, + indexedGeometryMap, + }; +} + +/** @internal */ +export function getPointStyleOverrides( + datum: DataSeriesDatum, + seriesIdentifier: XYChartSeriesIdentifier, + pointStyleAccessor?: PointStyleAccessor, +): Partial | undefined { + const styleOverride = pointStyleAccessor && pointStyleAccessor(datum, seriesIdentifier); + + if (!styleOverride) { + return; + } + + if (typeof styleOverride === 'string') { + return { + stroke: styleOverride, + }; + } + + return styleOverride; +} + +/** + * Get the original/initial Y value from the datum + * @param datum a DataSeriesDatum + * @param lookingForY0 if we are interested in the y0 value, false for y1 + * @param isBandChart if the chart is a band chart + * @param stackMode an optional stack mode + */ +function getDatumYValue( + { y1, y0, initialY1, initialY0 }: DataSeriesDatum, + lookingForY0: boolean, + isBandChart: boolean, + stackMode?: StackMode, +) { + if (isBandChart) { + return stackMode === StackMode.Percentage + ? // on band stacked charts in percentage mode, the values I'm looking for are the percentage value + // that are already computed and available on y0 and y1 + lookingForY0 + ? y0 + : y1 + : // in all other cases for band charts, I want to get back the original/initial value of y0 and y1 + // not the computed value + lookingForY0 + ? initialY0 + : initialY1; + } + // if not a band chart get use the original/initial value in every case except for stack as percentage + // in this case, we should take the difference between the bottom position of the bar and the top position + // of the bar + return stackMode === StackMode.Percentage ? (y1 ?? 0) - (y0 ?? 0) : initialY1; +} + +/** + * Get radius function form ratio and min/max mark size + * + * @todo add continuous/non-stepped function + * + * @param {DataSeriesDatum[]} data + * @param {number} lineWidth + * @param {number=50} markSizeRatio - 0 to 100 + * @internal + */ +export function getRadiusFn( + data: DataSeriesDatum[], + lineWidth: number, + markSizeRatio: number = 50, +): (mark: number | null, defaultRadius?: number) => number { + if (data.length === 0) { + return () => 0; + } + const { min, max } = data.reduce( + (acc, { mark }) => + mark === null + ? acc + : { + min: Math.min(acc.min, mark / 2), + max: Math.max(acc.max, mark / 2), + }, + { min: Infinity, max: -Infinity }, + ); + const adjustedMarkSizeRatio = Math.min(Math.max(markSizeRatio, 0), 100); + const radiusStep = (max - min || max * 100) / Math.pow(adjustedMarkSizeRatio, 2); + return function getRadius(mark, defaultRadius = 0): number { + if (mark === null) { + return defaultRadius; + } + const circleRadius = (mark / 2 - min) / radiusStep; + const baseMagicNumber = 2; + return circleRadius ? Math.sqrt(circleRadius + baseMagicNumber) + lineWidth : lineWidth; + }; +} diff --git a/src/chart_types/xy_chart/rendering/rendering.areas.test.ts b/src/chart_types/xy_chart/rendering/rendering.areas.test.ts index 642f30b58a..8014e71c6e 100644 --- a/src/chart_types/xy_chart/rendering/rendering.areas.test.ts +++ b/src/chart_types/xy_chart/rendering/rendering.areas.test.ts @@ -786,6 +786,7 @@ describe('Rendering points - areas', () => { test('Can render a splitted area and line', () => { const { areas } = geometries.geometries; + const [{ value: firstArea }] = areas; expect(firstArea.lines[0].split('M').length - 1).toBe(3); expect(firstArea.area.split('M').length - 1).toBe(3); @@ -815,7 +816,7 @@ describe('Rendering points - areas', () => { expect(zeroValueIndexdGeometry).toBeDefined(); expect(zeroValueIndexdGeometry.length).toBe(1); // moved to the bottom of the chart - expect(zeroValueIndexdGeometry[0].y).toBe(100); + expect(zeroValueIndexdGeometry[0].y).toBe(Infinity); // 0 radius point expect((zeroValueIndexdGeometry[0] as PointGeometry).radius).toBe(0); }); diff --git a/src/chart_types/xy_chart/rendering/rendering.bubble.test.ts b/src/chart_types/xy_chart/rendering/rendering.bubble.test.ts index 6f0116443e..9e45c0b0ea 100644 --- a/src/chart_types/xy_chart/rendering/rendering.bubble.test.ts +++ b/src/chart_types/xy_chart/rendering/rendering.bubble.test.ts @@ -23,7 +23,6 @@ import { MockGlobalSpec, MockSeriesSpec } from '../../../mocks/specs'; import { MockStore } from '../../../mocks/store'; import { ScaleType } from '../../../scales/constants'; import { Position } from '../../../utils/commons'; -import { PointGeometry } from '../../../utils/geometry'; import { computeSeriesGeometriesSelector } from '../state/selectors/compute_series_geometries'; const SPEC_ID = 'spec_1'; @@ -739,12 +738,8 @@ describe('Rendering points - bubble', () => { y: 100, }); expect(zeroValueIndexdGeometry).toBeDefined(); - expect(zeroValueIndexdGeometry.length).toBe(5); + expect(zeroValueIndexdGeometry.length).toBe(3); expect(zeroValueIndexdGeometry.find(({ value: { x } }) => x === 5)).toBeDefined(); - // moved to the bottom of the chart - expect((zeroValueIndexdGeometry[0] as PointGeometry).y).toBe(100); - // 0 radius point - expect((zeroValueIndexdGeometry[0] as PointGeometry).radius).toBe(0); }); }); describe('Remove points datum is not in domain', () => { diff --git a/src/chart_types/xy_chart/rendering/rendering.lines.test.ts b/src/chart_types/xy_chart/rendering/rendering.lines.test.ts index c6772af436..58973be29b 100644 --- a/src/chart_types/xy_chart/rendering/rendering.lines.test.ts +++ b/src/chart_types/xy_chart/rendering/rendering.lines.test.ts @@ -704,8 +704,8 @@ describe('Rendering points - line', () => { const zeroValueIndexdGeometry = geometriesIndex.find(5)!; expect(zeroValueIndexdGeometry).toBeDefined(); expect(zeroValueIndexdGeometry.length).toBe(1); - // moved to the bottom of the chart - expect((zeroValueIndexdGeometry[0] as PointGeometry).y).toBe(100); + // the zero value is moved vertically to infinity + expect((zeroValueIndexdGeometry[0] as PointGeometry).y).toBe(Infinity); // 0 radius point expect((zeroValueIndexdGeometry[0] as PointGeometry).radius).toBe(0); }); diff --git a/src/chart_types/xy_chart/rendering/rendering.test.ts b/src/chart_types/xy_chart/rendering/rendering.test.ts index d41f737711..085131a251 100644 --- a/src/chart_types/xy_chart/rendering/rendering.test.ts +++ b/src/chart_types/xy_chart/rendering/rendering.test.ts @@ -23,14 +23,9 @@ import { MockScale } from '../../../mocks/scale'; import { mergePartial, RecursivePartial } from '../../../utils/commons'; import { BarSeriesStyle, SharedGeometryStateStyle, PointStyle } from '../../../utils/themes/theme'; import { DataSeriesDatum, XYChartSeriesIdentifier } from '../utils/series'; -import { - getGeometryStateStyle, - isPointOnGeometry, - getBarStyleOverrides, - getPointStyleOverrides, - getClippedRanges, - getRadiusFn, -} from './rendering'; +import { getBarStyleOverrides } from './bars'; +import { getPointStyleOverrides, getRadiusFn } from './points'; +import { getGeometryStateStyle, isPointOnGeometry, getClippedRanges } from './utils'; describe('Rendering utils', () => { test('check if point is on geometry', () => { diff --git a/src/chart_types/xy_chart/rendering/rendering.ts b/src/chart_types/xy_chart/rendering/rendering.ts deleted file mode 100644 index 665470cc96..0000000000 --- a/src/chart_types/xy_chart/rendering/rendering.ts +++ /dev/null @@ -1,912 +0,0 @@ -/* - * 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 { area, line } from 'd3-shape'; - -import { LegendItem } from '../../../commons/legend'; -import { Scale } from '../../../scales'; -import { ScaleType } from '../../../scales/constants'; -import { isLogarithmicScale } from '../../../scales/types'; -import { MarkBuffer, StackMode } from '../../../specs'; -import { CanvasTextBBoxCalculator } from '../../../utils/bbox/canvas_text_bbox_calculator'; -import { mergePartial, Color, getDistance } from '../../../utils/commons'; -import { CurveType, getCurveFactory } from '../../../utils/curves'; -import { Dimensions } from '../../../utils/dimensions'; -import { - PointGeometry, - BarGeometry, - AreaGeometry, - LineGeometry, - isPointGeometry, - ClippedRanges, - BandedAccessorType, - BubbleGeometry, -} from '../../../utils/geometry'; -import { - AreaSeriesStyle, - LineSeriesStyle, - PointStyle, - SharedGeometryStateStyle, - BarSeriesStyle, - GeometryStateStyle, - LineStyle, - BubbleSeriesStyle, - DisplayValueStyle, -} from '../../../utils/themes/theme'; -import { IndexedGeometryMap, GeometryType } from '../utils/indexed_geometry_map'; -import { DataSeriesDatum, DataSeries, XYChartSeriesIdentifier } from '../utils/series'; -import { DisplayValueSpec, PointStyleAccessor, BarStyleAccessor } from '../utils/specs'; -import { DEFAULT_HIGHLIGHT_PADDING } from './constants'; - -export interface MarkSizeOptions { - enabled: boolean; - ratio?: number; -} -/** - * Returns a safe scaling factor for label text for fixed or range size inputs - * @internal - */ -function getFinalFontScalingFactor( - scale: number, - fixedFontSize: number, - limits: DisplayValueStyle['fontSize'], -): number { - if (typeof limits === 'number') { - // it's a fixed size, so it's always ok - return 1; - } - const finalFontSize = scale * fixedFontSize; - if (finalFontSize > limits.max) { - return limits.max / fixedFontSize; - } - if (finalFontSize < limits.min) { - // it's technically 1, but keep it generic in case the fixedFontSize changes - return limits.min / fixedFontSize; - } - return scale; -} - -/** - * Workout the text box size and fixedFontSize based on a collection of options - * @internal - */ -function computeBoxWidth( - text: string, - { - padding, - fontSize, - fontFamily, - bboxCalculator, - width, - }: { - padding: number; - fontSize: number | { min: number; max: number }; - fontFamily: string; - bboxCalculator: CanvasTextBBoxCalculator; - width: number; - }, - displayValueSettings: DisplayValueSpec | undefined, -): { fixedFontScale: number; displayValueWidth: number } { - const fixedFontScale = Math.max(typeof fontSize === 'number' ? fontSize : fontSize.min, 1); - - const computedDisplayValueWidth = bboxCalculator.compute(text || '', padding, fixedFontScale, fontFamily).width; - if (typeof fontSize !== 'number') { - return { - fixedFontScale, - displayValueWidth: computedDisplayValueWidth, - }; - } - return { - fixedFontScale, - displayValueWidth: - displayValueSettings && displayValueSettings.isValueContainedInElement ? width : computedDisplayValueWidth, - }; -} - -/** - * Returns value of `y1` or `filled.y1` or null - * @internal - */ -export const getYValue = ({ y1, filled }: DataSeriesDatum): number | null => { - if (y1 !== null) { - return y1; - } - - if (filled && filled.y1 !== undefined) { - return filled.y1; - } - - return null; -}; - -/** @internal */ -export function getPointStyleOverrides( - datum: DataSeriesDatum, - seriesIdentifier: XYChartSeriesIdentifier, - pointStyleAccessor?: PointStyleAccessor, -): Partial | undefined { - const styleOverride = pointStyleAccessor && pointStyleAccessor(datum, seriesIdentifier); - - if (!styleOverride) { - return; - } - - if (typeof styleOverride === 'string') { - return { - stroke: styleOverride, - }; - } - - return styleOverride; -} - -/** @internal */ -export function getBarStyleOverrides( - datum: DataSeriesDatum, - seriesIdentifier: XYChartSeriesIdentifier, - seriesStyle: BarSeriesStyle, - styleAccessor?: BarStyleAccessor, -): BarSeriesStyle { - const styleOverride = styleAccessor && styleAccessor(datum, seriesIdentifier); - - if (!styleOverride) { - return seriesStyle; - } - - if (typeof styleOverride === 'string') { - return { - ...seriesStyle, - rect: { - ...seriesStyle.rect, - fill: styleOverride, - }, - }; - } - - return mergePartial(seriesStyle, styleOverride, { - mergeOptionalPartialValues: true, - }); -} - -type GetRadiusFnReturn = (mark: number | null, defaultRadius?: number) => number; - -/** - * Get radius function form ratio and min/max mark size - * - * @todo add continuous/non-stepped function - * - * @param {Datum[]} radii - * @param {number} lineWidth - * @param {number=50} markSizeRatio - 0 to 100 - * @internal - */ -export function getRadiusFn(data: DataSeriesDatum[], lineWidth: number, markSizeRatio: number = 50): GetRadiusFnReturn { - if (data.length === 0) { - return () => 0; - } - const { min, max } = data.reduce( - (acc, { mark }) => - mark === null - ? acc - : { - min: Math.min(acc.min, mark / 2), - max: Math.max(acc.max, mark / 2), - }, - { min: Infinity, max: -Infinity }, - ); - const adjustedMarkSizeRatio = Math.min(Math.max(markSizeRatio, 0), 100); - const radiusStep = (max - min || max * 100) / Math.pow(adjustedMarkSizeRatio, 2); - return function getRadius(mark, defaultRadius = 0): number { - if (mark === null) { - return defaultRadius; - } - const circleRadius = (mark / 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, - panel: Dimensions, - color: Color, - lineStyle: LineStyle, - hasY0Accessors: boolean, - markSizeOptions: MarkSizeOptions, - styleAccessor?: PointStyleAccessor, - spatial = false, -): { - pointGeometries: PointGeometry[]; - indexedGeometryMap: IndexedGeometryMap; -} { - const indexedGeometryMap = new IndexedGeometryMap(); - const isLogScale = isLogarithmicScale(yScale); - const getRadius = markSizeOptions.enabled - ? getRadiusFn(dataSeries.data, lineStyle.strokeWidth, markSizeOptions.ratio) - : () => 0; - const geometryType = spatial ? GeometryType.spatial : GeometryType.linear; - const pointGeometries = dataSeries.data.reduce((acc, datum) => { - const { x: xValue, y0, y1, mark } = datum; - // don't create the point if not within the xScale domain - if (!xScale.isValueInDomain(xValue)) { - return acc; - } - // don't create the point if it that point was filled - if (isDatumFilled(datum)) { - return acc; - } - const x = xScale.scale(xValue); - - if (x === null) { - return acc; - } - - const points: PointGeometry[] = []; - const yDatums = hasY0Accessors ? [y0, y1] : [y1]; - - yDatums.forEach((yDatum, index) => { - // skip rendering point if y1 is null - if (y1 === null) { - return; - } - let y; - let radius = getRadius(mark); - // we fix 0 and negative values at y = 0 - if (yDatum === null || (isLogScale && yDatum <= 0)) { - y = yScale.range[0]; - radius = 0; - } else { - y = yScale.scale(yDatum); - } - - if (y === null) { - return acc; - } - const originalY = getDatumYValue(datum, index === 0, hasY0Accessors, dataSeries.stackMode); - const seriesIdentifier: XYChartSeriesIdentifier = { - key: dataSeries.key, - specId: dataSeries.specId, - yAccessor: dataSeries.yAccessor, - splitAccessors: dataSeries.splitAccessors, - seriesKeys: dataSeries.seriesKeys, - smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, - smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, - }; - const styleOverrides = getPointStyleOverrides(datum, seriesIdentifier, styleAccessor); - const pointGeometry: PointGeometry = { - radius, - x, - y, - color, - value: { - x: xValue, - y: originalY, - mark, - accessor: hasY0Accessors && index === 0 ? BandedAccessorType.Y0 : BandedAccessorType.Y1, - datum: datum.datum, - }, - transform: { - x: shift, - y: 0, - }, - seriesIdentifier, - styleOverrides, - panel, - }; - 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)) { - points.push(pointGeometry); - } - }); - return [...acc, ...points]; - }, [] as PointGeometry[]); - return { - pointGeometries, - indexedGeometryMap, - }; -} - -/** - * Get the original/initial Y value from the datum - * @param datum a DataSeriesDatum - * @param lookingForY0 if we are interested in the y0 value, false for y1 - * @param isBandChart if the chart is a band chart - * @param stackMode an optional stack mode - */ -function getDatumYValue( - { y1, y0, initialY1, initialY0 }: DataSeriesDatum, - lookingForY0: boolean, - isBandChart: boolean, - stackMode?: StackMode, -) { - if (isBandChart) { - return stackMode === StackMode.Percentage - ? // on band stacked charts in percentage mode, the values I'm looking for are the percentage value - // that are already computed and available on y0 and y1 - lookingForY0 - ? y0 - : y1 - : // in all other cases for band charts, I want to get back the original/initial value of y0 and y1 - // not the computed value - lookingForY0 - ? initialY0 - : initialY1; - } - // if not a band chart get use the original/initial value in every case except for stack as percentage - // in this case, we should take the difference between the bottom position of the bar and the top position - // of the bar - return stackMode === StackMode.Percentage ? (y1 ?? 0) - (y0 ?? 0) : initialY1; -} - -/** @internal */ -export function renderBars( - orderIndex: number, - dataSeries: DataSeries, - xScale: Scale, - yScale: Scale, - panel: Dimensions, - color: Color, - sharedSeriesStyle: BarSeriesStyle, - displayValueSettings?: DisplayValueSpec, - styleAccessor?: BarStyleAccessor, - minBarHeight?: number, - stackMode?: StackMode, - chartRotation?: number, -): { - barGeometries: BarGeometry[]; - indexedGeometryMap: IndexedGeometryMap; -} { - const indexedGeometryMap = new IndexedGeometryMap(); - const barGeometries: BarGeometry[] = []; - - const bboxCalculator = new CanvasTextBBoxCalculator(); - - // default padding to 1 for now - const padding = 1; - const { fontSize, fontFamily } = sharedSeriesStyle.displayValue; - const absMinHeight = minBarHeight && Math.abs(minBarHeight); - - dataSeries.data.forEach((datum) => { - const { y0, y1, initialY1, filled } = datum; - // don't create a bar if the initialY1 value is null. - if (y1 === null || initialY1 === null || (filled && filled.y1 !== undefined)) { - return; - } - // don't create a bar if not within the xScale domain - if (!xScale.isValueInDomain(datum.x)) { - return; - } - - let y: number | null = 0; - let y0Scaled; - if (yScale.type === ScaleType.Log) { - y = y1 === 0 || y1 === null ? yScale.range[0] : yScale.scale(y1); - if (yScale.isInverted) { - y0Scaled = y0 === 0 || y0 === null ? yScale.range[1] : yScale.scale(y0); - } else { - y0Scaled = y0 === 0 || y0 === null ? yScale.range[0] : yScale.scale(y0); - } - } else { - y = yScale.scale(y1); - if (yScale.isInverted) { - // use always zero as baseline if y0 is null - y0Scaled = y0 === null ? yScale.scale(0) : yScale.scale(y0); - } else { - y0Scaled = y0 === null ? yScale.scale(0) : yScale.scale(y0); - } - } - - if (y === null || y0Scaled === null) { - return; - } - let height = y0Scaled - y; - - // handle minBarHeight adjustment - if (absMinHeight !== undefined && height !== 0 && Math.abs(height) < absMinHeight) { - const heightDelta = absMinHeight - Math.abs(height); - if (height < 0) { - height = -absMinHeight; - y += heightDelta; - } else { - height = absMinHeight; - y -= heightDelta; - } - } - - const xScaled = xScale.scale(datum.x); - - if (xScaled === null) { - return; - } - - const x = xScaled + xScale.bandwidth * orderIndex; - const width = xScale.bandwidth; - const originalY1Value = stackMode === StackMode.Percentage ? y1 - (y0 ?? 0) : initialY1; - const formattedDisplayValue = - displayValueSettings && displayValueSettings.valueFormatter - ? displayValueSettings.valueFormatter(originalY1Value) - : undefined; - - // only show displayValue for even bars if showOverlappingValue - const displayValueText = - displayValueSettings && displayValueSettings.isAlternatingValueLabel && barGeometries.length % 2 - ? undefined - : formattedDisplayValue; - - const { displayValueWidth, fixedFontScale } = computeBoxWidth( - displayValueText || '', - { padding, fontSize, fontFamily, bboxCalculator, width }, - displayValueSettings, - ); - - const isHorizontalRotation = chartRotation == null || [0, 180].includes(chartRotation); - // Take 70% of space for the label text - const fontSizeFactor = 0.7; - // Pick the right side of the label's box to use as factor reference - const referenceWidth = Math.max(isHorizontalRotation ? displayValueWidth : fixedFontScale, 1); - - const textScalingFactor = getFinalFontScalingFactor( - (width * fontSizeFactor) / referenceWidth, - fixedFontScale, - fontSize, - ); - - const hideClippedValue = displayValueSettings ? displayValueSettings.hideClippedValue : undefined; - // Based on rotation scale the width of the text box - const bboxWidthFactor = isHorizontalRotation ? textScalingFactor : 1; - - const displayValue = - displayValueSettings && displayValueSettings.showValueLabel - ? { - fontScale: textScalingFactor, - fontSize: fixedFontScale, - text: displayValueText, - width: bboxWidthFactor * displayValueWidth, - height: textScalingFactor * fixedFontScale, - hideClippedValue, - isValueContainedInElement: displayValueSettings.isValueContainedInElement, - } - : undefined; - - const seriesIdentifier: XYChartSeriesIdentifier = { - key: dataSeries.key, - specId: dataSeries.specId, - yAccessor: dataSeries.yAccessor, - splitAccessors: dataSeries.splitAccessors, - seriesKeys: dataSeries.seriesKeys, - smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, - smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, - }; - - const seriesStyle = getBarStyleOverrides(datum, seriesIdentifier, sharedSeriesStyle, styleAccessor); - - const barGeometry: BarGeometry = { - displayValue, - x, - y, - transform: { - x: 0, - y: 0, - }, - width, - height, - color, - value: { - x: datum.x, - y: originalY1Value, - mark: null, - accessor: BandedAccessorType.Y1, - datum: datum.datum, - }, - seriesIdentifier, - seriesStyle, - panel, - }; - indexedGeometryMap.set(barGeometry); - barGeometries.push(barGeometry); - }); - - bboxCalculator.destroy(); - - return { - barGeometries, - indexedGeometryMap, - }; -} - -/** @internal */ -export function renderLine( - shift: number, - dataSeries: DataSeries, - xScale: Scale, - yScale: Scale, - panel: Dimensions, - color: Color, - curve: CurveType, - hasY0Accessors: boolean, - xScaleOffset: number, - seriesStyle: LineSeriesStyle, - markSizeOptions: MarkSizeOptions, - pointStyleAccessor?: PointStyleAccessor, - hasFit?: boolean, -): { - lineGeometry: LineGeometry; - indexedGeometryMap: IndexedGeometryMap; -} { - const isLogScale = isLogarithmicScale(yScale); - const pathGenerator = line() - .x(({ x }) => xScale.scaleOrThrow(x) - xScaleOffset) - .y((datum) => { - const yValue = getYValue(datum); - - if (yValue !== null) { - return yScale.scaleOrThrow(yValue); - } - - // this should never happen thanks to the defined function - return yScale.isInverted ? yScale.range[1] : yScale.range[0]; - }) - .defined((datum) => { - const yValue = getYValue(datum); - return yValue !== null && !(isLogScale && yValue <= 0) && xScale.isValueInDomain(datum.x); - }) - .curve(getCurveFactory(curve)); - - const { pointGeometries, indexedGeometryMap } = renderPoints( - shift - xScaleOffset, - dataSeries, - xScale, - yScale, - panel, - color, - seriesStyle.line, - hasY0Accessors, - markSizeOptions, - pointStyleAccessor, - ); - - const clippedRanges = getClippedRanges(dataSeries.data, xScale, xScaleOffset); - let linePath: string; - - try { - linePath = pathGenerator(dataSeries.data) || ''; - } catch { - // When values are not scalable - linePath = ''; - } - - const lineGeometry = { - line: linePath, - points: pointGeometries, - color, - transform: { - x: shift, - y: 0, - }, - seriesIdentifier: { - key: dataSeries.key, - specId: dataSeries.specId, - yAccessor: dataSeries.yAccessor, - splitAccessors: dataSeries.splitAccessors, - seriesKeys: dataSeries.seriesKeys, - smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, - smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, - }, - seriesLineStyle: seriesStyle.line, - seriesPointStyle: seriesStyle.point, - clippedRanges, - hideClippedRanges: !hasFit, - }; - return { - lineGeometry, - indexedGeometryMap, - }; -} - -/** @internal */ -export function renderBubble( - shift: number, - dataSeries: DataSeries, - xScale: Scale, - yScale: Scale, - color: Color, - panel: Dimensions, - hasY0Accessors: boolean, - xScaleOffset: number, - seriesStyle: BubbleSeriesStyle, - markSizeOptions: MarkSizeOptions, - isMixedChart: boolean, - pointStyleAccessor?: PointStyleAccessor, -): { - bubbleGeometry: BubbleGeometry; - indexedGeometryMap: IndexedGeometryMap; -} { - const { pointGeometries, indexedGeometryMap } = renderPoints( - shift - xScaleOffset, - dataSeries, - xScale, - yScale, - panel, - color, - seriesStyle.point, - hasY0Accessors, - markSizeOptions, - pointStyleAccessor, - !isMixedChart, - ); - - const bubbleGeometry = { - points: pointGeometries, - color, - seriesIdentifier: { - key: dataSeries.key, - specId: dataSeries.specId, - yAccessor: dataSeries.yAccessor, - splitAccessors: dataSeries.splitAccessors, - seriesKeys: dataSeries.seriesKeys, - smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, - smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, - }, - seriesPointStyle: seriesStyle.point, - }; - return { - bubbleGeometry, - indexedGeometryMap, - }; -} - -/** @internal */ - -export function renderArea( - shift: number, - dataSeries: DataSeries, - xScale: Scale, - yScale: Scale, - panel: Dimensions, - color: Color, - curve: CurveType, - hasY0Accessors: boolean, - xScaleOffset: number, - seriesStyle: AreaSeriesStyle, - markSizeOptions: MarkSizeOptions, - isStacked = false, - pointStyleAccessor?: PointStyleAccessor, - hasFit?: boolean, -): { - areaGeometry: AreaGeometry; - indexedGeometryMap: IndexedGeometryMap; -} { - const isLogScale = isLogarithmicScale(yScale); - - const pathGenerator = area() - .x(({ x }) => xScale.scaleOrThrow(x) - xScaleOffset) - .y1((datum) => { - const yValue = getYValue(datum); - if (yValue !== null) { - return yScale.scaleOrThrow(yValue); - } - // this should never happen thanks to the defined function - return yScale.isInverted ? yScale.range[1] : yScale.range[0]; - }) - .y0(({ y0 }) => { - return y0 === null || (isLogScale && y0 <= 0) ? yScale.range[0] : yScale.scaleOrThrow(y0); - }) - .defined((datum) => { - const yValue = getYValue(datum); - return yValue !== null && !(isLogScale && yValue <= 0) && xScale.isValueInDomain(datum.x); - }) - .curve(getCurveFactory(curve)); - - const clippedRanges = getClippedRanges(dataSeries.data, xScale, xScaleOffset); - - let y1Line: string | null; - - try { - y1Line = pathGenerator.lineY1()(dataSeries.data); - } catch { - // When values are not scalable - y1Line = null; - } - - const lines: string[] = []; - if (y1Line) { - lines.push(y1Line); - } - if (hasY0Accessors) { - let y0Line: string | null; - - try { - y0Line = pathGenerator.lineY0()(dataSeries.data); - } catch { - // When values are not scalable - y0Line = null; - } - if (y0Line) { - lines.push(y0Line); - } - } - - const { pointGeometries, indexedGeometryMap } = renderPoints( - shift - xScaleOffset, - dataSeries, - xScale, - yScale, - panel, - color, - seriesStyle.line, - hasY0Accessors, - markSizeOptions, - pointStyleAccessor, - false, - ); - - let areaPath: string; - - try { - areaPath = pathGenerator(dataSeries.data) || ''; - } catch { - // When values are not scalable - areaPath = ''; - } - - const areaGeometry: AreaGeometry = { - area: areaPath, - lines, - points: pointGeometries, - color, - transform: { - y: 0, - x: shift, - }, - seriesIdentifier: { - key: dataSeries.key, - specId: dataSeries.specId, - yAccessor: dataSeries.yAccessor, - splitAccessors: dataSeries.splitAccessors, - seriesKeys: dataSeries.seriesKeys, - smHorizontalAccessorValue: dataSeries.smHorizontalAccessorValue, - smVerticalAccessorValue: dataSeries.smVerticalAccessorValue, - }, - seriesAreaStyle: seriesStyle.area, - seriesAreaLineStyle: seriesStyle.line, - seriesPointStyle: seriesStyle.point, - isStacked, - clippedRanges, - hideClippedRanges: !hasFit, - }; - return { - areaGeometry, - indexedGeometryMap, - }; -} - -/** - * - * @param param0 - * @internal - */ -export function isDatumFilled({ filled, initialY1 }: DataSeriesDatum) { - return filled?.x !== undefined || filled?.y1 !== undefined || initialY1 === null || initialY1 === undefined; -} - -/** - * Gets clipped ranges that have been fitted to values - * @param dataset - * @param xScale - * @param xScaleOffset - * @param panel - * @internal - */ -export function getClippedRanges(dataset: DataSeriesDatum[], xScale: Scale, xScaleOffset: number): ClippedRanges { - let firstNonNullX: number | null = null; - let hasNull = false; - return dataset.reduce((acc, data) => { - const xScaled = xScale.scale(data.x); - if (xScaled === null) { - return acc; - } - - const xValue = xScaled - xScaleOffset + xScale.bandwidth / 2; - - if (isDatumFilled(data)) { - const endXValue = xScale.range[1] - xScale.bandwidth * (2 / 3); - if (firstNonNullX !== null && xValue === endXValue) { - acc.push([firstNonNullX, xValue]); - } - hasNull = true; - } else { - if (hasNull) { - if (firstNonNullX !== null) { - acc.push([firstNonNullX, xValue]); - } else { - acc.push([0, xValue]); - } - hasNull = false; - } - - firstNonNullX = xValue; - } - return acc; - }, []); -} - -/** @internal */ -export function getGeometryStateStyle( - seriesIdentifier: XYChartSeriesIdentifier, - sharedGeometryStyle: SharedGeometryStateStyle, - highlightedLegendItem?: LegendItem, - individualHighlight?: { [key: string]: boolean }, -): GeometryStateStyle { - const { default: defaultStyles, highlighted, unhighlighted } = sharedGeometryStyle; - - if (highlightedLegendItem) { - const isPartOfHighlightedSeries = seriesIdentifier.key === highlightedLegendItem.seriesIdentifier.key; - - return isPartOfHighlightedSeries ? highlighted : unhighlighted; - } - - if (individualHighlight) { - const { hasHighlight, hasGeometryHover } = individualHighlight; - if (!hasGeometryHover) { - return highlighted; - } - return hasHighlight ? highlighted : unhighlighted; - } - - return defaultStyles; -} - -/** @internal */ -export function isPointOnGeometry( - xCoordinate: number, - yCoordinate: number, - indexedGeometry: BarGeometry | PointGeometry, - buffer: MarkBuffer = DEFAULT_HIGHLIGHT_PADDING, -) { - const { x, y } = indexedGeometry; - if (isPointGeometry(indexedGeometry)) { - const { radius } = indexedGeometry; - const distance = getDistance( - { - x: xCoordinate, - y: yCoordinate, - }, - { - x, - y, - }, - ); - - const radiusBuffer = typeof buffer === 'number' ? buffer : buffer(radius); - - if (radiusBuffer === Infinity) { - return distance <= radius + DEFAULT_HIGHLIGHT_PADDING; - } - - 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/rendering/utils.ts b/src/chart_types/xy_chart/rendering/utils.ts new file mode 100644 index 0000000000..24f190fc3f --- /dev/null +++ b/src/chart_types/xy_chart/rendering/utils.ts @@ -0,0 +1,222 @@ +/* + * 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 { LegendItem } from '../../../commons/legend'; +import { Scale } from '../../../scales'; +import { LOG_MIN_ABS_DOMAIN } from '../../../scales/constants'; +import { getDomainPolarity } from '../../../scales/scale_continuous'; +import { isLogarithmicScale } from '../../../scales/types'; +import { MarkBuffer } from '../../../specs'; +import { getDistance } from '../../../utils/commons'; +import { BarGeometry, ClippedRanges, isPointGeometry, PointGeometry } from '../../../utils/geometry'; +import { GeometryStateStyle, SharedGeometryStateStyle } from '../../../utils/themes/theme'; +import { DataSeriesDatum, FilledValues, XYChartSeriesIdentifier } from '../utils/series'; +import { DEFAULT_HIGHLIGHT_PADDING } from './constants'; + +export interface MarkSizeOptions { + enabled: boolean; + ratio?: number; +} + +/** + * Returns value of `y1` or `filled.y1` or null by default. + * Passing a filled key (x, y1, y0) it will return that value or the filled one + * @internal + */ +export const getYDatumValue = ( + datum: DataSeriesDatum, + valueName: keyof Omit = 'y1', + returnFilled = true, +): number | null => { + const value = datum[valueName]; + if (value !== null || !returnFilled) { + return value; + } + return (datum.filled && datum.filled[valueName]) ?? null; +}; + +/** + * + * @param param0 + * @internal + */ +export function isDatumFilled({ filled, initialY1 }: DataSeriesDatum) { + return filled?.x !== undefined || filled?.y1 !== undefined || initialY1 === null || initialY1 === undefined; +} + +/** + * Gets clipped ranges that have been fitted to values + * @param dataset + * @param xScale + * @param xScaleOffset + * @internal + */ +export function getClippedRanges(dataset: DataSeriesDatum[], xScale: Scale, xScaleOffset: number): ClippedRanges { + let firstNonNullX: number | null = null; + let hasNull = false; + return dataset.reduce((acc, data) => { + const xScaled = xScale.scale(data.x); + if (xScaled === null) { + return acc; + } + + const xValue = xScaled - xScaleOffset + xScale.bandwidth / 2; + + if (isDatumFilled(data)) { + const endXValue = xScale.range[1] - xScale.bandwidth * (2 / 3); + if (firstNonNullX !== null && xValue === endXValue) { + acc.push([firstNonNullX, xValue]); + } + hasNull = true; + } else { + if (hasNull) { + if (firstNonNullX !== null) { + acc.push([firstNonNullX, xValue]); + } else { + acc.push([0, xValue]); + } + hasNull = false; + } + + firstNonNullX = xValue; + } + return acc; + }, []); +} + +/** @internal */ +export function getGeometryStateStyle( + seriesIdentifier: XYChartSeriesIdentifier, + sharedGeometryStyle: SharedGeometryStateStyle, + highlightedLegendItem?: LegendItem, + individualHighlight?: { [key: string]: boolean }, +): GeometryStateStyle { + const { default: defaultStyles, highlighted, unhighlighted } = sharedGeometryStyle; + + if (highlightedLegendItem) { + const isPartOfHighlightedSeries = seriesIdentifier.key === highlightedLegendItem.seriesIdentifier.key; + + return isPartOfHighlightedSeries ? highlighted : unhighlighted; + } + + if (individualHighlight) { + const { hasHighlight, hasGeometryHover } = individualHighlight; + if (!hasGeometryHover) { + return highlighted; + } + return hasHighlight ? highlighted : unhighlighted; + } + + return defaultStyles; +} + +/** @internal */ +export function isPointOnGeometry( + xCoordinate: number, + yCoordinate: number, + indexedGeometry: BarGeometry | PointGeometry, + buffer: MarkBuffer = DEFAULT_HIGHLIGHT_PADDING, +) { + const { x, y } = indexedGeometry; + if (isPointGeometry(indexedGeometry)) { + const { radius } = indexedGeometry; + const distance = getDistance( + { + x: xCoordinate, + y: yCoordinate, + }, + { + x, + y, + }, + ); + + const radiusBuffer = typeof buffer === 'number' ? buffer : buffer(radius); + + if (radiusBuffer === Infinity) { + return distance <= radius + DEFAULT_HIGHLIGHT_PADDING; + } + + return distance <= radius + radiusBuffer; + } + const { width, height } = indexedGeometry; + return yCoordinate >= y && yCoordinate <= y + height && xCoordinate >= x && xCoordinate <= x + width; +} + +/** + * The default zero baseline for area charts. + */ +const DEFAULT_ZERO_BASELINE = 0; +/** + * The zero baseline for log scales. + * We are currently limiting to 1 as min accepted domain for a log scale. + */ +const DEFAULT_LOG_ZERO_BASELINE = LOG_MIN_ABS_DOMAIN; + +/** @internal */ +export function isYValueDefined( + yScale: Scale, + xScale: Scale, +): (datum: DataSeriesDatum, valueName?: keyof Omit) => boolean { + const isLogScale = isLogarithmicScale(yScale); + const domainPolarity = getDomainPolarity(yScale.domain); + return (datum, valueName = 'y1') => { + const yValue = getYDatumValue(datum, valueName); + return ( + yValue !== null && + !((isLogScale && domainPolarity >= 0 && yValue <= 0) || (domainPolarity < 0 && yValue >= 0)) && + xScale.isValueInDomain(datum.x) && + yScale.isValueInDomain(yValue) + ); + }; +} + +/** @internal */ +export function getY1ScaledValueOrThrow(yScale: Scale): (datum: DataSeriesDatum) => number { + return (datum) => { + const yValue = getYDatumValue(datum); + return yScale.scaleOrThrow(yValue); + }; +} + +/** @internal */ +export function getY0ScaledValueOrThrow(yScale: Scale): (datum: DataSeriesDatum) => number { + const isLogScale = isLogarithmicScale(yScale); + const domainPolarity = getDomainPolarity(yScale.domain); + + return ({ y0 }) => { + if (y0 === null) { + if (isLogScale) { + // if all positive domain use 1 as baseline, -1 otherwise + return yScale.scaleOrThrow(domainPolarity >= 0 ? DEFAULT_LOG_ZERO_BASELINE : -DEFAULT_LOG_ZERO_BASELINE); + } + return yScale.scaleOrThrow(DEFAULT_ZERO_BASELINE); + } + if (isLogScale) { + // wrong y0 polarity + if ((domainPolarity >= 0 && y0 <= 0) || (domainPolarity < 0 && y0 >= 0)) { + // if all positive domain use 1 as baseline, -1 otherwise + return yScale.scaleOrThrow(domainPolarity >= 0 ? DEFAULT_LOG_ZERO_BASELINE : -DEFAULT_LOG_ZERO_BASELINE); + } + // if negative value, use -1 as max reference, 1 otherwise + return yScale.scaleOrThrow(y0); + } + return yScale.scaleOrThrow(y0); + }; +} 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 734eae4edb..ec63bb6db6 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,7 +39,7 @@ import { Rotation } from '../../../../utils/commons'; import { isValidPointerOverEvent } from '../../../../utils/events'; import { IndexedGeometry } from '../../../../utils/geometry'; import { Point } from '../../../../utils/point'; -import { isPointOnGeometry } from '../../rendering/rendering'; +import { isPointOnGeometry } from '../../rendering/utils'; import { formatTooltip } from '../../tooltip/tooltip'; import { BasicSeriesSpec, AxisSpec } from '../../utils/specs'; import { getAxesSpecForSpecId, getSpecsById } from '../utils/spec'; diff --git a/src/chart_types/xy_chart/state/utils/utils.ts b/src/chart_types/xy_chart/state/utils/utils.ts index b2ace9fa1d..7a9da69216 100644 --- a/src/chart_types/xy_chart/state/utils/utils.ts +++ b/src/chart_types/xy_chart/state/utils/utils.ts @@ -39,7 +39,11 @@ import { getPredicateFn, Predicate } from '../../../heatmap/utils/commons'; import { XDomain } from '../../domains/types'; import { mergeXDomain } from '../../domains/x_domain'; import { isStackedSpec, mergeYDomain } from '../../domains/y_domain'; -import { renderArea, renderBars, renderLine, renderBubble, isDatumFilled } from '../../rendering/rendering'; +import { renderArea } from '../../rendering/area'; +import { renderBars } from '../../rendering/bars'; +import { renderBubble } from '../../rendering/bubble'; +import { renderLine } from '../../rendering/line'; +import { isDatumFilled } from '../../rendering/utils'; import { defaultTickFormatter } from '../../utils/axis_utils'; import { fillSeries } from '../../utils/fill_series'; import { groupBy } from '../../utils/group_data_series'; diff --git a/src/mocks/series/utils.ts b/src/mocks/series/utils.ts index 8484bca685..36ac2808ff 100644 --- a/src/mocks/series/utils.ts +++ b/src/mocks/series/utils.ts @@ -17,7 +17,7 @@ * under the License. */ -import { getYValue } from '../../chart_types/xy_chart/rendering/rendering'; +import { getYDatumValue } from '../../chart_types/xy_chart/rendering/utils'; import { DataSeriesDatum } from '../../chart_types/xy_chart/utils/series'; /** @@ -44,4 +44,7 @@ export const getXValueData = (data: DataSeriesDatum[]): (number | string)[] => d * Returns value of `y1` or `filled.y1` or null * @internal */ -export const getYResolvedData = (data: DataSeriesDatum[]): (number | null)[] => data.map(getYValue); +export const getYResolvedData = (data: DataSeriesDatum[]): (number | null)[] => + data.map((d) => { + return getYDatumValue(d); + }); diff --git a/src/scales/constants.ts b/src/scales/constants.ts index 5a038c0289..38de953140 100644 --- a/src/scales/constants.ts +++ b/src/scales/constants.ts @@ -35,3 +35,6 @@ export const ScaleType = Object.freeze({ /** @public */ export type ScaleType = $Values; + +/** @internal */ +export const LOG_MIN_ABS_DOMAIN = 1; diff --git a/src/scales/scale_continuous.ts b/src/scales/scale_continuous.ts index 5aabf455a0..5c0797db98 100644 --- a/src/scales/scale_continuous.ts +++ b/src/scales/scale_continuous.ts @@ -33,7 +33,7 @@ import { ScaleContinuousType, Scale } from '.'; import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; import { maxValueWithUpperLimit, mergePartial } from '../utils/commons'; import { getMomentWithTz } from '../utils/data/date_time'; -import { ScaleType } from './constants'; +import { LOG_MIN_ABS_DOMAIN, ScaleType } from './constants'; /** * d3 scales excluding time scale @@ -62,38 +62,39 @@ const SCALES = { export function limitLogScaleDomain(domain: any[]) { if (domain[0] === 0) { if (domain[1] > 0) { - return [1, domain[1]]; + return [LOG_MIN_ABS_DOMAIN, domain[1]]; } if (domain[1] < 0) { - return [-1, domain[1]]; + return [-LOG_MIN_ABS_DOMAIN, domain[1]]; } - return [1, 1]; + return [LOG_MIN_ABS_DOMAIN, LOG_MIN_ABS_DOMAIN]; } if (domain[1] === 0) { if (domain[0] > 0) { - return [domain[0], 1]; + return [domain[0], LOG_MIN_ABS_DOMAIN]; } if (domain[0] < 0) { - return [domain[0], -1]; + return [domain[0], -LOG_MIN_ABS_DOMAIN]; } - return [1, 1]; + return [LOG_MIN_ABS_DOMAIN, LOG_MIN_ABS_DOMAIN]; } if (domain[0] < 0 && domain[1] > 0) { const isD0Min = Math.abs(domain[1]) - Math.abs(domain[0]) >= 0; if (isD0Min) { - return [1, domain[1]]; + return [LOG_MIN_ABS_DOMAIN, domain[1]]; } - return [domain[0], -1]; + return [domain[0], -LOG_MIN_ABS_DOMAIN]; } if (domain[0] > 0 && domain[1] < 0) { const isD0Max = Math.abs(domain[0]) - Math.abs(domain[1]) >= 0; if (isD0Max) { - return [domain[0], 1]; + return [domain[0], LOG_MIN_ABS_DOMAIN]; } - return [-1, domain[1]]; + return [-LOG_MIN_ABS_DOMAIN, domain[1]]; } return domain; } + interface ScaleData { /** The Type of continuous scale */ type: ScaleContinuousType; @@ -361,3 +362,18 @@ export class ScaleContinuous implements Scale { return value >= this.domain[0] && value <= this.domain[1]; } } + +/** @internal */ +export function getDomainPolarity(domain: number[]): number { + const [min, max] = domain; + // all positive or zero + if (min >= 0 && max >= 0) { + return 1; + } + // all negative or zero + if (min <= 0 && max <= 0) { + return -1; + } + // mixed + return 0; +} diff --git a/stories/area/17_negative.tsx b/stories/area/17_negative.tsx new file mode 100644 index 0000000000..6c409e8b46 --- /dev/null +++ b/stories/area/17_negative.tsx @@ -0,0 +1,63 @@ +/* + * 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 { select } from '@storybook/addon-knobs'; +import React from 'react'; + +import { AreaSeries, Axis, Chart, Position, ScaleType, timeFormatter } from '../../src'; +import { KIBANA_METRICS } from '../../src/utils/data_samples/test_dataset_kibana'; +import { SB_SOURCE_PANEL } from '../utils/storybook'; + +const dateFormatter = timeFormatter('HH:mm'); + +const data = KIBANA_METRICS.metrics.kibana_os_load[0].data.map(([x, y]) => { + return [x, -y]; +}); +export const Example = () => { + const scaleType = select( + 'Y scale', + { + [ScaleType.Linear]: ScaleType.Linear, + [ScaleType.Log]: ScaleType.Log, + }, + ScaleType.Linear, + ); + return ( + + + Number(d).toFixed(2)} /> + + + + ); +}; + +// storybook configuration +Example.story = { + parameters: { + options: { selectedPanel: SB_SOURCE_PANEL }, + }, +}; diff --git a/stories/area/18_negative_positive.tsx b/stories/area/18_negative_positive.tsx new file mode 100644 index 0000000000..f5002af184 --- /dev/null +++ b/stories/area/18_negative_positive.tsx @@ -0,0 +1,80 @@ +/* + * 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 { select } from '@storybook/addon-knobs'; +import React from 'react'; + +import { AreaSeries, Axis, Chart, Position, ScaleType, Settings, timeFormatter } from '../../src'; +import { KIBANA_METRICS } from '../../src/utils/data_samples/test_dataset_kibana'; +import { SB_SOURCE_PANEL } from '../utils/storybook'; + +const dateFormatter = timeFormatter('HH:mm'); + +export const Example = () => { + const dataset = KIBANA_METRICS.metrics.kibana_os_load[0]; + const scaleType = select( + 'Y scale', + { + [ScaleType.Linear]: ScaleType.Linear, + [ScaleType.Log]: ScaleType.Log, + }, + ScaleType.Linear, + ); + + return ( + + + + Number(d).toFixed(2)} /> + + { + return [x, i < dataset.data.length / 2 ? -y : y]; + })} + /> + { + return [x, i >= dataset.data.length / 2 ? -y : y]; + })} + /> + + ); +}; +// storybook configuration +Example.story = { + parameters: { + options: { selectedPanel: SB_SOURCE_PANEL }, + }, +}; diff --git a/stories/area/19_negative_band.tsx b/stories/area/19_negative_band.tsx new file mode 100644 index 0000000000..664fa6d898 --- /dev/null +++ b/stories/area/19_negative_band.tsx @@ -0,0 +1,84 @@ +/* + * 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 { select } from '@storybook/addon-knobs'; +import React from 'react'; + +import { AreaSeries, Axis, Chart, Fit, LineSeries, Position, ScaleType, Settings } from '../../src'; +import { SB_SOURCE_PANEL } from '../utils/storybook'; + +export const Example = () => { + const scaleType = select( + 'Y scale', + { + [ScaleType.Linear]: ScaleType.Linear, + [ScaleType.Log]: ScaleType.Log, + }, + ScaleType.Linear, + ); + + const data = [ + [0, -5, -2], + [1, -6, -2.1], + [2, -8, -0.9], + [3, -3, -1.2], + [4, -2.3, -1.6], + [5, -4, -3.4], + ]; + + return ( + + + + Number(d).toFixed(2)} /> + + + + { + return [x, (y1 + y0) / 2]; + })} + /> + + ); +}; +// storybook configuration +Example.story = { + parameters: { + options: { selectedPanel: SB_SOURCE_PANEL }, + }, +}; diff --git a/stories/area/area.stories.tsx b/stories/area/area.stories.tsx index d14005973e..7b16c2dbdc 100644 --- a/stories/area/area.stories.tsx +++ b/stories/area/area.stories.tsx @@ -40,6 +40,9 @@ export { Example as stackedSameNaming } from './10_stacked_same_naming'; export { Example as bandArea } from './13_band_area'; export { Example as stackedBand } from './14_stacked_band'; export { Example as stackedGrouped } from './15_stacked_grouped'; +export { Example as withNegativeValues } from './17_negative'; +export { Example as withNegativeAndPositive } from './18_negative_positive'; +export { Example as withNegativeBand } from './19_negative_band'; export { Example as testLinear } from './11_test_linear'; export { Example as testTime } from './12_test_time';