From 9060197e9192257a09a9902c950e87e491b789d1 Mon Sep 17 00:00:00 2001 From: plouc Date: Thu, 30 Dec 2021 23:49:56 +0900 Subject: [PATCH] feat(website): add a dedicated control for chart annotations --- packages/annotations/src/types.ts | 6 - .../controls/AnnotationsControl.tsx | 197 +++++++++++++++++ .../src/components/controls/ArrayControl.tsx | 208 +++++++++--------- .../src/components/controls/ControlsGroup.tsx | 15 ++ website/src/components/controls/types.ts | 13 +- website/src/data/components/network/props.ts | 21 +- .../src/lib/chart-properties/annotations.ts | 151 ++----------- 7 files changed, 352 insertions(+), 259 deletions(-) create mode 100644 website/src/components/controls/AnnotationsControl.tsx diff --git a/packages/annotations/src/types.ts b/packages/annotations/src/types.ts index acf5df5276..07fbc2ff46 100644 --- a/packages/annotations/src/types.ts +++ b/packages/annotations/src/types.ts @@ -63,12 +63,6 @@ export interface BaseAnnotationSpec { noteY: RelativeOrAbsolutePosition noteWidth?: number noteTextOffset?: number - // circle/dot - // size?: number - // // rect - // width?: number - // // rect - // height?: number } // This annotation can be used to draw a circle diff --git a/website/src/components/controls/AnnotationsControl.tsx b/website/src/components/controls/AnnotationsControl.tsx new file mode 100644 index 0000000000..e2b00aa8d1 --- /dev/null +++ b/website/src/components/controls/AnnotationsControl.tsx @@ -0,0 +1,197 @@ +import React, { memo, useCallback, useMemo } from 'react' +import omit from 'lodash/omit' +import { AnnotationMatcher } from '@nivo/annotations' +import { ChartProperty, Flavor } from '../../types' +import { AnnotationsControlConfig, ArrayControlConfig } from './types' +import { ArrayControl } from './ArrayControl' + +const fixAnnotation = (annotation: AnnotationMatcher): AnnotationMatcher => { + let adjusted: AnnotationMatcher = annotation + + if (annotation.type === 'rect') { + if (annotation.borderRadius === undefined) { + adjusted = { ...annotation, borderRadius: 2 } + } + } else { + if ((annotation as any).borderRadius !== undefined) { + adjusted = omit(annotation as any, 'borderRadius') as AnnotationMatcher + } + } + + return adjusted +} + +interface AnnotationsControlProps { + id: string + property: ChartProperty + flavors: Flavor[] + currentFlavor: Flavor + config: AnnotationsControlConfig + value: AnnotationMatcher[] + onChange: (annotations: AnnotationMatcher[]) => void + context?: any +} + +export const AnnotationsControl = memo( + ({ + id, + property, + flavors, + currentFlavor, + value, + config: { createDefaults }, + onChange, + }: AnnotationsControlProps) => { + const arrayProperty: Omit & { + control: ArrayControlConfig> + } = useMemo( + () => ({ + key: property.key, + group: property.group, + help: property.help, + type: property.type, + required: property.required, + flavors: property.flavors, + defaultValue: property.defaultValue, + control: { + type: 'array', + shouldCreate: true, + addLabel: 'add annotation', + shouldRemove: true, + getItemTitle: (index, annotation) => + `annotation[${index}] '${annotation.note}' (${annotation.type})`, + defaults: createDefaults, + props: [ + { + key: 'type', + flavors: property.flavors, + help: `Annotation type.`, + type: `'dot' | 'circle' | 'rect'`, + required: true, + control: { + type: 'choices', + choices: [ + { value: 'dot', label: 'dot' }, + { value: 'circle', label: 'circle' }, + { value: 'rect', label: 'rect' }, + ], + }, + }, + { + key: 'match', + flavors: property.flavors, + help: 'Annotation matcher.', + required: true, + type: 'object', + control: { + type: 'object', + isOpenedByDefault: true, + props: [ + { + key: 'id', + required: false, + flavors: property.flavors, + help: 'Match elements having the provided ID.', + type: 'string | number', + control: { + type: 'text', + }, + }, + ], + }, + }, + { + key: 'borderRadius', + flavors, + help: `Rect border radius.`, + type: 'number', + required: false, + when: (settings: any) => settings.type === 'rect', + control: { + type: 'range', + min: 0, + max: 12, + }, + }, + { + key: 'note', + flavors, + help: `Annotation note.`, + type: 'text', + required: true, + control: { type: 'text' }, + }, + { + key: 'noteX', + flavors, + help: `Annotation note x position.`, + type: 'number', + required: true, + control: { + type: 'range', + min: -300, + max: 300, + step: 5, + }, + }, + { + key: 'noteY', + flavors, + help: `Annotation note y position.`, + type: 'number', + required: true, + control: { + type: 'range', + min: -300, + max: 300, + step: 5, + }, + }, + { + key: 'noteTextOffset', + flavors, + help: `Annotation note text offset.`, + type: 'number', + required: false, + control: { + type: 'range', + min: -64, + max: 64, + }, + }, + { + key: 'offset', + flavors, + help: `Offset from annotated element.`, + type: 'number', + required: false, + control: { + type: 'range', + min: 0, + max: 32, + }, + }, + ], + }, + }), + [property, createDefaults] + ) + + const handleChange = useCallback( + (annotations: AnnotationMatcher[]) => onChange(annotations.map(fixAnnotation)), + [onChange] + ) + + return ( + > + id={id} + property={arrayProperty as ChartProperty} + value={value} + flavors={flavors} + currentFlavor={currentFlavor} + config={arrayProperty.control} + onChange={handleChange} + /> + ) + } +) diff --git a/website/src/components/controls/ArrayControl.tsx b/website/src/components/controls/ArrayControl.tsx index ba50ec8fd2..c002d7209f 100644 --- a/website/src/components/controls/ArrayControl.tsx +++ b/website/src/components/controls/ArrayControl.tsx @@ -7,120 +7,118 @@ import { Help } from './Help' import { Flavor, ChartProperty } from '../../types' import { ArrayControlConfig } from './types' -interface ArrayControlProps { +interface ArrayControlProps { id: string property: ChartProperty - value: unknown[] + value: Item[] flavors: Flavor[] currentFlavor: Flavor - config: ArrayControlConfig - onChange: (value: unknown) => void + config: ArrayControlConfig + onChange: (value: Item[]) => void context?: any } -export const ArrayControl = memo( - ({ - property, - flavors, - currentFlavor, - value, - onChange, - config: { - props, - shouldCreate = false, - addLabel = 'add', - shouldRemove = false, - removeLabel = 'remove', - defaults = {}, - getItemTitle, +function NonMemoizedArrayControl({ + property, + flavors, + currentFlavor, + value, + onChange, + config: { + props, + shouldCreate = false, + addLabel = 'add', + shouldRemove = false, + removeLabel = 'remove', + defaults = {} as Item, + getItemTitle, + }, +}: ArrayControlProps) { + const [activeItems, setActiveItems] = useState([0]) + const append = useCallback(() => { + onChange([...value, { ...defaults }]) + setActiveItems([value.length]) + }, [value, onChange, defaults, setActiveItems]) + + const remove = useCallback( + (index: number) => (event: MouseEvent) => { + event.stopPropagation() + const items = value.filter((_item: any, i) => i !== index) + setActiveItems([]) + onChange(items) }, - }: ArrayControlProps) => { - const [activeItems, setActiveItems] = useState([0]) - const append = useCallback(() => { - onChange([...value, { ...defaults }]) - setActiveItems([value.length]) - }, [value, onChange, defaults, setActiveItems]) - - const remove = useCallback( - (index: number) => (event: MouseEvent) => { - event.stopPropagation() - const items = value.filter((_item: any, i) => i !== index) - setActiveItems([]) - onChange(items) - }, - [value, onChange, setActiveItems] - ) - const change = useCallback( - (index: number) => (itemValue: unknown) => { - onChange( - value.map((v, i) => { - if (i === index) return itemValue - return v - }) - ) - }, - [value, onChange] - ) - const toggle = useCallback( - (index: number) => () => { - setActiveItems(items => { - if (items.includes(index)) { - return items.filter(i => i !== index) - } - return [...activeItems, index] + [value, onChange, setActiveItems] + ) + const change = useCallback( + (index: number) => (itemValue: Item) => { + onChange( + value.map((v, i) => { + if (i === index) return itemValue + return v }) - }, - [setActiveItems] - ) - - const subProps = useMemo( - () => - props.map(prop => ({ - ...prop, - name: prop.key, - group: property.group, - })), - [props] - ) - - return ( - <> -
- - {property.help} - {shouldCreate && {addLabel}} -
- {value.map((item, index) => ( - - - - {getItemTitle !== undefined - ? getItemTitle(index, item) - : `${property.key}[${index}]`} - {shouldRemove && ( - <RemoveButton onClick={remove(index)}> - {removeLabel} - </RemoveButton> - )} - - - - {activeItems.includes(index) && ( - - )} - - ))} - - ) - } -) + ) + }, + [value, onChange] + ) + const toggle = useCallback( + (index: number) => () => { + setActiveItems(items => { + if (items.includes(index)) { + return items.filter(i => i !== index) + } + return [...activeItems, index] + }) + }, + [setActiveItems] + ) + + const subProps = useMemo( + () => + props.map(prop => ({ + ...prop, + name: prop.key, + group: property.group, + })), + [props] + ) + + return ( + <> +
+ + {property.help} + {shouldCreate && {addLabel}} +
+ {value.map((item, index) => ( + + + + {getItemTitle !== undefined + ? getItemTitle(index, item) + : `${property.key}[${index}]`} + {shouldRemove && ( + <RemoveButton onClick={remove(index)}>{removeLabel}</RemoveButton> + )} + + + + {activeItems.includes(index) && ( + + )} + + ))} + + ) +} + +export const ArrayControl = memo(NonMemoizedArrayControl) as typeof NonMemoizedArrayControl const Header = styled(Cell)` border-bottom: 1px solid ${({ theme }) => theme.colors.borderLight}; diff --git a/website/src/components/controls/ControlsGroup.tsx b/website/src/components/controls/ControlsGroup.tsx index ee9658d9e9..5c5bf11caf 100644 --- a/website/src/components/controls/ControlsGroup.tsx +++ b/website/src/components/controls/ControlsGroup.tsx @@ -24,6 +24,7 @@ import { InheritedColorControl } from './InheritedColorControl' import { BlendModeControl } from './BlendModeControl' import PropertyDocumentation from './PropertyDocumentation' import { ValueFormatControl } from './ValueFormatControl' +import { AnnotationsControl } from './AnnotationsControl' import { ChartProperty, Flavor } from '../../types' export const shouldRenderProperty = (property: ChartProperty, currentSettings: any) => { @@ -402,6 +403,20 @@ const ControlSwitcher = memo( /> ) + case 'annotations': + return ( + + ) + default: throw new Error( `invalid control type: ${controlConfig!.type} for property: ${property.name}` diff --git a/website/src/components/controls/types.ts b/website/src/components/controls/types.ts index eb6577ea6d..d1658ab425 100644 --- a/website/src/components/controls/types.ts +++ b/website/src/components/controls/types.ts @@ -1,3 +1,4 @@ +import { AnnotationMatcher } from '@nivo/annotations' import { ChartProperty } from '../../types' export interface SwitchControlAttrs { @@ -113,15 +114,15 @@ export interface ObjectControlConfig { isOpenedByDefault?: boolean } -export interface ArrayControlConfig { +export interface ArrayControlConfig { type: 'array' props: Omit[] shouldCreate: boolean addLabel?: string shouldRemove: boolean removeLabel?: string - defaults?: object - getItemTitle?: (index: number, item: unknown) => string + defaults?: Item + getItemTitle?: (index: number, item: Item) => string } export interface TextControlConfig { @@ -134,6 +135,11 @@ export interface ColorsControlConfig { includeSequential?: boolean } +export interface AnnotationsControlConfig { + type: 'annotations' + createDefaults: AnnotationMatcher +} + export type ControlConfig = | SwitchControlAttrs | RangeControlConfig @@ -157,3 +163,4 @@ export type ControlConfig = | ArrayControlConfig | TextControlConfig | ColorsControlConfig + | AnnotationsControlConfig diff --git a/website/src/data/components/network/props.ts b/website/src/data/components/network/props.ts index 7faa225525..01196c0fe9 100644 --- a/website/src/data/components/network/props.ts +++ b/website/src/data/components/network/props.ts @@ -1,4 +1,4 @@ -import { commonDefaultProps as defaults } from '@nivo/network' +import { commonDefaultProps as defaults, svgDefaultProps } from '@nivo/network' import { groupProperties, themeProperty, motionProperties } from '../../../lib/componentProperties' import { chartDimensions, @@ -140,7 +140,7 @@ const props: ChartProperty[] = [ control: { type: 'switchableRange', disabledValue: dynamicNodeSizeValue, - defaultValue: defaults.nodeSize, + defaultValue: defaults.nodeSize as number, unit: 'px', min: 4, max: 64, @@ -160,7 +160,7 @@ const props: ChartProperty[] = [ key: 'nodeBlendMode', target: 'nodes', flavors: ['svg'], - defaultValue: defaults.nodeBlendMode, + defaultValue: svgDefaultProps.nodeBlendMode, }), { key: 'nodeBorderWidth', @@ -210,7 +210,7 @@ const props: ChartProperty[] = [ control: { type: 'switchableRange', disabledValue: dynamicLinkThicknessValue, - defaultValue: defaults.linkThickness, + defaultValue: defaults.linkThickness as number, unit: 'px', min: 1, max: 12, @@ -234,7 +234,7 @@ const props: ChartProperty[] = [ key: 'linkBlendMode', target: 'links', flavors: ['svg'], - defaultValue: defaults.linkBlendMode, + defaultValue: svgDefaultProps.linkBlendMode, }), isInteractive({ flavors: allFlavors, defaultValue: defaults.isInteractive }), { @@ -243,7 +243,7 @@ const props: ChartProperty[] = [ type: 'NetworkNodeTooltipComponent', required: false, help: 'Custom tooltip component for nodes.', - flavors: ['svg', 'canvas'], + flavors: allFlavors, description: ` An optional component allowing complete tooltip customisation, it must return a valid HTML element and will receive @@ -256,7 +256,7 @@ const props: ChartProperty[] = [ help: 'onClick handler.', type: '(node: NetworkComputedNode, event: MouseEvent) => void', required: false, - flavors: ['svg', 'canvas'], + flavors: allFlavors, }, { key: 'onMouseEnter', @@ -264,7 +264,7 @@ const props: ChartProperty[] = [ help: 'onMouseEnter handler.', type: '(node: ComputedNode, event: MouseEvent) => void', required: false, - flavors: ['svg', 'canvas'], + flavors: allFlavors, }, { key: 'onMouseMove', @@ -272,7 +272,7 @@ const props: ChartProperty[] = [ help: 'onMouseMove handler.', type: '(node: ComputedNode, event: MouseEvent) => void', required: false, - flavors: ['svg', 'canvas'], + flavors: allFlavors, }, { key: 'onMouseLeave', @@ -285,7 +285,7 @@ const props: ChartProperty[] = [ annotations({ target: 'nodes', flavors: allFlavors, - newDefaults: { + createDefaults: { type: 'circle', match: { id: '0' }, note: 'New annotation', @@ -293,7 +293,6 @@ const props: ChartProperty[] = [ noteY: 36, offset: 6, noteTextOffset: 5, - borderRadius: 3, }, }), { diff --git a/website/src/lib/chart-properties/annotations.ts b/website/src/lib/chart-properties/annotations.ts index efa8864c38..0812d56256 100644 --- a/website/src/lib/chart-properties/annotations.ts +++ b/website/src/lib/chart-properties/annotations.ts @@ -1,3 +1,4 @@ +import { AnnotationMatcher } from '@nivo/annotations' import { ChartProperty, Flavor } from '../../types' export const annotations = ({ @@ -7,143 +8,25 @@ export const annotations = ({ type = 'AnnotationMatcher[]', flavors, defaultValue = [], - newDefaults, + createDefaults, }: { key?: string target: string group?: string type?: string flavors: Flavor[] - defaultValue: any[] - newDefaults: any -}): ChartProperty => { - return { - key, - group, - help: `Annotations for ${target}.`, - type, - required: false, - flavors, - defaultValue, - control: { - type: 'array', - shouldCreate: true, - addLabel: 'add annotation', - shouldRemove: true, - getItemTitle: (index: number, annotation: any) => - `annotation[${index}] '${annotation.note}' (${annotation.type})`, - defaults: newDefaults, - props: [ - { - key: 'type', - flavors, - help: `Annotation type.`, - type: `'dot' | 'circle' | 'rect'`, - required: true, - control: { - type: 'choices', - choices: [ - { value: 'dot', label: 'dot' }, - { value: 'circle', label: 'circle' }, - { value: 'rect', label: 'rect' }, - ], - }, - }, - { - key: 'match', - flavors, - help: 'Annotation matcher.', - required: true, - type: 'object', - control: { - type: 'object', - isOpenedByDefault: true, - props: [ - { - key: 'id', - required: false, - flavors, - help: 'Match elements having the provided ID.', - type: 'string | number', - control: { - type: 'text', - }, - }, - ], - }, - }, - { - key: 'borderRadius', - flavors, - help: `Rect border radius.`, - type: 'number', - required: false, - when: (settings: any) => settings.type === 'rect', - control: { - type: 'range', - min: 0, - max: 12, - }, - }, - { - key: 'note', - flavors, - help: `Annotation note.`, - type: 'text', - required: true, - control: { type: 'text' }, - }, - { - key: 'noteX', - flavors, - help: `Annotation note x position.`, - type: 'number', - required: true, - control: { - type: 'range', - min: -300, - max: 300, - step: 5, - }, - }, - { - key: 'noteY', - flavors, - help: `Annotation note y position.`, - type: 'number', - required: true, - control: { - type: 'range', - min: -300, - max: 300, - step: 5, - }, - }, - { - key: 'noteTextOffset', - flavors, - help: `Annotation note text offset.`, - type: 'number', - required: true, - control: { - type: 'range', - min: -64, - max: 64, - }, - }, - { - key: 'offset', - flavors, - help: `Offset from annotated element.`, - type: 'number', - required: false, - control: { - type: 'range', - min: 0, - max: 32, - }, - }, - ], - }, - } -} + defaultValue?: AnnotationMatcher[] + createDefaults: AnnotationMatcher +}): ChartProperty => ({ + key, + group, + help: `Annotations for ${target}.`, + type, + required: false, + flavors, + defaultValue, + control: { + type: 'annotations', + createDefaults, + }, +})