From 138eafb544f6307d41a5f58a9dca2b925c5ffb64 Mon Sep 17 00:00:00 2001 From: plouc Date: Wed, 23 Dec 2020 08:31:38 +0900 Subject: [PATCH] feat(circle-packing): add support for mouse handlers to SVG and HTML implementations --- packages/circle-packing/src/CirclePacking.tsx | 33 ++++- .../src/CirclePackingCanvas.tsx | 16 ++- .../circle-packing/src/CirclePackingHtml.tsx | 33 ++++- .../src/CirclePackingTooltip.tsx | 11 ++ packages/circle-packing/src/CircleSvg.tsx | 35 +++++ packages/circle-packing/src/Circles.tsx | 123 +++++++++++++----- packages/circle-packing/src/hooks.ts | 44 ++++++- packages/circle-packing/src/props.ts | 2 + packages/circle-packing/src/types.ts | 26 +++- .../data/components/circle-packing/props.js | 40 ++++-- 10 files changed, 296 insertions(+), 67 deletions(-) create mode 100644 packages/circle-packing/src/CirclePackingTooltip.tsx diff --git a/packages/circle-packing/src/CirclePacking.tsx b/packages/circle-packing/src/CirclePacking.tsx index 771ded702..f6ec606b0 100644 --- a/packages/circle-packing/src/CirclePacking.tsx +++ b/packages/circle-packing/src/CirclePacking.tsx @@ -15,6 +15,14 @@ import { CircleSvg } from './CircleSvg' import { Labels } from './Labels' import { LabelSvg } from './LabelSvg' +type InnerCirclePackingProps = Partial< + Omit< + CirclePackingSvgProps, + 'data' | 'width' | 'height' | 'isInteractive' | 'animate' | 'motionConfig' + > +> & + Pick, 'data' | 'width' | 'height' | 'isInteractive'> + const InnerCirclePacking = ({ data, id = defaultProps.id, @@ -36,11 +44,14 @@ const InnerCirclePacking = ({ labelsSkipRadius = defaultProps.labelsSkipRadius, labelsTextColor = defaultProps.labelsTextColor as InheritedColorConfig>, layers = defaultProps.layers, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip = defaultProps.tooltip, role = defaultProps.role, -}: Partial< - Omit, 'data' | 'width' | 'height' | 'animate' | 'motionConfig'> -> & - Pick, 'data' | 'width' | 'height'>) => { +}: InnerCirclePackingProps) => { const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions( width, height, @@ -67,7 +78,19 @@ const InnerCirclePacking = ({ } if (layers.includes('circles')) { - layerById.circles = + layerById.circles = ( + + key="circles" + nodes={nodes} + isInteractive={isInteractive} + onMouseEnter={onMouseEnter} + onMouseMove={onMouseMove} + onMouseLeave={onMouseLeave} + onClick={onClick} + component={CircleSvg} + tooltip={tooltip} + /> + ) } if (enableLabels && layers.includes('labels')) { diff --git a/packages/circle-packing/src/CirclePackingCanvas.tsx b/packages/circle-packing/src/CirclePackingCanvas.tsx index 8749fe220..d9eb0a250 100644 --- a/packages/circle-packing/src/CirclePackingCanvas.tsx +++ b/packages/circle-packing/src/CirclePackingCanvas.tsx @@ -5,6 +5,14 @@ import { CirclePackingCanvasProps, ComputedDatum } from './types' import { defaultProps } from './props' import { useCirclePacking, useCirclePackingLabels } from './hooks' +type InnerCirclePackingCanvasProps = Partial< + Omit< + CirclePackingCanvasProps, + 'data' | 'width' | 'height' | 'animate' | 'motionConfig' + > +> & + Pick, 'data' | 'width' | 'height'> + const InnerCirclePackingCanvas = ({ data, id = defaultProps.id, @@ -28,13 +36,7 @@ const InnerCirclePackingCanvas = ({ // layers = defaultProps.layers, isInteractive, role = defaultProps.role, -}: Partial< - Omit< - CirclePackingCanvasProps, - 'data' | 'width' | 'height' | 'animate' | 'motionConfig' - > -> & - Pick, 'data' | 'width' | 'height'>) => { +}: InnerCirclePackingCanvasProps) => { const canvasEl = useRef(null) const theme = useTheme() diff --git a/packages/circle-packing/src/CirclePackingHtml.tsx b/packages/circle-packing/src/CirclePackingHtml.tsx index 8e874b094..49b6047e5 100644 --- a/packages/circle-packing/src/CirclePackingHtml.tsx +++ b/packages/circle-packing/src/CirclePackingHtml.tsx @@ -9,6 +9,14 @@ import { defaultProps } from './props' import { Labels } from './Labels' import { LabelHtml } from './LabelHtml' +type InnerCirclePackingHtmlProps = Partial< + Omit< + CirclePackingHtmlProps, + 'data' | 'width' | 'height' | 'isInteractive' | 'animate' | 'motionConfig' + > +> & + Pick, 'data' | 'width' | 'height' | 'isInteractive'> + export const InnerCirclePackingHtml = ({ data, id = defaultProps.id, @@ -30,11 +38,14 @@ export const InnerCirclePackingHtml = ({ labelsSkipRadius = defaultProps.labelsSkipRadius, labelsTextColor = defaultProps.labelsTextColor as InheritedColorConfig>, layers = defaultProps.layers, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip = defaultProps.tooltip, role = defaultProps.role, -}: Partial< - Omit, 'data' | 'width' | 'height' | 'animate' | 'motionConfig'> -> & - Pick, 'data' | 'width' | 'height'>) => { +}: InnerCirclePackingHtmlProps) => { const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions( width, height, @@ -61,7 +72,19 @@ export const InnerCirclePackingHtml = ({ } if (layers.includes('circles')) { - layerById.circles = key="circles" nodes={nodes} component={CircleHtml} /> + layerById.circles = ( + + key="circles" + nodes={nodes} + isInteractive={isInteractive} + onMouseEnter={onMouseEnter} + onMouseMove={onMouseMove} + onMouseLeave={onMouseLeave} + onClick={onClick} + component={CircleHtml} + tooltip={tooltip} + /> + ) } if (enableLabels && layers.includes('labels')) { diff --git a/packages/circle-packing/src/CirclePackingTooltip.tsx b/packages/circle-packing/src/CirclePackingTooltip.tsx new file mode 100644 index 000000000..5f0bbbc25 --- /dev/null +++ b/packages/circle-packing/src/CirclePackingTooltip.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { BasicTooltip } from '@nivo/tooltip' +import { ComputedDatum } from './types' + +export const CirclePackingTooltip = ({ + id, + formattedValue, + color, +}: ComputedDatum) => ( + +) diff --git a/packages/circle-packing/src/CircleSvg.tsx b/packages/circle-packing/src/CircleSvg.tsx index e69de29bb..30c75e4d2 100644 --- a/packages/circle-packing/src/CircleSvg.tsx +++ b/packages/circle-packing/src/CircleSvg.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { animated } from 'react-spring' +import { CircleProps } from './types' +import { useBoundMouseHandlers } from './hooks' + +export const CircleSvg = ({ + node, + style, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, +}: CircleProps) => { + const handlers = useBoundMouseHandlers(node, { + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + }) + + return ( + + ) +} diff --git a/packages/circle-packing/src/Circles.tsx b/packages/circle-packing/src/Circles.tsx index a3edc7b6b..72856ddcf 100644 --- a/packages/circle-packing/src/Circles.tsx +++ b/packages/circle-packing/src/Circles.tsx @@ -1,7 +1,8 @@ -import React from 'react' -import { useTransition, animated, to, SpringValue } from 'react-spring' +import React, { createElement, useMemo, MouseEvent } from 'react' +import { useTransition, to, SpringValue } from 'react-spring' import { useMotionConfig } from '@nivo/core' -import { ComputedDatum, CircleComponent } from './types' +import { useTooltip } from '@nivo/tooltip' +import { ComputedDatum, CircleComponent, MouseHandlers, CirclePackingCommonProps } from './types' /** * A negative radius value is invalid for an SVG circle, @@ -11,37 +12,87 @@ import { ComputedDatum, CircleComponent } from './types' export const interpolateRadius = (radiusValue: SpringValue) => to([radiusValue], radius => Math.max(0, radius)) -interface CirclesProps { +type CirclesProps = { nodes: ComputedDatum[] component: CircleComponent -} - -export const Circles = ({ nodes, component }: CirclesProps) => { - const { animate, config: springConfig } = useMotionConfig() + isInteractive: CirclePackingCommonProps['isInteractive'] + tooltip: CirclePackingCommonProps['tooltip'] +} & MouseHandlers - const enter = (node: ComputedDatum) => ({ +const getTransitionPhases = () => ({ + enter: (node: ComputedDatum) => ({ x: node.x, y: node.y, radius: 0, color: node.color, opacity: 0, - }) - - const update = (node: ComputedDatum) => ({ + }), + update: (node: ComputedDatum) => ({ x: node.x, y: node.y, radius: node.radius, color: node.color, opacity: 1, - }) - - const leave = (node: ComputedDatum) => ({ + }), + leave: (node: ComputedDatum) => ({ x: node.x, y: node.y, radius: 0, color: node.color, opacity: 0, - }) + }), +}) + +export const Circles = ({ + nodes, + component, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, +}: CirclesProps) => { + const { showTooltipFromEvent, hideTooltip } = useTooltip() + + const handleMouseEnter = useMemo(() => { + if (!isInteractive) return undefined + + return (node: ComputedDatum, event: MouseEvent) => { + showTooltipFromEvent(createElement(tooltip, node), event) + onMouseEnter?.(node, event) + } + }, [isInteractive, showTooltipFromEvent, tooltip, onMouseEnter]) + + const handleMouseMove = useMemo(() => { + if (!isInteractive) return undefined + + return (node: ComputedDatum, event: MouseEvent) => { + showTooltipFromEvent(createElement(tooltip, node), event) + onMouseMove?.(node, event) + } + }, [isInteractive, showTooltipFromEvent, tooltip, onMouseMove]) + + const handleMouseLeave = useMemo(() => { + if (!isInteractive) return undefined + + return (node: ComputedDatum, event: MouseEvent) => { + hideTooltip() + onMouseLeave?.(node, event) + } + }, [isInteractive, hideTooltip, onMouseLeave]) + + const handleClick = useMemo(() => { + if (!isInteractive) return undefined + + return (node: ComputedDatum, event: MouseEvent) => { + onClick?.(node, event) + } + }, [isInteractive, onClick]) + + const { animate, config: springConfig } = useMotionConfig() + + const transitionPhases = useMemo(() => getTransitionPhases(), []) const transition = useTransition< ComputedDatum, @@ -52,30 +103,32 @@ export const Circles = ({ nodes, component }: CirclesProps) color: string opacity: number } - >(data, { - key: datum => datum.id, - initial: update, - from: enter, - enter: update, - update, - leave, + >(nodes, { + key: node => node.id, + initial: transitionPhases.update, + from: transitionPhases.enter, + enter: transitionPhases.update, + update: transitionPhases.update, + leave: transitionPhases.leave, config: springConfig, immediate: !animate, }) return ( - - {transition((transitionProps, datum) => { - return ( - - ) + <> + {transition((transitionProps, node) => { + return React.createElement(component, { + key: node.id, + node, + style: { + ...transitionProps, + radius: interpolateRadius(transitionProps.radius), + }, + onMouseEnter: handleMouseEnter, + onMouseMove: handleMouseMove, + onMouseLeave: handleMouseLeave, + onClick: handleClick, + }) })} ) diff --git a/packages/circle-packing/src/hooks.ts b/packages/circle-packing/src/hooks.ts index f9897df0d..20708e99d 100644 --- a/packages/circle-packing/src/hooks.ts +++ b/packages/circle-packing/src/hooks.ts @@ -1,9 +1,15 @@ +import { useMemo, MouseEvent } from 'react' import { pack as d3Pack, hierarchy as d3Hierarchy } from 'd3-hierarchy' import cloneDeep from 'lodash/cloneDeep' import sortBy from 'lodash/sortBy' import { usePropertyAccessor, useValueFormatter, useTheme } from '@nivo/core' import { useInheritedColor, useOrdinalColorScale } from '@nivo/colors' -import { DatumWithChildren, CirclePackSvgProps, ComputedDatum } from './types' +import { + CirclePackingCommonProps, + CirclePackingCustomLayerProps, + ComputedDatum, + MouseHandlers, +} from './types' export const useCirclePacking = >({ data, @@ -28,6 +34,8 @@ export const useCirclePacking = >({ colorBy: CirclePackingCommonProps['colorBy'] childColor: CirclePackingCommonProps['childColor'] }): ComputedDatum[] => { + console.log('compute nodes') + const getId = usePropertyAccessor(id) const getValue = usePropertyAccessor(value) @@ -114,6 +122,7 @@ export const useCirclePackingLabels = ({ const theme = useTheme() const getTextColor = useInheritedColor>(textColor, theme) + // computing the labels const labels = useMemo( () => nodes @@ -126,6 +135,7 @@ export const useCirclePackingLabels = ({ [nodes, skipRadius, getLabel, getTextColor] ) + // apply extra filtering if provided return useMemo(() => { if (!filter) return labels @@ -133,6 +143,38 @@ export const useCirclePackingLabels = ({ }, [labels, filter]) } +export const useBoundMouseHandlers = ( + node: ComputedDatum, + { onMouseEnter, onMouseMove, onMouseLeave, onClick }: MouseHandlers +): Partial< + Record<'onMouseEnter' | 'onMouseMove' | 'onMouseLeave' | 'onClick', (event: MouseEvent) => void> +> => + useMemo( + () => ({ + onMouseEnter: onMouseEnter + ? (event: MouseEvent) => { + onMouseEnter(node, event) + } + : undefined, + onMouseMove: onMouseMove + ? (event: MouseEvent) => { + onMouseMove(node, event) + } + : undefined, + onMouseLeave: onMouseLeave + ? (event: MouseEvent) => { + onMouseLeave(node, event) + } + : undefined, + onClick: onClick + ? (event: MouseEvent) => { + onClick(node, event) + } + : undefined, + }), + [node, onMouseEnter, onMouseMove, onMouseLeave, onClick] + ) + /** * Memoize the context to pass to custom layers. */ diff --git a/packages/circle-packing/src/props.ts b/packages/circle-packing/src/props.ts index 12c6eadf8..acff63569 100644 --- a/packages/circle-packing/src/props.ts +++ b/packages/circle-packing/src/props.ts @@ -1,5 +1,6 @@ import { OrdinalColorScaleConfig } from '@nivo/colors' import { CirclePackingLayerId } from './types' +import { CirclePackingTooltip } from './CirclePackingTooltip' export const defaultProps = { id: 'id', @@ -21,6 +22,7 @@ export const defaultProps = { }, labelsSkipRadius: 8, isInteractive: true, + tooltip: CirclePackingTooltip, animate: true, motionConfig: 'gentle', role: 'img', diff --git a/packages/circle-packing/src/types.ts b/packages/circle-packing/src/types.ts index 87e1754e8..329e7e04a 100644 --- a/packages/circle-packing/src/types.ts +++ b/packages/circle-packing/src/types.ts @@ -41,8 +41,19 @@ export type CirclePackCustomLayer = React.FC = CirclePackLayerId | CirclePackCustomLayer -export interface CirclePackSvgProps> - extends Dimensions { +export type MouseHandler = ( + datum: ComputedDatum, + event: React.MouseEvent +) => void + +export type MouseHandlers = { + onClick?: MouseHandler + onMouseEnter?: MouseHandler + onMouseMove?: MouseHandler + onMouseLeave?: MouseHandler +} + +export interface CirclePackingCommonProps { data: RawDatum id: PropertyAccessor @@ -70,6 +81,7 @@ export interface CirclePackSvgProps layers: CirclePackingLayer[] isInteractive: boolean + tooltip: (props: ComputedDatum) => JSX.Element animate: boolean motionConfig: ModernMotionProps['motionConfig'] @@ -77,13 +89,15 @@ export interface CirclePackSvgProps role: string } -export type CirclePackingSvgProps = CirclePackingCommonProps +export type CirclePackingSvgProps = CirclePackingCommonProps & + MouseHandlers -export type CirclePackingHtmlProps = CirclePackingCommonProps +export type CirclePackingHtmlProps = CirclePackingCommonProps & + MouseHandlers export type CirclePackingCanvasProps = CirclePackingCommonProps -export interface CircleProps { +export type CircleProps = { node: ComputedDatum style: { x: SpringValue @@ -93,7 +107,7 @@ export interface CircleProps { color: SpringValue opacity: SpringValue } -} +} & MouseHandlers export type CircleComponent = (props: CircleProps) => JSX.Element diff --git a/website/src/data/components/circle-packing/props.js b/website/src/data/components/circle-packing/props.js index b858cf514..2f1837df5 100644 --- a/website/src/data/components/circle-packing/props.js +++ b/website/src/data/components/circle-packing/props.js @@ -263,7 +263,7 @@ const props = [ }, { key: 'isInteractive', - flavors: ['svg', 'html'], + flavors: ['svg', 'html', 'canvas'], help: 'Enable/disable interactivity.', type: 'boolean', required: false, @@ -272,22 +272,46 @@ const props = [ group: 'Interactivity', }, { - key: 'isZoomable', + key: 'onMouseEnter', flavors: ['svg', 'html'], - help: `Enable/disable zooming ('isInteractive' must also be 'true').`, - type: 'boolean', + group: 'Interactivity', + help: 'onMouseEnter handler, it receives target node data and mouse event.', + type: '(node, event) => void', required: false, - defaultValue: defaultProps.isZoomable, - controlType: 'switch', + }, + { + key: 'onMouseMove', + flavors: ['svg', 'html', 'canvas'], + group: 'Interactivity', + help: 'onMouseMove handler, it receives target node data and mouse event.', + type: '(node, event) => void', + required: false, + }, + { + key: 'onMouseLeave', + flavors: ['svg', 'html'], group: 'Interactivity', + help: 'onMouseLeave handler, it receives target node data and mouse event.', + type: '(node, event) => void', + required: false, }, { key: 'onClick', + flavors: ['svg', 'html', 'canvas'], group: 'Interactivity', + help: 'onClick handler, it receives target node data and mouse event.', + type: '(node, event) => void', + required: false, + }, + { + key: 'isZoomable', flavors: ['svg', 'html'], - help: 'onClick handler, it receives clicked node data and mouse event.', - type: 'Function', + help: `Enable/disable zooming ('isInteractive' must also be 'true').`, + type: 'boolean', required: false, + defaultValue: defaultProps.isZoomable, + controlType: 'switch', + group: 'Interactivity', }, ...motionProperties(['svg', 'html'], defaultProps, 'react-spring'), ]