Skip to content

Commit

Permalink
feat(dendogram): add support for a voronoi mesh
Browse files Browse the repository at this point in the history
  • Loading branch information
plouc committed May 3, 2024
1 parent 3e95886 commit f10b717
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 40 deletions.
1 change: 1 addition & 0 deletions packages/dendogram/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@nivo/colors": "workspace:*",
"@nivo/core": "workspace:*",
"@nivo/tooltip": "workspace:*",
"@nivo/voronoi": "workspace:*",
"@react-spring/web": "9.4.5 || ^9.7.2",
"@types/d3-hierarchy": "^3.1.7",
"@types/d3-scale": "^4.0.8",
Expand Down
23 changes: 23 additions & 0 deletions packages/dendogram/src/Dendogram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { svgDefaultProps } from './defaults'
import { useDendogram } from './hooks'
import { Links } from './Links'
import { Nodes } from './Nodes'
import { Mesh } from './Mesh'

type InnerDendogramProps<Datum extends object> = Omit<
DendogramSvgProps<Datum>,
Expand All @@ -26,6 +27,9 @@ const InnerDendogram = <Datum extends object>({
layout = svgDefaultProps.layout,
layers = svgDefaultProps.layers,
isInteractive = svgDefaultProps.isInteractive,
useMesh = svgDefaultProps.useMesh,
meshDetectionThreshold = svgDefaultProps.meshDetectionThreshold,
debugMesh = svgDefaultProps.debugMesh,
onNodeMouseEnter,
onNodeMouseMove,
onNodeMouseLeave,
Expand Down Expand Up @@ -63,6 +67,7 @@ const InnerDendogram = <Datum extends object>({
links: null,
nodes: null,
labels: null,
mesh: null,
}

if (layers.includes('links')) {
Expand Down Expand Up @@ -97,6 +102,24 @@ const InnerDendogram = <Datum extends object>({
)
}

if (layers.includes('mesh') && isInteractive && useMesh) {
layerById.mesh = (
<Mesh
key="mesh"
nodes={nodes}
width={innerWidth}
height={innerHeight}
margin={margin}
detectionThreshold={meshDetectionThreshold}
debug={debugMesh}
onMouseEnter={onNodeMouseEnter}
onMouseMove={onNodeMouseMove}
onMouseLeave={onNodeMouseLeave}
onClick={onNodeClick}
/>
)
}

const customLayerProps: CustomLayerProps<Datum> = useMemo(
() => ({
nodes,
Expand Down
95 changes: 95 additions & 0 deletions packages/dendogram/src/Mesh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { MouseEvent } from 'react'
import { createElement, memo, useCallback } from 'react'
import { Margin } from '@nivo/core'
import { useTooltip } from '@nivo/tooltip'
import { Mesh as BaseMesh } from '@nivo/voronoi'
import { ComputedNode, NodeMouseEventHandler, NodeTooltip } from './types'

interface MeshProps<Datum extends object> {
nodes: ComputedNode<Datum>[]
width: number
height: number
margin: Margin
onMouseEnter?: NodeMouseEventHandler<Datum>
onMouseMove?: NodeMouseEventHandler<Datum>
onMouseLeave?: NodeMouseEventHandler<Datum>
onClick?: NodeMouseEventHandler<Datum>
tooltip?: NodeTooltip<Datum>
detectionThreshold: number
debug: boolean
}

const NonMemoizedMesh = <Datum extends object>({
nodes,
width,
height,
margin,
onMouseEnter,
onMouseMove,
onMouseLeave,
onClick,
tooltip,
detectionThreshold,
debug,
}: MeshProps<Datum>) => {
const { showTooltipAt, hideTooltip } = useTooltip()

const handleMouseEnter = useCallback(
(node: ComputedNode<Datum>, event: MouseEvent) => {
if (tooltip !== undefined) {
showTooltipAt(
createElement(tooltip, { node }),
[node.x + margin.left, node.y ?? 0 + margin.top],
'top'
)
}
onMouseEnter && onMouseEnter(node, event)
},
[showTooltipAt, tooltip, margin.left, margin.top, onMouseEnter]
)

const handleMouseMove = useCallback(
(node: ComputedNode<Datum>, event: MouseEvent) => {
if (tooltip !== undefined) {
showTooltipAt(
createElement(tooltip, { node }),
[node.x + margin.left, node.y ?? 0 + margin.top],
'top'
)
}
onMouseMove && onMouseMove(node, event)
},
[showTooltipAt, tooltip, margin.left, margin.top, onMouseMove]
)

const handleMouseLeave = useCallback(
(node: ComputedNode<Datum>, event: MouseEvent) => {
hideTooltip()
onMouseLeave && onMouseLeave(node, event)
},
[hideTooltip, onMouseLeave]
)

const handleClick = useCallback(
(node: ComputedNode<Datum>, event: MouseEvent) => {
onClick && onClick(node, event)
},
[onClick]
)

return (
<BaseMesh
nodes={nodes}
width={width}
height={height}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
detectionThreshold={detectionThreshold}
debug={debug}
/>
)
}

export const Mesh = memo(NonMemoizedMesh) as typeof NonMemoizedMesh
10 changes: 8 additions & 2 deletions packages/dendogram/src/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const commonDefaultProps: Pick<
| 'linkThickness'
| 'linkColor'
| 'isInteractive'
| 'useMesh'
| 'meshDetectionThreshold'
| 'debugMesh'
| 'role'
| 'animate'
| 'motionConfig'
Expand All @@ -20,8 +23,11 @@ export const commonDefaultProps: Pick<
nodeSize: 16,
nodeColor: { scheme: 'nivo' },
linkThickness: 1,
linkColor: '#555555',
linkColor: { from: 'source.color', modifiers: [['opacity', 0.3]] },
isInteractive: true,
useMesh: false,
meshDetectionThreshold: Infinity,
debugMesh: false,
role: 'img',
animate: true,
motionConfig: 'gentle',
Expand All @@ -30,7 +36,7 @@ export const commonDefaultProps: Pick<
export const svgDefaultProps: typeof commonDefaultProps &
Required<Pick<DendogramSvgProps<any>, 'layers' | 'nodeComponent' | 'linkComponent'>> = {
...commonDefaultProps,
layers: ['links', 'nodes', 'labels'],
layers: ['links', 'nodes', 'labels', 'mesh'],
nodeComponent: Node,
linkComponent: Link,
}
5 changes: 4 additions & 1 deletion packages/dendogram/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { OrdinalColorScaleConfig, InheritedColorConfig } from '@nivo/colors'

export type Layout = 'top-to-bottom' | 'right-to-left' | 'bottom-to-top' | 'left-to-right'

export type LayerId = 'links' | 'nodes' | 'labels'
export type LayerId = 'links' | 'nodes' | 'labels' | 'mesh'

export interface DefaultDatum {
id: string
Expand Down Expand Up @@ -139,6 +139,9 @@ export interface CommonProps<Datum extends object> extends MotionProps {
linkColor: InheritedColorConfig<IntermediateComputedLink<Datum>>

isInteractive: boolean
useMesh: boolean
meshDetectionThreshold: number
debugMesh: boolean
onNodeMouseEnter: NodeMouseEventHandler<Datum>
onNodeMouseMove: NodeMouseEventHandler<Datum>
onNodeMouseLeave: NodeMouseEventHandler<Datum>
Expand Down
58 changes: 28 additions & 30 deletions packages/voronoi/src/Mesh.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, useState, useCallback, useMemo, MouseEvent, TouchEvent } from 'react'
import { getRelativeCursor } from '@nivo/core'
import { getRelativeCursor, getDistance } from '@nivo/core'
import { useVoronoiMesh } from './hooks'
import { XYAccessor } from './computeMesh'

Expand All @@ -20,6 +20,7 @@ interface MeshProps<Datum> {
onTouchMove?: TouchHandler<Datum>
onTouchEnd?: TouchHandler<Datum>
enableTouchCrosshair?: boolean
detectionThreshold?: number
debug?: boolean
}

Expand All @@ -37,6 +38,7 @@ export const Mesh = <Datum,>({
onTouchMove,
onTouchEnd,
enableTouchCrosshair = false,
detectionThreshold = Infinity,
debug,
}: MeshProps<Datum>) => {
const elementRef = useRef<SVGGElement>(null)
Expand All @@ -59,54 +61,50 @@ export const Mesh = <Datum,>({
return undefined
}, [debug, voronoi])

const getIndexAndNodeFromMouseEvent = useCallback(
(event: MouseEvent<SVGRectElement>) => {
const getIndexAndNodeFromEvent = useCallback(
(event: MouseEvent<SVGRectElement> | TouchEvent<SVGRectElement>) => {
if (!elementRef.current) {
return [null, null]
}

const [x, y] = getRelativeCursor(elementRef.current, event)
const index = delaunay.find(x, y)

return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null]
},
[delaunay, nodes]
)

const getIndexAndNodeFromTouchEvent = useCallback(
(event: TouchEvent<SVGRectElement>) => {
if (!elementRef.current) {
return [null, null]
let index: number | null = delaunay.find(x, y)
let node = index !== undefined ? nodes[index] : null
if (node && detectionThreshold !== Infinity) {
if (
getDistance(x, y, (node as any).x as number, (node as any).y as number) >
detectionThreshold
) {
index = null
node = null
}
}

const [x, y] = getRelativeCursor(elementRef.current, event)
const index = delaunay.find(x, y)

return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null]
return [node ? index : null, node] as [null, null] | [number, Datum]
},
[delaunay, nodes]
[delaunay, nodes, detectionThreshold]
)

const handleMouseEnter = useCallback(
(event: MouseEvent<SVGRectElement>) => {
const [index, node] = getIndexAndNodeFromMouseEvent(event)
const [index, node] = getIndexAndNodeFromEvent(event)
setCurrentIndex(index)
if (node) {
onMouseEnter?.(node, event)
}
},
[getIndexAndNodeFromMouseEvent, setCurrentIndex, onMouseEnter]
[getIndexAndNodeFromEvent, setCurrentIndex, onMouseEnter]
)

const handleMouseMove = useCallback(
(event: MouseEvent<SVGRectElement>) => {
const [index, node] = getIndexAndNodeFromMouseEvent(event)
const [index, node] = getIndexAndNodeFromEvent(event)
setCurrentIndex(index)
if (node) {
onMouseMove?.(node, event)
}
},
[getIndexAndNodeFromMouseEvent, setCurrentIndex, onMouseMove]
[getIndexAndNodeFromEvent, setCurrentIndex, onMouseMove]
)

const handleMouseLeave = useCallback(
Expand All @@ -125,39 +123,39 @@ export const Mesh = <Datum,>({

const handleClick = useCallback(
(event: MouseEvent<SVGRectElement>) => {
const [index, node] = getIndexAndNodeFromMouseEvent(event)
const [index, node] = getIndexAndNodeFromEvent(event)
setCurrentIndex(index)
if (node) {
onClick?.(node, event)
}
},
[getIndexAndNodeFromMouseEvent, setCurrentIndex, onClick]
[getIndexAndNodeFromEvent, setCurrentIndex, onClick]
)

const handleTouchStart = useCallback(
(event: TouchEvent<SVGRectElement>) => {
const [index, node] = getIndexAndNodeFromTouchEvent(event)
const [index, node] = getIndexAndNodeFromEvent(event)
if (enableTouchCrosshair) {
setCurrentIndex(index)
}
if (node) {
onTouchStart?.(node, event)
}
},
[getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchStart]
[getIndexAndNodeFromEvent, enableTouchCrosshair, onTouchStart]
)

const handleTouchMove = useCallback(
(event: TouchEvent<SVGRectElement>) => {
const [index, node] = getIndexAndNodeFromTouchEvent(event)
const [index, node] = getIndexAndNodeFromEvent(event)
if (enableTouchCrosshair) {
setCurrentIndex(index)
}
if (node) {
onTouchMove?.(node, event)
}
},
[getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchMove]
[getIndexAndNodeFromEvent, enableTouchCrosshair, onTouchMove]
)

const handleTouchEnd = useCallback(
Expand All @@ -181,7 +179,7 @@ export const Mesh = <Datum,>({
{debug && voronoi && (
<>
<path d={voronoiPath} stroke="red" strokeWidth={1} opacity={0.75} />
{/* highlight current cell */}
{/* highlight the current cell */}
{currentIndex !== null && (
<path fill="pink" opacity={0.35} d={voronoi.renderCell(currentIndex)} />
)}
Expand Down
2 changes: 1 addition & 1 deletion packages/voronoi/src/computeMesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ export const computeMesh = ({
const delaunay = Delaunay.from(points)
const voronoi = debug ? delaunay.voronoi([0, 0, width, height]) : undefined

return { delaunay, voronoi }
return { points, delaunay, voronoi }
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit f10b717

Please sign in to comment.