Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

new(xychart): add (EventEmitter, Tooltip)Context + basic Tooltips #825

Merged
merged 10 commits into from
Oct 5, 2020
4 changes: 4 additions & 0 deletions packages/visx-demo/src/sandboxes/visx-xychart/Example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
DataProvider,
BarSeries,
LineSeries,
Tooltip,
XYChart,
} from '@visx/xychart';
import ExampleControls from './ExampleControls';
Expand Down Expand Up @@ -82,6 +83,9 @@ export default function Example({ height }: Props) {
numTicks={numTicks}
animationTrajectory={animationTrajectory}
/>
<Tooltip
renderTooltip={({ tooltipData }) => <pre>{JSON.stringify(tooltipData, null, 2)}</pre>}
/>
</XYChart>
</DataProvider>
)}
Expand Down
7 changes: 7 additions & 0 deletions packages/visx-xychart/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,21 @@
"@types/classnames": "^2.2.9",
"@types/react": "*",
"@visx/axis": "1.0.0",
"@visx/event": "1.0.0",
"@visx/grid": "1.0.0",
"@visx/react-spring": "1.0.0",
"@visx/responsive": "1.0.0",
"@visx/scale": "1.0.0",
"@visx/shape": "1.0.0",
"@visx/text": "1.0.0",
"@visx/tooltip": "1.0.0",
"@visx/voronoi": "1.0.0",
"classnames": "^2.2.5",
"d3-array": "2.6.0",
"mitt": "^2.1.0",
"prop-types": "^15.6.2"
},
"devDependencies": {
"resize-observer-polyfill": "1.5.1"
}
}
95 changes: 95 additions & 0 deletions packages/visx-xychart/src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useCallback, useContext } from 'react';
import { useTooltipInPortal, defaultStyles } from '@visx/tooltip';
import { TooltipProps as BaseTooltipProps } from '@visx/tooltip/lib/tooltips/Tooltip';
import { PickD3Scale } from '@visx/scale';
import { UseTooltipPortalOptions } from '@visx/tooltip/lib/hooks/useTooltipInPortal';

import TooltipContext from '../context/TooltipContext';
import DataContext from '../context/DataContext';
import { TooltipContextType } from '../types';

export type RenderTooltipParams = TooltipContextType & {
colorScale?: PickD3Scale<'ordinal', string, string>;
};

export type TooltipProps = {
/**
* When TooltipContext.tooltipOpen=true, this function is invoked and if the
* return value is non-null, its content is rendered inside the tooltip container.
* Content will be rendered in an HTML parent.
*/
renderTooltip: (params: RenderTooltipParams) => React.ReactNode;
/**
* Tooltip depends on ResizeObserver, which may be pollyfilled globally
* or injected into this component.
*/
resizeObserverPolyfill?: UseTooltipPortalOptions['polyfill'];
} & Omit<BaseTooltipProps, 'left' | 'top' | 'children'> &
Pick<UseTooltipPortalOptions, 'debounce' | 'detectBounds' | 'scroll'>;

const INVISIBLE_STYLES: React.CSSProperties = {
position: 'absolute',
left: 0,
top: 0,
opacity: 0,
width: 0,
height: 0,
pointerEvents: 'none',
};

export default function Tooltip({
renderTooltip,
debounce,
detectBounds,
resizeObserverPolyfill,
scroll,
...tooltipProps
}: TooltipProps) {
const { colorScale, theme } = useContext(DataContext) || {};
const tooltipContext = useContext(TooltipContext);
const { containerRef, TooltipInPortal } = useTooltipInPortal({
debounce,
detectBounds,
polyfill: resizeObserverPolyfill,
scroll,
});

// To correctly position itself in a Portal, the tooltip must know its container bounds
// this is done by rendering an invisible node which can be used to find its parents element
const setContainerRef = useCallback(
(ownRef: HTMLElement | SVGElement | null) => {
containerRef(ownRef?.parentElement ?? null);
},
[containerRef],
);

const tooltipContent = tooltipContext?.tooltipOpen
? renderTooltip({ ...tooltipContext, colorScale })
: null;

return (
// Tooltip can be rendered as a child of SVG or HTML since its output is rendered in a Portal.
// So use svg element to find container ref because it's a valid child of SVG and HTML parents.
<>
<svg ref={setContainerRef} style={INVISIBLE_STYLES} />
{tooltipContext?.tooltipOpen && tooltipContent != null && (
<TooltipInPortal
left={tooltipContext?.tooltipLeft}
top={tooltipContext?.tooltipTop}
style={{
...defaultStyles,
background: theme?.backgroundColor ?? 'white',
boxShadow: `0 1px 2px ${
theme?.htmlLabelStyles?.color ? `${theme?.htmlLabelStyles?.color}55` : '#22222255'
}`,

...theme?.htmlLabelStyles,
}}
{...tooltipProps}
>
{tooltipContent}
</TooltipInPortal>
)}
</>
);
}
57 changes: 53 additions & 4 deletions packages/visx-xychart/src/components/XYChart.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import React, { useContext, useEffect } from 'react';
/* eslint-disable jsx-a11y/mouse-events-have-key-events */
import React, { useCallback, useContext, useEffect } from 'react';
import ParentSize from '@visx/responsive/lib/components/ParentSize';

import DataContext from '../context/DataContext';
import { Margin } from '../types';
import useEventEmitter from '../hooks/useEventEmitter';
import EventEmitterProvider from '../providers/EventEmitterProvider';
import TooltipContext from '../context/TooltipContext';
import TooltipProvider from '../providers/TooltipProvider';

const DEFAULT_MARGIN = { top: 50, right: 50, bottom: 50, left: 50 };

type Props = {
accessibilityLabel?: string;
events?: boolean;
width?: number;
height?: number;
Expand All @@ -15,8 +21,16 @@ type Props = {
};

export default function XYChart(props: Props) {
const { children, width, height, margin = DEFAULT_MARGIN } = props;
const {
accessibilityLabel = 'XYChart',
children,
width,
height,
margin = DEFAULT_MARGIN,
} = props;
const { setDimensions } = useContext(DataContext);
const tooltipContext = useContext(TooltipContext);
const emit = useEventEmitter();

// update dimensions in context
useEffect(() => {
Expand All @@ -25,14 +39,49 @@ export default function XYChart(props: Props) {
}
}, [setDimensions, width, height, margin]);

// if width and height aren't both provided, wrap in auto-sizer + preserve passed dims
const handleMouseTouchMove = useCallback(
(event: React.MouseEvent | React.TouchEvent) => emit?.('mousemove', event),
[emit],
);
const handleMouseOutTouchEnd = useCallback(
(event: React.MouseEvent | React.TouchEvent) => emit?.('mouseout', event),
[emit],
);

// if Context or dimensions are not available, wrap self in the needed providers
if (width == null || height == null) {
return <ParentSize>{dims => <XYChart {...dims} {...props} />}</ParentSize>;
}
if (emit == null) {
return (
<EventEmitterProvider>
<XYChart {...props} />
</EventEmitterProvider>
);
}
if (tooltipContext == null) {
return (
<TooltipProvider>
<XYChart {...props} />
</TooltipProvider>
);
}

return width > 0 && height > 0 ? (
<svg width={width} height={height}>
<svg width={width} height={height} aria-label={accessibilityLabel}>
{children}
{/** capture all mouse/touch events and emit them. */}
<rect
x={margin.left}
y={margin.top}
width={width - margin.left - margin.right}
height={height - margin.top - margin.bottom}
fill="transparent"
onMouseMove={handleMouseTouchMove}
onTouchMove={handleMouseTouchMove}
onMouseOut={handleMouseOutTouchEnd}
onTouchEnd={handleMouseOutTouchEnd}
/>
</svg>
) : null;
}
42 changes: 39 additions & 3 deletions packages/visx-xychart/src/components/series/BarSeries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import withRegisteredData, { WithRegisteredDataProps } from '../../enhancers/wit
import getScaledValueFactory from '../../utils/getScaledValueFactory';
import isValidNumber from '../../typeguards/isValidNumber';
import getScaleBandwidth from '../../utils/getScaleBandwidth';
import findNearestDatumX from '../../utils/findNearestDatumX';
import findNearestDatumY from '../../utils/findNearestDatumY';
import useEventEmitter, { HandlerParams } from '../../hooks/useEventEmitter';
import TooltipContext from '../../context/TooltipContext';

type BarSeriesProps<
XScale extends AxisScale,
Expand Down Expand Up @@ -36,7 +40,9 @@ function BarSeries<XScale extends AxisScale, YScale extends AxisScale, Datum ext
yAccessor,
yScale,
}: BarSeriesProps<XScale, YScale, Datum> & WithRegisteredDataProps<XScale, YScale, Datum>) {
const { colorScale, theme, innerWidth = 0, innerHeight = 0 } = useContext(DataContext);
const { colorScale, theme, width, height, innerWidth = 0, innerHeight = 0 } = useContext(
DataContext,
);
const getScaledX = useCallback(getScaledValueFactory(xScale, xAccessor), [xScale, xAccessor]);
const getScaledY = useCallback(getScaledValueFactory(yScale, yAccessor), [yScale, yAccessor]);
const [xMin, xMax] = xScale.range().map(Number);
Expand Down Expand Up @@ -80,10 +86,40 @@ function BarSeries<XScale extends AxisScale, YScale extends AxisScale, Datum ext
});
}, [barThickness, color, data, getScaledX, getScaledY, horizontal, xZeroPosition, yZeroPosition]);

const { showTooltip, hideTooltip } = useContext(TooltipContext) ?? {};
const handleMouseMove = useCallback(
(params: HandlerParams | undefined) => {
const { svgPoint } = params || {};
if (svgPoint && width && height && showTooltip) {
const datum = (horizontal ? findNearestDatumY : findNearestDatumX)({
point: svgPoint,
key: dataKey,
data,
xScale,
yScale,
xAccessor,
yAccessor,
width,
height,
});
if (datum) {
showTooltip({
tooltipData: datum.datum,
tooltipLeft: svgPoint.x,
tooltipTop: svgPoint.y,
});
}
}
},
[dataKey, data, horizontal, xScale, yScale, xAccessor, yAccessor, width, height, showTooltip],
);
useEventEmitter('mousemove', handleMouseMove);
useEventEmitter('mouseout', hideTooltip);

return (
<g className="vx-bar-series">
{bars.map(({ x, y, width, height, fill }, i) => (
<rect key={i} x={x} y={y} width={width} height={height} fill={fill} />
{bars.map(({ x, y, width: barWidth, height: barHeight, fill }, i) => (
<rect key={i} x={x} y={y} width={barWidth} height={barHeight} fill={fill} />
))}
</g>
);
Expand Down
35 changes: 34 additions & 1 deletion packages/visx-xychart/src/components/series/LineSeries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import DataContext from '../../context/DataContext';
import { SeriesProps } from '../../types';
import withRegisteredData, { WithRegisteredDataProps } from '../../enhancers/withRegisteredData';
import getScaledValueFactory from '../../utils/getScaledValueFactory';
import useEventEmitter, { HandlerParams } from '../../hooks/useEventEmitter';
import findNearestDatumXY from '../../utils/findNearestDatumXY';
import TooltipContext from '../../context/TooltipContext';

type LineSeriesProps<
XScale extends AxisScale,
Expand All @@ -21,11 +24,41 @@ function LineSeries<XScale extends AxisScale, YScale extends AxisScale, Datum ex
yScale,
...lineProps
}: LineSeriesProps<XScale, YScale, Datum> & WithRegisteredDataProps<XScale, YScale, Datum>) {
const { colorScale, theme } = useContext(DataContext);
const { colorScale, theme, width, height } = useContext(DataContext);
const { showTooltip, hideTooltip } = useContext(TooltipContext) ?? {};
const getScaledX = useCallback(getScaledValueFactory(xScale, xAccessor), [xScale, xAccessor]);
const getScaledY = useCallback(getScaledValueFactory(yScale, yAccessor), [yScale, yAccessor]);
const color = colorScale?.(dataKey) ?? theme?.colors?.[0] ?? '#222';

const handleMouseMove = useCallback(
(params: HandlerParams | undefined) => {
const { svgPoint } = params || {};
if (svgPoint && width && height && showTooltip) {
const datum = findNearestDatumXY({
point: svgPoint,
key: dataKey,
data,
xScale,
yScale,
xAccessor,
yAccessor,
width,
height,
});
if (datum) {
showTooltip({
tooltipData: datum.datum,
tooltipLeft: svgPoint.x,
tooltipTop: svgPoint.y,
});
}
}
},
[dataKey, data, xScale, yScale, xAccessor, yAccessor, width, height, showTooltip],
);
useEventEmitter('mousemove', handleMouseMove);
useEventEmitter('mouseout', hideTooltip);

return (
<LinePath
data={data}
Expand Down
6 changes: 6 additions & 0 deletions packages/visx-xychart/src/context/EventEmitterContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createContext } from 'react';
import { EventEmitterContextType } from '../types';

const EventEmitterContext = createContext<EventEmitterContextType | null>(null);

export default EventEmitterContext;
6 changes: 6 additions & 0 deletions packages/visx-xychart/src/context/TooltipContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { createContext } from 'react';
import { TooltipContextType } from '../types';

const TooltipContext = createContext<TooltipContextType | null>(null);

export default TooltipContext;
35 changes: 35 additions & 0 deletions packages/visx-xychart/src/hooks/useEventEmitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useCallback, useContext, useEffect } from 'react';
import { localPoint } from '@visx/event';
import EventEmitterContext from '../context/EventEmitterContext';

export type EventType = 'mousemove' | 'mouseout' | 'touchmove' | 'touchend' | 'click';
export type HandlerParams = {
event: React.MouseEvent | React.TouchEvent;
svgPoint: ReturnType<typeof localPoint>;
};
export type Handler = (params?: HandlerParams) => void;

/**
* Hook for optionally subscribing to a specified EventType,
* and returns emitter for emitting events.
*/
export default function useEventEmitter(eventType?: EventType, handler?: Handler) {
const emitter = useContext(EventEmitterContext);

/** wrap emitter.emit so we can enforce stricter type signature */
const emit = useCallback(
(type: EventType, event: HandlerParams['event']) =>
emitter?.emit<HandlerParams>(type, { event, svgPoint: localPoint(event) }),
[emitter],
);

useEffect(() => {
if (emitter && eventType && handler) {
emitter.on<HandlerParams>(eventType, handler);
return () => emitter?.off<HandlerParams>(eventType, handler);
}
return undefined;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not necessary to return undefined?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get lint errors if I return nothing (not all paths return a value) and undefined was the only non-function return value allowed by TS

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh right. ignore my comment then

}, [emitter, eventType, handler]);

return emitter ? emit : null;
}
Loading