diff --git a/packages/bar/src/Bar.tsx b/packages/bar/src/Bar.tsx index 11ec1d2a1..d0dd26ec1 100644 --- a/packages/bar/src/Bar.tsx +++ b/packages/bar/src/Bar.tsx @@ -1,14 +1,4 @@ import { Axes, Grid } from '@nivo/axes' -import { BarAnnotations } from './BarAnnotations' -import { - BarCustomLayerProps, - BarDatum, - BarLayer, - BarLayerId, - BarSvgProps, - ComputedBarDatumWithValue, -} from './types' -import { BarLegends } from './BarLegends' import { CartesianMarkers, Container, @@ -18,10 +8,21 @@ import { useDimensions, useMotionConfig, } from '@nivo/core' -import { Fragment, ReactNode, createElement, useMemo } from 'react' -import { svgDefaultProps } from './props' import { useTransition } from '@react-spring/web' +import { Fragment, ReactNode, createElement, useMemo } from 'react' +import { BarAnnotations } from './BarAnnotations' +import { BarLegends } from './BarLegends' import { useBar } from './hooks' +import { svgDefaultProps } from './props' +import { + BarCustomLayerProps, + BarDatum, + BarLayer, + BarLayerId, + BarSvgProps, + ComputedBarDatumWithValue, +} from './types' +import { BarTotals } from './BarTotals' type InnerBarProps<RawDatum extends BarDatum> = Omit< BarSvgProps<RawDatum>, @@ -102,6 +103,9 @@ const InnerBar = <RawDatum extends BarDatum>({ barAriaDescribedBy, initialHiddenIds, + + enableTotals = svgDefaultProps.enableTotals, + totalsOffset = svgDefaultProps.totalsOffset, }: InnerBarProps<RawDatum>) => { const { animate, config: springConfig } = useMotionConfig() const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions( @@ -122,6 +126,7 @@ const InnerBar = <RawDatum extends BarDatum>({ shouldRenderBarLabel, toggleSerie, legendsWithData, + barTotals, } = useBar<RawDatum>({ indexBy, label, @@ -151,6 +156,7 @@ const InnerBar = <RawDatum extends BarDatum>({ legends, legendLabel, initialHiddenIds, + totalsOffset, }) const transition = useTransition< @@ -283,6 +289,7 @@ const InnerBar = <RawDatum extends BarDatum>({ grid: null, legends: null, markers: null, + totals: null, } if (layers.includes('annotations')) { @@ -362,6 +369,17 @@ const InnerBar = <RawDatum extends BarDatum>({ ) } + if (layers.includes('totals') && enableTotals) { + layerById.totals = ( + <BarTotals + data={barTotals} + springConfig={springConfig} + animate={animate} + layout={layout} + /> + ) + } + const layerContext: BarCustomLayerProps<RawDatum> = useMemo( () => ({ ...commonProps, diff --git a/packages/bar/src/BarCanvas.tsx b/packages/bar/src/BarCanvas.tsx index 7f69196a6..fefd2a04f 100644 --- a/packages/bar/src/BarCanvas.tsx +++ b/packages/bar/src/BarCanvas.tsx @@ -2,16 +2,19 @@ import { BarCanvasCustomLayerProps, BarCanvasLayer, BarCanvasProps, + BarCommonProps, BarDatum, ComputedBarDatum, } from './types' import { + CompleteTheme, Container, Margin, getRelativeCursor, isCursorInRect, useDimensions, useTheme, + useValueFormatter, } from '@nivo/core' import { ForwardedRef, @@ -32,6 +35,7 @@ import { renderAxesToCanvas, renderGridLinesToCanvas } from '@nivo/axes' import { renderLegendToCanvas } from '@nivo/legends' import { useTooltip } from '@nivo/tooltip' import { useBar } from './hooks' +import { BarTotalsData } from './compute/totals' type InnerBarCanvasProps<RawDatum extends BarDatum> = Omit< BarCanvasProps<RawDatum>, @@ -52,6 +56,22 @@ const findBarUnderCursor = <RawDatum,>( const isNumber = (value: unknown): value is number => typeof value === 'number' +function renderTotalsToCanvas<RawDatum extends BarDatum>( + ctx: CanvasRenderingContext2D, + barTotals: BarTotalsData[], + theme: CompleteTheme, + layout: BarCommonProps<RawDatum>['layout'] = canvasDefaultProps.layout +) { + ctx.fillStyle = theme.text.fill + ctx.font = `bold ${theme.labels.text.fontSize}px ${theme.labels.text.fontFamily}` + ctx.textBaseline = layout === 'vertical' ? 'alphabetic' : 'middle' + ctx.textAlign = layout === 'vertical' ? 'center' : 'start' + + barTotals.forEach(barTotal => { + ctx.fillText(barTotal.formattedValue, barTotal.x, barTotal.y) + }) +} + const InnerBarCanvas = <RawDatum extends BarDatum>({ data, indexBy, @@ -166,6 +186,9 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({ pixelRatio = canvasDefaultProps.pixelRatio, canvasRef, + + enableTotals = canvasDefaultProps.enableTotals, + totalsOffset = canvasDefaultProps.totalsOffset, }: InnerBarCanvasProps<RawDatum>) => { const canvasEl = useRef<HTMLCanvasElement | null>(null) @@ -187,6 +210,7 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({ getLabelColor, shouldRenderBarLabel, legendsWithData, + barTotals, } = useBar<RawDatum>({ indexBy, label, @@ -215,6 +239,7 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({ labelSkipHeight, legends, legendLabel, + totalsOffset, }) const { showTooltipFromEvent, hideTooltip } = useTooltip() @@ -285,6 +310,8 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({ ] ) + const formatValue = useValueFormatter(valueFormat) + useEffect(() => { const ctx = canvasEl.current?.getContext('2d') @@ -362,6 +389,8 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({ }) } else if (layer === 'annotations') { renderAnnotationsToCanvas(ctx, { annotations: boundAnnotations, theme }) + } else if (layer === 'totals' && enableTotals) { + renderTotalsToCanvas(ctx, barTotals, theme, layout) } else if (typeof layer === 'function') { layer(ctx, layerContext) } @@ -404,6 +433,9 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({ shouldRenderBarLabel, theme, width, + barTotals, + enableTotals, + formatValue, ]) const handleMouseHover = useCallback( diff --git a/packages/bar/src/BarTotals.tsx b/packages/bar/src/BarTotals.tsx new file mode 100644 index 000000000..dc6c0ea5c --- /dev/null +++ b/packages/bar/src/BarTotals.tsx @@ -0,0 +1,75 @@ +import { useTheme } from '@nivo/core' +import { AnimationConfig, animated, useTransition } from '@react-spring/web' +import { BarCommonProps, BarDatum } from './types' +import { svgDefaultProps } from './props' +import { BarTotalsData } from './compute/totals' + +interface Props<RawDatum extends BarDatum> { + data: BarTotalsData[] + springConfig: Partial<AnimationConfig> + animate: boolean + layout?: BarCommonProps<RawDatum>['layout'] +} + +export const BarTotals = <RawDatum extends BarDatum>({ + data, + springConfig, + animate, + layout = svgDefaultProps.layout, +}: Props<RawDatum>) => { + const theme = useTheme() + const totalsTransition = useTransition< + BarTotalsData, + { + x: number + y: number + labelOpacity: number + } + >(data, { + keys: barTotal => barTotal.key, + from: barTotal => ({ + x: layout === 'vertical' ? barTotal.x : barTotal.animationOffset, + y: layout === 'vertical' ? barTotal.animationOffset : barTotal.y, + labelOpacity: 0, + }), + enter: barTotal => ({ + x: barTotal.x, + y: barTotal.y, + labelOpacity: 1, + }), + update: barTotal => ({ + x: barTotal.x, + y: barTotal.y, + labelOpacity: 1, + }), + leave: barTotal => ({ + x: layout === 'vertical' ? barTotal.x : barTotal.animationOffset, + y: layout === 'vertical' ? barTotal.animationOffset : barTotal.y, + labelOpacity: 0, + }), + config: springConfig, + immediate: !animate, + initial: animate ? undefined : null, + }) + + return totalsTransition((style, barTotal) => ( + <animated.text + key={barTotal.key} + x={style.x} + y={style.y} + fillOpacity={style.labelOpacity} + style={{ + ...theme.labels.text, + pointerEvents: 'none', + fill: theme.text.fill, + }} + fontWeight="bold" + fontSize={theme.labels.text.fontSize} + fontFamily={theme.labels.text.fontFamily} + textAnchor={layout === 'vertical' ? 'middle' : 'start'} + alignmentBaseline={layout === 'vertical' ? 'alphabetic' : 'middle'} + > + {barTotal.formattedValue} + </animated.text> + )) +} diff --git a/packages/bar/src/compute/totals.ts b/packages/bar/src/compute/totals.ts new file mode 100644 index 000000000..f9b29ceaa --- /dev/null +++ b/packages/bar/src/compute/totals.ts @@ -0,0 +1,155 @@ +import { AnyScale, ScaleBand } from '@nivo/scales' +import { defaultProps } from '../props' +import { BarCommonProps, BarDatum, ComputedBarDatum } from '../types' + +export interface BarTotalsData { + key: string + x: number + y: number + value: number + formattedValue: string + animationOffset: number +} + +export const computeBarTotals = <RawDatum extends BarDatum>( + bars: ComputedBarDatum<RawDatum>[], + xScale: ScaleBand<string> | AnyScale, + yScale: ScaleBand<string> | AnyScale, + layout: BarCommonProps<RawDatum>['layout'] = defaultProps.layout, + groupMode: BarCommonProps<RawDatum>['groupMode'] = defaultProps.groupMode, + totalsOffset: number, + formatValue: (value: number) => string +) => { + const totals = [] as BarTotalsData[] + + if (bars.length === 0) return totals + + const totalsByIndex = new Map<string | number, number>() + + const barWidth = bars[0].width + const barHeight = bars[0].height + + if (groupMode === 'stacked') { + const totalsPositivesByIndex = new Map<string | number, number>() + + bars.forEach(bar => { + const { indexValue, value } = bar.data + updateTotalsByIndex(totalsByIndex, indexValue, Number(value)) + updateTotalsPositivesByIndex(totalsPositivesByIndex, indexValue, Number(value)) + }) + + totalsPositivesByIndex.forEach((totalsPositive, indexValue) => { + const indexTotal = totalsByIndex.get(indexValue) || 0 + + let xPosition: number + let yPosition: number + let animationOffset: number + + if (layout === 'vertical') { + xPosition = xScale(indexValue) + yPosition = yScale(totalsPositive) + animationOffset = yScale(totalsPositive / 2) + } else { + xPosition = xScale(totalsPositive) + yPosition = yScale(indexValue) + animationOffset = xScale(totalsPositive / 2) + } + + xPosition += layout === 'vertical' ? barWidth / 2 : totalsOffset + yPosition += layout === 'vertical' ? -totalsOffset : barHeight / 2 + + totals.push({ + key: 'total_' + indexValue, + x: xPosition, + y: yPosition, + value: indexTotal, + formattedValue: formatValue(indexTotal), + animationOffset, + }) + }) + } else if (groupMode === 'grouped') { + const greatestValueByIndex = new Map<string | number, number>() + const numberOfBarsByIndex = new Map() + + bars.forEach(bar => { + const { indexValue, value } = bar.data + updateTotalsByIndex(totalsByIndex, indexValue, Number(value)) + updateGreatestValueByIndex(greatestValueByIndex, indexValue, Number(value)) + updateNumberOfBarsByIndex(numberOfBarsByIndex, indexValue) + }) + + greatestValueByIndex.forEach((greatestValue, indexValue) => { + const indexTotal = totalsByIndex.get(indexValue) || 0 + const numberOfBars = numberOfBarsByIndex.get(indexValue) + + let xPosition: number + let yPosition: number + let animationOffset: number + + if (layout === 'vertical') { + xPosition = xScale(indexValue) + yPosition = yScale(greatestValue) + animationOffset = yScale(greatestValue / 2) + } else { + xPosition = xScale(greatestValue) + yPosition = yScale(indexValue) + animationOffset = xScale(greatestValue / 2) + } + + const indexBarsWidth = numberOfBars * barWidth + const indexBarsHeight = numberOfBars * barHeight + + xPosition += layout === 'vertical' ? indexBarsWidth / 2 : totalsOffset + yPosition += layout === 'vertical' ? -totalsOffset : indexBarsHeight / 2 + + totals.push({ + key: 'total_' + indexValue, + x: xPosition, + y: yPosition, + value: indexTotal, + formattedValue: formatValue(indexTotal), + animationOffset, + }) + }) + } + return totals +} + +// this function is used to compute the total value for the indexes. The total value is later rendered on the chart +export const updateTotalsByIndex = ( + totalsByIndex: Map<string | number, number>, + indexValue: string | number, + value: number +) => { + const currentIndexValue = totalsByIndex.get(indexValue) || 0 + totalsByIndex.set(indexValue, currentIndexValue + value) +} + +// this function is used to compute only the positive values of the indexes. Useful to position the text right above the last stacked bar. It prevents overlapping in case of negative values +export const updateTotalsPositivesByIndex = ( + totalsPositivesByIndex: Map<string | number, number>, + indexValue: string | number, + value: number +) => { + const currentIndexValue = totalsPositivesByIndex.get(indexValue) || 0 + totalsPositivesByIndex.set(indexValue, currentIndexValue + (value > 0 ? value : 0)) +} + +// this function is used to keep track of the highest value for the indexes. Useful to position the text above the longest grouped bar +export const updateGreatestValueByIndex = ( + greatestValueByIndex: Map<string | number, number>, + indexValue: string | number, + value: number +) => { + const currentGreatestValue = greatestValueByIndex.get(indexValue) || 0 + greatestValueByIndex.set(indexValue, Math.max(currentGreatestValue, Number(value))) +} + +// this function is used to save the number of bars for the indexes. Useful to position the text in the middle of the grouped bars +export const updateNumberOfBarsByIndex = ( + numberOfBarsByIndex: Map<string | number, number>, + indexValue: string | number +) => { + const currentNumberOfBars = numberOfBarsByIndex.get(indexValue) || 0 + numberOfBarsByIndex.set(indexValue, currentNumberOfBars + 1) +} diff --git a/packages/bar/src/hooks.ts b/packages/bar/src/hooks.ts index 880b5983b..635b711c4 100644 --- a/packages/bar/src/hooks.ts +++ b/packages/bar/src/hooks.ts @@ -11,6 +11,7 @@ import { } from './types' import { defaultProps } from './props' import { generateGroupedBars, generateStackedBars, getLegendData } from './compute' +import { computeBarTotals } from './compute/totals' export const useBar = <RawDatum extends BarDatum>({ indexBy = defaultProps.indexBy, @@ -41,6 +42,7 @@ export const useBar = <RawDatum extends BarDatum>({ labelSkipHeight = defaultProps.labelSkipHeight, legends = defaultProps.legends, legendLabel, + totalsOffset = defaultProps.totalsOffset, }: { indexBy?: BarCommonProps<RawDatum>['indexBy'] label?: BarCommonProps<RawDatum>['label'] @@ -70,6 +72,7 @@ export const useBar = <RawDatum extends BarDatum>({ labelSkipHeight?: BarCommonProps<RawDatum>['labelSkipHeight'] legends?: BarCommonProps<RawDatum>['legends'] legendLabel?: BarCommonProps<RawDatum>['legendLabel'] + totalsOffset?: BarCommonProps<RawDatum>['totalsOffset'] }) => { const [hiddenIds, setHiddenIds] = useState(initialHiddenIds ?? []) const toggleSerie = useCallback((id: string | number) => { @@ -167,6 +170,11 @@ export const useBar = <RawDatum extends BarDatum>({ [legends, legendData, bars, groupMode, layout, legendLabel, reverse] ) + const barTotals = useMemo( + () => computeBarTotals(bars, xScale, yScale, layout, groupMode, totalsOffset, formatValue), + [bars, xScale, yScale, layout, groupMode, totalsOffset, formatValue] + ) + return { bars, barsWithValue, @@ -183,5 +191,6 @@ export const useBar = <RawDatum extends BarDatum>({ hiddenIds, toggleSerie, legendsWithData, + barTotals, } } diff --git a/packages/bar/src/index.ts b/packages/bar/src/index.ts index 179dd8da6..45855e52b 100644 --- a/packages/bar/src/index.ts +++ b/packages/bar/src/index.ts @@ -1,7 +1,8 @@ export * from './Bar' +export * from './BarCanvas' export * from './BarItem' export * from './BarTooltip' -export * from './BarCanvas' +export * from './BarTotals' export * from './ResponsiveBar' export * from './ResponsiveBarCanvas' export * from './props' diff --git a/packages/bar/src/props.ts b/packages/bar/src/props.ts index e069933c7..98c53fcf7 100644 --- a/packages/bar/src/props.ts +++ b/packages/bar/src/props.ts @@ -1,6 +1,6 @@ import { BarItem } from './BarItem' import { BarTooltip } from './BarTooltip' -import { ComputedDatum } from './types' +import { BarCanvasLayerId, BarLayerId, ComputedDatum } from './types' import { InheritedColorConfig, OrdinalColorScaleConfig } from '@nivo/colors' import { ScaleBandSpec, ScaleSpec } from '@nivo/scales' @@ -47,11 +47,14 @@ export const defaultProps = { initialHiddenIds: [], annotations: [], markers: [], + + enableTotals: false, + totalsOffset: 10, } export const svgDefaultProps = { ...defaultProps, - layers: ['grid', 'axes', 'bars', 'markers', 'legends', 'annotations'], + layers: ['grid', 'axes', 'bars', 'totals', 'markers', 'legends', 'annotations'] as BarLayerId[], barComponent: BarItem, defs: [], @@ -66,7 +69,7 @@ export const svgDefaultProps = { export const canvasDefaultProps = { ...defaultProps, - layers: ['grid', 'axes', 'bars', 'legends', 'annotations'], + layers: ['grid', 'axes', 'bars', 'totals', 'legends', 'annotations'] as BarCanvasLayerId[], pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio ?? 1 : 1, } diff --git a/packages/bar/src/types.ts b/packages/bar/src/types.ts index 24e8ea52e..ec545851e 100644 --- a/packages/bar/src/types.ts +++ b/packages/bar/src/types.ts @@ -95,7 +95,8 @@ export interface BarLegendProps extends LegendProps { export type LabelFormatter = (label: string | number) => string | number export type ValueFormatter = (value: number) => string | number -export type BarLayerId = 'grid' | 'axes' | 'bars' | 'markers' | 'legends' | 'annotations' +export type BarLayerId = 'grid' | 'axes' | 'bars' | 'markers' | 'legends' | 'annotations' | 'totals' +export type BarCanvasLayerId = Exclude<BarLayerId, 'markers'> interface BarCustomLayerBaseProps<RawDatum> extends Pick< @@ -138,9 +139,7 @@ export type BarCanvasCustomLayer<RawDatum> = ( ) => void export type BarCustomLayer<RawDatum> = React.FC<BarCustomLayerProps<RawDatum>> -export type BarCanvasLayer<RawDatum> = - | Exclude<BarLayerId, 'markers'> - | BarCanvasCustomLayer<RawDatum> +export type BarCanvasLayer<RawDatum> = BarCanvasLayerId | BarCanvasCustomLayer<RawDatum> export type BarLayer<RawDatum> = BarLayerId | BarCustomLayer<RawDatum> export interface BarItemProps<RawDatum extends BarDatum> @@ -259,6 +258,9 @@ export type BarCommonProps<RawDatum> = { renderWrapper?: boolean initialHiddenIds: readonly (string | number)[] + + enableTotals: boolean + totalsOffset: number } export type BarSvgProps<RawDatum extends BarDatum> = Partial<BarCommonProps<RawDatum>> & diff --git a/packages/bar/tests/Bar.test.tsx b/packages/bar/tests/Bar.test.tsx index 37cbc1ae5..1fe68574c 100644 --- a/packages/bar/tests/Bar.test.tsx +++ b/packages/bar/tests/Bar.test.tsx @@ -1,7 +1,7 @@ import { mount } from 'enzyme' -import { create, act, ReactTestRenderer } from 'react-test-renderer' +import { create, act, ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer' import { LegendSvg, LegendSvgItem } from '@nivo/legends' -import { Bar, BarDatum, BarItemProps, ComputedDatum, BarItem, BarTooltip } from '../' +import { Bar, BarDatum, BarItemProps, ComputedDatum, BarItem, BarTooltip, BarTotals } from '../' type IdValue = { id: string @@ -614,6 +614,163 @@ it('should render bars in grouped mode after updating starting values from 0', ( }) }) +describe('totals layer', () => { + it('should have the total text for each index with vertical layout', () => { + const instance = create( + <Bar + width={500} + height={300} + enableTotals={true} + keys={['costA', 'costB']} + data={[ + { id: 'one', costA: 1, costB: 1 }, + { id: 'two', costA: 2, costB: 1 }, + { id: 'three', costA: 3, costB: 1 }, + ]} + animate={false} + /> + ).root + + const totals = instance.findByType(BarTotals).findAllByType('text') + + totals.forEach((total, index) => { + const value = total.findByType('text').children[0] + if (index === 0) { + expect(value).toBe(`2`) + } else if (index === 1) { + expect(value).toBe(`3`) + } else if (index === 2) { + expect(value).toBe(`4`) + } + }) + }) + it('should have the total text for each index with horizontal layout', () => { + const instance = create( + <Bar + width={500} + height={300} + enableTotals={true} + keys={['value1', 'value2']} + layout="horizontal" + data={[ + { id: 'one', value1: 1, value2: 1 }, + { id: 'two', value1: 2, value2: 2 }, + { id: 'three', value1: 3, value2: 3 }, + ]} + animate={false} + valueFormat=" >-$" + /> + ).root + + const totals = instance.findByType(BarTotals).findAllByType('text') + + totals.forEach((total, index) => { + const value = total.findByType('text').children[0] + if (index === 0) { + expect(value).toBe(`$2`) + } else if (index === 1) { + expect(value).toBe(`$4`) + } else if (index === 2) { + expect(value).toBe(`$6`) + } + }) + }) + it('should have the total text for each index with grouped group mode and vertical layout', () => { + const instance = create( + <Bar + width={500} + height={300} + enableTotals={true} + keys={['value1', 'value2']} + groupMode="grouped" + data={[ + { id: 'one', value1: -1, value2: -1 }, + { id: 'two', value1: -2, value2: -2 }, + ]} + animate={false} + /> + ).root + + const totals = instance.findByType(BarTotals).findAllByType('text') + + totals.forEach((total, index) => { + const value = total.findByType('text').children[0] + if (index === 0) { + expect(value).toBe(`-2`) + } else { + expect(value).toBe(`-4`) + } + }) + }) + it('should have the total text for each index with grouped group mode and horizontal layout', () => { + const instance = create( + <Bar + width={500} + height={300} + enableTotals={true} + keys={['value1', 'value2']} + groupMode="grouped" + layout="horizontal" + data={[ + { id: 'one', value1: -10, value2: 10 }, + { id: 'two', value1: -2, value2: 3 }, + { id: 'three', value1: 1, value2: 2 }, + ]} + animate={false} + /> + ).root + + const totals = instance.findByType(BarTotals).findAllByType('text') + + totals.forEach((total, index) => { + const value = total.findByType('text').children[0] + if (index === 0) { + expect(value).toBe(`0`) + } else if (index === 1) { + expect(value).toBe(`1`) + } else if (index === 2) { + expect(value).toBe(`3`) + } + }) + }) + it('should follow the theme configurations', () => { + const instance = create( + <Bar + width={500} + height={300} + enableTotals={true} + theme={{ + labels: { + text: { + fontSize: 14, + fontFamily: 'serif', + }, + }, + text: { + fill: 'red', + }, + }} + keys={['value1', 'value2']} + data={[ + { id: 'one', value1: 1, value2: 1 }, + { id: 'two', value1: 2, value2: 1 }, + { id: 'three', value1: 3, value2: 1 }, + ]} + animate={false} + /> + ).root + + const totals = instance.findByType(BarTotals).findAllByType('text') + + totals.forEach((total, index) => { + const props = total.findByType('text').props + expect(props.style.fill).toBe('red') + expect(props.fontSize).toBe(14) + expect(props.fontFamily).toBe('serif') + }) + }) +}) + 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 aa2a3b832..2c9ddb5f6 100644 --- a/storybook/stories/bar/Bar.stories.tsx +++ b/storybook/stories/bar/Bar.stories.tsx @@ -294,6 +294,10 @@ export const WithSymlogScale: Story = { ), } +export const WithTotals: Story = { + render: () => <Bar {...commonProps} enableTotals={true} totalsOffset={10} />, +} + 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 6af1929ba..989f3706c 100644 --- a/website/src/data/components/bar/meta.yml +++ b/website/src/data/components/bar/meta.yml @@ -34,21 +34,19 @@ Bar: link: bar--custom-legend-labels - label: Using annotations link: bar--with-annotations + - label: Using totals + link: bar--with-totals 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 below the x axis (or y axis if using horizontal layout). - The bar item component can be customized to render any valid SVG element, it will receive current bar style, data and event handlers, the storybook offers an [example](storybook:bar--custom-bar-item). - The responsive alternative of this component is `ResponsiveBar`. - This component is available in the `@nivo/api`, see [sample](api:/samples/bar.svg) or [try it using the API client](self:/bar/api). - See the [dedicated guide](self:/guides/legends) on how to setup legends for this component. However it requires an extra property for each legend configuration you pass to @@ -56,7 +54,6 @@ Bar: legend's data and accept `indexes` or `keys`. `indexes` is suitable for simple bar chart with a single data serie while `keys` may be used if you have several ones (groups). - BarCanvas: package: '@nivo/bar' tags: @@ -72,5 +69,4 @@ BarCanvas: A variation around the [Bar](self:/bar) component. Well suited for large data sets as it does not impact DOM tree depth, however you'll lose the isomorphic ability and transitions. - - The responsive alternative of this component is `ResponsiveBarCanvas`. + The responsive alternative of this component is `ResponsiveBarCanvas`. \ No newline at end of file diff --git a/website/src/data/components/bar/props.ts b/website/src/data/components/bar/props.ts index ba5111e69..2e5cd406b 100644 --- a/website/src/data/components/bar/props.ts +++ b/website/src/data/components/bar/props.ts @@ -416,6 +416,31 @@ const props: ChartProperty[] = [ control: { type: 'inheritedColor' }, group: 'Labels', }, + { + key: 'enableTotals', + help: 'Enable/disable totals labels.', + type: 'boolean', + flavors: ['svg', 'canvas', 'api'], + required: false, + defaultValue: svgDefaultProps.enableTotals, + group: 'Labels', + control: { type: 'switch' }, + }, + { + key: 'totalsOffset', + help: 'Offset from the bar edge for the total label.', + type: 'number', + flavors: ['svg', 'canvas', 'api'], + required: false, + defaultValue: svgDefaultProps.totalsOffset, + group: 'Labels', + control: { + type: 'range', + unit: 'px', + min: 0, + max: 40, + }, + }, ...chartGrid({ flavors: allFlavors, xDefault: svgDefaultProps.enableGridX, diff --git a/website/src/pages/bar/api.tsx b/website/src/pages/bar/api.tsx index 5c30e2571..198fe6191 100644 --- a/website/src/pages/bar/api.tsx +++ b/website/src/pages/bar/api.tsx @@ -109,6 +109,8 @@ const BarApi = () => { enableGridY: true, enableLabel: true, + enableTotals: false, + totalsOffset: 10, labelSkipWidth: 12, labelSkipHeight: 12, labelTextColor: { diff --git a/website/src/pages/bar/canvas.js b/website/src/pages/bar/canvas.js index 398521c0d..f053bd1d5 100644 --- a/website/src/pages/bar/canvas.js +++ b/website/src/pages/bar/canvas.js @@ -89,6 +89,8 @@ const initialProperties = { enableGridY: false, enableLabel: true, + enableTotals: false, + totalsOffset: 10, labelSkipWidth: 12, labelSkipHeight: 12, labelTextColor: { diff --git a/website/src/pages/bar/index.js b/website/src/pages/bar/index.js index 74254db8e..e7c91f2d1 100644 --- a/website/src/pages/bar/index.js +++ b/website/src/pages/bar/index.js @@ -107,6 +107,8 @@ const initialProperties = { enableGridY: true, enableLabel: true, + enableTotals: false, + totalsOffset: 10, labelSkipWidth: 12, labelSkipHeight: 12, labelTextColor: {