diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-bar-chart-with-value-label-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-bar-chart-with-value-label-visually-looks-correct-1-snap.png index e2ac14c406..b56f46aa3a 100644 Binary files a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-bar-chart-with-value-label-visually-looks-correct-1-snap.png and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-bar-chart-with-value-label-visually-looks-correct-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-value-labels-positioning-clip-both-geometry-and-chart-area-values-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-value-labels-positioning-clip-both-geometry-and-chart-area-values-1-snap.png new file mode 100644 index 0000000000..54e4b04973 Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-value-labels-positioning-clip-both-geometry-and-chart-area-values-1-snap.png differ diff --git a/integration/tests/bar_stories.test.ts b/integration/tests/bar_stories.test.ts index 4b3378fa7b..5d1374ab77 100644 --- a/integration/tests/bar_stories.test.ts +++ b/integration/tests/bar_stories.test.ts @@ -122,6 +122,11 @@ describe('Bar series stories', () => { }); }); }); + it('clip both geometry and chart area values', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--with-value-label&knob-show single series=&knob-show value label=true&knob-alternating value label=&knob-contain value label within bar element=&knob-hide label if overflows chart edges=true&knob-hide label if overflows bar geometry=true&knob-debug=&knob-value font size=11&knob-value color=%23000&knob-offsetX=0&knob-offsetY=10&knob-data volume size=s&knob-split series=&knob-stacked series=&knob-chartRotation=0&knob-legend=right', + ); + }); }); describe('functional accessors', () => { diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index 4785c671a9..e862bfb14a 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -645,9 +645,10 @@ export type Direction = $Values; // @public (undocumented) export interface DisplayValueSpec { - hideClippedValue?: boolean; isAlternatingValueLabel?: boolean; + // @deprecated isValueContainedInElement?: boolean; + overflowConstraints?: Array; showValueLabel?: boolean; valueFormatter?: TickFormatter; } @@ -1093,6 +1094,15 @@ export type Key = CategoryKey; // @public (undocumented) export type LabelAccessor = (value: PrimitiveValue) => string; +// @public (undocumented) +export const LabelOverflowConstraint: Readonly<{ + BarGeometry: "barGeometry"; + ChartEdges: "chartEdges"; +}>; + +// @public (undocumented) +export type LabelOverflowConstraint = $Values; + // @public (undocumented) export interface LayerValue { depth: number; diff --git a/packages/charts/src/chart_types/xy_chart/renderer/canvas/values/bar.ts b/packages/charts/src/chart_types/xy_chart/renderer/canvas/values/bar.ts index 9c3f30e6cc..f830839487 100644 --- a/packages/charts/src/chart_types/xy_chart/renderer/canvas/values/bar.ts +++ b/packages/charts/src/chart_types/xy_chart/renderer/canvas/values/bar.ts @@ -15,6 +15,7 @@ import { Dimensions } from '../../../../../utils/dimensions'; import { BarGeometry } from '../../../../../utils/geometry'; import { Point } from '../../../../../utils/point'; import { Theme, TextAlignment } from '../../../../../utils/themes/theme'; +import { LabelOverflowConstraint } from '../../../utils/specs'; import { renderText, wrapLines } from '../primitives/text'; import { renderDebugRect } from '../utils/debug'; import { withPanelTransform } from '../utils/panel_transform'; @@ -45,7 +46,7 @@ export function renderBarValues(ctx: CanvasRenderingContext2D, props: BarValuesP if (!displayValue) { continue; } - const { text, fontSize, fontScale } = displayValue; + const { text, fontSize, fontScale, overflowConstraints, isValueContainedInElement } = displayValue; let textLines = { lines: [text], width: displayValue.width, @@ -60,7 +61,7 @@ export function renderBarValues(ctx: CanvasRenderingContext2D, props: BarValuesP textOpacity: 1, }; - const { x, y, align, baseline, rect } = positionText( + const { x, y, align, baseline, rect, overflow } = positionText( bars[i], displayValue, rotation, @@ -68,15 +69,20 @@ export function renderBarValues(ctx: CanvasRenderingContext2D, props: BarValuesP alignment, ); - if (displayValue.isValueContainedInElement) { + if (isValueContainedInElement) { const width = rotation === 0 || rotation === 180 ? bars[i].width : bars[i].height; textLines = wrapLines(ctx, textLines.lines[0], font, fontSize, width, 100); } - if (displayValue.hideClippedValue && isOverflow(rect, renderingArea, rotation)) { + if (overflowConstraints.has(LabelOverflowConstraint.ChartEdges) && isOverflow(rect, renderingArea, rotation)) { + continue; + } + if (overflowConstraints.has(LabelOverflowConstraint.BarGeometry) && overflow) { continue; } if (debug) { - renderDebugRect(ctx, rect); + withPanelTransform(ctx, panel, rotation, renderingArea, (ctx) => { + renderDebugRect(ctx, rect); + }); } const { width, height } = textLines; const linesLength = textLines.lines.length; @@ -108,6 +114,7 @@ export function renderBarValues(ctx: CanvasRenderingContext2D, props: BarValuesP } } } + function repositionTextLine( origin: Point, chartRotation: Rotation, @@ -253,7 +260,7 @@ function positionText( chartRotation: Rotation, offsets: { offsetX: number; offsetY: number }, alignment?: TextAlignment, -): { x: number; y: number; align: TextAlign; baseline: TextBaseline; rect: Rect } { +): { x: number; y: number; align: TextAlign; baseline: TextBaseline; rect: Rect; overflow: boolean } { const { offsetX, offsetY } = offsets; const { alignmentOffsetX, alignmentOffsetY } = computeAlignmentOffset(geom, valueBox, chartRotation, alignment); @@ -273,6 +280,7 @@ function positionText( width: valueBox.width, height: valueBox.height, }, + overflow: valueBox.width > geom.width || valueBox.height > geom.height, }; } case CHART_DIRECTION.RightToLeft: { @@ -289,6 +297,7 @@ function positionText( width: valueBox.height, height: valueBox.width, }, + overflow: valueBox.height > geom.width || valueBox.width > geom.height, }; } case CHART_DIRECTION.LeftToRight: { @@ -305,6 +314,7 @@ function positionText( width: valueBox.height, height: valueBox.width, }, + overflow: valueBox.height > geom.width || valueBox.width > geom.height, }; } case CHART_DIRECTION.BottomUp: @@ -322,6 +332,7 @@ function positionText( width: valueBox.width, height: valueBox.height, }, + overflow: valueBox.width > geom.width || valueBox.height > geom.height, }; } } diff --git a/packages/charts/src/chart_types/xy_chart/rendering/bars.ts b/packages/charts/src/chart_types/xy_chart/rendering/bars.ts index 0c8af49ffb..09a27afd98 100644 --- a/packages/charts/src/chart_types/xy_chart/rendering/bars.ts +++ b/packages/charts/src/chart_types/xy_chart/rendering/bars.ts @@ -15,7 +15,7 @@ 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'; +import { BarStyleAccessor, DisplayValueSpec, LabelOverflowConstraint, StackMode } from '../utils/specs'; /** @internal */ export function renderBars( @@ -115,19 +115,14 @@ export function renderBars( const x = xScaled + xScale.bandwidth * orderIndex + xScale.bandwidth / 2 - width / 2; const originalY1Value = stackMode === StackMode.Percentage ? y1 - (y0 ?? 0) : initialY1; - const formattedDisplayValue = - displayValueSettings && displayValueSettings.valueFormatter - ? displayValueSettings.valueFormatter(originalY1Value) - : undefined; + const formattedDisplayValue = displayValueSettings?.valueFormatter?.(originalY1Value); // only show displayValue for even bars if showOverlappingValue const displayValueText = - displayValueSettings && displayValueSettings.isAlternatingValueLabel && barGeometries.length % 2 - ? undefined - : formattedDisplayValue; + displayValueSettings?.isAlternatingValueLabel && barGeometries.length % 2 ? undefined : formattedDisplayValue; const { displayValueWidth, fixedFontScale } = computeBoxWidth( - displayValueText || '', + displayValueText ?? '', { padding, fontSize, fontFamily, bboxCalculator, width }, displayValueSettings, ); @@ -143,21 +138,26 @@ export function renderBars( fixedFontScale, fontSize, ); + const overflowConstraints: Set = new Set( + displayValueSettings?.overflowConstraints ?? [ + LabelOverflowConstraint.ChartEdges, + LabelOverflowConstraint.BarGeometry, + ], + ); - 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 + const displayValue: BarGeometry['displayValue'] | undefined = + displayValueText && displayValueSettings?.showValueLabel ? { fontScale: textScalingFactor, fontSize: fixedFontScale, text: displayValueText, width: bboxWidthFactor * displayValueWidth, height: textScalingFactor * fixedFontScale, - hideClippedValue, - isValueContainedInElement: displayValueSettings.isValueContainedInElement, + overflowConstraints, + isValueContainedInElement: displayValueSettings?.isValueContainedInElement ?? false, } : undefined; diff --git a/packages/charts/src/chart_types/xy_chart/utils/specs.ts b/packages/charts/src/chart_types/xy_chart/utils/specs.ts index d53a95ff77..dc5a27a491 100644 --- a/packages/charts/src/chart_types/xy_chart/utils/specs.ts +++ b/packages/charts/src/chart_types/xy_chart/utils/specs.ts @@ -373,18 +373,47 @@ export type YDomainRange = YDomainBase & DomainRange & LogScaleOptions; /** @public */ export type CustomXDomain = (DomainRange & Pick) | OrdinalDomain; +/** @public */ +export const LabelOverflowConstraint = Object.freeze({ + BarGeometry: 'barGeometry' as const, + ChartEdges: 'chartEdges' as const, +}); + +/** @public */ +export type LabelOverflowConstraint = $Values; + /** @public */ export interface DisplayValueSpec { - /** Show value label in chart element */ + /** + * Show value label in chart element + * @defaultValue false + */ showValueLabel?: boolean; - /** If value labels are shown, skips every other label */ + /** + * If value labels are shown, skips every other label + * @defaultValue false + */ isAlternatingValueLabel?: boolean; - /** Function for formatting values; will use axis tickFormatter if none specified */ + /** + * Function for formatting values; will use axis tickFormatter if none specified + * @defaultValue false + */ valueFormatter?: TickFormatter; - /** If true will contain value label within element, else dimensions are computed based on value */ + /** + * If true will contain value label within element, else dimensions are computed based on value + * @deprecated This feature is deprecated and will be removed. Wrapping numbers into multiple lines + * is not considered a good practice. + * @defaultValue false + */ isValueContainedInElement?: boolean; - /** If true will hide values that are clipped at chart edges */ - hideClippedValue?: boolean; + + /** + * An option to hide the value label on certain conditions: + * - `barGeometry` the label is not rendered if the width/height overflows the associated bar geometry, + * - `chartEdges` the label is not rendered if it overflows the chart projection area. + * @defaultValue ['barGeometry', 'chartEdges'] + */ + overflowConstraints?: Array; } /** @public */ diff --git a/packages/charts/src/utils/geometry.ts b/packages/charts/src/utils/geometry.ts index 848b2d5a00..f9382ff8be 100644 --- a/packages/charts/src/utils/geometry.ts +++ b/packages/charts/src/utils/geometry.ts @@ -9,6 +9,7 @@ import { $Values } from 'utility-types'; import { XYChartSeriesIdentifier } from '../chart_types/xy_chart/utils/series'; +import { LabelOverflowConstraint } from '../chart_types/xy_chart/utils/specs'; import { Fill, Stroke } from '../geoms/types'; import { Color } from './common'; import { Dimensions } from './dimensions'; @@ -91,13 +92,13 @@ export interface BarGeometry { }; color: Color; displayValue?: { - fontScale?: number; + fontScale: number; fontSize: number; text: any; width: number; height: number; - hideClippedValue?: boolean; - isValueContainedInElement?: boolean; + overflowConstraints: Set; + isValueContainedInElement: boolean; }; seriesIdentifier: XYChartSeriesIdentifier; value: GeometryValue; diff --git a/stories/bar/2_label_value.tsx b/stories/bar/2_label_value.tsx index 5f6feac102..755841897f 100644 --- a/stories/bar/2_label_value.tsx +++ b/stories/bar/2_label_value.tsx @@ -9,7 +9,16 @@ import { boolean, color, number, select } from '@storybook/addon-knobs'; import React from 'react'; -import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '../../packages/charts/src'; +import { + Axis, + BarSeries, + Chart, + DisplayValueSpec, + LabelOverflowConstraint, + Position, + ScaleType, + Settings, +} from '../../packages/charts/src'; import { SeededDataGenerator } from '../../packages/charts/src/mocks/utils'; import { getChartRotationKnob, getPositionKnob } from '../utils/knobs'; @@ -17,9 +26,9 @@ const dataGen = new SeededDataGenerator(); function generateDataWithAdditional(num: number) { return [...dataGen.generateSimpleSeries(num), { x: num, y: 0.25, g: 0 }, { x: num + 1, y: 8, g: 0 }]; } -const frozenDataSmallVolume = generateDataWithAdditional(10); -const frozenDataMediumVolume = generateDataWithAdditional(50); -const frozenDataHighVolume = generateDataWithAdditional(1500); +const frozenDataSmallVolume = generateDataWithAdditional(5); +const frozenDataMediumVolume = generateDataWithAdditional(30); +const frozenDataHighVolume = generateDataWithAdditional(500); const frozenData: { [key: string]: any[] } = { s: frozenDataSmallVolume, @@ -28,16 +37,24 @@ const frozenData: { [key: string]: any[] } = { }; export const Example = () => { + const singleSeries = boolean('show single series', false); const showValueLabel = boolean('show value label', true); const isAlternatingValueLabel = boolean('alternating value label', false); const isValueContainedInElement = boolean('contain value label within bar element', false); - const hideClippedValue = boolean('hide clipped value', false); - + const overflowChartEdges = boolean('hide label if overflows chart edges', false); + const overflowBarGeometry = boolean('hide label if overflows bar geometry', false); + const overflowConstraints: DisplayValueSpec['overflowConstraints'] = []; + if (overflowChartEdges) { + overflowConstraints.push(LabelOverflowConstraint.ChartEdges); + } + if (overflowBarGeometry) { + overflowConstraints.push(LabelOverflowConstraint.BarGeometry); + } const displayValueSettings = { showValueLabel, isAlternatingValueLabel, isValueContainedInElement, - hideClippedValue, + overflowConstraints, }; const debug = boolean('debug', false); @@ -45,13 +62,13 @@ export const Example = () => { const theme = { barSeriesStyle: { displayValue: { - fontSize: number('value font size', 10), + fontSize: Number(number('value font size', 10)), fontFamily: "'Open Sans', Helvetica, Arial, sans-serif", fontStyle: 'normal', padding: 0, fill: color('value color', '#000'), - offsetX: number('offsetX', 0), - offsetY: number('offsetY', 0), + offsetX: Number(number('offsetX', 0)), + offsetY: Number(number('offsetY', 0)), }, }, }; @@ -95,26 +112,28 @@ export const Example = () => { stackAccessors={stackAccessors} data={data} /> - + {!singleSeries && ( + + )} ); }; diff --git a/stories/bar/51_label_value_advanced.tsx b/stories/bar/51_label_value_advanced.tsx index b66bbfd306..761161d2c8 100644 --- a/stories/bar/51_label_value_advanced.tsx +++ b/stories/bar/51_label_value_advanced.tsx @@ -9,7 +9,16 @@ import { boolean, color, number, select } from '@storybook/addon-knobs'; import React from 'react'; -import { Axis, BarSeries, Chart, Position, ScaleType, Settings } from '../../packages/charts/src'; +import { + Axis, + BarSeries, + Chart, + DisplayValueSpec, + LabelOverflowConstraint, + Position, + ScaleType, + Settings, +} from '../../packages/charts/src'; import { SeededDataGenerator } from '../../packages/charts/src/mocks/utils'; import { getChartRotationKnob } from '../utils/knobs'; @@ -31,13 +40,20 @@ export const Example = () => { const showValueLabel = boolean('show value label', true); const isAlternatingValueLabel = boolean('alternating value label', false); const isValueContainedInElement = boolean('contain value label within bar element', false); - const hideClippedValue = boolean('hide clipped value', false); - + const overflowChartEdges = boolean('hide label if overflows chart edges', false); + const overflowBarGeometry = boolean('hide label if overflows bar geometry', false); + const overflowConstraints: DisplayValueSpec['overflowConstraints'] = []; + if (overflowChartEdges) { + overflowConstraints.push(LabelOverflowConstraint.ChartEdges); + } + if (overflowBarGeometry) { + overflowConstraints.push(LabelOverflowConstraint.BarGeometry); + } const displayValueSettings = { showValueLabel, isAlternatingValueLabel, isValueContainedInElement, - hideClippedValue, + overflowConstraints, }; const debug = boolean('debug', false);