Skip to content

Commit 9060197

Browse files
committedDec 31, 2021
feat(website): add a dedicated control for chart annotations
1 parent 07a1e33 commit 9060197

File tree

7 files changed

+352
-259
lines changed

7 files changed

+352
-259
lines changed
 

‎packages/annotations/src/types.ts

-6
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,6 @@ export interface BaseAnnotationSpec<Datum> {
6363
noteY: RelativeOrAbsolutePosition
6464
noteWidth?: number
6565
noteTextOffset?: number
66-
// circle/dot
67-
// size?: number
68-
// // rect
69-
// width?: number
70-
// // rect
71-
// height?: number
7266
}
7367

7468
// This annotation can be used to draw a circle
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import React, { memo, useCallback, useMemo } from 'react'
2+
import omit from 'lodash/omit'
3+
import { AnnotationMatcher } from '@nivo/annotations'
4+
import { ChartProperty, Flavor } from '../../types'
5+
import { AnnotationsControlConfig, ArrayControlConfig } from './types'
6+
import { ArrayControl } from './ArrayControl'
7+
8+
const fixAnnotation = (annotation: AnnotationMatcher<any>): AnnotationMatcher<any> => {
9+
let adjusted: AnnotationMatcher<any> = annotation
10+
11+
if (annotation.type === 'rect') {
12+
if (annotation.borderRadius === undefined) {
13+
adjusted = { ...annotation, borderRadius: 2 }
14+
}
15+
} else {
16+
if ((annotation as any).borderRadius !== undefined) {
17+
adjusted = omit(annotation as any, 'borderRadius') as AnnotationMatcher<any>
18+
}
19+
}
20+
21+
return adjusted
22+
}
23+
24+
interface AnnotationsControlProps {
25+
id: string
26+
property: ChartProperty
27+
flavors: Flavor[]
28+
currentFlavor: Flavor
29+
config: AnnotationsControlConfig
30+
value: AnnotationMatcher<any>[]
31+
onChange: (annotations: AnnotationMatcher<any>[]) => void
32+
context?: any
33+
}
34+
35+
export const AnnotationsControl = memo(
36+
({
37+
id,
38+
property,
39+
flavors,
40+
currentFlavor,
41+
value,
42+
config: { createDefaults },
43+
onChange,
44+
}: AnnotationsControlProps) => {
45+
const arrayProperty: Omit<ChartProperty, 'control'> & {
46+
control: ArrayControlConfig<AnnotationMatcher<any>>
47+
} = useMemo(
48+
() => ({
49+
key: property.key,
50+
group: property.group,
51+
help: property.help,
52+
type: property.type,
53+
required: property.required,
54+
flavors: property.flavors,
55+
defaultValue: property.defaultValue,
56+
control: {
57+
type: 'array',
58+
shouldCreate: true,
59+
addLabel: 'add annotation',
60+
shouldRemove: true,
61+
getItemTitle: (index, annotation) =>
62+
`annotation[${index}] '${annotation.note}' (${annotation.type})`,
63+
defaults: createDefaults,
64+
props: [
65+
{
66+
key: 'type',
67+
flavors: property.flavors,
68+
help: `Annotation type.`,
69+
type: `'dot' | 'circle' | 'rect'`,
70+
required: true,
71+
control: {
72+
type: 'choices',
73+
choices: [
74+
{ value: 'dot', label: 'dot' },
75+
{ value: 'circle', label: 'circle' },
76+
{ value: 'rect', label: 'rect' },
77+
],
78+
},
79+
},
80+
{
81+
key: 'match',
82+
flavors: property.flavors,
83+
help: 'Annotation matcher.',
84+
required: true,
85+
type: 'object',
86+
control: {
87+
type: 'object',
88+
isOpenedByDefault: true,
89+
props: [
90+
{
91+
key: 'id',
92+
required: false,
93+
flavors: property.flavors,
94+
help: 'Match elements having the provided ID.',
95+
type: 'string | number',
96+
control: {
97+
type: 'text',
98+
},
99+
},
100+
],
101+
},
102+
},
103+
{
104+
key: 'borderRadius',
105+
flavors,
106+
help: `Rect border radius.`,
107+
type: 'number',
108+
required: false,
109+
when: (settings: any) => settings.type === 'rect',
110+
control: {
111+
type: 'range',
112+
min: 0,
113+
max: 12,
114+
},
115+
},
116+
{
117+
key: 'note',
118+
flavors,
119+
help: `Annotation note.`,
120+
type: 'text',
121+
required: true,
122+
control: { type: 'text' },
123+
},
124+
{
125+
key: 'noteX',
126+
flavors,
127+
help: `Annotation note x position.`,
128+
type: 'number',
129+
required: true,
130+
control: {
131+
type: 'range',
132+
min: -300,
133+
max: 300,
134+
step: 5,
135+
},
136+
},
137+
{
138+
key: 'noteY',
139+
flavors,
140+
help: `Annotation note y position.`,
141+
type: 'number',
142+
required: true,
143+
control: {
144+
type: 'range',
145+
min: -300,
146+
max: 300,
147+
step: 5,
148+
},
149+
},
150+
{
151+
key: 'noteTextOffset',
152+
flavors,
153+
help: `Annotation note text offset.`,
154+
type: 'number',
155+
required: false,
156+
control: {
157+
type: 'range',
158+
min: -64,
159+
max: 64,
160+
},
161+
},
162+
{
163+
key: 'offset',
164+
flavors,
165+
help: `Offset from annotated element.`,
166+
type: 'number',
167+
required: false,
168+
control: {
169+
type: 'range',
170+
min: 0,
171+
max: 32,
172+
},
173+
},
174+
],
175+
},
176+
}),
177+
[property, createDefaults]
178+
)
179+
180+
const handleChange = useCallback(
181+
(annotations: AnnotationMatcher<any>[]) => onChange(annotations.map(fixAnnotation)),
182+
[onChange]
183+
)
184+
185+
return (
186+
<ArrayControl<AnnotationMatcher<any>>
187+
id={id}
188+
property={arrayProperty as ChartProperty<any>}
189+
value={value}
190+
flavors={flavors}
191+
currentFlavor={currentFlavor}
192+
config={arrayProperty.control}
193+
onChange={handleChange}
194+
/>
195+
)
196+
}
197+
)

‎website/src/components/controls/ArrayControl.tsx

+103-105
Original file line numberDiff line numberDiff line change
@@ -7,120 +7,118 @@ import { Help } from './Help'
77
import { Flavor, ChartProperty } from '../../types'
88
import { ArrayControlConfig } from './types'
99

10-
interface ArrayControlProps {
10+
interface ArrayControlProps<Item> {
1111
id: string
1212
property: ChartProperty
13-
value: unknown[]
13+
value: Item[]
1414
flavors: Flavor[]
1515
currentFlavor: Flavor
16-
config: ArrayControlConfig
17-
onChange: (value: unknown) => void
16+
config: ArrayControlConfig<Item>
17+
onChange: (value: Item[]) => void
1818
context?: any
1919
}
2020

21-
export const ArrayControl = memo(
22-
({
23-
property,
24-
flavors,
25-
currentFlavor,
26-
value,
27-
onChange,
28-
config: {
29-
props,
30-
shouldCreate = false,
31-
addLabel = 'add',
32-
shouldRemove = false,
33-
removeLabel = 'remove',
34-
defaults = {},
35-
getItemTitle,
21+
function NonMemoizedArrayControl<Item = object>({
22+
property,
23+
flavors,
24+
currentFlavor,
25+
value,
26+
onChange,
27+
config: {
28+
props,
29+
shouldCreate = false,
30+
addLabel = 'add',
31+
shouldRemove = false,
32+
removeLabel = 'remove',
33+
defaults = {} as Item,
34+
getItemTitle,
35+
},
36+
}: ArrayControlProps<Item>) {
37+
const [activeItems, setActiveItems] = useState([0])
38+
const append = useCallback(() => {
39+
onChange([...value, { ...defaults }])
40+
setActiveItems([value.length])
41+
}, [value, onChange, defaults, setActiveItems])
42+
43+
const remove = useCallback(
44+
(index: number) => (event: MouseEvent) => {
45+
event.stopPropagation()
46+
const items = value.filter((_item: any, i) => i !== index)
47+
setActiveItems([])
48+
onChange(items)
3649
},
37-
}: ArrayControlProps) => {
38-
const [activeItems, setActiveItems] = useState([0])
39-
const append = useCallback(() => {
40-
onChange([...value, { ...defaults }])
41-
setActiveItems([value.length])
42-
}, [value, onChange, defaults, setActiveItems])
43-
44-
const remove = useCallback(
45-
(index: number) => (event: MouseEvent) => {
46-
event.stopPropagation()
47-
const items = value.filter((_item: any, i) => i !== index)
48-
setActiveItems([])
49-
onChange(items)
50-
},
51-
[value, onChange, setActiveItems]
52-
)
53-
const change = useCallback(
54-
(index: number) => (itemValue: unknown) => {
55-
onChange(
56-
value.map((v, i) => {
57-
if (i === index) return itemValue
58-
return v
59-
})
60-
)
61-
},
62-
[value, onChange]
63-
)
64-
const toggle = useCallback(
65-
(index: number) => () => {
66-
setActiveItems(items => {
67-
if (items.includes(index)) {
68-
return items.filter(i => i !== index)
69-
}
70-
return [...activeItems, index]
50+
[value, onChange, setActiveItems]
51+
)
52+
const change = useCallback(
53+
(index: number) => (itemValue: Item) => {
54+
onChange(
55+
value.map((v, i) => {
56+
if (i === index) return itemValue
57+
return v
7158
})
72-
},
73-
[setActiveItems]
74-
)
75-
76-
const subProps = useMemo(
77-
() =>
78-
props.map(prop => ({
79-
...prop,
80-
name: prop.key,
81-
group: property.group,
82-
})),
83-
[props]
84-
)
85-
86-
return (
87-
<>
88-
<Header>
89-
<PropertyHeader {...property} />
90-
<Help>{property.help}</Help>
91-
{shouldCreate && <AddButton onClick={append}>{addLabel}</AddButton>}
92-
</Header>
93-
{value.map((item, index) => (
94-
<Fragment key={index}>
95-
<SubHeader isOpened={activeItems.includes(index)} onClick={toggle(index)}>
96-
<Title>
97-
{getItemTitle !== undefined
98-
? getItemTitle(index, item)
99-
: `${property.key}[${index}]`}
100-
{shouldRemove && (
101-
<RemoveButton onClick={remove(index)}>
102-
{removeLabel}
103-
</RemoveButton>
104-
)}
105-
</Title>
106-
<Toggle isOpened={activeItems.includes(index)} />
107-
</SubHeader>
108-
{activeItems.includes(index) && (
109-
<ControlsGroup
110-
name={property.key}
111-
flavors={flavors}
112-
currentFlavor={currentFlavor}
113-
controls={subProps}
114-
settings={item}
115-
onChange={change(index)}
116-
/>
117-
)}
118-
</Fragment>
119-
))}
120-
</>
121-
)
122-
}
123-
)
59+
)
60+
},
61+
[value, onChange]
62+
)
63+
const toggle = useCallback(
64+
(index: number) => () => {
65+
setActiveItems(items => {
66+
if (items.includes(index)) {
67+
return items.filter(i => i !== index)
68+
}
69+
return [...activeItems, index]
70+
})
71+
},
72+
[setActiveItems]
73+
)
74+
75+
const subProps = useMemo(
76+
() =>
77+
props.map(prop => ({
78+
...prop,
79+
name: prop.key,
80+
group: property.group,
81+
})),
82+
[props]
83+
)
84+
85+
return (
86+
<>
87+
<Header>
88+
<PropertyHeader {...property} />
89+
<Help>{property.help}</Help>
90+
{shouldCreate && <AddButton onClick={append}>{addLabel}</AddButton>}
91+
</Header>
92+
{value.map((item, index) => (
93+
<Fragment key={index}>
94+
<SubHeader isOpened={activeItems.includes(index)} onClick={toggle(index)}>
95+
<Title>
96+
{getItemTitle !== undefined
97+
? getItemTitle(index, item)
98+
: `${property.key}[${index}]`}
99+
{shouldRemove && (
100+
<RemoveButton onClick={remove(index)}>{removeLabel}</RemoveButton>
101+
)}
102+
</Title>
103+
<Toggle isOpened={activeItems.includes(index)} />
104+
</SubHeader>
105+
{activeItems.includes(index) && (
106+
<ControlsGroup
107+
name={property.key}
108+
flavors={flavors}
109+
currentFlavor={currentFlavor}
110+
controls={subProps}
111+
settings={item}
112+
onChange={change(index)}
113+
/>
114+
)}
115+
</Fragment>
116+
))}
117+
</>
118+
)
119+
}
120+
121+
export const ArrayControl = memo(NonMemoizedArrayControl) as typeof NonMemoizedArrayControl
124122

125123
const Header = styled(Cell)`
126124
border-bottom: 1px solid ${({ theme }) => theme.colors.borderLight};

‎website/src/components/controls/ControlsGroup.tsx

+15
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { InheritedColorControl } from './InheritedColorControl'
2424
import { BlendModeControl } from './BlendModeControl'
2525
import PropertyDocumentation from './PropertyDocumentation'
2626
import { ValueFormatControl } from './ValueFormatControl'
27+
import { AnnotationsControl } from './AnnotationsControl'
2728
import { ChartProperty, Flavor } from '../../types'
2829

2930
export const shouldRenderProperty = (property: ChartProperty, currentSettings: any) => {
@@ -402,6 +403,20 @@ const ControlSwitcher = memo(
402403
/>
403404
)
404405

406+
case 'annotations':
407+
return (
408+
<AnnotationsControl
409+
id={id}
410+
property={property}
411+
flavors={flavors}
412+
currentFlavor={currentFlavor}
413+
config={controlConfig}
414+
value={value}
415+
context={context}
416+
onChange={handleChange}
417+
/>
418+
)
419+
405420
default:
406421
throw new Error(
407422
`invalid control type: ${controlConfig!.type} for property: ${property.name}`

‎website/src/components/controls/types.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AnnotationMatcher } from '@nivo/annotations'
12
import { ChartProperty } from '../../types'
23

34
export interface SwitchControlAttrs {
@@ -113,15 +114,15 @@ export interface ObjectControlConfig {
113114
isOpenedByDefault?: boolean
114115
}
115116

116-
export interface ArrayControlConfig {
117+
export interface ArrayControlConfig<Item = object> {
117118
type: 'array'
118119
props: Omit<ChartProperty, 'group'>[]
119120
shouldCreate: boolean
120121
addLabel?: string
121122
shouldRemove: boolean
122123
removeLabel?: string
123-
defaults?: object
124-
getItemTitle?: (index: number, item: unknown) => string
124+
defaults?: Item
125+
getItemTitle?: (index: number, item: Item) => string
125126
}
126127

127128
export interface TextControlConfig {
@@ -134,6 +135,11 @@ export interface ColorsControlConfig {
134135
includeSequential?: boolean
135136
}
136137

138+
export interface AnnotationsControlConfig {
139+
type: 'annotations'
140+
createDefaults: AnnotationMatcher<any>
141+
}
142+
137143
export type ControlConfig =
138144
| SwitchControlAttrs
139145
| RangeControlConfig
@@ -157,3 +163,4 @@ export type ControlConfig =
157163
| ArrayControlConfig
158164
| TextControlConfig
159165
| ColorsControlConfig
166+
| AnnotationsControlConfig

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

+10-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { commonDefaultProps as defaults } from '@nivo/network'
1+
import { commonDefaultProps as defaults, svgDefaultProps } from '@nivo/network'
22
import { groupProperties, themeProperty, motionProperties } from '../../../lib/componentProperties'
33
import {
44
chartDimensions,
@@ -140,7 +140,7 @@ const props: ChartProperty[] = [
140140
control: {
141141
type: 'switchableRange',
142142
disabledValue: dynamicNodeSizeValue,
143-
defaultValue: defaults.nodeSize,
143+
defaultValue: defaults.nodeSize as number,
144144
unit: 'px',
145145
min: 4,
146146
max: 64,
@@ -160,7 +160,7 @@ const props: ChartProperty[] = [
160160
key: 'nodeBlendMode',
161161
target: 'nodes',
162162
flavors: ['svg'],
163-
defaultValue: defaults.nodeBlendMode,
163+
defaultValue: svgDefaultProps.nodeBlendMode,
164164
}),
165165
{
166166
key: 'nodeBorderWidth',
@@ -210,7 +210,7 @@ const props: ChartProperty[] = [
210210
control: {
211211
type: 'switchableRange',
212212
disabledValue: dynamicLinkThicknessValue,
213-
defaultValue: defaults.linkThickness,
213+
defaultValue: defaults.linkThickness as number,
214214
unit: 'px',
215215
min: 1,
216216
max: 12,
@@ -234,7 +234,7 @@ const props: ChartProperty[] = [
234234
key: 'linkBlendMode',
235235
target: 'links',
236236
flavors: ['svg'],
237-
defaultValue: defaults.linkBlendMode,
237+
defaultValue: svgDefaultProps.linkBlendMode,
238238
}),
239239
isInteractive({ flavors: allFlavors, defaultValue: defaults.isInteractive }),
240240
{
@@ -243,7 +243,7 @@ const props: ChartProperty[] = [
243243
type: 'NetworkNodeTooltipComponent',
244244
required: false,
245245
help: 'Custom tooltip component for nodes.',
246-
flavors: ['svg', 'canvas'],
246+
flavors: allFlavors,
247247
description: `
248248
An optional component allowing complete tooltip customisation,
249249
it must return a valid HTML element and will receive
@@ -256,23 +256,23 @@ const props: ChartProperty[] = [
256256
help: 'onClick handler.',
257257
type: '(node: NetworkComputedNode, event: MouseEvent) => void',
258258
required: false,
259-
flavors: ['svg', 'canvas'],
259+
flavors: allFlavors,
260260
},
261261
{
262262
key: 'onMouseEnter',
263263
group: 'Interactivity',
264264
help: 'onMouseEnter handler.',
265265
type: '(node: ComputedNode, event: MouseEvent) => void',
266266
required: false,
267-
flavors: ['svg', 'canvas'],
267+
flavors: allFlavors,
268268
},
269269
{
270270
key: 'onMouseMove',
271271
group: 'Interactivity',
272272
help: 'onMouseMove handler.',
273273
type: '(node: ComputedNode, event: MouseEvent) => void',
274274
required: false,
275-
flavors: ['svg', 'canvas'],
275+
flavors: allFlavors,
276276
},
277277
{
278278
key: 'onMouseLeave',
@@ -285,15 +285,14 @@ const props: ChartProperty[] = [
285285
annotations({
286286
target: 'nodes',
287287
flavors: allFlavors,
288-
newDefaults: {
288+
createDefaults: {
289289
type: 'circle',
290290
match: { id: '0' },
291291
note: 'New annotation',
292292
noteX: 160,
293293
noteY: 36,
294294
offset: 6,
295295
noteTextOffset: 5,
296-
borderRadius: 3,
297296
},
298297
}),
299298
{
+17-134
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AnnotationMatcher } from '@nivo/annotations'
12
import { ChartProperty, Flavor } from '../../types'
23

34
export const annotations = ({
@@ -7,143 +8,25 @@ export const annotations = ({
78
type = 'AnnotationMatcher[]',
89
flavors,
910
defaultValue = [],
10-
newDefaults,
11+
createDefaults,
1112
}: {
1213
key?: string
1314
target: string
1415
group?: string
1516
type?: string
1617
flavors: Flavor[]
17-
defaultValue: any[]
18-
newDefaults: any
19-
}): ChartProperty => {
20-
return {
21-
key,
22-
group,
23-
help: `Annotations for ${target}.`,
24-
type,
25-
required: false,
26-
flavors,
27-
defaultValue,
28-
control: {
29-
type: 'array',
30-
shouldCreate: true,
31-
addLabel: 'add annotation',
32-
shouldRemove: true,
33-
getItemTitle: (index: number, annotation: any) =>
34-
`annotation[${index}] '${annotation.note}' (${annotation.type})`,
35-
defaults: newDefaults,
36-
props: [
37-
{
38-
key: 'type',
39-
flavors,
40-
help: `Annotation type.`,
41-
type: `'dot' | 'circle' | 'rect'`,
42-
required: true,
43-
control: {
44-
type: 'choices',
45-
choices: [
46-
{ value: 'dot', label: 'dot' },
47-
{ value: 'circle', label: 'circle' },
48-
{ value: 'rect', label: 'rect' },
49-
],
50-
},
51-
},
52-
{
53-
key: 'match',
54-
flavors,
55-
help: 'Annotation matcher.',
56-
required: true,
57-
type: 'object',
58-
control: {
59-
type: 'object',
60-
isOpenedByDefault: true,
61-
props: [
62-
{
63-
key: 'id',
64-
required: false,
65-
flavors,
66-
help: 'Match elements having the provided ID.',
67-
type: 'string | number',
68-
control: {
69-
type: 'text',
70-
},
71-
},
72-
],
73-
},
74-
},
75-
{
76-
key: 'borderRadius',
77-
flavors,
78-
help: `Rect border radius.`,
79-
type: 'number',
80-
required: false,
81-
when: (settings: any) => settings.type === 'rect',
82-
control: {
83-
type: 'range',
84-
min: 0,
85-
max: 12,
86-
},
87-
},
88-
{
89-
key: 'note',
90-
flavors,
91-
help: `Annotation note.`,
92-
type: 'text',
93-
required: true,
94-
control: { type: 'text' },
95-
},
96-
{
97-
key: 'noteX',
98-
flavors,
99-
help: `Annotation note x position.`,
100-
type: 'number',
101-
required: true,
102-
control: {
103-
type: 'range',
104-
min: -300,
105-
max: 300,
106-
step: 5,
107-
},
108-
},
109-
{
110-
key: 'noteY',
111-
flavors,
112-
help: `Annotation note y position.`,
113-
type: 'number',
114-
required: true,
115-
control: {
116-
type: 'range',
117-
min: -300,
118-
max: 300,
119-
step: 5,
120-
},
121-
},
122-
{
123-
key: 'noteTextOffset',
124-
flavors,
125-
help: `Annotation note text offset.`,
126-
type: 'number',
127-
required: true,
128-
control: {
129-
type: 'range',
130-
min: -64,
131-
max: 64,
132-
},
133-
},
134-
{
135-
key: 'offset',
136-
flavors,
137-
help: `Offset from annotated element.`,
138-
type: 'number',
139-
required: false,
140-
control: {
141-
type: 'range',
142-
min: 0,
143-
max: 32,
144-
},
145-
},
146-
],
147-
},
148-
}
149-
}
18+
defaultValue?: AnnotationMatcher<any>[]
19+
createDefaults: AnnotationMatcher<any>
20+
}): ChartProperty => ({
21+
key,
22+
group,
23+
help: `Annotations for ${target}.`,
24+
type,
25+
required: false,
26+
flavors,
27+
defaultValue,
28+
control: {
29+
type: 'annotations',
30+
createDefaults,
31+
},
32+
})

0 commit comments

Comments
 (0)
Please sign in to comment.