Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Bar): Add option to chose label position #2585

Merged
merged 10 commits into from
Jul 2, 2024
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
Loading