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 5 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/bar/src/Bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Fragment, ReactNode, createElement, useMemo } from 'react'
import { svgDefaultProps } from './props'
import { useTransition } from '@react-spring/web'
import { useBar } from './hooks'
import { BarTotals } from './BarTotals'

type InnerBarProps<RawDatum extends BarDatum> = Omit<
BarSvgProps<RawDatum>,
Expand Down Expand Up @@ -283,6 +284,7 @@ const InnerBar = <RawDatum extends BarDatum>({
grid: null,
legends: null,
markers: null,
totals: null,
}

if (layers.includes('annotations')) {
Expand Down Expand Up @@ -362,6 +364,19 @@ const InnerBar = <RawDatum extends BarDatum>({
)
}

if (layers.includes('totals')) {
plouc marked this conversation as resolved.
Show resolved Hide resolved
layerById.totals = (
<BarTotals
key={'totals'}
plouc marked this conversation as resolved.
Show resolved Hide resolved
layout={layout}
groupMode={groupMode}
bars={bars}
xScale={xScale}
yScale={yScale}
/>
)
}

const layerContext: BarCustomLayerProps<RawDatum> = useMemo(
() => ({
...commonProps,
Expand Down
100 changes: 99 additions & 1 deletion packages/bar/src/BarCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BarCanvasCustomLayerProps,
BarCanvasLayer,
BarCanvasProps,
BarCommonProps,
BarDatum,
ComputedBarDatum,
} from './types'
Expand All @@ -22,7 +23,7 @@ import {
useMemo,
useRef,
} from 'react'
import { canvasDefaultProps } from './props'
import { canvasDefaultProps, defaultProps } from './props'
import {
renderAnnotationsToCanvas,
useAnnotations,
Expand All @@ -32,6 +33,13 @@ import { renderAxesToCanvas, renderGridLinesToCanvas } from '@nivo/axes'
import { renderLegendToCanvas } from '@nivo/legends'
import { useTooltip } from '@nivo/tooltip'
import { useBar } from './hooks'
import {
updateGreatestValueByIndex,
updateNumberOfBarsByIndex,
updateTotalsByIndex,
updateTotalsPositivesByIndex,
} from './BarTotals'
plouc marked this conversation as resolved.
Show resolved Hide resolved
import { AnyScale, ScaleBand } from '@nivo/scales'

type InnerBarCanvasProps<RawDatum extends BarDatum> = Omit<
BarCanvasProps<RawDatum>,
Expand All @@ -52,6 +60,93 @@ const findBarUnderCursor = <RawDatum,>(

const isNumber = (value: unknown): value is number => typeof value === 'number'

function renderTotalsToCanvas<RawDatum extends BarDatum>(
ctx: CanvasRenderingContext2D,
bars: ComputedBarDatum<RawDatum>[],
xScale: ScaleBand<string> | AnyScale,
yScale: ScaleBand<string> | AnyScale,
layout: BarCommonProps<RawDatum>['layout'] = defaultProps.layout,
groupMode: BarCommonProps<RawDatum>['groupMode'] = defaultProps.groupMode
) {
if (bars.length === 0) return

const totalsByIndex = new Map<string | number, number>()

const barWidth = bars[0].width
const barHeight = bars[0].height
const yOffsetVertically = -10
plouc marked this conversation as resolved.
Show resolved Hide resolved
const xOffsetHorizontally = 20
const fontSize = 12
plouc marked this conversation as resolved.
Show resolved Hide resolved

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

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) => {
let xPosition: number
let yPosition: number

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

ctx.fillText(
String(totalsByIndex.get(indexValue)),
xPosition + (layout === 'vertical' ? barWidth / 2 : xOffsetHorizontally),
yPosition + (layout === 'vertical' ? yOffsetVertically : barHeight / 2)
)
})
}

if (groupMode === 'grouped') {
plouc marked this conversation as resolved.
Show resolved Hide resolved
const greatestValueByIndex = new Map<string | number, number>()
plouc marked this conversation as resolved.
Show resolved Hide resolved
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) => {
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

ctx.fillText(
String(totalsByIndex.get(indexValue)),
xPosition + (layout === 'vertical' ? indexBarsWidth / 2 : xOffsetHorizontally),
yPosition + (layout === 'vertical' ? yOffsetVertically : indexBarsHeight / 2)
)
})
}
}

const InnerBarCanvas = <RawDatum extends BarDatum>({
data,
indexBy,
Expand Down Expand Up @@ -362,6 +457,8 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
})
} else if (layer === 'annotations') {
renderAnnotationsToCanvas(ctx, { annotations: boundAnnotations, theme })
} else if (layer === 'totals') {
renderTotalsToCanvas(ctx, bars, xScale, yScale, layout, groupMode)
} else if (typeof layer === 'function') {
layer(ctx, layerContext)
}
Expand Down Expand Up @@ -404,6 +501,7 @@ const InnerBarCanvas = <RawDatum extends BarDatum>({
shouldRenderBarLabel,
theme,
width,
bars,
])

const handleMouseHover = useCallback(
Expand Down
154 changes: 154 additions & 0 deletions packages/bar/src/BarTotals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { AnyScale, ScaleBand } from '@nivo/scales'
import { defaultProps } from './props'
import { BarCommonProps, BarDatum, ComputedBarDatum } from './types'

interface BarTotalsProps<RawDatum extends BarDatum> {
bars: ComputedBarDatum<RawDatum>[]
xScale: ScaleBand<string> | AnyScale
yScale: ScaleBand<string> | AnyScale
layout?: BarCommonProps<RawDatum>['layout']
groupMode?: BarCommonProps<RawDatum>['groupMode']
}

export const BarTotals = <RawDatum extends BarDatum>({
bars,
xScale,
yScale,
layout = defaultProps.layout,
groupMode = defaultProps.groupMode,
}: BarTotalsProps<RawDatum>) => {
plouc marked this conversation as resolved.
Show resolved Hide resolved
if (bars.length === 0) return <></>
const totals: JSX.Element[] = []

const totalsByIndex = new Map<string | number, number>()

const barWidth = bars[0].width
const barHeight = bars[0].height
const yOffsetVertically = -10
plouc marked this conversation as resolved.
Show resolved Hide resolved
const xOffsetHorizontally = 20
const fontSize = 12

const commonProps = {
fill: '#222222',
fontWeight: 'bold',
fontSize,
textAnchor: 'middle',
alignmentBaseline: 'middle',
} as const

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) => {
let xPosition: number
let yPosition: number

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

totals.push(
<text
key={'total_' + indexValue}
x={xPosition + (layout === 'vertical' ? barWidth / 2 : xOffsetHorizontally)}
y={yPosition + (layout === 'vertical' ? yOffsetVertically : barHeight / 2)}
{...commonProps}
>
{totalsByIndex.get(indexValue)}
</text>
)
})
}

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) => {
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

totals.push(
<text
key={'total_' + indexValue}
x={
xPosition +
(layout === 'vertical' ? indexBarsWidth / 2 : xOffsetHorizontally)
}
y={
yPosition +
(layout === 'vertical' ? yOffsetVertically : indexBarsHeight / 2)
}
{...commonProps}
>
{totalsByIndex.get(indexValue)}
</text>
)
})
}

return <>{totals}</>
}

export const updateTotalsByIndex = (
plouc marked this conversation as resolved.
Show resolved Hide resolved
totalsByIndex: Map<string | number, number>,
indexValue: string | number,
value: number
) => {
const currentIndexValue = totalsByIndex.get(indexValue) || 0
totalsByIndex.set(indexValue, currentIndexValue + value)
}

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

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

export const updateNumberOfBarsByIndex = (
numberOfBarsByIndex: Map<string | number, number>,
indexValue: string | number
) => {
const currentNumberOfBars = numberOfBarsByIndex.get(indexValue) || 0
numberOfBarsByIndex.set(indexValue, currentNumberOfBars + 1)
}
1 change: 1 addition & 0 deletions packages/bar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export * from './BarTooltip'
export * from './BarCanvas'
export * from './ResponsiveBar'
export * from './ResponsiveBarCanvas'
export * from './BarTotals'
export * from './props'
export * from './types'
6 changes: 3 additions & 3 deletions packages/bar/src/props.ts
Original file line number Diff line number Diff line change
@@ -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 @@ -51,7 +51,7 @@ export const defaultProps = {

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 +66,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,
}
7 changes: 3 additions & 4 deletions packages/bar/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
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
Loading
Loading