Skip to content

Commit

Permalink
feat(annotations): improve svg & canvas note type handling
Browse files Browse the repository at this point in the history
  • Loading branch information
plouc authored and wyze committed Jun 22, 2021
1 parent e808e25 commit bf0ba03
Show file tree
Hide file tree
Showing 8 changed files with 102 additions and 70 deletions.
29 changes: 14 additions & 15 deletions packages/annotations/src/Annotation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,36 @@ import { CircleAnnotationOutline } from './CircleAnnotationOutline'
import { DotAnnotationOutline } from './DotAnnotationOutline'
import { RectAnnotationOutline } from './RectAnnotationOutline'
import {
AnnotationSpec,
BoundAnnotation,
isCircleAnnotation,
isDotAnnotation,
isRectAnnotation,
NoteCanvasRenderer,
isSvgNote,
} from './types'

export const Annotation = <Datum,>(
annotationSpec: Omit<AnnotationSpec<Datum>, 'note'> & {
note: Exclude<AnnotationSpec<Datum>['note'], NoteCanvasRenderer>
export const Annotation = <Datum,>(annotation: BoundAnnotation<Datum>) => {
const { datum, x, y, note } = annotation
if (!isSvgNote(note)) {
throw new Error('note should be a valid react element')
}
) => {
const { datum, x, y, note } = annotationSpec

const computed = useComputedAnnotation<Datum>(annotationSpec)
const computed = useComputedAnnotation(annotation)

return (
<>
<AnnotationLink points={computed.points} isOutline={true} />
{isCircleAnnotation(annotationSpec) && (
<CircleAnnotationOutline x={x} y={y} size={annotationSpec.size} />
{isCircleAnnotation(annotation) && (
<CircleAnnotationOutline x={x} y={y} size={annotation.size} />
)}
{isDotAnnotation(annotationSpec) && (
<DotAnnotationOutline x={x} y={y} size={annotationSpec.size} />
{isDotAnnotation(annotation) && (
<DotAnnotationOutline x={x} y={y} size={annotation.size} />
)}
{isRectAnnotation(annotationSpec) && (
{isRectAnnotation(annotation) && (
<RectAnnotationOutline
x={x}
y={y}
width={annotationSpec.width}
height={annotationSpec.height}
width={annotation.width}
height={annotation.height}
/>
)}
<AnnotationLink points={computed.points} />
Expand Down
4 changes: 2 additions & 2 deletions packages/annotations/src/AnnotationNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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'
import { NoteSvg } from './types'

export const AnnotationNote = <Datum,>({
datum,
Expand All @@ -13,7 +13,7 @@ export const AnnotationNote = <Datum,>({
datum: Datum
x: number
y: number
note: Exclude<AnnotationSpec<Datum>['note'], NoteCanvasRenderer>
note: NoteSvg<Datum>
}) => {
const theme = useTheme()
const { animate, config: springConfig } = useMotionConfig()
Expand Down
12 changes: 7 additions & 5 deletions packages/annotations/src/canvas.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { CompleteTheme } from '@nivo/core'
import {
ComputedAnnotationSpec,
ComputedAnnotation,
isCanvasNote,
isCircleAnnotation,
isDotAnnotation,
isRectAnnotation,
NoteComponent,
} from './types'

const drawPoints = (ctx: CanvasRenderingContext2D, points: [number, number][]) => {
Expand All @@ -23,16 +23,18 @@ export const renderAnnotationsToCanvas = <Datum>(
annotations,
theme,
}: {
annotations: (Omit<ComputedAnnotationSpec<Datum>, 'note'> & {
note: Exclude<ComputedAnnotationSpec<Datum>['note'], NoteComponent>
})[]
annotations: ComputedAnnotation<Datum>[]
theme: CompleteTheme
}
) => {
if (annotations.length === 0) return

ctx.save()
annotations.forEach(annotation => {
if (!isCanvasNote(annotation.note)) {
throw new Error('note is invalid for canvas implementation')
}

if (theme.annotations.link.outlineWidth > 0) {
ctx.lineCap = 'square'
ctx.strokeStyle = theme.annotations.link.outlineColor
Expand Down
47 changes: 24 additions & 23 deletions packages/annotations/src/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import {
import { defaultProps } from './props'
import {
AnnotationSpec,
AnnotationSpecWithMatcher,
isCircleAnnotation,
isRectAnnotation,
AnnotationPositionGetter,
AnnotationDimensionsGetter,
ComputedAnnotation,
BoundAnnotation,
AnnotationMatcher,
AnnotationInstructions,
} from './types'

export const bindAnnotations = <
Expand All @@ -30,11 +31,11 @@ export const bindAnnotations = <
getDimensions,
}: {
data: Datum[]
annotations: AnnotationSpecWithMatcher<Datum>[]
annotations: AnnotationMatcher<Datum>[]
getPosition: AnnotationPositionGetter<Datum>
getDimensions: AnnotationDimensionsGetter<Datum>
}): AnnotationSpec<Datum>[] =>
annotations.reduce((acc: AnnotationSpec<Datum>[], annotation) => {
}): BoundAnnotation<Datum>[] =>
annotations.reduce((acc: BoundAnnotation<Datum>[], annotation) => {
filter<Datum>(data, annotation.match).forEach(datum => {
const position = getPosition(datum)
const dimensions = getDimensions(datum, annotation.offset || 0)
Expand Down Expand Up @@ -63,16 +64,16 @@ export const getLinkAngle = (
}

export const computeAnnotation = <Datum>(
annotationSpec: Omit<AnnotationSpec<Datum>, 'datum' | 'note'>
): ComputedAnnotation => {
annotation: AnnotationSpec<Datum>
): AnnotationInstructions => {
const {
x,
y,
noteX,
noteY,
noteWidth = defaultProps.noteWidth,
noteTextOffset = defaultProps.noteTextOffset,
} = annotationSpec
} = annotation

let computedNoteX: number
let computedNoteY: number
Expand All @@ -98,41 +99,41 @@ export const computeAnnotation = <Datum>(

const angle = getLinkAngle(x, y, computedNoteX, computedNoteY)

if (isCircleAnnotation<Datum>(annotationSpec)) {
const position = positionFromAngle(degreesToRadians(angle), annotationSpec.size / 2)
if (isCircleAnnotation<Datum>(annotation)) {
const position = positionFromAngle(degreesToRadians(angle), annotation.size / 2)
computedX += position.x
computedY += position.y
}

if (isRectAnnotation(annotationSpec)) {
if (isRectAnnotation<Datum>(annotation)) {
const eighth = Math.round((angle + 90) / 45) % 8
if (eighth === 0) {
computedY -= annotationSpec.height / 2
computedY -= annotation.height / 2
}
if (eighth === 1) {
computedX += annotationSpec.width / 2
computedY -= annotationSpec.height / 2
computedX += annotation.width / 2
computedY -= annotation.height / 2
}
if (eighth === 2) {
computedX += annotationSpec.width / 2
computedX += annotation.width / 2
}
if (eighth === 3) {
computedX += annotationSpec.width / 2
computedY += annotationSpec.height / 2
computedX += annotation.width / 2
computedY += annotation.height / 2
}
if (eighth === 4) {
computedY += annotationSpec.height / 2
computedY += annotation.height / 2
}
if (eighth === 5) {
computedX -= annotationSpec.width / 2
computedY += annotationSpec.height / 2
computedX -= annotation.width / 2
computedY += annotation.height / 2
}
if (eighth === 6) {
computedX -= annotationSpec.width / 2
computedX -= annotation.width / 2
}
if (eighth === 7) {
computedX -= annotationSpec.width / 2
computedY -= annotationSpec.height / 2
computedX -= annotation.width / 2
computedY -= annotation.height / 2
}
}

Expand Down
11 changes: 6 additions & 5 deletions packages/annotations/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { useMemo } from 'react'
import { bindAnnotations, computeAnnotation } from './compute'
import {
AnnotationDimensionsGetter,
AnnotationMatcher,
AnnotationPositionGetter,
AnnotationSpec,
AnnotationSpecWithMatcher,
} from './types'

/**
* Bind annotations to a dataset.
*/
export const useAnnotations = <Datum>(params: {
data: Datum[]
annotations: AnnotationSpecWithMatcher<Datum>[]
annotations: AnnotationMatcher<Datum>[]
getPosition: AnnotationPositionGetter<Datum>
getDimensions: AnnotationDimensionsGetter<Datum>
}) =>
Expand All @@ -36,9 +39,7 @@ export const useComputedAnnotations = <Datum>({
[annotations]
)

export const useComputedAnnotation = <Datum>(
annotationSpec: Omit<AnnotationSpec<Datum>, 'datum' | 'note'>
) =>
export const useComputedAnnotation = <Datum>(annotationSpec: AnnotationSpec<Datum>) =>
useMemo(() => computeAnnotation<Datum>(annotationSpec), [
annotationSpec.type,
annotationSpec.x,
Expand Down
61 changes: 45 additions & 16 deletions packages/annotations/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CompleteTheme } from '@nivo/core'
import React, { ReactElement } from 'react'

// When passing a simple number, the position
// is considered to be a relative position,
Expand All @@ -22,7 +23,11 @@ export type AnnotationDimensionsGetter<Datum> = (
height: number
}

export type NoteCanvasRenderer = <Datum>(
export type NoteComponent<Datum> = (props: { datum: Datum; x: number; y: number }) => JSX.Element

export type NoteSvg<Datum> = string | ReactElement | NoteComponent<Datum>

export type NoteCanvasRenderer<Datum> = (
ctx: CanvasRenderingContext2D,
props: {
datum: Datum
Expand All @@ -32,16 +37,34 @@ export type NoteCanvasRenderer = <Datum>(
}
) => void

export type NoteComponent = <Datum>(props: { datum: Datum; x: number; y: number }) => JSX.Element
export type NoteCanvas<Datum> = string | NoteCanvasRenderer<Datum>

export interface CommonAnnotationSpec<Datum> {
// the datum associated to the annotated element
datum: Datum
export type Note<Datum> = NoteSvg<Datum> | NoteCanvas<Datum>

export const isSvgNote = <Datum>(note: Note<Datum>): note is NoteSvg<Datum> => {
const noteType = typeof note

return (
React.isValidElement(note) ||
noteType === 'string' ||
noteType === 'function' ||
noteType === 'object'
)
}

export const isCanvasNote = <Datum>(note: Note<Datum>): note is NoteCanvas<Datum> => {
const noteType = typeof note

return noteType === 'string' || noteType === 'function'
}

// Define the kind of annotation you wish to render
export interface BaseAnnotationSpec<Datum> {
// x coordinate of the annotated element
x: number
// y coordinate of the annotated element
y: number
note: string | NoteComponent | NoteCanvasRenderer
note: Note<Datum>
// x coordinate of the note, can be either
// relative to the annotated element or absolute.
noteX: RelativeOrAbsolutePosition
Expand All @@ -60,38 +83,38 @@ export interface CommonAnnotationSpec<Datum> {

// This annotation can be used to draw a circle
// around the annotated element.
export type CircleAnnotationSpec<Datum> = CommonAnnotationSpec<Datum> & {
export type CircleAnnotationSpec<Datum> = BaseAnnotationSpec<Datum> & {
type: 'circle'
// diameter of the circle
size: number
}

export const isCircleAnnotation = <Datum>(
annotationSpec: Omit<AnnotationSpec<Datum>, 'datum' | 'note'>
annotationSpec: AnnotationSpec<Datum>
): annotationSpec is CircleAnnotationSpec<Datum> => annotationSpec.type === 'circle'

// This annotation can be used to put a dot
// on the annotated element.
export type DotAnnotationSpec<Datum> = CommonAnnotationSpec<Datum> & {
export type DotAnnotationSpec<Datum> = BaseAnnotationSpec<Datum> & {
type: 'dot'
// diameter of the dot
size: number
}

export const isDotAnnotation = <Datum>(
annotationSpec: Omit<AnnotationSpec<Datum>, 'datum' | 'note'>
annotationSpec: AnnotationSpec<Datum>
): annotationSpec is DotAnnotationSpec<Datum> => annotationSpec.type === 'dot'

// This annotation can be used to draw a rectangle
// around the annotated element.
export type RectAnnotationSpec<Datum> = CommonAnnotationSpec<Datum> & {
export type RectAnnotationSpec<Datum> = BaseAnnotationSpec<Datum> & {
type: 'rect'
width: number
height: number
}

export const isRectAnnotation = <Datum>(
annotationSpec: Omit<AnnotationSpec<Datum>, 'datum' | 'note'>
annotationSpec: AnnotationSpec<Datum>
): annotationSpec is RectAnnotationSpec<Datum> => annotationSpec.type === 'rect'

export type AnnotationSpec<Datum> =
Expand All @@ -101,12 +124,18 @@ export type AnnotationSpec<Datum> =

export type AnnotationType = AnnotationSpec<unknown>['type']

export type AnnotationSpecWithMatcher<Datum> = AnnotationSpec<Datum> & {
export type AnnotationMatcher<Datum> = AnnotationSpec<Datum> & {
match: (datum: Datum) => boolean
offset?: number
}

export type ComputedAnnotation = {
// annotation once it has been bound to a specific datum
// according to `match`.
export type BoundAnnotation<Datum> = AnnotationSpec<Datum> & {
datum: Datum
}

export type AnnotationInstructions = {
// points of the link
points: [number, number][]
// position of the text
Expand All @@ -115,6 +144,6 @@ export type ComputedAnnotation = {
angle: number
}

export type ComputedAnnotationSpec<Datum> = AnnotationSpec<Datum> & {
computed: ComputedAnnotation
export type ComputedAnnotation<Datum> = BoundAnnotation<Datum> & {
computed: AnnotationInstructions
}
4 changes: 2 additions & 2 deletions packages/swarmplot/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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 { AnnotationMatcher, useAnnotations } from '@nivo/annotations'
import { Scale } from '@nivo/scales'
import {
computeValueScale,
Expand Down Expand Up @@ -240,7 +240,7 @@ const getNodeAnnotationDimensions = (node: ComputedDatum<unknown>, offset: numbe

export const useSwarmPlotAnnotations = <RawDatum>(
nodes: ComputedDatum<RawDatum>[],
annotations: AnnotationSpecWithMatcher<ComputedDatum<RawDatum>>[]
annotations: AnnotationMatcher<ComputedDatum<RawDatum>>[]
) =>
useAnnotations<ComputedDatum<RawDatum>>({
data: nodes,
Expand Down
Loading

0 comments on commit bf0ba03

Please sign in to comment.