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 bar totals #2525

Merged
merged 28 commits into from Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2c7d970
feat: add bar totals layer
joaopedromatias Mar 2, 2024
b3a81c7
fix: insert unique key prop to each total
joaopedromatias Mar 2, 2024
485c661
test: insert tests to totals bar layer
joaopedromatias Mar 2, 2024
418f871
docs: insert recipe on website bar page
joaopedromatias Mar 2, 2024
fe284c1
fix: update scale functions types
joaopedromatias Mar 4, 2024
d632462
refactor: enable totals by prop instead of directly on layers
joaopedromatias Mar 5, 2024
a40f8cf
style: apply theme configuration on totals
joaopedromatias Mar 5, 2024
0b97fcb
feat: make totals offsets configurable
joaopedromatias Mar 5, 2024
15beb01
refactor: centralize compute of totals bar
joaopedromatias Mar 5, 2024
7bea4a1
chore: re-format website docs yml
joaopedromatias Mar 5, 2024
eeefd62
refactor: use props along with layers to enable totals
joaopedromatias Mar 5, 2024
4d12cdc
fix: remove unnused variable
joaopedromatias Mar 5, 2024
e3edd63
style: add transitions to bar totals
joaopedromatias Mar 6, 2024
16bd568
test: update tests to find totals component
joaopedromatias Mar 6, 2024
84e4369
docs: add enable totals docs on website
joaopedromatias Mar 6, 2024
fcf3194
refactor: use totals computed value through hook on canvas
joaopedromatias Mar 6, 2024
b582221
fix: remove unnused var
joaopedromatias Mar 6, 2024
515eb69
fix: add enableTotals prop to default bar props on website
joaopedromatias Mar 6, 2024
ad8806b
docs(website): add default enableTotals prop to canvas and svg flavors
joaopedromatias Mar 6, 2024
b3cd571
style: align total label text based on layout mode
joaopedromatias Mar 6, 2024
49e30e0
feat: add value format to totals labels
joaopedromatias Mar 7, 2024
81b1756
refactor: configure totals transition inside its component
joaopedromatias Mar 8, 2024
66eb0f1
refactor: format value in totals compute function
joaopedromatias Mar 8, 2024
3050350
style: prevent overlap on zero totals offset
joaopedromatias Mar 8, 2024
f3c0876
types: remove optional syntax
joaopedromatias Mar 8, 2024
f621950
feat: animation offset is calculated individually by index
joaopedromatias Mar 8, 2024
ab67405
chore: change order of initializing default layers
joaopedromatias Mar 13, 2024
999ba05
refactor: add numeric value to bar totals data
joaopedromatias Mar 19, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/bar/src/Bar.tsx
Expand Up @@ -17,11 +17,13 @@ import {
bindDefs,
useDimensions,
useMotionConfig,
useTheme,
} from '@nivo/core'
import { Fragment, ReactNode, createElement, useMemo } from 'react'
import { svgDefaultProps } from './props'
import { useTransition } from '@react-spring/web'
import { useBar } from './hooks'
import { computeBarTotals } from './compute/totals'

type InnerBarProps<RawDatum extends BarDatum> = Omit<
BarSvgProps<RawDatum>,
Expand Down Expand Up @@ -102,7 +104,11 @@ const InnerBar = <RawDatum extends BarDatum>({
barAriaDescribedBy,

initialHiddenIds,

enableTotals = svgDefaultProps.enableTotals,
totalsOffset = svgDefaultProps.totalsOffset,
}: InnerBarProps<RawDatum>) => {
const theme = useTheme()
const { animate, config: springConfig } = useMotionConfig()
const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions(
width,
Expand Down Expand Up @@ -401,6 +407,16 @@ const InnerBar = <RawDatum extends BarDatum>({
]
)

const barTotals = computeBarTotals(
enableTotals,
bars,
xScale,
yScale,
layout,
groupMode,
totalsOffset
)

return (
<SvgWrapper
width={outerWidth}
Expand All @@ -420,6 +436,21 @@ const InnerBar = <RawDatum extends BarDatum>({

return layerById?.[layer] ?? null
})}
{barTotals.map(barTotal => (
plouc marked this conversation as resolved.
Show resolved Hide resolved
<text
key={barTotal.key}
x={barTotal.x}
y={barTotal.y}
fill={theme.text.fill}
fontSize={theme.text.fontSize}
fontWeight="bold"
textAnchor="middle"
alignmentBaseline="middle"
data-test="bar-total"
>
{barTotal.value}
</text>
))}
</SvgWrapper>
)
}
Expand Down
26 changes: 26 additions & 0 deletions packages/bar/src/BarCanvas.tsx
Expand Up @@ -32,6 +32,7 @@ import { renderAxesToCanvas, renderGridLinesToCanvas } from '@nivo/axes'
import { renderLegendToCanvas } from '@nivo/legends'
import { useTooltip } from '@nivo/tooltip'
import { useBar } from './hooks'
import { computeBarTotals } from './compute/totals'

type InnerBarCanvasProps<RawDatum extends BarDatum> = Omit<
BarCanvasProps<RawDatum>,
Expand Down Expand Up @@ -166,6 +167,9 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
pixelRatio = canvasDefaultProps.pixelRatio,

canvasRef,

enableTotals = canvasDefaultProps.enableTotals,
totalsOffset = canvasDefaultProps.totalsOffset,
}: InnerBarCanvasProps<RawDatum>) => {
const canvasEl = useRef<HTMLCanvasElement | null>(null)

Expand Down Expand Up @@ -367,6 +371,25 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
}
})

const barTotals = computeBarTotals(
enableTotals,
bars,
xScale,
yScale,
layout,
groupMode,
totalsOffset
)

ctx.fillStyle = theme.text.fill
ctx.font = `bold ${theme.text.fontSize}px sans-serif`
plouc marked this conversation as resolved.
Show resolved Hide resolved
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'

barTotals.forEach(barTotal => {
ctx.fillText(String(barTotal.value), barTotal.x, barTotal.y)
})

ctx.save()
}, [
axisBottom,
Expand Down Expand Up @@ -404,6 +427,9 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
shouldRenderBarLabel,
theme,
width,
bars,
enableTotals,
totalsOffset,
])

const handleMouseHover = useCallback(
Expand Down
142 changes: 142 additions & 0 deletions packages/bar/src/compute/totals.ts
@@ -0,0 +1,142 @@
import { AnyScale, ScaleBand } from '@nivo/scales'
import { defaultProps } from '../props'
import { BarCommonProps, BarDatum, ComputedBarDatum } from '../types'

interface BarTotalsData {
key: string
x: number
y: number
value: number
}

export const computeBarTotals = <RawDatum extends BarDatum>(
enableTotals: boolean,
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
) => {
const totals = [] as BarTotalsData[]

if (!enableTotals || 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

if (layout === 'vertical') {
xPosition = xScale(indexValue)
yPosition = yScale(totalsPositive)
} else {
xPosition = xScale(totalsPositive)
yPosition = yScale(indexValue)
}

xPosition += layout === 'vertical' ? barWidth / 2 : totalsOffset
yPosition += layout === 'vertical' ? -totalsOffset : barHeight / 2

totals.push({
key: 'total_' + indexValue,
x: xPosition,
y: yPosition,
value: indexTotal,
})
})
} 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

let xPosition: number
let yPosition: number

if (layout === 'vertical') {
xPosition = xScale(indexValue)
yPosition = yScale(greatestValue)
} else {
xPosition = xScale(greatestValue)
yPosition = yScale(indexValue)
}

const indexBarsWidth = numberOfBarsByIndex.get(indexValue) * barWidth
const indexBarsHeight = numberOfBarsByIndex.get(indexValue) * barHeight

xPosition += layout === 'vertical' ? indexBarsWidth / 2 : totalsOffset
yPosition += layout === 'vertical' ? -totalsOffset : indexBarsHeight / 2

totals.push({
key: 'total_' + indexValue,
x: xPosition,
y: yPosition,
value: indexTotal,
})
})
}
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)
}
9 changes: 6 additions & 3 deletions 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'

Expand Down Expand Up @@ -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', 'markers', 'legends', 'annotations'] as BarLayerId[],
barComponent: BarItem,

defs: [],
Expand All @@ -66,7 +69,7 @@ export const svgDefaultProps = {

export const canvasDefaultProps = {
...defaultProps,
layers: ['grid', 'axes', 'bars', 'legends', 'annotations'],
layers: ['grid', 'axes', 'bars', 'legends', 'annotations'] as BarCanvasLayerId[],

pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio ?? 1 : 1,
}
8 changes: 5 additions & 3 deletions packages/bar/src/types.ts
Expand Up @@ -96,6 +96,7 @@ 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 BarCanvasLayerId = Exclude<BarLayerId, 'markers'>

interface BarCustomLayerBaseProps<RawDatum>
extends Pick<
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -259,6 +258,9 @@ export type BarCommonProps<RawDatum> = {
renderWrapper?: boolean

initialHiddenIds: readonly (string | number)[]

enableTotals?: boolean
plouc marked this conversation as resolved.
Show resolved Hide resolved
totalsOffset?: number
}

export type BarSvgProps<RawDatum extends BarDatum> = Partial<BarCommonProps<RawDatum>> &
Expand Down