Skip to content

Commit c1b84c9

Browse files
committedAug 28, 2021
feat(scatterplot): migrate from react-motion to react-spring
1 parent 34e6d37 commit c1b84c9

File tree

11 files changed

+225
-312
lines changed

11 files changed

+225
-312
lines changed
 

‎packages/scatterplot/AnimatedNodes.tsx

-69
This file was deleted.

‎packages/scatterplot/old_index.d.ts

-53
This file was deleted.

‎packages/scatterplot/src/Node.tsx

+28-18
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,35 @@
1-
import { ScatterPlotNodeProps, ScatterPlotDatum } from './types'
1+
import { animated } from '@react-spring/web'
2+
import { ScatterPlotDatum, ScatterPlotNodeProps } from './types'
3+
import { useCallback } from 'react'
4+
5+
const interpolateRadius = (size: number) => size / 2
26

37
export const Node = <RawDatum extends ScatterPlotDatum>({
4-
x,
5-
y,
6-
size,
7-
color,
8+
node,
9+
style,
810
blendMode,
11+
isInteractive,
912
onMouseEnter,
1013
onMouseMove,
1114
onMouseLeave,
1215
onClick,
13-
}: ScatterPlotNodeProps<RawDatum>) => (
14-
<circle
15-
cx={x}
16-
cy={y}
17-
r={size / 2}
18-
fill={color}
19-
style={{ mixBlendMode: blendMode }}
20-
onMouseEnter={onMouseEnter}
21-
onMouseMove={onMouseMove}
22-
onMouseLeave={onMouseLeave}
23-
onClick={onClick}
24-
/>
25-
)
16+
}: ScatterPlotNodeProps<RawDatum>) => {
17+
const handleMouseEnter = useCallback(event => onMouseEnter?.(node, event), [node, onMouseEnter])
18+
const handleMouseMove = useCallback(event => onMouseMove?.(node, event), [node, onMouseMove])
19+
const handleMouseLeave = useCallback(event => onMouseLeave?.(node, event), [node, onMouseLeave])
20+
const handleClick = useCallback(event => onClick?.(node, event), [node, onClick])
21+
22+
return (
23+
<animated.circle
24+
cx={style.x}
25+
cy={style.y}
26+
r={style.size.to(interpolateRadius)}
27+
fill={style.color}
28+
style={{ mixBlendMode: blendMode }}
29+
onMouseEnter={isInteractive ? handleMouseEnter : undefined}
30+
onMouseMove={isInteractive ? handleMouseMove : undefined}
31+
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
32+
onClick={isInteractive ? handleClick : undefined}
33+
/>
34+
)
35+
}

‎packages/scatterplot/src/NodeWrapper.tsx

-87
This file was deleted.

‎packages/scatterplot/src/Nodes.tsx

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useTransition } from '@react-spring/web'
2+
import { CssMixBlendMode, useMotionConfig } from '@nivo/core'
3+
import { useTooltip } from '@nivo/tooltip'
4+
import {
5+
ScatterPlotCommonProps,
6+
ScatterPlotDatum,
7+
ScatterPlotNode,
8+
ScatterPlotNodeData,
9+
} from './types'
10+
import { createElement, useCallback } from 'react'
11+
12+
interface NodesProps<RawDatum extends ScatterPlotDatum> {
13+
nodes: ScatterPlotNodeData<RawDatum>[]
14+
nodeComponent: ScatterPlotNode<RawDatum>
15+
isInteractive: boolean
16+
onMouseEnter?: ScatterPlotCommonProps<RawDatum>['onMouseEnter']
17+
onMouseMove?: ScatterPlotCommonProps<RawDatum>['onMouseMove']
18+
onMouseLeave?: ScatterPlotCommonProps<RawDatum>['onMouseLeave']
19+
onClick?: ScatterPlotCommonProps<RawDatum>['onClick']
20+
tooltip: ScatterPlotCommonProps<RawDatum>['tooltip']
21+
blendMode: CssMixBlendMode
22+
}
23+
24+
const getNodeKey = <RawDatum extends ScatterPlotDatum>(node: ScatterPlotNodeData<RawDatum>) =>
25+
node.id
26+
const regularTransition = <RawDatum extends ScatterPlotDatum>(
27+
node: ScatterPlotNodeData<RawDatum>
28+
) => ({
29+
x: node.x,
30+
y: node.y,
31+
size: node.size,
32+
color: node.style.color,
33+
})
34+
const leaveTransition = <RawDatum extends ScatterPlotDatum>(
35+
node: ScatterPlotNodeData<RawDatum>
36+
) => ({
37+
x: node.x,
38+
y: node.y,
39+
size: 0,
40+
color: node.style.color,
41+
})
42+
43+
export const Nodes = <RawDatum extends ScatterPlotDatum>({
44+
nodes,
45+
nodeComponent,
46+
isInteractive,
47+
onMouseEnter,
48+
onMouseMove,
49+
onMouseLeave,
50+
onClick,
51+
tooltip,
52+
blendMode,
53+
}: NodesProps<RawDatum>) => {
54+
const { animate, config: springConfig } = useMotionConfig()
55+
const transition = useTransition<
56+
ScatterPlotNodeData<RawDatum>,
57+
{
58+
x: number
59+
y: number
60+
size: number
61+
color: string
62+
}
63+
>(nodes, {
64+
keys: getNodeKey,
65+
from: regularTransition,
66+
enter: regularTransition,
67+
update: regularTransition,
68+
leave: leaveTransition,
69+
config: springConfig,
70+
immediate: !animate,
71+
})
72+
73+
const { showTooltipFromEvent, hideTooltip } = useTooltip()
74+
const handleMouseEnter = useCallback(
75+
(node, event) => {
76+
showTooltipFromEvent(createElement(tooltip, { node }), event)
77+
onMouseEnter?.(node, event)
78+
},
79+
[tooltip, showTooltipFromEvent, onMouseEnter]
80+
)
81+
const handleMouseMove = useCallback(
82+
(node, event) => {
83+
showTooltipFromEvent(createElement(tooltip, { node }), event)
84+
onMouseMove?.(node, event)
85+
},
86+
[tooltip, showTooltipFromEvent, onMouseMove]
87+
)
88+
const handleMouseLeave = useCallback(
89+
(node, event) => {
90+
hideTooltip()
91+
onMouseLeave?.(node, event)
92+
},
93+
[hideTooltip, onMouseLeave]
94+
)
95+
const handleClick = useCallback((node, event) => onClick?.(node, event), [onClick])
96+
97+
return (
98+
<>
99+
{transition((style, node) =>
100+
createElement(nodeComponent, {
101+
node,
102+
style,
103+
blendMode,
104+
isInteractive,
105+
onMouseEnter: isInteractive ? handleMouseEnter : undefined,
106+
onMouseMove: isInteractive ? handleMouseMove : undefined,
107+
onMouseLeave: isInteractive ? handleMouseLeave : undefined,
108+
onClick: isInteractive ? handleClick : undefined,
109+
})
110+
)}
111+
</>
112+
)
113+
}

‎packages/scatterplot/src/ScatterPlot.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { BoxLegendSvg } from '@nivo/legends'
55
import { useScatterPlot } from './hooks'
66
import { svgDefaultProps } from './props'
77
import { ScatterPlotAnnotations } from './ScatterPlotAnnotations'
8-
import { StaticNodes } from './StaticNodes'
8+
import { Nodes } from './Nodes'
99
import { Mesh } from './Mesh'
1010
import { ScatterPlotDatum, ScatterPlotLayerId, ScatterPlotSvgProps } from './types'
1111

@@ -28,7 +28,7 @@ const InnerScatterPlot = <RawDatum extends ScatterPlotDatum>({
2828
blendMode = svgDefaultProps.blendMode,
2929
nodeId = svgDefaultProps.nodeId,
3030
nodeSize = svgDefaultProps.nodeSize,
31-
renderNode = svgDefaultProps.renderNode,
31+
nodeComponent = svgDefaultProps.nodeComponent,
3232
enableGridX = svgDefaultProps.enableGridX,
3333
enableGridY = svgDefaultProps.enableGridY,
3434
gridXValues,
@@ -126,10 +126,10 @@ const InnerScatterPlot = <RawDatum extends ScatterPlotDatum>({
126126

127127
if (layers.includes('nodes')) {
128128
layerById.nodes = (
129-
<StaticNodes<RawDatum>
129+
<Nodes<RawDatum>
130130
key="nodes"
131131
nodes={nodes}
132-
renderNode={renderNode}
132+
nodeComponent={nodeComponent}
133133
isInteractive={isInteractive}
134134
tooltip={tooltip}
135135
blendMode={blendMode}

‎packages/scatterplot/src/StaticNodes.tsx

-53
This file was deleted.

‎packages/scatterplot/src/props.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const commonDefaultProps = {
2929
ScatterPlotDatum
3030
>['nodeId'],
3131
nodeSize: 9,
32-
renderNode: Node,
32+
nodeComponent: Node,
3333

3434
colors: { scheme: 'nivo' } as ScatterPlotCommonProps<ScatterPlotDatum>['colors'],
3535

‎packages/scatterplot/src/types.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AriaAttributes, FunctionComponent, MouseEvent } from 'react'
2+
import { SpringValues } from '@react-spring/web'
23
import {
34
Dimensions,
45
Box,
@@ -49,15 +50,18 @@ export interface ScatterPlotNodeData<RawDatum extends ScatterPlotDatum> {
4950

5051
export interface ScatterPlotNodeProps<RawDatum extends ScatterPlotDatum> {
5152
node: ScatterPlotNodeData<RawDatum>
52-
x: number
53-
y: number
54-
size: number
55-
color: string
53+
style: SpringValues<{
54+
x: number
55+
y: number
56+
size: number
57+
color: string
58+
}>
5659
blendMode: CssMixBlendMode
57-
onMouseEnter?: (event: MouseEvent<any>) => void
58-
onMouseMove?: (event: MouseEvent<any>) => void
59-
onMouseLeave?: (event: MouseEvent<any>) => void
60-
onClick?: (event: MouseEvent<any>) => void
60+
isInteractive: boolean
61+
onMouseEnter?: ScatterPlotMouseHandler<RawDatum>
62+
onMouseMove?: ScatterPlotMouseHandler<RawDatum>
63+
onMouseLeave?: ScatterPlotMouseHandler<RawDatum>
64+
onClick?: ScatterPlotMouseHandler<RawDatum>
6165
}
6266
export type ScatterPlotNode<RawDatum extends ScatterPlotDatum> = FunctionComponent<
6367
ScatterPlotNodeProps<RawDatum>
@@ -162,7 +166,7 @@ export type ScatterPlotSvgProps<RawDatum extends ScatterPlotDatum> = Partial<
162166
ModernMotionProps & {
163167
blendMode?: CssMixBlendMode
164168
layers?: ScatterPlotLayerId[]
165-
renderNode?: ScatterPlotNode<RawDatum>
169+
nodeComponent?: ScatterPlotNode<RawDatum>
166170
markers?: CartesianMarkerProps<RawDatum['x'] | RawDatum['y']>[]
167171
}
168172

‎packages/scatterplot/tests/ScatterPlot.test.tsx

+17-16
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ describe('nodes', () => {
199199
const nodes = wrapper.find('Node')
200200
expect(nodes).toHaveLength(5)
201201
nodes.forEach(node => {
202-
expect(node.prop('size')).toBe(12)
202+
expect(node.prop('node').size).toBe(12)
203203
})
204204
})
205205

@@ -228,24 +228,25 @@ describe('nodes', () => {
228228

229229
const nodes = wrapper.find('Node')
230230
expect(nodes).toHaveLength(3)
231-
expect(nodes.at(0).prop('size')).toBe(6)
232-
expect(nodes.at(1).prop('size')).toBe(10)
233-
expect(nodes.at(2).prop('size')).toBe(16)
231+
expect(nodes.at(0).prop('node').size).toBe(6)
232+
expect(nodes.at(1).prop('node').size).toBe(10)
233+
expect(nodes.at(2).prop('node').size).toBe(16)
234234
})
235235

236236
it('should allow to use a custom node', () => {
237237
const CustomNode = () => <g />
238238

239-
const wrapper = mount(<ScatterPlot<TestDatum> {...baseProps} renderNode={CustomNode} />)
239+
const wrapper = mount(<ScatterPlot<TestDatum> {...baseProps} nodeComponent={CustomNode} />)
240240

241241
const nodes = wrapper.find(CustomNode)
242242
expect(nodes).toHaveLength(5)
243243
nodes.forEach(node => {
244-
expect(node.prop('node')).toBeDefined()
245-
expect(node.prop('x')).toBeDefined()
246-
expect(node.prop('y')).toBeDefined()
247-
expect(node.prop('size')).toBe(9)
248-
expect(node.prop('color')).toBeDefined()
244+
const nodeProp = node.prop('node')
245+
expect(nodeProp).toBeDefined()
246+
expect(nodeProp.x).toBeDefined()
247+
expect(nodeProp.y).toBeDefined()
248+
expect(nodeProp.size).toBe(9)
249+
expect(nodeProp.style.color).toBeDefined()
249250
expect(node.prop('blendMode')).toBe('normal')
250251
expect(node.prop('onMouseEnter')).toBeDefined()
251252
expect(node.prop('onMouseMove')).toBeDefined()
@@ -268,7 +269,7 @@ describe('nodes', () => {
268269
/>
269270
)
270271

271-
const nodes = wrapper.find('NodeWrapper')
272+
const nodes = wrapper.find('Node')
272273
expect(nodes).toHaveLength(5)
273274
nodes.forEach((node, index) => {
274275
expect(node.prop('node').id).toBe(ids[index])
@@ -283,7 +284,7 @@ describe('tooltip', () => {
283284
let tooltip = wrapper.find('Tooltip').at(1)
284285
expect(tooltip.exists()).toBe(false)
285286

286-
const node = wrapper.find('NodeWrapper').at(2)
287+
const node = wrapper.find('Node').at(2)
287288
node.find('circle').simulate('mouseenter')
288289

289290
tooltip = wrapper.find('Tooltip').at(1)
@@ -294,7 +295,7 @@ describe('tooltip', () => {
294295
it('should allow to disable tooltip by disabling interactivity', () => {
295296
const wrapper = mount(<ScatterPlot<TestDatum> {...baseProps} isInteractive={false} />)
296297

297-
wrapper.find('NodeWrapper').at(2).find('circle').simulate('mouseenter')
298+
wrapper.find('Node').at(2).find('circle').simulate('mouseenter')
298299
expect(wrapper.find('Tooltip').exists()).toBe(false)
299300
})
300301

@@ -305,7 +306,7 @@ describe('tooltip', () => {
305306
let tooltip = wrapper.find(CustomTooltip)
306307
expect(tooltip.exists()).toBe(false)
307308

308-
const node = wrapper.find('NodeWrapper').at(2)
309+
const node = wrapper.find('Node').at(2)
309310
node.find('circle').simulate('mouseenter')
310311

311312
tooltip = wrapper.find(CustomTooltip)
@@ -334,7 +335,7 @@ describe('event handlers', () => {
334335
/>
335336
)
336337

337-
const node = wrapper.find('NodeWrapper').at(1)
338+
const node = wrapper.find('Node').at(1)
338339
node.find('circle').simulate(eventHandler.simulated)
339340

340341
expect(mock).toHaveBeenCalledTimes(1)
@@ -362,7 +363,7 @@ describe('event handlers', () => {
362363
/>
363364
)
364365

365-
wrapper.find('NodeWrapper').at(1).find('circle').simulate(eventHandler.simulated)
366+
wrapper.find('Node').at(1).find('circle').simulate(eventHandler.simulated)
366367
expect(mock).not.toHaveBeenCalled()
367368
})
368369
})

‎website/src/data/components/scatterplot/props.js

+49-2
Original file line numberDiff line numberDiff line change
@@ -325,11 +325,58 @@ const props = [
325325
required: false,
326326
defaultValue: svgDefaultProps.layers,
327327
},
328+
{
329+
key: 'nodeComponent',
330+
flavors: ['svg'],
331+
group: 'Customization',
332+
help: 'Override default node rendering for SVG implementation.',
333+
type: 'FunctionComponent<ScatterPlotNodeProps<RawDatum>>',
334+
description: `
335+
When you override the default node component, you should use
336+
an \`animated\` element if you wish to preserve transitions,
337+
for example:
338+
339+
\`\`\`
340+
import { animated } from '@react-spring/web'
341+
342+
export const MyCustomNode = (props) => (
343+
<animated.circle
344+
cx={props.style.x}
345+
cy={props.style.y}
346+
r={props.style.size.to(size => size / 2)}
347+
fill={style.color}
348+
style={{ mixBlendMode: props.blendMode }}
349+
/>
350+
)
351+
\`\`\`
352+
353+
The \`style\` property contains \`react-spring\` values, suitable
354+
for \`animated.*\` elements.
355+
356+
You can have a look at the [default node implementation](https://github.com/plouc/nivo/blob/master/packages/scatterplot/src/Node.tsx)
357+
to see how props are used by default.
358+
`,
359+
required: false,
360+
},
328361
{
329362
key: 'renderNode',
330-
flavors: ['svg', 'canvas'],
363+
flavors: ['canvas'],
331364
group: 'Customization',
332-
help: 'Override default node rendering.',
365+
help: 'Override default node rendering for canvas implementation.',
366+
type: '(ctx: CanvasRenderingContext2D, props: ScatterPlotLayerProps<RawDatum>) => void',
367+
description: `
368+
This is how the default rendering is done:
369+
370+
\`\`\`
371+
const renderNode = (ctx, node) => {
372+
ctx.beginPath()
373+
ctx.arc(node.x, node.y, node.size / 2, 0, 2 * Math.PI)
374+
ctx.fillStyle = node.style.color
375+
ctx.fill()
376+
}
377+
\`\`\`
378+
`,
379+
required: false,
333380
},
334381
{
335382
key: 'enableGridX',

0 commit comments

Comments
 (0)
Please sign in to comment.