Skip to content

Commit bb81efb

Browse files
committedSep 7, 2021
feat(radar): add support for custom slice tooltip
1 parent 2e69633 commit bb81efb

File tree

11 files changed

+245
-55
lines changed

11 files changed

+245
-55
lines changed
 

‎packages/core/index.d.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -385,9 +385,9 @@ declare module '@nivo/core' {
385385
export type ValueFormat<Value, Context = void> =
386386
| string // d3 formatter
387387
// explicit formatting function
388-
| Context extends void
389-
? (value: Value) => string
390-
: (value: Value, context: Context) => string
388+
| (Context extends void
389+
? (value: Value) => string
390+
: (value: Value, context: Context) => string)
391391
export function getValueFormatter<Value, Context = void>(
392392
format?: ValueFormat<Value, Context>
393393
): Context extends void ? (value: Value) => string : (value: Value, context: Context) => string

‎packages/radar/src/Radar.tsx

+21-14
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Container, useDimensions, SvgWrapper } from '@nivo/core'
33
import { BoxLegendSvg } from '@nivo/legends'
44
import { RadarShapes } from './RadarShapes'
55
import { RadarGrid } from './RadarGrid'
6-
import { RadarTooltip } from './RadarTooltip'
6+
import { RadarSlices } from './RadarSlices'
77
import { RadarDots } from './RadarDots'
88
import { svgDefaultProps } from './props'
99
import { RadarLayerId, RadarSvgProps } from './types'
@@ -44,6 +44,7 @@ const InnerRadar = <D extends Record<string, unknown>>({
4444
fillOpacity = svgDefaultProps.fillOpacity,
4545
blendMode = svgDefaultProps.blendMode,
4646
isInteractive = svgDefaultProps.isInteractive,
47+
sliceTooltip = svgDefaultProps.sliceTooltip,
4748
legends = svgDefaultProps.legends,
4849
role,
4950
ariaLabel,
@@ -83,6 +84,7 @@ const InnerRadar = <D extends Record<string, unknown>>({
8384
const layerById: Record<RadarLayerId, ReactNode> = {
8485
grid: null,
8586
shapes: null,
87+
slices: null,
8688
dots: null,
8789
legends: null,
8890
}
@@ -125,6 +127,23 @@ const InnerRadar = <D extends Record<string, unknown>>({
125127
)
126128
}
127129

130+
if (layers.includes('slices') && isInteractive) {
131+
layerById.slices = (
132+
<g key="slices" transform={`translate(${centerX}, ${centerY})`}>
133+
<RadarSlices<D>
134+
data={data}
135+
keys={keys}
136+
getIndex={getIndex}
137+
formatValue={formatValue}
138+
colorByKey={colorByKey}
139+
radius={radius}
140+
angleStep={angleStep}
141+
tooltip={sliceTooltip}
142+
/>
143+
</g>
144+
)
145+
}
146+
128147
if (layers.includes('dots') && enableDots) {
129148
layerById.dots = (
130149
<g key="dots" transform={`translate(${centerX}, ${centerY})`}>
@@ -177,19 +196,7 @@ const InnerRadar = <D extends Record<string, unknown>>({
177196
>
178197
{layerById.grid}
179198
{layerById.shapes}
180-
{isInteractive && (
181-
<g transform={`translate(${centerX}, ${centerY})`}>
182-
<RadarTooltip<D>
183-
data={data}
184-
keys={keys}
185-
getIndex={getIndex}
186-
formatValue={formatValue}
187-
colorByKey={colorByKey}
188-
radius={radius}
189-
angleStep={angleStep}
190-
/>
191-
</g>
192-
)}
199+
{layerById.slices}
193200
{layerById.dots}
194201
{layerById.legends}
195202
</SvgWrapper>

‎packages/radar/src/RadarGrid.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const RadarGrid = ({
3434
}, [indices, levels, radius, angleStep])
3535

3636
return (
37-
<g>
37+
<>
3838
{angles.map((angle, i) => {
3939
const position = positionFromAngle(angle, radius)
4040
return (
@@ -64,6 +64,6 @@ export const RadarGrid = ({
6464
labelOffset={labelOffset}
6565
label={label}
6666
/>
67-
</g>
67+
</>
6868
)
6969
}

‎packages/radar/src/RadarTooltipItem.tsx ‎packages/radar/src/RadarSlice.tsx

+28-25
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { useMemo, useState, useCallback, ReactNode } from 'react'
1+
import { useMemo, useState, useCallback, createElement } from 'react'
22
import { Arc } from 'd3-shape'
33
import { positionFromAngle, useTheme } from '@nivo/core'
4-
import { TableTooltip, Chip, useTooltip } from '@nivo/tooltip'
5-
import { RadarDataProps } from './types'
4+
import { useTooltip } from '@nivo/tooltip'
5+
import { RadarCommonProps, RadarDataProps, RadarSliceTooltipDatum } from './types'
66

7-
interface RadarTooltipItemProps<D extends Record<string, unknown>> {
7+
interface RadarSliceProps<D extends Record<string, unknown>> {
88
datum: D
99
keys: RadarDataProps<D>['keys']
1010
index: string | number
@@ -14,11 +14,10 @@ interface RadarTooltipItemProps<D extends Record<string, unknown>> {
1414
endAngle: number
1515
radius: number
1616
arcGenerator: Arc<void, { startAngle: number; endAngle: number }>
17+
tooltip: RadarCommonProps['sliceTooltip']
1718
}
1819

19-
type TooltipRow = [ReactNode, string, number | string]
20-
21-
export const RadarTooltipItem = <D extends Record<string, unknown>>({
20+
export const RadarSlice = <D extends Record<string, unknown>>({
2221
datum,
2322
keys,
2423
index,
@@ -28,35 +27,39 @@ export const RadarTooltipItem = <D extends Record<string, unknown>>({
2827
startAngle,
2928
endAngle,
3029
arcGenerator,
31-
}: RadarTooltipItemProps<D>) => {
30+
tooltip,
31+
}: RadarSliceProps<D>) => {
3232
const [isHover, setIsHover] = useState(false)
3333
const theme = useTheme()
3434
const { showTooltipFromEvent, hideTooltip } = useTooltip()
3535

36-
const tooltip = useMemo(() => {
37-
// first use number values to be able to sort
38-
const rows: TooltipRow[] = keys.map(key => [
39-
<Chip key={key} color={colorByKey[key]} />,
40-
key,
41-
datum[key] as number,
42-
])
43-
rows.sort((a, b) => (a[2] as number) - (b[2] as number))
44-
rows.reverse()
36+
const tooltipData = useMemo(() => {
37+
const data: RadarSliceTooltipDatum[] = keys.map(key => ({
38+
color: colorByKey[key],
39+
id: key,
40+
value: datum[key] as number,
41+
formattedValue: formatValue(datum[key] as number, key),
42+
}))
43+
data.sort((a, b) => a.value - b.value)
44+
data.reverse()
4545

46-
// then replace with formatted values
47-
rows.forEach(row => {
48-
row[2] = formatValue(row[2] as number, row[1])
49-
})
46+
return data
47+
}, [datum, keys, formatValue, colorByKey])
5048

51-
return <TableTooltip title={<strong>{index}</strong>} rows={rows} />
52-
}, [datum, keys, index, formatValue, colorByKey])
5349
const showItemTooltip = useCallback(
5450
event => {
5551
setIsHover(true)
56-
showTooltipFromEvent(tooltip, event)
52+
showTooltipFromEvent(
53+
createElement(tooltip, {
54+
index,
55+
data: tooltipData,
56+
}),
57+
event
58+
)
5759
},
58-
[showTooltipFromEvent, tooltip]
60+
[showTooltipFromEvent, tooltip, index, tooltipData]
5961
)
62+
6063
const hideItemTooltip = useCallback(() => {
6164
setIsHover(false)
6265
hideTooltip()
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useMemo } from 'react'
2+
import { TableTooltip, Chip } from '@nivo/tooltip'
3+
import { RadarSliceTooltipProps } from './types'
4+
5+
export const RadarSliceTooltip = ({ index, data }: RadarSliceTooltipProps) => {
6+
const rows = useMemo(
7+
() =>
8+
data.map(datum => [
9+
<Chip key={datum.id} color={datum.color} />,
10+
datum.id,
11+
datum.formattedValue,
12+
]),
13+
[data]
14+
)
15+
16+
return <TableTooltip title={<strong>{index}</strong>} rows={rows} />
17+
}
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,35 @@
11
import { arc as d3Arc } from 'd3-shape'
2-
import { RadarTooltipItem } from './RadarTooltipItem'
3-
import { RadarColorMapping, RadarDataProps } from './types'
2+
import { RadarSlice } from './RadarSlice'
3+
import { RadarColorMapping, RadarCommonProps, RadarDataProps } from './types'
44

5-
interface RadarTooltipProps<D extends Record<string, unknown>> {
5+
interface RadarSlicesProps<D extends Record<string, unknown>> {
66
data: RadarDataProps<D>['data']
77
keys: RadarDataProps<D>['keys']
88
getIndex: (d: D) => string | number
99
formatValue: (value: number, context: string) => string
1010
colorByKey: RadarColorMapping
1111
radius: number
1212
angleStep: number
13+
tooltip: RadarCommonProps['sliceTooltip']
1314
}
1415

15-
export const RadarTooltip = <D extends Record<string, unknown>>({
16+
export const RadarSlices = <D extends Record<string, unknown>>({
1617
data,
1718
keys,
1819
getIndex,
1920
formatValue,
2021
colorByKey,
2122
radius,
2223
angleStep,
23-
}: RadarTooltipProps<D>) => {
24+
tooltip,
25+
}: RadarSlicesProps<D>) => {
2426
const arc = d3Arc<{ startAngle: number; endAngle: number }>().outerRadius(radius).innerRadius(0)
2527

2628
const halfAngleStep = angleStep * 0.5
2729
let rootStartAngle = -halfAngleStep
2830

2931
return (
30-
<g>
32+
<>
3133
{data.map(d => {
3234
const index = getIndex(d)
3335
const startAngle = rootStartAngle
@@ -36,7 +38,7 @@ export const RadarTooltip = <D extends Record<string, unknown>>({
3638
rootStartAngle += angleStep
3739

3840
return (
39-
<RadarTooltipItem
41+
<RadarSlice
4042
key={index}
4143
datum={d}
4244
keys={keys}
@@ -47,9 +49,10 @@ export const RadarTooltip = <D extends Record<string, unknown>>({
4749
endAngle={endAngle}
4850
radius={radius}
4951
arcGenerator={arc}
52+
tooltip={tooltip}
5053
/>
5154
)
5255
})}
53-
</g>
56+
</>
5457
)
5558
}

‎packages/radar/src/props.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { RadarGridLabel } from './RadarGridLabel'
2+
import { RadarSliceTooltip } from './RadarSliceTooltip'
23
import { RadarLayerId } from './types'
34

45
export const svgDefaultProps = {
5-
layers: ['grid', 'shapes', 'dots', 'legends'] as RadarLayerId[],
6+
layers: ['grid', 'shapes', 'slices', 'dots', 'legends'] as RadarLayerId[],
67

78
maxValue: 'auto' as const,
89

@@ -30,6 +31,7 @@ export const svgDefaultProps = {
3031
blendMode: 'normal' as const,
3132

3233
isInteractive: true,
34+
sliceTooltip: RadarSliceTooltip,
3335

3436
legends: [],
3537
role: 'img',

‎packages/radar/src/types.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,26 @@ export interface PointProps {
5252
}
5353
}
5454

55-
export type RadarLayerId = 'grid' | 'shapes' | 'dots' | 'legends'
55+
export interface RadarSliceTooltipDatum {
56+
color: string
57+
id: string
58+
value: number
59+
formattedValue: string
60+
}
61+
62+
export interface RadarSliceTooltipProps {
63+
index: string | number
64+
data: RadarSliceTooltipDatum[]
65+
}
66+
export type RadarSliceTooltipComponent = FunctionComponent<RadarSliceTooltipProps>
67+
68+
export type RadarLayerId = 'grid' | 'shapes' | 'slices' | 'dots' | 'legends'
5669

5770
export type RadarColorMapping = Record<string, string>
5871

5972
export interface RadarCommonProps {
6073
maxValue: number | 'auto'
74+
// second argument passed to the formatter is the key
6175
valueFormat: ValueFormat<number, string>
6276

6377
layers: RadarLayerId[]
@@ -90,7 +104,7 @@ export interface RadarCommonProps {
90104
borderColor: InheritedColorConfig<{ key: string; color: string }>
91105

92106
isInteractive: boolean
93-
tooltipFormat: ValueFormat<number>
107+
sliceTooltip: RadarSliceTooltipComponent
94108

95109
renderWrapper: boolean
96110

‎packages/radar/tests/.eslintrc.yml

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
env:
2+
jest: true
3+
4+
rules:
5+
react/prop-types: 0

‎packages/radar/tests/Radar.test.tsx

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { mount } from 'enzyme'
2+
// @ts-ignore
3+
import { Radar, RadarSvgProps } from '../src'
4+
import { RadarSliceTooltipProps } from '../dist/types'
5+
6+
type TestDatum = {
7+
A: number
8+
B: number
9+
category: string
10+
}
11+
12+
const baseProps: RadarSvgProps<TestDatum> = {
13+
width: 500,
14+
height: 300,
15+
data: [
16+
{ A: 10, B: 20, category: 'first' },
17+
{ A: 20, B: 30, category: 'second' },
18+
{ A: 30, B: 10, category: 'third' },
19+
],
20+
keys: ['A', 'B'],
21+
indexBy: 'category',
22+
animate: false,
23+
}
24+
25+
it('should render a basic radar chart', () => {
26+
const wrapper = mount(<Radar<TestDatum> {...baseProps} />)
27+
28+
const shapes = wrapper.find('RadarShapes')
29+
expect(shapes).toHaveLength(2)
30+
31+
const shape0 = shapes.at(0)
32+
expect(shape0.prop('item')).toBe('A')
33+
const shape0path = shape0.find('path')
34+
expect(shape0path.prop('fill')).toBe('rgba(232, 193, 160, 1)')
35+
36+
const shape1 = shapes.at(1)
37+
expect(shape1.prop('item')).toBe('B')
38+
const shape1path = shape1.find('path')
39+
expect(shape1path.prop('fill')).toBe('rgba(244, 117, 96, 1)')
40+
})
41+
42+
describe('tooltip', () => {
43+
it('should show a tooltip with index and corresponding values', () => {
44+
const wrapper = mount(<Radar<TestDatum> {...baseProps} />)
45+
46+
const slices = wrapper.find('RadarSlice')
47+
expect(slices).toHaveLength(3)
48+
49+
const slice0 = slices.at(0)
50+
expect(slice0.prop('index')).toBe('first')
51+
52+
slice0.find('path').simulate('mouseenter')
53+
let tooltip = wrapper.find('RadarSliceTooltip')
54+
expect(tooltip.text()).toBe(['first', 'B', 20, 'A', 10].join(''))
55+
56+
const slice1 = slices.at(1)
57+
expect(slice1.prop('index')).toBe('second')
58+
59+
slice1.find('path').simulate('mouseenter')
60+
tooltip = wrapper.find('RadarSliceTooltip')
61+
expect(tooltip.text()).toBe(['second', 'B', 30, 'A', 20].join(''))
62+
63+
const slice2 = slices.at(2)
64+
expect(slice2.prop('index')).toBe('third')
65+
66+
slice2.find('path').simulate('mouseenter')
67+
tooltip = wrapper.find('RadarSliceTooltip')
68+
expect(tooltip.text()).toBe(['third', 'A', 30, 'B', 10].join(''))
69+
})
70+
71+
it('should support a custom slice tooltip', () => {
72+
const CustomSliceTooltip = ({ index, data }: RadarSliceTooltipProps) => (
73+
<div>
74+
{index}: {data.map(d => `${d.id} -> ${d.value} (${d.color})`).join(', ')}
75+
</div>
76+
)
77+
78+
const wrapper = mount(<Radar<TestDatum> {...baseProps} sliceTooltip={CustomSliceTooltip} />)
79+
80+
wrapper.find('RadarSlice').at(1).find('path').simulate('mouseenter')
81+
82+
const tooltip = wrapper.find('CustomSliceTooltip')
83+
expect(tooltip.exists()).toBe(true)
84+
expect(tooltip.text()).toBe('second: B -> 30 (#f47560), A -> 20 (#e8c1a0)')
85+
})
86+
})
87+
88+
describe('style', () => {
89+
it('custom colors array', () => {
90+
const colors = ['rgba(255, 0, 0, 1)', 'rgba(0, 0, 255, 1)']
91+
const wrapper = mount(<Radar {...baseProps} colors={colors} />)
92+
93+
expect(wrapper.find('RadarShapes').at(0).find('path').prop('fill')).toBe(colors[0])
94+
expect(wrapper.find('RadarShapes').at(1).find('path').prop('fill')).toBe(colors[1])
95+
})
96+
97+
it('custom colors function', () => {
98+
const colorMapping = {
99+
A: 'rgba(255, 0, 0, 1)',
100+
B: 'rgba(0, 0, 255, 1)',
101+
}
102+
const wrapper = mount(
103+
<Radar<TestDatum>
104+
{...baseProps}
105+
colors={d => colorMapping[d.key as keyof typeof colorMapping]}
106+
/>
107+
)
108+
109+
expect(wrapper.find('RadarShapes').at(0).find('path').prop('fill')).toBe(colorMapping.A)
110+
expect(wrapper.find('RadarShapes').at(1).find('path').prop('fill')).toBe(colorMapping.B)
111+
})
112+
})
113+
114+
describe('accessibility', () => {
115+
it('should forward root aria properties to the SVG element', () => {
116+
const wrapper = mount(
117+
<Radar<TestDatum>
118+
{...baseProps}
119+
ariaLabel="AriaLabel"
120+
ariaLabelledBy="AriaLabelledBy"
121+
ariaDescribedBy="AriaDescribedBy"
122+
/>
123+
)
124+
125+
const svg = wrapper.find('svg')
126+
127+
expect(svg.prop('aria-label')).toBe('AriaLabel')
128+
expect(svg.prop('aria-labelledby')).toBe('AriaLabelledBy')
129+
expect(svg.prop('aria-describedby')).toBe('AriaDescribedBy')
130+
})
131+
})

‎website/src/data/components/radar/props.ts

+8
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,14 @@ const props: ChartProperty[] = [
415415
defaultValue: svgDefaultProps.isInteractive,
416416
controlType: 'switch',
417417
},
418+
{
419+
key: 'sliceTooltip',
420+
group: 'Interactivity',
421+
type: 'FunctionComponent<RadarSliceTooltipProps>',
422+
required: false,
423+
help: 'Override default slice tooltip.',
424+
flavors: ['svg'],
425+
},
418426
...motionProperties(['svg'], svgDefaultProps, 'react-spring'),
419427
]
420428

0 commit comments

Comments
 (0)
Please sign in to comment.