From f2e440ed0b87830898198d32c12f2996f259e85e Mon Sep 17 00:00:00 2001 From: Erwan Vasseure Date: Tue, 2 Jul 2024 07:24:24 +0100 Subject: [PATCH] feat(Bar): Add option to chose label position (#2585) * Add function to compute a bar label's layout * Add support to SVG bars * Add support to CANVAS bar * Add tests * Storybook + website * review: columns 3 for website * review: mirror offset when reversed * review: mirror text anchor while horizontal and reversed * review: center -> middle --- packages/bar/src/Bar.tsx | 20 +++--- packages/bar/src/BarCanvas.tsx | 14 ++++- packages/bar/src/BarItem.tsx | 3 +- packages/bar/src/compute/common.ts | 49 +++++++++++++++ packages/bar/src/props.ts | 2 + packages/bar/src/types.ts | 13 ++-- packages/bar/tests/Bar.test.tsx | 79 ++++++++++++++++++++++++ storybook/stories/bar/Bar.stories.tsx | 15 ++++- website/src/data/components/bar/meta.yml | 2 + website/src/data/components/bar/props.ts | 33 ++++++++++ website/src/pages/bar/api.tsx | 2 + website/src/pages/bar/canvas.js | 2 + website/src/pages/bar/index.js | 2 + 13 files changed, 220 insertions(+), 16 deletions(-) diff --git a/packages/bar/src/Bar.tsx b/packages/bar/src/Bar.tsx index 0b5cfa3c78..797b559e4c 100644 --- a/packages/bar/src/Bar.tsx +++ b/packages/bar/src/Bar.tsx @@ -17,12 +17,14 @@ import { svgDefaultProps } from './props' import { BarCustomLayerProps, BarDatum, + BarItemProps, BarLayer, BarLayerId, BarSvgProps, ComputedBarDatumWithValue, } from './types' import { BarTotals } from './BarTotals' +import { useComputeLabelLayout } from './compute/common' type InnerBarProps = Omit< BarSvgProps, @@ -67,6 +69,8 @@ const InnerBar = ({ labelSkipWidth = svgDefaultProps.labelSkipWidth, labelSkipHeight = svgDefaultProps.labelSkipHeight, labelTextColor, + labelPosition = svgDefaultProps.labelPosition, + labelOffset = svgDefaultProps.labelOffset, markers = svgDefaultProps.markers, @@ -161,6 +165,8 @@ const InnerBar = ({ totalsOffset, }) + const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, labelOffset) + const transition = useTransition< ComputedBarDatumWithValue, { @@ -174,6 +180,7 @@ const InnerBar = ({ opacity: number transform: string width: number + textAnchor: BarItemProps['style']['textAnchor'] } >(barsWithValue, { keys: bar => bar.key, @@ -183,8 +190,7 @@ const InnerBar = ({ height: 0, labelColor: getLabelColor(bar) as string, labelOpacity: 0, - labelX: bar.width / 2, - labelY: bar.height / 2, + ...computeLabelLayout(bar.width, bar.height), transform: `translate(${bar.x}, ${bar.y + bar.height})`, width: bar.width, ...(layout === 'vertical' @@ -201,8 +207,7 @@ const InnerBar = ({ height: bar.height, labelColor: getLabelColor(bar) as string, labelOpacity: 1, - labelX: bar.width / 2, - labelY: bar.height / 2, + ...computeLabelLayout(bar.width, bar.height), transform: `translate(${bar.x}, ${bar.y})`, width: bar.width, }), @@ -212,8 +217,7 @@ const InnerBar = ({ height: bar.height, labelColor: getLabelColor(bar) as string, labelOpacity: 1, - labelX: bar.width / 2, - labelY: bar.height / 2, + ...computeLabelLayout(bar.width, bar.height), transform: `translate(${bar.x}, ${bar.y})`, width: bar.width, }), @@ -223,15 +227,15 @@ const InnerBar = ({ height: 0, labelColor: getLabelColor(bar) as string, labelOpacity: 0, - labelX: bar.width / 2, + ...computeLabelLayout(bar.width, bar.height), labelY: 0, transform: `translate(${bar.x}, ${bar.y + bar.height})`, width: bar.width, ...(layout === 'vertical' ? {} : { + ...computeLabelLayout(bar.width, bar.height), labelX: 0, - labelY: bar.height / 2, height: bar.height, transform: `translate(${bar.x}, ${bar.y})`, width: 0, diff --git a/packages/bar/src/BarCanvas.tsx b/packages/bar/src/BarCanvas.tsx index e884ee5048..27459641e9 100644 --- a/packages/bar/src/BarCanvas.tsx +++ b/packages/bar/src/BarCanvas.tsx @@ -36,6 +36,7 @@ import { renderLegendToCanvas } from '@nivo/legends' import { useTooltip } from '@nivo/tooltip' import { useBar } from './hooks' import { BarTotalsData } from './compute/totals' +import { useComputeLabelLayout } from './compute/common' type InnerBarCanvasProps = Omit< BarCanvasProps, @@ -102,6 +103,9 @@ const InnerBarCanvas = ({ gridXValues, gridYValues, + labelPosition = canvasDefaultProps.labelPosition, + labelOffset = canvasDefaultProps.labelOffset, + layers = canvasDefaultProps.layers as BarCanvasLayer[], renderBar = ( ctx, @@ -114,6 +118,9 @@ const InnerBarCanvas = ({ label, labelColor, shouldRenderLabel, + labelX, + labelY, + textAnchor, } ) => { ctx.fillStyle = color @@ -150,9 +157,9 @@ const InnerBarCanvas = ({ if (shouldRenderLabel) { ctx.textBaseline = 'middle' - ctx.textAlign = 'center' + ctx.textAlign = textAnchor === 'middle' ? 'center' : textAnchor ctx.fillStyle = labelColor - ctx.fillText(label, x + width / 2, y + height / 2) + ctx.fillText(label, x + labelX, y + labelY) } }, @@ -311,6 +318,7 @@ const InnerBarCanvas = ({ ) const formatValue = useValueFormatter(valueFormat) + const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, labelOffset) useEffect(() => { const ctx = canvasEl.current?.getContext('2d') @@ -375,6 +383,7 @@ const InnerBarCanvas = ({ label: getLabel(bar.data), labelColor: getLabelColor(bar) as string, shouldRenderLabel: shouldRenderBarLabel(bar), + ...computeLabelLayout(bar.width, bar.height), }) }) } else if (layer === 'legends') { @@ -436,6 +445,7 @@ const InnerBarCanvas = ({ barTotals, enableTotals, formatValue, + computeLabelLayout, ]) const handleMouseHover = useCallback( diff --git a/packages/bar/src/BarItem.tsx b/packages/bar/src/BarItem.tsx index 79089aedcc..52d7742358 100644 --- a/packages/bar/src/BarItem.tsx +++ b/packages/bar/src/BarItem.tsx @@ -17,6 +17,7 @@ export const BarItem = ({ labelY, transform, width, + textAnchor, }, borderRadius, @@ -108,7 +109,7 @@ export const BarItem = ({ >(data: }, {}) as Exclude export const coerceValue = (value: T) => [value, Number(value)] as const + +export type BarLabelLayout = { + labelX: number + labelY: number + textAnchor: 'start' | 'middle' | 'end' +} + +/** + * Compute the label position and alignment based on a given position and offset. + */ +export function useComputeLabelLayout( + layout: BarCommonProps['layout'] = defaultProps.layout, + reverse: BarCommonProps['reverse'] = defaultProps.reverse, + labelPosition: BarCommonProps['labelPosition'] = defaultProps.labelPosition, + labelOffset: BarCommonProps['labelOffset'] = defaultProps.labelOffset +): (width: number, height: number) => BarLabelLayout { + return (width: number, height: number) => { + // If the chart is reversed, we want to make sure the offset is also reversed + const computedLabelOffset = labelOffset * (reverse ? -1 : 1) + + if (layout === 'horizontal') { + let x = width / 2 + if (labelPosition === 'start') { + x = reverse ? width : 0 + } else if (labelPosition === 'end') { + x = reverse ? 0 : width + } + return { + labelX: x + computedLabelOffset, + labelY: height / 2, + textAnchor: labelPosition === 'middle' ? 'middle' : reverse ? 'end' : 'start', + } + } else { + let y = height / 2 + if (labelPosition === 'start') { + y = reverse ? 0 : height + } else if (labelPosition === 'end') { + y = reverse ? height : 0 + } + return { + labelX: width / 2, + labelY: y - computedLabelOffset, + textAnchor: 'middle', + } + } + } +} diff --git a/packages/bar/src/props.ts b/packages/bar/src/props.ts index 98c53fcf72..b9940fe5da 100644 --- a/packages/bar/src/props.ts +++ b/packages/bar/src/props.ts @@ -28,6 +28,8 @@ export const defaultProps = { enableLabel: true, label: 'formattedValue', + labelPosition: 'middle' as const, + labelOffset: 0, labelSkipWidth: 0, labelSkipHeight: 0, labelTextColor: { from: 'theme', theme: 'labels.text.fill' }, diff --git a/packages/bar/src/types.ts b/packages/bar/src/types.ts index b28334e059..22a4ab5756 100644 --- a/packages/bar/src/types.ts +++ b/packages/bar/src/types.ts @@ -16,6 +16,7 @@ import { InheritedColorConfig, OrdinalColorScaleConfig } from '@nivo/colors' import { LegendProps } from '@nivo/legends' import { AnyScale, ScaleSpec, ScaleBandSpec } from '@nivo/scales' import { SpringValues } from '@react-spring/web' +import { BarLabelLayout } from './compute/common' export interface BarDatum { [key: string]: string | number @@ -165,6 +166,7 @@ export interface BarItemProps opacity: number transform: string width: number + textAnchor: 'start' | 'middle' }> label: string @@ -189,10 +191,11 @@ export type RenderBarProps = Omit< | 'ariaDescribedBy' | 'ariaHidden' | 'ariaDisabled' -> & { - borderColor: string - labelColor: string -} +> & + BarLabelLayout & { + borderColor: string + labelColor: string + } export interface BarTooltipProps extends ComputedDatum { color: string @@ -234,6 +237,8 @@ export type BarCommonProps = { enableLabel: boolean label: PropertyAccessor, string> + labelPosition: 'start' | 'middle' | 'end' + labelOffset: number labelFormat: string | LabelFormatter labelSkipWidth: number labelSkipHeight: number diff --git a/packages/bar/tests/Bar.test.tsx b/packages/bar/tests/Bar.test.tsx index 1fe68574c7..e4d4f4dd1d 100644 --- a/packages/bar/tests/Bar.test.tsx +++ b/packages/bar/tests/Bar.test.tsx @@ -2,6 +2,7 @@ import { mount } from 'enzyme' import { create, act, ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer' import { LegendSvg, LegendSvgItem } from '@nivo/legends' import { Bar, BarDatum, BarItemProps, ComputedDatum, BarItem, BarTooltip, BarTotals } from '../' +import { useComputeLabelLayout } from '../src/compute/common' type IdValue = { id: string @@ -771,6 +772,84 @@ describe('totals layer', () => { }) }) +describe('labelPosition', () => { + it.each` + labelPosition | layout | expected + ${'start'} | ${'vertical'} | ${200} + ${'middle'} | ${'vertical'} | ${100} + ${'end'} | ${'vertical'} | ${0} + ${'start'} | ${'horizontal'} | ${0} + ${'middle'} | ${'horizontal'} | ${100} + ${'end'} | ${'horizontal'} | ${200} + `( + 'should position labels correctly on $layout charts when labelPosition=$labelPosition', + ({ labelPosition, layout, expected }) => { + const instance = create( + + ).root + + for (const bar of instance.findAllByType(BarItem)) { + const { labelX, labelY } = bar.props.style + if (layout === 'vertical') { + expect(labelY.animation.to).toBe(expected) + } else { + expect(labelX.animation.to).toBe(expected) + } + } + } + ) +}) + +describe('useComputeLabelLayout', () => { + it.each` + labelPosition | layout | offset | reverse | expectedValue | expectedTextAnchor + ${'start'} | ${'vertical'} | ${0} | ${false} | ${200} | ${'middle'} + ${'middle'} | ${'vertical'} | ${0} | ${false} | ${100} | ${'middle'} + ${'end'} | ${'vertical'} | ${0} | ${false} | ${0} | ${'middle'} + ${'start'} | ${'horizontal'} | ${0} | ${false} | ${0} | ${'start'} + ${'middle'} | ${'horizontal'} | ${0} | ${false} | ${100} | ${'middle'} + ${'end'} | ${'horizontal'} | ${0} | ${false} | ${200} | ${'start'} + ${'middle'} | ${'vertical'} | ${-10} | ${false} | ${110} | ${'middle'} + ${'middle'} | ${'vertical'} | ${10} | ${false} | ${90} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${-10} | ${false} | ${90} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${10} | ${false} | ${110} | ${'middle'} + ${'start'} | ${'vertical'} | ${0} | ${true} | ${0} | ${'middle'} + ${'middle'} | ${'vertical'} | ${0} | ${true} | ${100} | ${'middle'} + ${'end'} | ${'vertical'} | ${0} | ${true} | ${200} | ${'middle'} + ${'start'} | ${'horizontal'} | ${0} | ${true} | ${200} | ${'end'} + ${'middle'} | ${'horizontal'} | ${0} | ${true} | ${100} | ${'middle'} + ${'end'} | ${'horizontal'} | ${0} | ${true} | ${0} | ${'end'} + ${'middle'} | ${'vertical'} | ${-10} | ${true} | ${90} | ${'middle'} + ${'middle'} | ${'vertical'} | ${10} | ${true} | ${110} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${-10} | ${true} | ${110} | ${'middle'} + ${'middle'} | ${'horizontal'} | ${10} | ${true} | ${90} | ${'middle'} + `( + 'should compute the correct label layout for (layout: $layout, labelPosition: $labelPosition, offset: $offset, reverse: $reverse)', + ({ labelPosition, layout, offset, reverse, expectedValue, expectedTextAnchor }) => { + const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, offset) + const { labelX, labelY, textAnchor } = computeLabelLayout(200, 200) + if (layout === 'vertical') { + expect(labelY).toBe(expectedValue) + } else { + expect(labelX).toBe(expectedValue) + } + expect(textAnchor).toBe(expectedTextAnchor) + } + ) +}) + describe('tooltip', () => { it('should render a tooltip when hovering a slice', () => { let component: ReactTestRenderer diff --git a/storybook/stories/bar/Bar.stories.tsx b/storybook/stories/bar/Bar.stories.tsx index 2c9ddb5f6f..fae22968b2 100644 --- a/storybook/stories/bar/Bar.stories.tsx +++ b/storybook/stories/bar/Bar.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { generateCountriesData, sets } from '@nivo/generators' import { random, range } from 'lodash' import { useTheme } from '@nivo/core' -import { Bar, BarDatum, BarItemProps } from '@nivo/bar' +import { Bar, BarCanvas, BarDatum, BarItemProps } from '@nivo/bar' import { AxisTickProps } from '@nivo/axes' const meta: Meta = { @@ -298,6 +298,19 @@ export const WithTotals: Story = { render: () => , } +export const WithTopLabels: Story = { + render: () => ( + + ), +} + const DataGenerator = (initialIndex, initialState) => { let index = initialIndex let state = initialState diff --git a/website/src/data/components/bar/meta.yml b/website/src/data/components/bar/meta.yml index 989f3706c5..d13f37f38d 100644 --- a/website/src/data/components/bar/meta.yml +++ b/website/src/data/components/bar/meta.yml @@ -36,6 +36,8 @@ Bar: link: bar--with-annotations - label: Using totals link: bar--with-totals + - label: Using top labels + link: bar--with-top-labels description: | Bar chart which can display multiple data series, stacked or side by side. Also supports both vertical and horizontal layout, with negative values descending diff --git a/website/src/data/components/bar/props.ts b/website/src/data/components/bar/props.ts index d0dd72c743..c4582c2c65 100644 --- a/website/src/data/components/bar/props.ts +++ b/website/src/data/components/bar/props.ts @@ -416,6 +416,39 @@ const props: ChartProperty[] = [ control: { type: 'inheritedColor' }, group: 'Labels', }, + { + key: 'labelPosition', + help: 'Defines the position of the label relative to its bar.', + type: `'start' | 'middle' | 'end'`, + flavors: allFlavors, + required: false, + defaultValue: svgDefaultProps.labelPosition, + control: { + type: 'radio', + choices: [ + { label: 'start', value: 'start' }, + { label: 'middle', value: 'middle' }, + { label: 'end', value: 'end' }, + ], + columns: 3, + }, + group: 'Labels', + }, + { + key: 'labelOffset', + help: 'Defines the vertical or horizontal (depends on layout) offset of the label.', + type: 'number', + flavors: ['svg', 'canvas', 'api'], + required: false, + defaultValue: svgDefaultProps.labelOffset, + control: { + type: 'range', + unit: 'px', + min: -16, + max: 16, + }, + group: 'Labels', + }, { key: 'enableTotals', help: 'Enable/disable totals labels.', diff --git a/website/src/pages/bar/api.tsx b/website/src/pages/bar/api.tsx index 198fe61915..742cabab31 100644 --- a/website/src/pages/bar/api.tsx +++ b/website/src/pages/bar/api.tsx @@ -117,6 +117,8 @@ const BarApi = () => { from: 'color', modifiers: [['darker', 1.6]], }, + labelPosition: 'middle', + labelOffset: 0, }} /> diff --git a/website/src/pages/bar/canvas.js b/website/src/pages/bar/canvas.js index f053bd1d53..55ac6e11f3 100644 --- a/website/src/pages/bar/canvas.js +++ b/website/src/pages/bar/canvas.js @@ -97,6 +97,8 @@ const initialProperties = { from: 'color', modifiers: [['darker', 1.6]], }, + labelPosition: 'middle', + labelOffset: 0, isInteractive: true, 'custom tooltip example': false, diff --git a/website/src/pages/bar/index.js b/website/src/pages/bar/index.js index e7c91f2d13..c440ea6afa 100644 --- a/website/src/pages/bar/index.js +++ b/website/src/pages/bar/index.js @@ -115,6 +115,8 @@ const initialProperties = { from: 'color', modifiers: [['darker', 1.6]], }, + labelPosition: 'middle', + labelOffset: 0, legends: [ {