Skip to content

Commit

Permalink
feat(Bar): Add option to chose label position (#2585)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
evasseure authored Jul 2, 2024
1 parent 839ff6d commit f2e440e
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 16 deletions.
20 changes: 12 additions & 8 deletions packages/bar/src/Bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RawDatum extends BarDatum> = Omit<
BarSvgProps<RawDatum>,
Expand Down Expand Up @@ -67,6 +69,8 @@ const InnerBar = <RawDatum extends BarDatum>({
labelSkipWidth = svgDefaultProps.labelSkipWidth,
labelSkipHeight = svgDefaultProps.labelSkipHeight,
labelTextColor,
labelPosition = svgDefaultProps.labelPosition,
labelOffset = svgDefaultProps.labelOffset,

markers = svgDefaultProps.markers,

Expand Down Expand Up @@ -161,6 +165,8 @@ const InnerBar = <RawDatum extends BarDatum>({
totalsOffset,
})

const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, labelOffset)

const transition = useTransition<
ComputedBarDatumWithValue<RawDatum>,
{
Expand All @@ -174,6 +180,7 @@ const InnerBar = <RawDatum extends BarDatum>({
opacity: number
transform: string
width: number
textAnchor: BarItemProps<RawDatum>['style']['textAnchor']
}
>(barsWithValue, {
keys: bar => bar.key,
Expand All @@ -183,8 +190,7 @@ const InnerBar = <RawDatum extends BarDatum>({
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'
Expand All @@ -201,8 +207,7 @@ const InnerBar = <RawDatum extends BarDatum>({
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,
}),
Expand All @@ -212,8 +217,7 @@ const InnerBar = <RawDatum extends BarDatum>({
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,
}),
Expand All @@ -223,15 +227,15 @@ const InnerBar = <RawDatum extends BarDatum>({
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,
Expand Down
14 changes: 12 additions & 2 deletions packages/bar/src/BarCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RawDatum extends BarDatum> = Omit<
BarCanvasProps<RawDatum>,
Expand Down Expand Up @@ -102,6 +103,9 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
gridXValues,
gridYValues,

labelPosition = canvasDefaultProps.labelPosition,
labelOffset = canvasDefaultProps.labelOffset,

layers = canvasDefaultProps.layers as BarCanvasLayer<RawDatum>[],
renderBar = (
ctx,
Expand All @@ -114,6 +118,9 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
label,
labelColor,
shouldRenderLabel,
labelX,
labelY,
textAnchor,
}
) => {
ctx.fillStyle = color
Expand Down Expand Up @@ -150,9 +157,9 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({

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)
}
},

Expand Down Expand Up @@ -311,6 +318,7 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
)

const formatValue = useValueFormatter(valueFormat)
const computeLabelLayout = useComputeLabelLayout(layout, reverse, labelPosition, labelOffset)

useEffect(() => {
const ctx = canvasEl.current?.getContext('2d')
Expand Down Expand Up @@ -375,6 +383,7 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
label: getLabel(bar.data),
labelColor: getLabelColor(bar) as string,
shouldRenderLabel: shouldRenderBarLabel(bar),
...computeLabelLayout(bar.width, bar.height),
})
})
} else if (layer === 'legends') {
Expand Down Expand Up @@ -436,6 +445,7 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
barTotals,
enableTotals,
formatValue,
computeLabelLayout,
])

const handleMouseHover = useCallback(
Expand Down
3 changes: 2 additions & 1 deletion packages/bar/src/BarItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const BarItem = <RawDatum extends BarDatum>({
labelY,
transform,
width,
textAnchor,
},

borderRadius,
Expand Down Expand Up @@ -108,7 +109,7 @@ export const BarItem = <RawDatum extends BarDatum>({
<animated.text
x={labelX}
y={labelY}
textAnchor="middle"
textAnchor={textAnchor}
dominantBaseline="central"
fillOpacity={labelOpacity}
style={{
Expand Down
49 changes: 49 additions & 0 deletions packages/bar/src/compute/common.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ScaleBandSpec, ScaleBand, computeScale } from '@nivo/scales'
import { defaultProps } from '../props'
import { BarCommonProps, BarDatum } from '../types'

/**
* Generates indexed scale.
Expand Down Expand Up @@ -45,3 +47,50 @@ export const filterNullValues = <RawDatum extends Record<string, unknown>>(data:
}, {}) as Exclude<RawDatum, null | undefined | false | '' | 0>

export const coerceValue = <T>(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<RawDatum extends BarDatum>(
layout: BarCommonProps<RawDatum>['layout'] = defaultProps.layout,
reverse: BarCommonProps<RawDatum>['reverse'] = defaultProps.reverse,
labelPosition: BarCommonProps<RawDatum>['labelPosition'] = defaultProps.labelPosition,
labelOffset: BarCommonProps<RawDatum>['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',
}
}
}
}
2 changes: 2 additions & 0 deletions packages/bar/src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
13 changes: 9 additions & 4 deletions packages/bar/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -165,6 +166,7 @@ export interface BarItemProps<RawDatum extends BarDatum>
opacity: number
transform: string
width: number
textAnchor: 'start' | 'middle'
}>

label: string
Expand All @@ -189,10 +191,11 @@ export type RenderBarProps<RawDatum extends BarDatum> = Omit<
| 'ariaDescribedBy'
| 'ariaHidden'
| 'ariaDisabled'
> & {
borderColor: string
labelColor: string
}
> &
BarLabelLayout & {
borderColor: string
labelColor: string
}

export interface BarTooltipProps<RawDatum> extends ComputedDatum<RawDatum> {
color: string
Expand Down Expand Up @@ -234,6 +237,8 @@ export type BarCommonProps<RawDatum> = {

enableLabel: boolean
label: PropertyAccessor<ComputedDatum<RawDatum>, string>
labelPosition: 'start' | 'middle' | 'end'
labelOffset: number
labelFormat: string | LabelFormatter
labelSkipWidth: number
labelSkipHeight: number
Expand Down
79 changes: 79 additions & 0 deletions packages/bar/tests/Bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
<Bar
width={200}
height={200}
keys={['costA', 'costB']}
data={[
{ id: 'one', costA: 1, costB: 1 },
{ id: 'two', costA: 1, costB: 1 },
]}
animate={false}
groupMode="grouped"
labelPosition={labelPosition}
layout={layout}
/>
).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
Expand Down
15 changes: 14 additions & 1 deletion storybook/stories/bar/Bar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Bar> = {
Expand Down Expand Up @@ -298,6 +298,19 @@ export const WithTotals: Story = {
render: () => <Bar {...commonProps} enableTotals={true} totalsOffset={10} />,
}

export const WithTopLabels: Story = {
render: () => (
<Bar
{...commonProps}
data={generateCountriesData(keys, { size: 2 }) as BarDatum[]}
labelPosition="end"
layout="vertical"
labelOffset={-10}
groupMode="grouped"
/>
),
}

const DataGenerator = (initialIndex, initialState) => {
let index = initialIndex
let state = initialState
Expand Down
Loading

0 comments on commit f2e440e

Please sign in to comment.