diff --git a/packages/annotations/src/Annotation.tsx b/packages/annotations/src/Annotation.tsx index 05dc0d10f6..eb4897b5c2 100644 --- a/packages/annotations/src/Annotation.tsx +++ b/packages/annotations/src/Annotation.tsx @@ -1,59 +1,44 @@ import React from 'react' -import { defaultProps } from './props' import { useComputedAnnotation } from './hooks' import { AnnotationNote } from './AnnotationNote' import { AnnotationLink } from './AnnotationLink' import { CircleAnnotationOutline } from './CircleAnnotationOutline' import { DotAnnotationOutline } from './DotAnnotationOutline' import { RectAnnotationOutline } from './RectAnnotationOutline' -import { AnnotationType, RelativeOrAbsolutePosition } from './types' +import { + AnnotationSpec, + isCircleAnnotation, + isDotAnnotation, + isRectAnnotation, + NoteCanvasRenderer, +} from './types' -export const Annotation = ({ - datum, - type, - x, - y, - size, - width, - height, - noteX, - noteY, - noteWidth = defaultProps.noteWidth, - noteTextOffset = defaultProps.noteTextOffset, - note, -}: { - datum: Datum - type: AnnotationType - x: number - y: number - size?: number - width?: number - height?: number - noteX: RelativeOrAbsolutePosition - noteY: RelativeOrAbsolutePosition - noteWidth?: number - noteTextOffset?: number - note: any -}) => { - const computed = useComputedAnnotation({ - type, - x, - y, - size, - width, - height, - noteX, - noteY, - noteWidth, - noteTextOffset, - }) +export const Annotation = ( + annotationSpec: Omit, 'note'> & { + note: Exclude['note'], NoteCanvasRenderer> + } +) => { + const { datum, x, y, note } = annotationSpec + + const computed = useComputedAnnotation(annotationSpec) return ( <> - {type === 'circle' && } - {type === 'dot' && } - {type === 'rect' && } + {isCircleAnnotation(annotationSpec) && ( + + )} + {isDotAnnotation(annotationSpec) && ( + + )} + {isRectAnnotation(annotationSpec) && ( + + )} diff --git a/packages/annotations/src/AnnotationLink.tsx b/packages/annotations/src/AnnotationLink.tsx index 2c5b6dbf21..6c3ff850dc 100644 --- a/packages/annotations/src/AnnotationLink.tsx +++ b/packages/annotations/src/AnnotationLink.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { animated } from '@react-spring/web' import { useAnimatedPath, useTheme } from '@nivo/core' @@ -10,12 +10,16 @@ export const AnnotationLink = ({ isOutline?: boolean }) => { const theme = useTheme() - const [firstPoint, ...otherPoints] = points - const path = otherPoints.reduce( - (acc, [x, y]) => `${acc} L${x},${y}`, - `M${firstPoint[0]},${firstPoint[1]}` - ) + const path = useMemo(() => { + const [firstPoint, ...otherPoints] = points + + return otherPoints.reduce( + (acc, [x, y]) => `${acc} L${x},${y}`, + `M${firstPoint[0]},${firstPoint[1]}` + ) + }, [[points]]) + const animatedPath = useAnimatedPath(path) if (isOutline && theme.annotations.link.outlineWidth <= 0) { diff --git a/packages/annotations/src/AnnotationNote.tsx b/packages/annotations/src/AnnotationNote.tsx index b8158b247e..99865d4672 100644 --- a/packages/annotations/src/AnnotationNote.tsx +++ b/packages/annotations/src/AnnotationNote.tsx @@ -1,7 +1,8 @@ -import React from 'react' +import React, { createElement } from 'react' import omit from 'lodash/omit' import { useSpring, animated } from '@react-spring/web' import { useTheme, useMotionConfig } from '@nivo/core' +import { AnnotationSpec, NoteCanvasRenderer } from './types' export const AnnotationNote = ({ datum, @@ -12,8 +13,7 @@ export const AnnotationNote = ({ datum: Datum x: number y: number - // PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired - note: any + note: Exclude['note'], NoteCanvasRenderer> }) => { const theme = useTheme() const { animate, config: springConfig } = useMotionConfig() @@ -26,7 +26,7 @@ export const AnnotationNote = ({ }) if (typeof note === 'function') { - return note({ x, y, datum }) + return createElement(note, { x, y, datum }) } return ( diff --git a/packages/annotations/src/DotAnnotationOutline.tsx b/packages/annotations/src/DotAnnotationOutline.tsx index 92e644d834..0c0fca6b6d 100644 --- a/packages/annotations/src/DotAnnotationOutline.tsx +++ b/packages/annotations/src/DotAnnotationOutline.tsx @@ -1,11 +1,12 @@ import React from 'react' import { useSpring, animated } from '@react-spring/web' import { useMotionConfig, useTheme } from '@nivo/core' +import { defaultProps } from './props' export const DotAnnotationOutline = ({ x, y, - size = 4, + size = defaultProps.dotSize, }: { x: number y: number diff --git a/packages/annotations/src/canvas.ts b/packages/annotations/src/canvas.ts index 69f771f0a4..66ff1be840 100644 --- a/packages/annotations/src/canvas.ts +++ b/packages/annotations/src/canvas.ts @@ -1,4 +1,11 @@ import { CompleteTheme } from '@nivo/core' +import { + ComputedAnnotationSpec, + isCircleAnnotation, + isDotAnnotation, + isRectAnnotation, + NoteComponent, +} from './types' const drawPoints = (ctx: CanvasRenderingContext2D, points: [number, number][]) => { points.forEach(([x, y], index) => { @@ -10,13 +17,15 @@ const drawPoints = (ctx: CanvasRenderingContext2D, points: [number, number][]) = }) } -export const renderAnnotationsToCanvas = ( +export const renderAnnotationsToCanvas = ( ctx: CanvasRenderingContext2D, { annotations, theme, }: { - annotations: any[] + annotations: (Omit, 'note'> & { + note: Exclude['note'], NoteComponent> + })[] theme: CompleteTheme } ) => { @@ -35,7 +44,7 @@ export const renderAnnotationsToCanvas = ( ctx.lineCap = 'butt' } - if (annotation.type === 'circle' && theme.annotations.outline.outlineWidth > 0) { + if (isCircleAnnotation(annotation) && theme.annotations.outline.outlineWidth > 0) { ctx.strokeStyle = theme.annotations.outline.outlineColor ctx.lineWidth = theme.annotations.outline.strokeWidth + theme.annotations.outline.outlineWidth * 2 @@ -43,14 +52,16 @@ export const renderAnnotationsToCanvas = ( ctx.arc(annotation.x, annotation.y, annotation.size / 2, 0, 2 * Math.PI) ctx.stroke() } - if (annotation.type === 'dot' && theme.annotations.symbol.outlineWidth > 0) { + + if (isDotAnnotation(annotation) && theme.annotations.symbol.outlineWidth > 0) { ctx.strokeStyle = theme.annotations.symbol.outlineColor ctx.lineWidth = theme.annotations.symbol.outlineWidth * 2 ctx.beginPath() ctx.arc(annotation.x, annotation.y, annotation.size / 2, 0, 2 * Math.PI) ctx.stroke() } - if (annotation.type === 'rect' && theme.annotations.outline.outlineWidth > 0) { + + if (isRectAnnotation(annotation) && theme.annotations.outline.outlineWidth > 0) { ctx.strokeStyle = theme.annotations.outline.outlineColor ctx.lineWidth = theme.annotations.outline.strokeWidth + theme.annotations.outline.outlineWidth * 2 @@ -70,20 +81,22 @@ export const renderAnnotationsToCanvas = ( drawPoints(ctx, annotation.computed.points) ctx.stroke() - if (annotation.type === 'circle') { + if (isCircleAnnotation(annotation)) { ctx.strokeStyle = theme.annotations.outline.stroke ctx.lineWidth = theme.annotations.outline.strokeWidth ctx.beginPath() ctx.arc(annotation.x, annotation.y, annotation.size / 2, 0, 2 * Math.PI) ctx.stroke() } - if (annotation.type === 'dot') { + + if (isDotAnnotation(annotation)) { ctx.fillStyle = theme.annotations.symbol.fill ctx.beginPath() ctx.arc(annotation.x, annotation.y, annotation.size / 2, 0, 2 * Math.PI) ctx.fill() } - if (annotation.type === 'rect') { + + if (isRectAnnotation(annotation)) { ctx.strokeStyle = theme.annotations.outline.stroke ctx.lineWidth = theme.annotations.outline.strokeWidth ctx.beginPath() diff --git a/packages/annotations/src/compute.ts b/packages/annotations/src/compute.ts index 4109d47d57..b16589a6bd 100644 --- a/packages/annotations/src/compute.ts +++ b/packages/annotations/src/compute.ts @@ -1,4 +1,4 @@ -import isPlainObject from 'lodash/isPlainObject' +import isNumber from 'lodash/isNumber' import filter from 'lodash/filter' import omit from 'lodash/omit' import { @@ -8,32 +8,43 @@ import { positionFromAngle, } from '@nivo/core' import { defaultProps } from './props' -import { AnnotationSpec, AnnotationSpecWithMatcher } from './types' - -const defaultPositionAccessor = item => ({ x: item.x, y: item.y }) - -export const bindAnnotations = ({ - items, +import { + AnnotationSpec, + AnnotationSpecWithMatcher, + isCircleAnnotation, + isRectAnnotation, + AnnotationPositionGetter, + AnnotationDimensionsGetter, + ComputedAnnotation, +} from './types' + +export const bindAnnotations = < + Datum = { + x: number + y: number + } +>({ + data, annotations, - getPosition = defaultPositionAccessor, + getPosition, getDimensions, }: { - items: Datum[] + data: Datum[] annotations: AnnotationSpecWithMatcher[] - getPosition: any - getDimensions: any -}) => - annotations.reduce((acc, annotation) => { - filter(items, annotation.match).forEach(item => { - const position = getPosition(item) - const dimensions = getDimensions(item, annotation.offset || 0) + getPosition: AnnotationPositionGetter + getDimensions: AnnotationDimensionsGetter +}): AnnotationSpec[] => + annotations.reduce((acc: AnnotationSpec[], annotation) => { + filter(data, annotation.match).forEach(datum => { + const position = getPosition(datum) + const dimensions = getDimensions(datum, annotation.offset || 0) acc.push({ ...omit(annotation, ['match', 'offset']), ...position, ...dimensions, - datum: item, size: annotation.size || dimensions.size, + datum, }) }) @@ -51,35 +62,35 @@ export const getLinkAngle = ( return absoluteAngleDegrees(radiansToDegrees(angle)) } -export const computeAnnotation = ({ - type, - x, - y, - size, - width, - height, - noteX, - noteY, - noteWidth = defaultProps.noteWidth, - noteTextOffset = defaultProps.noteTextOffset, -}: AnnotationSpec) => { - let computedNoteX - let computedNoteY - - if (isPlainObject(noteX)) { - if (noteX.abs !== undefined) { - computedNoteX = noteX.abs - } - } else { +export const computeAnnotation = ( + annotationSpec: Omit, 'datum' | 'note'> +): ComputedAnnotation => { + const { + x, + y, + noteX, + noteY, + noteWidth = defaultProps.noteWidth, + noteTextOffset = defaultProps.noteTextOffset, + } = annotationSpec + + let computedNoteX: number + let computedNoteY: number + + if (isNumber(noteX)) { computedNoteX = x + noteX + } else if (noteX.abs !== undefined) { + computedNoteX = noteX.abs + } else { + throw new Error(`noteX should be either a number or an object containing an 'abs' property`) } - if (isPlainObject(noteY)) { - if (noteY.abs !== undefined) { - computedNoteY = noteY.abs - } - } else { + if (isNumber(noteY)) { computedNoteY = y + noteY + } else if (noteY.abs !== undefined) { + computedNoteY = noteY.abs + } else { + throw new Error(`noteY should be either a number or an object containing an 'abs' property`) } let computedX = x @@ -87,49 +98,49 @@ export const computeAnnotation = ({ const angle = getLinkAngle(x, y, computedNoteX, computedNoteY) - if (type === 'circle') { - const position = positionFromAngle(degreesToRadians(angle), size / 2) + if (isCircleAnnotation(annotationSpec)) { + const position = positionFromAngle(degreesToRadians(angle), annotationSpec.size / 2) computedX += position.x computedY += position.y } - if (type === 'rect') { + if (isRectAnnotation(annotationSpec)) { const eighth = Math.round((angle + 90) / 45) % 8 if (eighth === 0) { - computedY -= height / 2 + computedY -= annotationSpec.height / 2 } if (eighth === 1) { - computedX += width / 2 - computedY -= height / 2 + computedX += annotationSpec.width / 2 + computedY -= annotationSpec.height / 2 } if (eighth === 2) { - computedX += width / 2 + computedX += annotationSpec.width / 2 } if (eighth === 3) { - computedX += width / 2 - computedY += height / 2 + computedX += annotationSpec.width / 2 + computedY += annotationSpec.height / 2 } if (eighth === 4) { - computedY += height / 2 + computedY += annotationSpec.height / 2 } if (eighth === 5) { - computedX -= width / 2 - computedY += height / 2 + computedX -= annotationSpec.width / 2 + computedY += annotationSpec.height / 2 } if (eighth === 6) { - computedX -= width / 2 + computedX -= annotationSpec.width / 2 } if (eighth === 7) { - computedX -= width / 2 - computedY -= height / 2 + computedX -= annotationSpec.width / 2 + computedY -= annotationSpec.height / 2 } } let textX = computedNoteX - let textY = computedNoteY - noteTextOffset + const textY = computedNoteY - noteTextOffset let noteLineX = computedNoteX - let noteLineY = computedNoteY + const noteLineY = computedNoteY if ((angle + 90) % 360 > 180) { textX -= noteWidth @@ -143,7 +154,7 @@ export const computeAnnotation = ({ [computedX, computedY], [computedNoteX, computedNoteY], [noteLineX, noteLineY], - ], + ] as [number, number][], text: [textX, textY], angle: angle + 90, } diff --git a/packages/annotations/src/hooks.ts b/packages/annotations/src/hooks.ts index f4a3eabce9..1b30792196 100644 --- a/packages/annotations/src/hooks.ts +++ b/packages/annotations/src/hooks.ts @@ -1,66 +1,53 @@ import { useMemo } from 'react' import { bindAnnotations, computeAnnotation } from './compute' -import { AnnotationSpec, AnnotationSpecWithMatcher } from './types' +import { + AnnotationDimensionsGetter, + AnnotationPositionGetter, + AnnotationSpec, + AnnotationSpecWithMatcher, +} from './types' -export const useAnnotations = ({ - items, - annotations, - getPosition, - getDimensions, -}: { - items: Datum[] +export const useAnnotations = (params: { + data: Datum[] annotations: AnnotationSpecWithMatcher[] - getPosition: any - getDimensions: any + getPosition: AnnotationPositionGetter + getDimensions: AnnotationDimensionsGetter }) => - useMemo( - () => - bindAnnotations({ - items, - annotations, - getPosition, - getDimensions, - }), - [items, annotations, getPosition, getDimensions] - ) + useMemo(() => bindAnnotations(params), [ + params.data, + params.annotations, + params.getPosition, + params.getDimensions, + ]) -export const useComputedAnnotations = ({ annotations }: { annotations: AnnotationSpec[] }) => +export const useComputedAnnotations = ({ + annotations, +}: { + annotations: AnnotationSpec[] +}) => useMemo( () => annotations.map(annotation => ({ ...annotation, - computed: computeAnnotation({ + computed: computeAnnotation({ ...annotation, }), })), [annotations] ) -export const useComputedAnnotation = ({ - type, - x, - y, - size, - width, - height, - noteX, - noteY, - noteWidth, - noteTextOffset, -}: AnnotationSpec) => - useMemo( - () => - computeAnnotation({ - type, - x, - y, - size, - width, - height, - noteX, - noteY, - noteWidth, - noteTextOffset, - }), - [type, x, y, size, width, height, noteX, noteY, noteWidth, noteTextOffset] - ) +export const useComputedAnnotation = ( + annotationSpec: Omit, 'datum' | 'note'> +) => + useMemo(() => computeAnnotation(annotationSpec), [ + annotationSpec.type, + annotationSpec.x, + annotationSpec.y, + annotationSpec.size, + annotationSpec.width, + annotationSpec.height, + annotationSpec.noteX, + annotationSpec.noteY, + annotationSpec.noteWidth, + annotationSpec.noteTextOffset, + ]) diff --git a/packages/annotations/src/props.ts b/packages/annotations/src/props.ts index 9d033c5e85..caaa60069b 100644 --- a/packages/annotations/src/props.ts +++ b/packages/annotations/src/props.ts @@ -1,33 +1,14 @@ -import PropTypes from 'prop-types' - +/* export const annotationSpecPropType = PropTypes.shape({ match: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, - - type: PropTypes.oneOf(['circle', 'rect', 'dot']).isRequired, - - noteX: PropTypes.oneOfType([ - PropTypes.number, - PropTypes.shape({ - abs: PropTypes.number.isRequired, - }), - ]).isRequired, - noteY: PropTypes.oneOfType([ - PropTypes.number, - PropTypes.shape({ - abs: PropTypes.number.isRequired, - }), - ]).isRequired, - noteWidth: PropTypes.number, - noteTextOffset: PropTypes.number, - note: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, - offset: PropTypes.number, }) +*/ export const defaultProps = { + dotSize: 4, noteWidth: 120, noteTextOffset: 8, - animate: true, motionStiffness: 90, motionDamping: 13, diff --git a/packages/annotations/src/types.ts b/packages/annotations/src/types.ts index 700e71e4ec..d0401c8c08 100644 --- a/packages/annotations/src/types.ts +++ b/packages/annotations/src/types.ts @@ -1,21 +1,120 @@ -export type AnnotationType = 'circle' | 'dot' | 'rect' +import { CompleteTheme } from '@nivo/core' +// When passing a simple number, the position +// is considered to be a relative position, +// when using an object with an `abs` property, +// then it's absolute. export type RelativeOrAbsolutePosition = number | { abs: number } -export interface AnnotationSpec { - type: AnnotationType +export type AnnotationPositionGetter = ( + datum: Datum +) => { x: number y: number +} + +export type AnnotationDimensionsGetter = ( + datum: Datum, + offset: number +) => { size: number width: number height: number +} + +export type NoteCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: { + datum: Datum + x: number + y: number + theme: CompleteTheme + } +) => void + +export type NoteComponent = (props: { datum: Datum; x: number; y: number }) => JSX.Element + +export interface CommonAnnotationSpec { + // the datum associated to the annotated element + datum: Datum + // x coordinate of the annotated element + x: number + // y coordinate of the annotated element + y: number + note: string | NoteComponent | NoteCanvasRenderer + // x coordinate of the note, can be either + // relative to the annotated element or absolute. noteX: RelativeOrAbsolutePosition + // y coordinate of the note, can be either + // relative to the annotated element or absolute. 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 +// around the annotated element. +export type CircleAnnotationSpec = CommonAnnotationSpec & { + type: 'circle' + // diameter of the circle + size: number } -export interface AnnotationSpecWithMatcher extends AnnotationSpec { +export const isCircleAnnotation = ( + annotationSpec: Omit, 'datum' | 'note'> +): annotationSpec is CircleAnnotationSpec => annotationSpec.type === 'circle' + +// This annotation can be used to put a dot +// on the annotated element. +export type DotAnnotationSpec = CommonAnnotationSpec & { + type: 'dot' + // diameter of the dot + size: number +} + +export const isDotAnnotation = ( + annotationSpec: Omit, 'datum' | 'note'> +): annotationSpec is DotAnnotationSpec => annotationSpec.type === 'dot' + +// This annotation can be used to draw a rectangle +// around the annotated element. +export type RectAnnotationSpec = CommonAnnotationSpec & { + type: 'rect' + width: number + height: number +} + +export const isRectAnnotation = ( + annotationSpec: Omit, 'datum' | 'note'> +): annotationSpec is RectAnnotationSpec => annotationSpec.type === 'rect' + +export type AnnotationSpec = + | CircleAnnotationSpec + | DotAnnotationSpec + | RectAnnotationSpec + +export type AnnotationType = AnnotationSpec['type'] + +export type AnnotationSpecWithMatcher = AnnotationSpec & { match: (datum: Datum) => boolean offset?: number } + +export type ComputedAnnotation = { + // points of the link + points: [number, number][] + // position of the text + text: [number, number] + // in degrees + angle: number +} + +export type ComputedAnnotationSpec = AnnotationSpec & { + computed: ComputedAnnotation +} diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index da281419f4..32ac58cdbe 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -323,6 +323,7 @@ declare module '@nivo/core' { export function degreesToRadians(degrees: number): number export function radiansToDegrees(radians: number): number + export function absoluteAngleDegrees(degrees: number): number type Accessor = T extends string ? U[T] : never diff --git a/packages/funnel/index.d.ts b/packages/funnel/index.d.ts index 47630f6dfe..6c24a889cd 100644 --- a/packages/funnel/index.d.ts +++ b/packages/funnel/index.d.ts @@ -9,6 +9,7 @@ import * as React from 'react' import { Dimensions, Box, Theme, MotionProps } from '@nivo/core' import { OrdinalColorScaleConfig, InheritedColorConfig } from '@nivo/colors' +import { AnnotationSpecWithMatcher } from '@nivo/annotations' declare module '@nivo/funnel' { export interface Position { @@ -107,6 +108,8 @@ declare module '@nivo/funnel' { layers?: Layer[] + annotations?: AnnotationSpecWithMatcher[] + isInteractive?: boolean currentPartSizeExtension?: number currentBorderWidth?: number diff --git a/packages/funnel/src/FunnelAnnotations.js b/packages/funnel/src/FunnelAnnotations.js index 11d5132cd4..edf8cd9a0c 100644 --- a/packages/funnel/src/FunnelAnnotations.js +++ b/packages/funnel/src/FunnelAnnotations.js @@ -2,12 +2,10 @@ import React from 'react' import { Annotation } from '@nivo/annotations' import { useFunnelAnnotations } from './hooks' -export const FunnelAnnotations = ({ parts, annotations, width, height }) => { +export const FunnelAnnotations = ({ parts, annotations }) => { const boundAnnotations = useFunnelAnnotations(parts, annotations) - return boundAnnotations.map((annotation, i) => ( - - )) + return boundAnnotations.map((annotation, i) => ) } FunnelAnnotations.propTypes = {} diff --git a/packages/funnel/src/hooks.js b/packages/funnel/src/hooks.js index c5b2ab5aee..dfca5e0ad2 100644 --- a/packages/funnel/src/hooks.js +++ b/packages/funnel/src/hooks.js @@ -555,8 +555,12 @@ export const useFunnel = ({ export const useFunnelAnnotations = (parts, annotations) => { return useAnnotations({ - items: parts, + data: parts, annotations, + getPosition: part => ({ + x: part.x, + y: part.y, + }), getDimensions: (part, offset) => { const width = part.width + offset * 2 const height = part.height + offset * 2 diff --git a/packages/funnel/src/props.js b/packages/funnel/src/props.js index e0e6f334e5..eaa52e11e6 100644 --- a/packages/funnel/src/props.js +++ b/packages/funnel/src/props.js @@ -10,7 +10,6 @@ import PropTypes from 'prop-types' import { ordinalColorsPropType, inheritedColorPropType } from '@nivo/colors' import { MotionConfigProvider } from '@nivo/core' import { motionPropTypes } from '@nivo/core' -import { annotationSpecPropType } from '@nivo/annotations' export const FunnelPropTypes = { data: PropTypes.arrayOf( @@ -50,7 +49,7 @@ export const FunnelPropTypes = { afterSeparatorLength: PropTypes.number.isRequired, afterSeparatorOffset: PropTypes.number.isRequired, - annotations: PropTypes.arrayOf(annotationSpecPropType).isRequired, + annotations: PropTypes.arrayOf(PropTypes.object).isRequired, isInteractive: PropTypes.bool.isRequired, currentPartSizeExtension: PropTypes.number.isRequired, diff --git a/packages/scatterplot/src/ScatterPlotAnnotations.js b/packages/scatterplot/src/ScatterPlotAnnotations.js index fb08402470..0ea5dff6b2 100644 --- a/packages/scatterplot/src/ScatterPlotAnnotations.js +++ b/packages/scatterplot/src/ScatterPlotAnnotations.js @@ -2,17 +2,10 @@ import React from 'react' import { Annotation } from '@nivo/annotations' import { useScatterPlotAnnotations } from './hooks' -const ScatterPlotAnnotations = ({ nodes, annotations, innerWidth, innerHeight }) => { +const ScatterPlotAnnotations = ({ nodes, annotations }) => { const boundAnnotations = useScatterPlotAnnotations(nodes, annotations) - return boundAnnotations.map((annotation, i) => ( - - )) + return boundAnnotations.map((annotation, i) => ) } ScatterPlotAnnotations.propTypes = {} diff --git a/packages/scatterplot/src/hooks.js b/packages/scatterplot/src/hooks.js index d4082c59c1..62418deb82 100644 --- a/packages/scatterplot/src/hooks.js +++ b/packages/scatterplot/src/hooks.js @@ -80,10 +80,15 @@ export const useScatterPlot = ({ export const useScatterPlotAnnotations = (items, annotations) => useAnnotations({ - items, + data: items, annotations, + getPosition: node => ({ + x: node.x, + y: node.y, + }), getDimensions: (node, offset) => { const size = node.size + offset * 2 + return { size, width: size, height: size } }, }) diff --git a/packages/scatterplot/src/props.js b/packages/scatterplot/src/props.js index 7f4938209d..8c022944c3 100644 --- a/packages/scatterplot/src/props.js +++ b/packages/scatterplot/src/props.js @@ -12,7 +12,6 @@ import { ordinalColorsPropType } from '@nivo/colors' import { axisPropType } from '@nivo/axes' import { LegendPropShape } from '@nivo/legends' import { scalePropType } from '@nivo/scales' -import { annotationSpecPropType } from '@nivo/annotations' import Node from './Node' import Tooltip from './Tooltip' @@ -55,7 +54,7 @@ const commonPropTypes = { axisBottom: axisPropType, axisLeft: axisPropType, - annotations: PropTypes.arrayOf(annotationSpecPropType).isRequired, + annotations: PropTypes.arrayOf(PropTypes.object).isRequired, nodeSize: PropTypes.oneOfType([ PropTypes.number, diff --git a/packages/swarmplot/legacy/index.d.ts b/packages/swarmplot/legacy/index.d.ts deleted file mode 100644 index cb0ad53216..0000000000 --- a/packages/swarmplot/legacy/index.d.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * This file is part of the nivo project. - * - * Copyright 2016-present, Raphaƫl Benitte. - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -import { Component } from 'react' -import { AxisProps, GridValues } from '@nivo/axes' -import { Box, MotionProps, Dimensions, Theme } from '@nivo/core' -import { OrdinalColorScaleConfig, InheritedColorConfig } from '@nivo/colors' - -declare module '@nivo/swarmplot' { - export interface ComputedNode { - id: string - index: number - group: string - label: string - value: number - formattedValue: number | string - x: number - y: number - size: number - color: string - data: Datum - } - - export interface LayerProps { - nodes: ComputedNode[] - xScale: (input: number) => number - yScale: (input: number) => number - innerWidth: number - innerHeight: number - outerWidth: number - outerHeight: number - margin: number - getBorderColor: () => string - getBorderWidth: () => number - animate: boolean - motionStiffness: number - motionDamping: number - } - - export enum SwarmPlotLayerType { - Grid = 'grid', - Axes = 'axes', - Nodes = 'nodes', - Mesh = 'mesh', - Annotations = 'annotations', - } - - type DatumAccessor = (datum: Datum) => T - type ComputedNodeAccessor = (node: ComputedNode) => T - - export interface DynamicSizeSpec { - key: string - values: [number, number] - sizes: [number, number] - } - - export type SwarmPlotMouseHandler = ( - node: ComputedNode, - event: React.MouseEvent - ) => void - - export type SwarmPlotCustomLayer = (props: LayerProps) => JSX.Element - export type Layers = SwarmPlotCustomLayer | SwarmPlotLayerType - - type ValueFormatter = (datum: Datum) => string | number - - interface CommonSwarmPlotProps { - data: Datum[] - - margin?: Box - - groups: string[] - groupBy?: string | DatumAccessor - identity?: string | DatumAccessor - label?: string | DatumAccessor - value?: string | DatumAccessor - valueScale?: any - valueFormat?: string | ValueFormatter - size?: number | DatumAccessor | DynamicSizeSpec - spacing?: number - layout?: 'horizontal' | 'vertical' - gap?: number - - forceStrength?: number - simulationIterations?: number - - layers?: Layers[] - - colors?: OrdinalColorScaleConfig - colorBy?: string | ComputedNodeAccessor - theme?: Theme - borderWidth?: number | ComputedNodeAccessor - borderColor?: InheritedColorConfig> - - enableGridX?: boolean - gridXValues?: GridValues - enableGridY?: boolean - gridYValues?: GridValues - - axisTop?: AxisProps | null - axisRight?: AxisProps | null - axisBottom?: AxisProps | null - axisLeft?: AxisProps | null - - isInteractive?: boolean - useMesh?: boolean - debugMesh?: boolean - onMouseEnter?: SwarmPlotMouseHandler - onMouseMove?: SwarmPlotMouseHandler - onMouseLeave?: SwarmPlotMouseHandler - onClick?: SwarmPlotMouseHandler - tooltip?: any - } - - export type SwarmPlotProps = CommonSwarmPlotProps & MotionProps & { role?: string } - - export class SwarmPlot extends Component {} - export class ResponsiveSwarmPlot extends Component {} - - export type SwarmPlotCanvasProps = CommonSwarmPlotProps & { - pixelRatio?: number - } - - export class SwarmPlotCanvas extends Component {} - export class ResponsiveSwarmPlotCanvas extends Component {} -} diff --git a/packages/swarmplot/src/Circles.tsx b/packages/swarmplot/src/Circles.tsx index b0a2212a62..f472fd2b25 100644 --- a/packages/swarmplot/src/Circles.tsx +++ b/packages/swarmplot/src/Circles.tsx @@ -4,6 +4,7 @@ import { useMotionConfig, useTheme } from '@nivo/core' import { useInheritedColor } from '@nivo/colors' import { useTooltip } from '@nivo/tooltip' import { ComputedDatum, CircleComponent, MouseHandlers, SwarmPlotCommonProps } from './types' +import { useBorderWidth } from './hooks' /** * A negative radius value is invalid for an SVG circle, @@ -103,6 +104,7 @@ export const Circles = ({ const { animate, config: springConfig } = useMotionConfig() const theme = useTheme() + const getBorderWidth = useBorderWidth(borderWidth) const getBorderColor = useInheritedColor>(borderColor, theme) const transitionPhases = useMemo(() => getTransitionPhases(getBorderColor), [ @@ -139,7 +141,7 @@ export const Circles = ({ style: { ...transitionProps, radius: interpolateRadius(transitionProps.radius), - borderWidth, + borderWidth: getBorderWidth(node), }, onMouseEnter: handleMouseEnter, onMouseMove: handleMouseMove, diff --git a/packages/swarmplot/src/SwarmPlot.tsx b/packages/swarmplot/src/SwarmPlot.tsx index 7eb4d41906..f6b0f136cf 100644 --- a/packages/swarmplot/src/SwarmPlot.tsx +++ b/packages/swarmplot/src/SwarmPlot.tsx @@ -8,7 +8,7 @@ import { defaultProps } from './props' import { useSwarmPlot } from './hooks' import { Circles } from './Circles' import { CircleSvg } from './CircleSvg' -// import SwarmPlotAnnotations from './SwarmPlotAnnotations' +import { SwarmPlotAnnotations } from './SwarmPlotAnnotations' type InnerSwarmPlotProps = Partial< Omit< @@ -55,6 +55,7 @@ const InnerSwarmPlot = ({ // onMouseLeave, // onClick, tooltip = defaultProps.tooltip, + annotations = defaultProps.annotations, role = defaultProps.role, }: InnerSwarmPlotProps) => { const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions( @@ -86,9 +87,9 @@ const InnerSwarmPlot = ({ const layerById: Record = { grid: null, axes: null, - nodes: null, - mesh: null, + circles: null, annotations: null, + mesh: null, } if (layers.includes('grid')) { @@ -97,9 +98,11 @@ const InnerSwarmPlot = ({ key="grid" width={innerWidth} height={innerHeight} - xScale={enableGridX ? (xScale as any) : null} + // @ts-ignore should be fixed when axes package is migrated to TS + xScale={enableGridX ? xScale : null} xValues={gridXValues} - yScale={enableGridY ? (yScale as any) : null} + // @ts-ignore should be fixed when axes package is migrated to TS + yScale={enableGridY ? yScale : null} yValues={gridYValues} /> ) @@ -109,8 +112,10 @@ const InnerSwarmPlot = ({ layerById.axes = ( ({ ) } - if (layers.includes('nodes')) { - layerById.nodes = ( + if (layers.includes('circles')) { + layerById.circles = ( - key="nodes" + key="circles" nodes={nodes} borderWidth={0} borderColor={borderColor} @@ -135,6 +140,16 @@ const InnerSwarmPlot = ({ ) } + if (layers.includes('annotations')) { + layerById.annotations = ( + + key="annotations" + nodes={nodes} + annotations={annotations} + /> + ) + } + if (isInteractive && useMesh) { layerById.mesh = ( { - const boundAnnotations = useSwarmPlotAnnotations(nodes, annotations) - - return boundAnnotations.map((annotation, i) => ( - - )) -} - -SwarmPlotAnnotations.propTypes = {} - -export default SwarmPlotAnnotations diff --git a/packages/swarmplot/src/SwarmPlotAnnotations.tsx b/packages/swarmplot/src/SwarmPlotAnnotations.tsx new file mode 100644 index 0000000000..a142f0ec21 --- /dev/null +++ b/packages/swarmplot/src/SwarmPlotAnnotations.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import { Annotation } from '@nivo/annotations' +import { ComputedDatum, SwarmPlotSvgProps } from './types' +import { useSwarmPlotAnnotations } from './hooks' + +export const SwarmPlotAnnotations = ({ + nodes, + annotations, +}: { + nodes: ComputedDatum[] + annotations: SwarmPlotSvgProps['annotations'] +}) => { + const boundAnnotations = useSwarmPlotAnnotations(nodes, annotations) + + return ( + <> + {boundAnnotations.map((annotation, i) => ( + + ))} + + ) +} diff --git a/packages/swarmplot/src/SwarmPlotCanvas.tsx b/packages/swarmplot/src/SwarmPlotCanvas.tsx index 4280828a5d..5139ffc879 100644 --- a/packages/swarmplot/src/SwarmPlotCanvas.tsx +++ b/packages/swarmplot/src/SwarmPlotCanvas.tsx @@ -18,8 +18,8 @@ export const renderCircleDefault = ( getBorderColor, }: { node: ComputedDatum - getBorderWidth: any - getBorderColor: any + getBorderWidth: (node: ComputedDatum) => number + getBorderColor: (node: ComputedDatum) => string } ) => { const nodeBorderWidth = getBorderWidth(node) @@ -181,7 +181,7 @@ export const InnerSwarmPlotCanvas = ({ }) } - if (layer === 'nodes') { + if (layer === 'circles') { nodes.forEach(node => { renderCircle(ctx, { node, diff --git a/packages/swarmplot/src/compute.ts b/packages/swarmplot/src/compute.ts index 90e1f2dcea..d60f92696d 100644 --- a/packages/swarmplot/src/compute.ts +++ b/packages/swarmplot/src/compute.ts @@ -4,17 +4,25 @@ import isString from 'lodash/isString' import get from 'lodash/get' import { ScaleLinear, scaleLinear, ScaleOrdinal, scaleOrdinal } from 'd3-scale' import { forceSimulation, forceX, forceY, forceCollide, ForceX, ForceY } from 'd3-force' -// @ts-ignore -import { computeScale, createDateNormalizer, generateSeriesAxis } from '@nivo/scales' +import { + // @ts-ignore + computeScale, + // @ts-ignore + createDateNormalizer, + // @ts-ignore + generateSeriesAxis, + Scale, + TimeScaleFormatted, +} from '@nivo/scales' import { ComputedDatum, PreSimulationDatum, SizeSpec, SimulationForces } from './types' -export const getParsedValue = scaleSpec => { +export const getParsedValue = (scaleSpec: Scale) => { if (scaleSpec.type === 'linear') { return parseFloat - } else if (scaleSpec.type === 'time' && scaleSpec.format !== 'native') { + } else if (scaleSpec.type === 'time' && (scaleSpec as TimeScaleFormatted).format !== 'native') { return createDateNormalizer(scaleSpec) } else { - return x => x + return (x: number | string | Date) => x } } @@ -61,7 +69,7 @@ export const computeValueScale = ({ height: number axis: 'x' | 'y' getValue: (datum: RawDatum) => number - scale: any + scale: Scale data: RawDatum[] }) => { const values = data.map(getValue) @@ -175,7 +183,7 @@ export const computeNodes = ({ getSize: (datum: RawDatum) => number forces: SimulationForces simulationIterations: number - valueScaleConfig: any + valueScaleConfig: Scale }) => { const config = { horizontal: ['x', 'y'], diff --git a/packages/swarmplot/src/hooks.ts b/packages/swarmplot/src/hooks.ts index 873637bce6..51ee938b5d 100644 --- a/packages/swarmplot/src/hooks.ts +++ b/packages/swarmplot/src/hooks.ts @@ -2,6 +2,8 @@ import { useMemo } from 'react' import { ScaleLinear, ScaleOrdinal } from 'd3-scale' import { usePropertyAccessor, useValueFormatter } from '@nivo/core' import { useOrdinalColorScale } from '@nivo/colors' +import { AnnotationSpecWithMatcher, useAnnotations } from '@nivo/annotations' +import { Scale } from '@nivo/scales' import { computeValueScale, computeOrdinalScale, @@ -23,7 +25,7 @@ export const useValueScale = ({ height: number axis: 'x' | 'y' getValue: (datum: RawDatum) => number - scale: any + scale: Scale data: RawDatum[] }) => useMemo( @@ -216,3 +218,33 @@ export const useSwarmPlot = ({ getColor, } } + +export const useBorderWidth = ( + borderWidth: SwarmPlotCommonProps['borderWidth'] +) => + useMemo(() => { + if (typeof borderWidth === 'function') return borderWidth + return () => borderWidth + }, [borderWidth]) + +const getNodeAnnotationPosition = (node: ComputedDatum) => ({ + x: node.x, + y: node.y, +}) + +const getNodeAnnotationDimensions = (node: ComputedDatum, offset: number) => { + const size = node.size + offset * 2 + + return { size, width: size, height: size } +} + +export const useSwarmPlotAnnotations = ( + nodes: ComputedDatum[], + annotations: AnnotationSpecWithMatcher>[] +) => + useAnnotations>({ + data: nodes, + annotations, + getPosition: getNodeAnnotationPosition, + getDimensions: getNodeAnnotationDimensions, + }) diff --git a/packages/swarmplot/src/props.ts b/packages/swarmplot/src/props.ts index 41e0515da2..7bf7a8f89a 100644 --- a/packages/swarmplot/src/props.ts +++ b/packages/swarmplot/src/props.ts @@ -1,10 +1,12 @@ +import { Scale } from '@nivo/scales' import { SwarmPlotLayerId } from './types' import { SwarmPlotTooltip } from './SwarmPlotTooltip' export const defaultProps = { id: 'id', value: 'value', - valueScale: { type: 'linear', min: 0, max: 'auto' }, + valueScale: { type: 'linear', min: 0, max: 'auto' } as Scale, + // label: 'id', groupBy: 'group', size: 6, spacing: 2, @@ -16,7 +18,7 @@ export const defaultProps = { colorBy: 'group', borderWidth: 0, borderColor: 'rgba(0, 0, 0, 0)', - layers: ['grid', 'axes', 'nodes', 'mesh', 'annotations'] as SwarmPlotLayerId[], + layers: ['grid', 'axes', 'circles', 'annotations', 'mesh'] as SwarmPlotLayerId[], enableGridX: true, enableGridY: true, axisTop: {}, @@ -29,8 +31,7 @@ export const defaultProps = { tooltip: SwarmPlotTooltip, animate: true, motionConfig: 'gentle', + annotations: [], role: 'img', - // label: 'id', - // annotations: [], pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio ?? 1 : 1, } diff --git a/packages/swarmplot/src/types.ts b/packages/swarmplot/src/types.ts index 34d6ee74a3..ca3d53b830 100644 --- a/packages/swarmplot/src/types.ts +++ b/packages/swarmplot/src/types.ts @@ -4,6 +4,8 @@ import { ForceX, ForceY, ForceCollide } from 'd3-force' import { PropertyAccessor, ValueFormat, Theme, ModernMotionProps, Box } from '@nivo/core' import { InheritedColorConfig, OrdinalColorScaleConfig } from '@nivo/colors' import { GridValues, AxisProps } from '@nivo/axes' +import { Scale } from '@nivo/scales' +import { AnnotationSpecWithMatcher } from '@nivo/annotations' export interface ComputedDatum { id: string @@ -30,10 +32,21 @@ export type SimulationForces = { collision: ForceCollide> } -export type SwarmPlotLayerId = 'grid' | 'axes' | 'nodes' | 'mesh' | 'annotations' +export type SwarmPlotLayerId = 'grid' | 'axes' | 'circles' | 'annotations' | 'mesh' export interface SwarmPlotCustomLayerProps { nodes: ComputedDatum[] + /* + xScale: (input: number) => number + yScale: (input: number) => number + innerWidth: number + innerHeight: number + outerWidth: number + outerHeight: number + margin: number + getBorderColor: () => string + getBorderWidth: () => number + */ } export type SwarmPlotCustomLayer = React.FC> @@ -72,7 +85,7 @@ export type SwarmPlotCommonProps = { id: PropertyAccessor label: PropertyAccessor, string> value: PropertyAccessor - valueScale: any + valueScale: Scale valueFormat: ValueFormat groupBy: PropertyAccessor size: SizeSpec @@ -84,7 +97,7 @@ export type SwarmPlotCommonProps = { theme?: Theme colors: OrdinalColorScaleConfig, 'color'>> colorBy: PropertyAccessor, 'color'>, string> - borderWidth: number | PropertyAccessor, number> + borderWidth: number | ((node: ComputedDatum) => number) borderColor: InheritedColorConfig> enableGridX: boolean gridXValues?: GridValues @@ -99,10 +112,10 @@ export type SwarmPlotCommonProps = { debugMesh: boolean tooltip: (props: ComputedDatum) => JSX.Element layers: SwarmPlotLayer[] + annotations: AnnotationSpecWithMatcher>[] animate: boolean motionConfig: ModernMotionProps['motionConfig'] role: string - // annotations: PropTypes.arrayOf(annotationSpecPropType).isRequired, } export type SwarmPlotSvgProps = SwarmPlotCommonProps & diff --git a/packages/swarmplot/stories/SwarmPlotLayers.tsx b/packages/swarmplot/stories/SwarmPlotLayers.tsx index 4b05409a5e..be598f19f8 100644 --- a/packages/swarmplot/stories/SwarmPlotLayers.tsx +++ b/packages/swarmplot/stories/SwarmPlotLayers.tsx @@ -1,9 +1,10 @@ import React, { useMemo, useState } from 'react' import { generateSwarmPlotData } from '@nivo/generators' +// @ts-ignore import { PatternLines } from '../../core/src' import { SwarmPlot } from '../src' -const backgroundLayer = ({ xScale, innerHeight }) => ( +const BackgroundLayer = ({ xScale, innerHeight }) => ( <> { layers={[ 'grid', 'axes', - backgroundLayer, - 'nodes', + BackgroundLayer, + 'circles', props => , ]} theme={{ background: 'rgb(199, 234, 229)' }} diff --git a/packages/swarmplot/stories/SwarmPlotRenderNode.tsx b/packages/swarmplot/stories/SwarmPlotRenderNode.tsx index bbd420a2d7..1e00ee4747 100644 --- a/packages/swarmplot/stories/SwarmPlotRenderNode.tsx +++ b/packages/swarmplot/stories/SwarmPlotRenderNode.tsx @@ -96,7 +96,7 @@ const SwarmPlotRenderNode = () => { data={data.data} groups={data.groups} groupBy="group" - identity="id" + id="id" value="price" valueScale={{ type: 'linear', @@ -118,8 +118,8 @@ const SwarmPlotRenderNode = () => { legendPosition: 'middle', legendOffset: 50, }} - renderNode={props => } - layers={['grid', 'axes', shadowsLayer, 'nodes']} + circleComponent={props => } + layers={['grid', 'axes', shadowsLayer, 'circles']} layout="horizontal" theme={theme} /> diff --git a/website/src/pages/swarmplot/index.js b/website/src/pages/swarmplot/index.js index b723dfcae0..fa4776d3c5 100644 --- a/website/src/pages/swarmplot/index.js +++ b/website/src/pages/swarmplot/index.js @@ -131,6 +131,32 @@ const ScatterPlot = () => { data: node, }) }} + annotations={[ + { + type: 'circle', + match: { index: 40 }, + noteX: 40, + noteY: 40, + offset: 4, + note: 'Node at index: 40', + }, + { + type: 'rect', + match: { index: 80 }, + noteX: -40, + noteY: -40, + offset: 4, + note: 'Node at index: 80', + }, + { + type: 'dot', + match: { index: 120 }, + noteX: 0, + noteY: { abs: -20 }, + size: 6, + note: 'Node at index: 120', + }, + ]} /> ) }}