Skip to content

Commit

Permalink
new(annotation): add new Annotation components (airbnb#907)
Browse files Browse the repository at this point in the history
* internal(annotation): move LinePathAnnotation to deprecated dir

* new(annotation): add AnnotationLabel

* new(annotation): add AnnotationConnector

* test(annotation): add AnnotationLabel, AnnotationConnector boilerplate tests

* new(annotation): add better dir structure, add working Annotation + EditableAnnotation

* new(annotation): add drag handlers to EditableAnnotation, refactor to useDrag

* fix(annotation): fix EditableAnnotation callbacks

* new(annotation): add CircleSubject, LineSubject, grab cursor to EditableAnnotation

* internal(annotation/EditableAnnotation): programmatically update drag state

* test(annotation): add tests for each component

* docs(annotation): update readme

* new(annotation): polish api

* types(annotation): remove undefined in HandlerArgs

* new(annotation/Label): add resizeObserverPolyfill prop
  • Loading branch information
williaster authored Nov 5, 2020
1 parent 033a32c commit 5387453
Show file tree
Hide file tree
Showing 19 changed files with 662 additions and 8 deletions.
6 changes: 1 addition & 5 deletions packages/visx-annotation/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@
</a>
</p>

**Status**

We recommend using [react-annotation](http://react-annotation.susielu.com/) by @susielu. This
package is a work in progress. In the future we may make some helpers built on top of
react-annotation.
Annotations enable you to label points, thresholds, or regions of a visualization to provide additional context for your chart consumer.

## Installation

Expand Down
5 changes: 4 additions & 1 deletion packages/visx-annotation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@
"dependencies": {
"@types/classnames": "^2.2.9",
"@types/react": "*",
"@visx/drag": "1.0.0",
"@visx/group": "1.0.0",
"@visx/point": "1.0.0",
"@visx/shape": "1.1.0",
"@visx/text": "1.0.0",
"classnames": "^2.2.5",
"prop-types": "^15.5.10"
"prop-types": "^15.5.10",
"react-use-measure": "2.0.1"
},
"publishConfig": {
"access": "public"
Expand Down
14 changes: 14 additions & 0 deletions packages/visx-annotation/src/components/Annotation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import React from 'react';
import AnnotationContext from '../context/AnnotationContext';
import { AnnotationContextType } from '../types';

export type AnnotationProps = Pick<AnnotationContextType, 'x' | 'y' | 'dx' | 'dy'> & {
/** Annotation children (Subject, Label, Connector) */
children: React.ReactNode;
};

export default function Annotation({ x, y, dx, dy, children }: AnnotationProps) {
return (
<AnnotationContext.Provider value={{ x, y, dx, dy }}>{children}</AnnotationContext.Provider>
);
}
38 changes: 38 additions & 0 deletions packages/visx-annotation/src/components/CircleSubject.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React, { useContext } from 'react';
import cx from 'classnames';
import { AnnotationContextType } from '../types';
import AnnotationContext from '../context/AnnotationContext';

export type CircleSubjectProps = Pick<AnnotationContextType, 'x' | 'y'> & {
/** Optional className to apply to CircleSubject in addition to 'visx-annotation-subject'. */
className?: string;
/** Color of CircleSubject. */
stroke?: string;
/** Radius of CircleSubject. */
radius?: number;
};

export default function CircleSubject({
className,
x: propsX,
y: propsY,
stroke = '#222',
radius = 24,
...restProps
}: CircleSubjectProps & Omit<React.SVGProps<SVGCircleElement>, keyof CircleSubjectProps>) {
// if props are provided, they take precedence over context
const annotationContext = useContext(AnnotationContext);

return (
<circle
className={cx('visx-annotation-subject', 'visx-annotation-subject-circle', className)}
cx={propsX || annotationContext.x}
cy={propsY || annotationContext.y}
r={radius}
fill="transparent"
pointerEvents="none"
stroke={stroke}
{...restProps}
/>
);
}
71 changes: 71 additions & 0 deletions packages/visx-annotation/src/components/Connector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React, { useContext } from 'react';
import cx from 'classnames';
import { AnnotationContextType } from '../types';
import AnnotationContext from '../context/AnnotationContext';

// @TODO
// add end marker support

export type AnnotationConnectorProps = Pick<AnnotationContextType, 'x' | 'y' | 'dx' | 'dy'> & {
/** Optional className to apply to container in addition to 'visx-annotation-connector'. */
className?: string;
/** Connector type. */
type?: 'line' | 'elbow';
/** Color of the connector line. */
stroke?: string;
/** Optional additional props. */
pathProps?: React.SVGProps<SVGPathElement>;
};

export default function AnnotationConnector({
className,
x: propsX,
y: propsY,
dx: propsDx,
dy: propsDy,
type = 'elbow',
stroke = '#222',
pathProps,
}: AnnotationConnectorProps) {
// if props are provided, they take precedence over context
const annotationContext = useContext(AnnotationContext);
const x0 = propsX == null ? annotationContext.x ?? 0 : propsX;
const y0 = propsY == null ? annotationContext.y ?? 0 : propsY;
const dx = propsDx == null ? annotationContext.dx ?? 0 : propsDx;
const dy = propsDy == null ? annotationContext.dy ?? 0 : propsDy;
let x1: number = x0; // only used with elbow type
let y1: number = y0;
const x2 = x0 + dx;
const y2 = y0 + dy;

if (type === 'elbow') {
// if dx < dy, find the intesection of y=x or y=-x from subject, with vertical line to label
if (Math.abs(dx) <= Math.abs(dy)) {
// target line is vertical x = x2
x1 = x2;
// intersection with y=x line (if dy > 0) or y=x (if dy < 0)
const sign = dy > 0 ? 1 : -1;
y1 = y0 + sign * Math.abs(x1 - x0);
}
// if dx > dy, find the intesection of y=x or y=-x from subject, with horizontal line to label
else {
// target line is horizontal y = y2
y1 = y2;
// find intersection with y=-x line (if dx > 0) or y=x (if dx < 0)
const sign = dx > 0 ? 1 : -1;
x1 = x0 + sign * Math.abs(y1 - y0);
}
}

return (
<path
className={cx('visx-annotation-connector', className)}
strokeWidth={1}
stroke={stroke}
fill="transparent"
pointerEvents="none"
d={`M${x0},${y0}${type === 'elbow' ? `L${x1},${y1}` : ''}L${x2},${y2}`}
{...pathProps}
/>
);
}
184 changes: 184 additions & 0 deletions packages/visx-annotation/src/components/EditableAnnotation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/* eslint-disable react/jsx-handler-names */
import React, { useCallback, useRef } from 'react';
import useDrag, { UseDrag, HandlerArgs as DragHandlerArgs } from '@visx/drag/lib/useDrag';
import { AnnotationContextType } from '../types';
import Annotation from './Annotation';

export type EditableAnnotationProps = Pick<AnnotationContextType, 'x' | 'y' | 'dx' | 'dy'> & {
/** Width of the possible drag canvas (e.g., SVG container). */
width: number;
/** Height of the possible drag canvas (e.g., SVG container). */
height: number;
/** Annotation children (Subject, Label, Connector) */
children: React.ReactNode;
/** Optional circle props to set on the subject drag handle. */
subjectDragHandleProps?: React.SVGProps<SVGCircleElement>;
/** Optional circle props to set on the label drag handle. */
labelDragHandleProps?: React.SVGProps<SVGCircleElement>;
/** Callback invoked on drag start. */
onDragStart?: ({ x, y, dx, dy, event }: HandlerArgs) => void;
/** Callback invoked on drag move. */
onDragMove?: ({ x, y, dx, dy, event }: HandlerArgs) => void;
/** Callback invoked on drag end. */
onDragEnd?: ({ x, y, dx, dy, event }: HandlerArgs) => void;
};

export type HandlerArgs = {
x: number;
y: number;
dx: number;
dy: number;
event: React.MouseEvent | React.TouchEvent;
};

const defaultDragHandleProps = {
r: 10,
fill: 'transparent',
stroke: '#777',
strokeDasharray: '4,2',
strokeWidth: 2,
};

export default function EditableAnnotation({
x: subjectX = 0,
y: subjectY = 0,
dx: labelDx = 0,
dy: labelDy = 0,
children,
width,
height,
subjectDragHandleProps,
labelDragHandleProps,
onDragStart,
onDragMove,
onDragEnd,
}: EditableAnnotationProps) {
// chicken before the egg, we need these to reference drag state
// in drag callbacks which are defined before useDrag() state is available
const subjectDragRef = useRef<UseDrag>();
const labelDragRef = useRef<UseDrag>();

const handleDragStart = useCallback(
({ event }: DragHandlerArgs) => {
if (onDragStart) {
onDragStart({
event,
x: subjectX + (subjectDragRef.current?.dx ?? 0),
y: subjectY + (subjectDragRef.current?.dy ?? 0),
dx: labelDx + (labelDragRef.current?.dx ?? 0),
dy: labelDy + (labelDragRef.current?.dy ?? 0),
});
}
},
[labelDx, labelDy, onDragStart, subjectX, subjectY],
);

const handleDragMove = useCallback(
({ event }: DragHandlerArgs) => {
if (onDragMove) {
onDragMove({
event,
x: subjectX + (subjectDragRef.current?.dx ?? 0),
y: subjectY + (subjectDragRef.current?.dy ?? 0),
dx: labelDx + (labelDragRef.current?.dx ?? 0),
dy: labelDy + (labelDragRef.current?.dy ?? 0),
});
}
},
[labelDx, labelDy, onDragMove, subjectX, subjectY],
);

const handleDragEnd = useCallback(
({ event }: DragHandlerArgs) => {
if (onDragEnd) {
onDragEnd({
event,
x: subjectX + (subjectDragRef.current?.dx ?? 0),
y: subjectY + (subjectDragRef.current?.dy ?? 0),
dx: labelDx + (labelDragRef.current?.dx ?? 0),
dy: labelDy + (labelDragRef.current?.dy ?? 0),
});
}
},
[labelDx, labelDy, onDragEnd, subjectX, subjectY],
);

const subjectDrag = useDrag({
onDragStart: handleDragStart,
onDragMove: handleDragMove,
onDragEnd: handleDragEnd,
x: subjectX,
y: subjectY,
});

const labelDrag = useDrag({
onDragStart: handleDragStart,
onDragMove: handleDragMove,
onDragEnd: handleDragEnd,
x: labelDx,
y: labelDy,
});

// enable referencing these in the callbacks defined before useDrag is called
subjectDragRef.current = subjectDrag;
labelDragRef.current = labelDrag;

return (
<>
<Annotation
x={subjectX + subjectDrag.dx}
y={subjectY + subjectDrag.dy}
dx={labelDx + labelDrag.dx}
dy={labelDy + labelDrag.dy}
>
{children}
</Annotation>
{subjectDrag.isDragging && (
<rect
width={width}
height={height}
onMouseMove={subjectDrag.dragMove}
onMouseUp={subjectDrag.dragEnd}
fill="transparent"
/>
)}
<circle
cx={subjectX}
cy={subjectY}
transform={`translate(${subjectDrag.dx},${subjectDrag.dy})`}
onMouseMove={subjectDrag.dragMove}
onMouseUp={subjectDrag.dragEnd}
onMouseDown={subjectDrag.dragStart}
onTouchStart={subjectDrag.dragStart}
onTouchMove={subjectDrag.dragMove}
onTouchEnd={subjectDrag.dragEnd}
cursor={subjectDrag.isDragging ? 'grabbing' : 'grab'}
{...defaultDragHandleProps}
{...subjectDragHandleProps}
/>
{labelDrag.isDragging && (
<rect
width={width}
height={height}
onMouseMove={labelDrag.dragMove}
onMouseUp={labelDrag.dragEnd}
fill="transparent"
/>
)}
<circle
cx={subjectX + subjectDrag.dx + labelDx}
cy={subjectY + subjectDrag.dy + labelDy}
transform={`translate(${labelDrag.dx},${labelDrag.dy})`}
onMouseMove={labelDrag.dragMove}
onMouseUp={labelDrag.dragEnd}
onMouseDown={labelDrag.dragStart}
onTouchStart={labelDrag.dragStart}
onTouchMove={labelDrag.dragMove}
onTouchEnd={labelDrag.dragEnd}
cursor={labelDrag.isDragging ? 'grabbing' : 'grab'}
{...defaultDragHandleProps}
{...labelDragHandleProps}
/>
</>
);
}
Loading

0 comments on commit 5387453

Please sign in to comment.