From 71e70bd0afc8042db4e212d7eb53ae829cdb502d Mon Sep 17 00:00:00 2001 From: plouc Date: Mon, 6 May 2024 11:32:23 +0900 Subject: [PATCH] feat(tree): add canvas support --- packages/tree/package.json | 2 +- packages/tree/src/Label.tsx | 2 +- packages/tree/src/ResponsiveTreeCanvas.tsx | 11 + packages/tree/src/Tree.tsx | 4 +- packages/tree/src/TreeCanvas.tsx | 294 ++++++++++++++++ packages/tree/src/canvas.ts | 45 +++ packages/tree/src/defaults.ts | 25 +- packages/tree/src/index.ts | 2 + packages/tree/src/types.ts | 55 ++- pnpm-lock.yaml | 24 +- storybook/stories/tree/Tree.stories.tsx | 36 +- storybook/stories/tree/TreeCanvas.stories.tsx | 314 ++++++++++++++++++ website/src/@types/file_types.d.ts | 1 + website/src/data/components/tree/meta.yml | 21 +- website/src/data/components/tree/props.ts | 133 ++++++-- website/src/data/nav.ts | 1 + website/src/pages/tree/canvas.tsx | 157 +++++++++ website/src/pages/tree/index.tsx | 2 +- 18 files changed, 1069 insertions(+), 60 deletions(-) create mode 100644 packages/tree/src/ResponsiveTreeCanvas.tsx create mode 100644 packages/tree/src/TreeCanvas.tsx create mode 100644 packages/tree/src/canvas.ts create mode 100644 storybook/stories/tree/TreeCanvas.stories.tsx create mode 100644 website/src/pages/tree/canvas.tsx diff --git a/packages/tree/package.json b/packages/tree/package.json index 59f3ee021..e1a97c663 100644 --- a/packages/tree/package.json +++ b/packages/tree/package.json @@ -30,9 +30,9 @@ "!dist/tsconfig.tsbuildinfo" ], "dependencies": { - "@nivo/annotations": "workspace:*", "@nivo/colors": "workspace:*", "@nivo/core": "workspace:*", + "@nivo/text": "workspace:*", "@nivo/tooltip": "workspace:*", "@nivo/voronoi": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", diff --git a/packages/tree/src/Label.tsx b/packages/tree/src/Label.tsx index f71be3a2a..a8c22fc84 100644 --- a/packages/tree/src/Label.tsx +++ b/packages/tree/src/Label.tsx @@ -18,7 +18,7 @@ export const Label = ({ label, animatedProps }: LabelComponentProps( + props: ResponsiveTreeCanvasProps +) => ( + + {({ width, height }) => width={width} height={height} {...props} />} + +) diff --git a/packages/tree/src/Tree.tsx b/packages/tree/src/Tree.tsx index 093e03feb..b9fb84926 100644 --- a/packages/tree/src/Tree.tsx +++ b/packages/tree/src/Tree.tsx @@ -1,6 +1,6 @@ import { createElement, Fragment, ReactNode, useMemo } from 'react' import { Container, useDimensions, SvgWrapper } from '@nivo/core' -import { DefaultDatum, LayerId, TreeSvgProps, CustomLayerProps } from './types' +import { DefaultDatum, LayerId, TreeSvgProps, CustomSvgLayerProps } from './types' import { svgDefaultProps } from './defaults' import { useTree } from './hooks' import { Links } from './Links' @@ -168,7 +168,7 @@ const InnerTree = ({ ) } - const customLayerProps: CustomLayerProps = useMemo( + const customLayerProps: CustomSvgLayerProps = useMemo( () => ({ nodes, nodeByUid, diff --git a/packages/tree/src/TreeCanvas.tsx b/packages/tree/src/TreeCanvas.tsx new file mode 100644 index 000000000..3019f5e0c --- /dev/null +++ b/packages/tree/src/TreeCanvas.tsx @@ -0,0 +1,294 @@ +import { MouseEvent, useCallback, useEffect, useMemo, useRef, useState, createElement } from 'react' +import { Container, getRelativeCursor, isCursorInRect, useDimensions, useTheme } from '@nivo/core' +import { setCanvasFont } from '@nivo/text' +import { useTooltip } from '@nivo/tooltip' +import { useVoronoiMesh, renderVoronoiToCanvas, renderVoronoiCellToCanvas } from '@nivo/voronoi' +import { DefaultDatum, TreeCanvasProps, CustomCanvasLayerProps, ComputedNode } from './types' +import { canvasDefaultProps } from './defaults' +import { useTree } from './hooks' +import { useLabels } from './labelsHooks' + +type InnerTreeCanvasProps = Omit< + TreeCanvasProps, + 'animate' | 'motionConfig' | 'renderWrapper' | 'theme' +> + +const InnerTreeCanvas = ({ + width, + height, + pixelRatio = canvasDefaultProps.pixelRatio, + margin: partialMargin, + data, + identity, + mode = canvasDefaultProps.mode, + layout = canvasDefaultProps.layout, + nodeSize = canvasDefaultProps.nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor = canvasDefaultProps.nodeColor, + fixNodeColorAtDepth = canvasDefaultProps.fixNodeColorAtDepth, + renderNode = canvasDefaultProps.renderNode, + linkCurve = canvasDefaultProps.linkCurve, + linkThickness = canvasDefaultProps.linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor = canvasDefaultProps.linkColor, + renderLink = canvasDefaultProps.renderLink, + enableLabel = canvasDefaultProps.enableLabel, + label = canvasDefaultProps.label, + labelsPosition = canvasDefaultProps.labelsPosition, + orientLabel = canvasDefaultProps.orientLabel, + labelOffset = canvasDefaultProps.labelOffset, + renderLabel = canvasDefaultProps.renderLabel, + layers = canvasDefaultProps.layers, + isInteractive = canvasDefaultProps.isInteractive, + // meshDetectionThreshold = canvasDefaultProps.meshDetectionThreshold, + debugMesh = canvasDefaultProps.debugMesh, + highlightAncestorNodes = canvasDefaultProps.highlightAncestorNodes, + highlightDescendantNodes = canvasDefaultProps.highlightDescendantNodes, + highlightAncestorLinks = canvasDefaultProps.highlightAncestorLinks, + highlightDescendantLinks = canvasDefaultProps.highlightDescendantLinks, + // onNodeMouseEnter, + // onNodeMouseMove, + // onNodeMouseLeave, + onNodeClick, + nodeTooltip, + role = canvasDefaultProps.role, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: InnerTreeCanvasProps) => { + const canvasEl = useRef(null) + + const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions( + width, + height, + partialMargin + ) + + const theme = useTheme() + + const { nodes, nodeByUid, links, linkGenerator, setCurrentNode } = useTree({ + data, + identity, + layout, + mode, + width: innerWidth, + height: innerHeight, + nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor, + fixNodeColorAtDepth, + highlightAncestorNodes, + highlightDescendantNodes, + linkCurve, + linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor, + highlightAncestorLinks, + highlightDescendantLinks, + }) + + const labels = useLabels({ + nodes, + label, + layout, + labelsPosition, + orientLabel, + labelOffset, + }) + + const { delaunay, voronoi } = useVoronoiMesh({ + points: nodes, + width: innerWidth, + height: innerHeight, + debug: debugMesh, + }) + + const customLayerProps: CustomCanvasLayerProps = useMemo( + () => ({ + nodes, + nodeByUid, + links, + innerWidth, + innerHeight, + linkGenerator, + }), + [nodes, nodeByUid, links, innerWidth, innerHeight, linkGenerator] + ) + + const [currentNodeIndex, setCurrentNodeIndex] = useState(null) + + useEffect(() => { + if (canvasEl.current === null) return + + canvasEl.current.width = outerWidth * pixelRatio + canvasEl.current.height = outerHeight * pixelRatio + + const ctx = canvasEl.current.getContext('2d')! + + ctx.scale(pixelRatio, pixelRatio) + + ctx.fillStyle = theme.background + ctx.fillRect(0, 0, outerWidth, outerHeight) + + ctx.translate(margin.left, margin.top) + + layers.forEach(layer => { + if (layer === 'links') { + linkGenerator.context(ctx) + + links.forEach(link => { + renderLink(ctx, { link, linkGenerator }) + }) + } else if (layer === 'nodes') { + nodes.forEach(node => { + renderNode(ctx, { node }) + }) + } else if (layer === 'labels' && enableLabel) { + setCanvasFont(ctx, theme.labels.text) + + labels.forEach(label => { + renderLabel(ctx, { label, theme }) + }) + } else if (layer === 'mesh' && debugMesh) { + renderVoronoiToCanvas(ctx, voronoi!) + if (currentNodeIndex !== null) { + renderVoronoiCellToCanvas(ctx, voronoi!, currentNodeIndex) + } + } else if (typeof layer === 'function') { + layer(ctx, customLayerProps) + } + }) + }, [ + canvasEl, + outerWidth, + outerHeight, + pixelRatio, + margin.left, + margin.top, + theme, + layers, + nodes, + nodeByUid, + renderNode, + links, + renderLink, + linkGenerator, + labels, + enableLabel, + renderLabel, + voronoi, + debugMesh, + currentNodeIndex, + customLayerProps, + ]) + + const getNodeFromMouseEvent = useCallback( + ( + event: MouseEvent + ): [node: ComputedNode | null, nodeIndex: number | null] => { + const [x, y] = getRelativeCursor(canvasEl.current!, event) + if (!isCursorInRect(margin.left, margin.top, innerWidth, innerHeight, x, y)) + return [null, null] + + const nodeIndex = delaunay.find(x - margin.left, y - margin.top) + return [nodes[nodeIndex], nodeIndex] + }, + [canvasEl, margin, innerWidth, innerHeight, delaunay, nodes] + ) + + const { showTooltipFromEvent, hideTooltip } = useTooltip() + + const handleMouseHover = useCallback( + (event: MouseEvent) => { + const [node, nodeIndex] = getNodeFromMouseEvent(event) + setCurrentNode(node) + setCurrentNodeIndex(nodeIndex) + + if (node) { + nodeTooltip && showTooltipFromEvent(createElement(nodeTooltip, { node }), event) + + /* + if (currentNode && currentNode.id !== node.id) { + onMouseLeave && onMouseLeave(currentNode, event) + onMouseEnter && onMouseEnter(node, event) + } + if (!currentNode) { + onMouseEnter && onMouseEnter(node, event) + } + onMouseMove && onMouseMove(node, event) + */ + } else { + hideTooltip() + // currentNode && onMouseLeave && onMouseLeave(currentNode, event) + } + }, + [ + getNodeFromMouseEvent, + // currentNode, + setCurrentNode, + setCurrentNodeIndex, + showTooltipFromEvent, + hideTooltip, + nodeTooltip, + // onMouseEnter, + // onMouseMove, + // onMouseLeave, + ] + ) + + const handleClick = useCallback( + (event: MouseEvent) => { + if (onNodeClick) { + const [node] = getNodeFromMouseEvent(event) + node && onNodeClick?.(node, event) + } + }, + [getNodeFromMouseEvent, onNodeClick] + ) + + return ( + + ) +} + +export const TreeCanvas = ({ + isInteractive = canvasDefaultProps.isInteractive, + animate = canvasDefaultProps.animate, + motionConfig = canvasDefaultProps.motionConfig, + theme, + renderWrapper, + ...otherProps +}: TreeCanvasProps) => ( + + isInteractive={isInteractive} {...otherProps} /> + +) diff --git a/packages/tree/src/canvas.ts b/packages/tree/src/canvas.ts new file mode 100644 index 000000000..910d8f906 --- /dev/null +++ b/packages/tree/src/canvas.ts @@ -0,0 +1,45 @@ +import { degreesToRadians } from '@nivo/core' +import { drawCanvasText } from '@nivo/text' +import { LinkCanvasRendererProps, NodeCanvasRendererProps, LabelCanvasRendererProps } from './types' + +export const renderNode = ( + ctx: CanvasRenderingContext2D, + { node }: NodeCanvasRendererProps +) => { + ctx.beginPath() + ctx.arc(node.x, node.y, node.size / 2, 0, 2 * Math.PI) + ctx.fillStyle = node.color + ctx.fill() +} + +export const renderLink = ( + ctx: CanvasRenderingContext2D, + { link, linkGenerator }: LinkCanvasRendererProps +) => { + ctx.strokeStyle = link.color + ctx.lineWidth = link.thickness + ctx.beginPath() + linkGenerator({ + source: [link.source.x, link.source.y], + target: [link.target.x, link.target.y], + }) + ctx.stroke() +} + +export const renderLabel = ( + ctx: CanvasRenderingContext2D, + { label, theme }: LabelCanvasRendererProps +) => { + ctx.save() + + ctx.translate(label.x, label.y) + ctx.rotate(degreesToRadians(label.rotation)) + + ctx.textBaseline = 'middle' + ctx.textAlign = label.textAnchor === 'middle' ? 'center' : label.textAnchor + ctx.fillStyle = '#000' + + drawCanvasText(ctx, theme.labels.text, label.label) + + ctx.restore() +} diff --git a/packages/tree/src/defaults.ts b/packages/tree/src/defaults.ts index 5d2c39b2b..dd34440e2 100644 --- a/packages/tree/src/defaults.ts +++ b/packages/tree/src/defaults.ts @@ -1,7 +1,8 @@ -import { CommonProps, TreeSvgProps } from './types' +import { CommonProps, TreeCanvasProps, TreeSvgProps } from './types' import { Node } from './Node' import { Link } from './Link' import { Label } from './Label' +import { renderNode, renderLink, renderLabel } from './canvas' export const commonDefaultProps: Pick< CommonProps, @@ -19,7 +20,6 @@ export const commonDefaultProps: Pick< | 'labelsPosition' | 'orientLabel' | 'labelOffset' - | 'labelComponent' | 'isInteractive' | 'useMesh' | 'meshDetectionThreshold' @@ -46,7 +46,6 @@ export const commonDefaultProps: Pick< labelsPosition: 'outward', orientLabel: true, labelOffset: 6, - labelComponent: Label, isInteractive: true, useMesh: true, meshDetectionThreshold: Infinity, @@ -61,9 +60,27 @@ export const commonDefaultProps: Pick< } export const svgDefaultProps: typeof commonDefaultProps & - Required, 'layers' | 'nodeComponent' | 'linkComponent'>> = { + Required< + Pick, 'layers' | 'nodeComponent' | 'linkComponent' | 'labelComponent'> + > = { ...commonDefaultProps, layers: ['links', 'nodes', 'labels', 'mesh'], nodeComponent: Node, linkComponent: Link, + labelComponent: Label, +} + +export const canvasDefaultProps: typeof commonDefaultProps & + Required< + Pick< + TreeCanvasProps, + 'layers' | 'renderNode' | 'renderLink' | 'renderLabel' | 'pixelRatio' + > + > = { + ...commonDefaultProps, + layers: ['links', 'nodes', 'labels', 'mesh'], + renderNode, + renderLink, + renderLabel, + pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, } diff --git a/packages/tree/src/index.ts b/packages/tree/src/index.ts index 91412b411..a071a4726 100644 --- a/packages/tree/src/index.ts +++ b/packages/tree/src/index.ts @@ -1,5 +1,7 @@ export * from './Tree' export * from './ResponsiveTree' +export * from './TreeCanvas' +export * from './ResponsiveTreeCanvas' export * from './hooks' export * from './labelsHooks' export * from './types' diff --git a/packages/tree/src/types.ts b/packages/tree/src/types.ts index 37a14b157..2403da1f7 100644 --- a/packages/tree/src/types.ts +++ b/packages/tree/src/types.ts @@ -2,7 +2,7 @@ import { AriaAttributes, FunctionComponent, MouseEvent } from 'react' import { HierarchyNode } from 'd3-hierarchy' import { Link as LinkShape, DefaultLinkObject } from 'd3-shape' import { SpringValues } from '@react-spring/web' -import { Box, Dimensions, MotionProps, Theme, PropertyAccessor } from '@nivo/core' +import { Box, Dimensions, MotionProps, Theme, PropertyAccessor, CompleteTheme } from '@nivo/core' import { OrdinalColorScaleConfig, InheritedColorConfig } from '@nivo/colors' export type TreeMode = 'tree' | 'dendogram' @@ -89,6 +89,14 @@ export interface NodeComponentProps { } export type NodeComponent = FunctionComponent> +export interface NodeCanvasRendererProps { + node: ComputedNode +} +export type NodeCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: NodeCanvasRendererProps +) => void + export interface NodeTooltipProps { node: ComputedNode } @@ -124,6 +132,15 @@ export interface LinkComponentProps { } export type LinkComponent = FunctionComponent> +export interface LinkCanvasRendererProps { + link: ComputedLink + linkGenerator: LinkGenerator +} +export type LinkCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: LinkCanvasRendererProps +) => void + export type LinkMouseEventHandler = (node: ComputedLink, event: MouseEvent) => void export interface LinkTooltipProps { @@ -157,7 +174,16 @@ export interface LabelComponentProps { } export type LabelComponent = FunctionComponent> -export interface CustomLayerProps { +export interface LabelCanvasRendererProps { + label: ComputedLabel + theme: CompleteTheme +} +export type LabelCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: LabelCanvasRendererProps +) => void + +export interface CustomSvgLayerProps { nodes: readonly ComputedNode[] nodeByUid: Record> links: readonly ComputedLink[] @@ -166,7 +192,13 @@ export interface CustomLayerProps { linkGenerator: LinkGenerator setCurrentNode: (node: ComputedNode | null) => void } -export type CustomSvgLayer = FunctionComponent> +export type CustomSvgLayer = FunctionComponent> + +export type CustomCanvasLayerProps = Omit, 'setCurrentNode'> +export type CustomCanvasLayer = ( + ctx: CanvasRenderingContext2D, + props: CustomCanvasLayerProps +) => void export interface TreeDataProps { data: Datum @@ -196,7 +228,6 @@ export interface CommonProps extends MotionProps { labelsPosition: LabelsPosition orientLabel: boolean labelOffset: number - labelComponent: LabelComponent isInteractive: boolean useMesh: boolean @@ -218,10 +249,11 @@ export interface CommonProps extends MotionProps { linkTooltip: LinkTooltip role: string - renderWrapper: boolean ariaLabel: AriaAttributes['aria-label'] ariaLabelledBy: AriaAttributes['aria-labelledby'] ariaDescribedBy: AriaAttributes['aria-describedby'] + + renderWrapper: boolean } export type TreeSvgProps = TreeDataProps & @@ -230,6 +262,19 @@ export type TreeSvgProps = TreeDataProps & layers?: (LayerId | CustomSvgLayer)[] nodeComponent?: NodeComponent linkComponent?: LinkComponent + labelComponent?: LabelComponent } export type ResponsiveTreeSvgProps = Omit, 'height' | 'width'> + +export type TreeCanvasProps = TreeDataProps & + Dimensions & + Partial> & { + layers?: (LayerId | CustomCanvasLayer)[] + renderNode?: NodeCanvasRenderer + renderLink?: LinkCanvasRenderer + renderLabel?: LabelCanvasRenderer + pixelRatio?: number + } + +export type ResponsiveTreeCanvasProps = Omit, 'height' | 'width'> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d62eec72f..74c97739a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1488,15 +1488,15 @@ importers: packages/tree: dependencies: - '@nivo/annotations': - specifier: workspace:* - version: link:../annotations '@nivo/colors': specifier: workspace:* version: link:../colors '@nivo/core': specifier: workspace:* version: link:../core + '@nivo/text': + specifier: workspace:* + version: link:../text '@nivo/tooltip': specifier: workspace:* version: link:../tooltip @@ -11553,7 +11553,6 @@ packages: dependencies: ms: 2.1.3 supports-color: 5.5.0 - dev: true /debug@3.2.7(supports-color@8.1.1): resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} @@ -11565,6 +11564,7 @@ packages: dependencies: ms: 2.1.3 supports-color: 8.1.1 + dev: true /debug@4.3.4(supports-color@5.5.0): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} @@ -11844,7 +11844,7 @@ packages: '@types/tmp': 0.0.33 application-config-path: 0.1.0 command-exists: 1.2.9 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) eol: 0.9.1 get-port: 3.2.0 glob: 7.2.3 @@ -12603,7 +12603,7 @@ packages: /eslint-import-resolver-node@0.3.6: resolution: {integrity: sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) resolve: 1.22.2 transitivePeerDependencies: - supports-color @@ -12612,7 +12612,7 @@ packages: /eslint-import-resolver-node@0.3.7: resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.12.0 resolve: 1.22.2 transitivePeerDependencies: @@ -12638,7 +12638,7 @@ packages: optional: true dependencies: '@typescript-eslint/parser': 5.59.1(eslint@8.39.0)(typescript@4.9.5) - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) eslint-import-resolver-node: 0.3.6 find-up: 2.1.0 pkg-dir: 2.0.0 @@ -12668,7 +12668,7 @@ packages: optional: true dependencies: '@typescript-eslint/parser': 5.59.1(eslint@7.32.0)(typescript@4.9.5) - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) eslint: 7.32.0 eslint-import-resolver-node: 0.3.7 transitivePeerDependencies: @@ -12746,7 +12746,7 @@ packages: array-includes: 3.1.6 array.prototype.flat: 1.3.1 array.prototype.flatmap: 1.3.1 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 7.32.0 eslint-import-resolver-node: 0.3.7 @@ -13329,7 +13329,7 @@ packages: resolution: {integrity: sha512-/l77JHcOUrDUX8V67E287VEUQT0lbm71gdGVoodnlWBziarYKgMcpqT7xvh/HM8Jv52phw8Bd8tY+a7QjOr7Yg==} engines: {node: '>=6.0.0'} dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) es6-promise: 4.2.8 raw-body: 2.4.3 transitivePeerDependencies: @@ -18985,7 +18985,7 @@ packages: engines: {node: '>= 4.4.x'} hasBin: true dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) iconv-lite: 0.4.24 sax: 1.2.4 transitivePeerDependencies: diff --git a/storybook/stories/tree/Tree.stories.tsx b/storybook/stories/tree/Tree.stories.tsx index 3305bfe49..2b12eac98 100644 --- a/storybook/stories/tree/Tree.stories.tsx +++ b/storybook/stories/tree/Tree.stories.tsx @@ -67,7 +67,21 @@ const minimalData = { ], } -const commonProperties: Partial> = { +const commonProperties: Pick< + TreeSvgProps, + | 'width' + | 'height' + | 'margin' + | 'data' + | 'identity' + | 'activeNodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkThickness' + | 'activeLinkThickness' + | 'linkColor' + | 'theme' +> = { width: 900, height: 600, margin: { top: 70, right: 70, bottom: 70, left: 70 }, @@ -79,13 +93,27 @@ const commonProperties: Partial> = { linkThickness: 2, activeLinkThickness: 6, linkColor: { from: 'target.color', modifiers: [['opacity', 0.4]] }, + theme: { + labels: { + text: { + outlineWidth: 2, + outlineColor: '#ffffff', + }, + }, + }, } const NodeTooltip = ({ node }: NodeTooltipProps) => { const theme = useTheme() return ( -
+
id: {node.id}
path:{' '} @@ -297,8 +325,8 @@ const LabelsPositionDemo = ({ config, mode }: { config: LabelsPositionConfig; mo theme={{ labels: { text: { - outlineWidth: 6, - outlineColor: '#fff', + outlineWidth: 2, + outlineColor: '#ffffff', }, }, }} diff --git a/storybook/stories/tree/TreeCanvas.stories.tsx b/storybook/stories/tree/TreeCanvas.stories.tsx new file mode 100644 index 000000000..e907b093c --- /dev/null +++ b/storybook/stories/tree/TreeCanvas.stories.tsx @@ -0,0 +1,314 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { generateLibTree } from '@nivo/generators' +import { useTheme } from '@nivo/core' +import { + Tree, + TreeCanvas, + NodeTooltipProps, + TreeCanvasProps, + Layout, + LabelsPosition, + TreeMode, + NodeCanvasRendererProps, +} from '@nivo/tree' + +const meta: Meta = { + title: 'TreeCanvas', + component: TreeCanvas, + tags: ['autodocs'], + argTypes: { + mode: { + control: 'select', + options: ['tree', 'dendogram'], + }, + layout: { + control: 'select', + options: ['top-to-bottom', 'right-to-left', 'bottom-to-top', 'left-to-right'], + }, + onNodeMouseEnter: { action: 'node mouse enter' }, + onNodeMouseMove: { action: 'node mouse move' }, + onNodeMouseLeave: { action: 'node mouse leave' }, + onNodeClick: { action: 'node clicked' }, + }, + args: { + mode: 'dendogram', + layout: 'top-to-bottom', + }, +} + +export default meta +type Story = StoryObj + +const generateData = () => { + const data = generateLibTree() + + return { data } +} + +const minimalData = { + id: 'R', + children: [ + { + id: 'A', + children: [{ id: '00' }, { id: '01' }, { id: '02' }], + }, + { + id: 'B', + }, + { + id: 'C', + children: [{ id: '00' }, { id: '01' }, { id: '02' }], + }, + ], +} + +const commonProperties: Pick< + TreeCanvasProps, + | 'width' + | 'height' + | 'margin' + | 'data' + | 'identity' + | 'activeNodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkThickness' + | 'activeLinkThickness' + | 'linkColor' + | 'theme' +> = { + width: 900, + height: 600, + margin: { top: 70, right: 70, bottom: 70, left: 70 }, + ...generateData(), + identity: 'name', + activeNodeSize: 20, + nodeColor: { scheme: 'tableau10' }, + fixNodeColorAtDepth: 1, + linkThickness: 2, + activeLinkThickness: 6, + linkColor: { from: 'target.color', modifiers: [['opacity', 0.4]] }, + theme: { + labels: { + text: { + outlineWidth: 2, + outlineColor: '#ffffff', + }, + }, + }, +} + +const NodeTooltip = ({ node }: NodeTooltipProps) => { + const theme = useTheme() + + return ( +
+ id: {node.id} +
+ path:{' '} + + {node.ancestorIds.join(' > ')} > {node.id} + +
+ uid: {node.uid} +
+ ) +} + +export const Basic: Story = { + render: args => ( + + ), +} + +export const WithNodeTooltip: Story = { + render: args => ( + + ), +} + +const renderNodeCustom = ( + ctx: CanvasRenderingContext2D, + { node }: NodeCanvasRendererProps +) => { + ctx.save() + + ctx.translate(node.x, node.y) + + if (node.isActive) { + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = node.color + } else { + ctx.fillStyle = node.color + } + + ctx.beginPath() + ctx.arc(0, 0, node.size / 2, 0, 2 * Math.PI) + ctx.fill() + if (node.isActive) { + ctx.stroke() + } + + if (node.isActive) { + } + + ctx.restore() +} + +export const CustomNodeRendering: Story = { + render: args => ( + + ), +} + +interface LabelsPositionConfig { + layout: Layout + labelsPosition: LabelsPosition + orientLabel: boolean +} + +const LabelsPositionDemo = ({ config, mode }: { config: LabelsPositionConfig; mode: TreeMode }) => { + return ( +
+
+ layout + {config.layout} + labelsPosition + {config.labelsPosition} + orientLabel + {config.orientLabel ? 'true' : 'false'} +
+ +
+ ) +} + +const layouts: Layout[] = ['top-to-bottom', 'bottom-to-top', 'left-to-right', 'right-to-left'] +const labelsPositions: LabelsPosition[] = ['outward', 'inward', 'layout', 'layout-opposite'] + +const getLabelsPositionConfigs = (): LabelsPositionConfig[] => { + const configs: LabelsPositionConfig[] = [] + + layouts.forEach(layout => { + const isVertical = layout.includes('top') + + labelsPositions.forEach(labelsPosition => { + const config: LabelsPositionConfig = { + layout, + labelsPosition, + orientLabel: false, + } + configs.push(config) + + // Orienting labels only affects vertical layouts. + if (isVertical) configs.push({ ...config, orientLabel: true }) + }) + }) + + return configs +} + +export const LabelsPositionDemos: Story = { + render: args => { + const labelsPositionConfigs = getLabelsPositionConfigs() + + return ( +
+ {labelsPositionConfigs.map((config, index) => ( + + ))} +
+ ) + }, +} diff --git a/website/src/@types/file_types.d.ts b/website/src/@types/file_types.d.ts index ad23f6491..921476239 100644 --- a/website/src/@types/file_types.d.ts +++ b/website/src/@types/file_types.d.ts @@ -238,6 +238,7 @@ declare module '*tree/meta.yml' { const meta: { flavors: ChartMetaFlavors Tree: ChartMeta + TreeCanvas: ChartMeta } export default meta diff --git a/website/src/data/components/tree/meta.yml b/website/src/data/components/tree/meta.yml index a6abc5474..2c0d8b543 100644 --- a/website/src/data/components/tree/meta.yml +++ b/website/src/data/components/tree/meta.yml @@ -1,12 +1,14 @@ flavors: - flavor: svg path: /tree/ + - flavor: canvas + path: /tree/canvas/ Tree: package: '@nivo/tree' tags: - - hierarchy - experimental + - hierarchy - tree stories: - label: Labels position demos @@ -24,3 +26,20 @@ Tree: you should be able to use the `useTree` hook directly in order to build a fully custom graph, this hook takes a config object which is very close to the component's props. + +TreeCanvas: + package: '@nivo/tree' + tags: + - experimental + - hierarchy + - tree + - canvas + stories: + - label: Custom node rendering + link: treecanvas--custom-node-rendering + - label: With node tooltip + link: treecanvas--with-node-tooltip + description: | + A canvas alternative to the [Tree](self:/tree) component. + Well suited for large data sets as it does not impact DOM tree depth, + however you'll lose the isomorphic ability and transitions. diff --git a/website/src/data/components/tree/props.ts b/website/src/data/components/tree/props.ts index 7774f435c..8ca66e4cb 100644 --- a/website/src/data/components/tree/props.ts +++ b/website/src/data/components/tree/props.ts @@ -8,20 +8,40 @@ import { } from '../../../lib/chart-properties' import { ChartProperty, Flavor } from '../../../types' -const allFlavors: Flavor[] = ['svg'] +const allFlavors: Flavor[] = ['svg', 'canvas'] const props: ChartProperty[] = [ { - key: 'data', group: 'Base', + key: 'data', flavors: allFlavors, help: 'The hierarchical data object.', + description: ` + A typical data object should look like this: + + \`\`\` + { + id: '0', + children: [ + { id: 'A', children: [{ id: '0' }, { id: '1' }] }, + { id: 'B', children: [{ id: '0' }] }, + { id: 'C' }, + ], + } + \`\`\` + + Please note that you should **never** mutate the data object, + because otherwise nivo won't know that it changed, we make heavy + use of memoization internally via React hooks, so if you want + to update the data, the reference should change, meaning you + should pass a new object. + `, type: 'object', required: true, }, { - key: 'identity', group: 'Base', + key: 'identity', flavors: allFlavors, help: 'The key or function to use to retrieve nodes identity.', description: ` @@ -37,8 +57,8 @@ const props: ChartProperty[] = [ defaultValue: defaults.identity, }, { - key: 'mode', group: 'Base', + key: 'mode', help: `Type of tree diagram.`, type: `'tree' | 'dendogram'`, flavors: allFlavors, @@ -53,13 +73,13 @@ const props: ChartProperty[] = [ }, }, { + group: 'Base', key: 'layout', help: 'Defines the diagram layout.', flavors: allFlavors, type: `'top-to-bottom' | 'right-to-left' | 'bottom-to-top' | 'left-to-right'`, required: false, defaultValue: defaults.layout, - group: 'Base', control: { type: 'choices', choices: ['top-to-bottom', 'right-to-left', 'bottom-to-top', 'left-to-right'].map( @@ -74,8 +94,8 @@ const props: ChartProperty[] = [ // Style themeProperty(allFlavors), { - key: 'nodeSize', group: 'Style', + key: 'nodeSize', type: 'number | (node: IntermediateComputedNode) => number', control: { type: 'lineWidth' }, help: 'Defines the size of the nodes, statically or dynamically.', @@ -84,8 +104,8 @@ const props: ChartProperty[] = [ flavors: allFlavors, }, { - key: 'activeNodeSize', group: 'Style', + key: 'activeNodeSize', type: 'number | (node: ComputedNode) => number', control: { type: 'range', min: 0, max: 40, unit: 'px' }, help: 'Defines the size of active nodes, statically or dynamically.', @@ -94,8 +114,8 @@ const props: ChartProperty[] = [ flavors: allFlavors, }, { - key: 'inactiveNodeSize', group: 'Style', + key: 'inactiveNodeSize', type: 'number | (node: ComputedNode) => number', control: { type: 'range', min: 0, max: 40, unit: 'px' }, help: 'Defines the size of inactive nodes, statically or dynamically.', @@ -125,13 +145,13 @@ const props: ChartProperty[] = [ control: { type: 'range', min: 0, max: 5 }, }, { + group: 'Style', key: 'linkCurve', help: 'Defines the type of curve to use to draw links.', flavors: allFlavors, type: `'bump' | 'linear' | 'step' | 'step-before' | 'step-after'`, required: false, defaultValue: defaults.linkCurve, - group: 'Style', control: { type: 'choices', choices: ['bump', 'linear', 'step', 'step-before', 'step-after'].map(choice => ({ @@ -141,18 +161,18 @@ const props: ChartProperty[] = [ }, }, { - key: 'linkThickness', group: 'Style', + key: 'linkThickness', type: 'number | (link: IntermediateComputedLink) => number', control: { type: 'lineWidth' }, help: 'Defines the thickness of the links, statically or dynamically.', required: false, defaultValue: defaults.linkThickness, - flavors: ['svg'], + flavors: allFlavors, }, { - key: 'activeLinkThickness', group: 'Style', + key: 'activeLinkThickness', type: 'number | (link: ComputedLink) => number', control: { type: 'lineWidth' }, help: 'Defines the size of active links, statically or dynamically.', @@ -161,8 +181,8 @@ const props: ChartProperty[] = [ flavors: allFlavors, }, { - key: 'inactiveLinkThickness', group: 'Style', + key: 'inactiveLinkThickness', type: 'number | (link: ComputedLink) => number', control: { type: 'lineWidth' }, help: 'Defines the thickness of inactive links, statically or dynamically.', @@ -171,8 +191,8 @@ const props: ChartProperty[] = [ flavors: allFlavors, }, { - key: 'linkColor', group: 'Style', + key: 'linkColor', type: 'InheritedColorConfig', control: { type: 'inheritedColor', @@ -187,7 +207,7 @@ const props: ChartProperty[] = [ `, required: false, defaultValue: defaults.linkColor, - flavors: ['svg'], + flavors: allFlavors, }, // Labels { @@ -274,19 +294,48 @@ const props: ChartProperty[] = [ flavors: ['svg'], required: false, }, + { + group: 'Labels', + key: 'renderLabel', + type: 'LabelCanvasRenderer', + help: 'Override the default label canvas rendering.', + description: ` + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + flavors: ['canvas'], + required: false, + }, // Customization { group: 'Customization', key: 'layers', - type: `('links' | 'nodes' | 'mesh' | CustomSvgLayer)[]`, + type: { + svg: `('links' | 'nodes' | 'mesh' | CustomSvgLayer)[]`, + canvas: `('links' | 'nodes' | 'mesh' | CustomCanvasLayer)[]`, + }, help: 'Defines the order of layers and add custom layers.', - description: ` - You can also use this to insert extra layers - to the chart, the extra layer must be a function. - - The layer function which will receive the chart's - context & computed data and must return a valid SVG element. - `, + description: { + svg: ` + You can also use this property to insert extra layers to the chart, + the extra layer must be a function component. + + This component is going to get the chart's context and computed data + as props and should return a valid SVG element. + `, + canvas: ` + You can also use this property to insert extra layers to the chart, + the extra layer must be a function. + + The function is going to get the canvas 2d context as first argument + and the chart's context and computed data as second. + + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + }, defaultValue: svgDefaults.layers, flavors: allFlavors, }, @@ -303,6 +352,19 @@ const props: ChartProperty[] = [ flavors: ['svg'], required: false, }, + { + group: 'Customization', + key: 'renderNode', + type: 'NodeCanvasRenderer', + help: 'Override the default node canvas rendering.', + description: ` + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + flavors: ['canvas'], + required: false, + }, { group: 'Customization', key: 'linkComponent', @@ -316,6 +378,19 @@ const props: ChartProperty[] = [ flavors: ['svg'], required: false, }, + { + group: 'Customization', + key: 'renderLink', + type: 'LinkCanvasRenderer', + help: 'Override the default link canvas rendering.', + description: ` + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + flavors: ['canvas'], + required: false, + }, // Interactivity isInteractive({ flavors: allFlavors, @@ -324,8 +399,8 @@ const props: ChartProperty[] = [ { group: 'Interactivity', key: 'useMesh', - flavors: allFlavors, - help: 'Use a voronoi mesh to detect mouse interactions.', + flavors: ['svg'], + help: 'Use a voronoi mesh to detect mouse interactions. Always `true` for the canvas implementation', description: ` Use a voronoi mesh to detect mouse interactions, this can be useful when the tree is dense, or if the nodes are small and you want to @@ -405,7 +480,7 @@ const props: ChartProperty[] = [ { group: 'Interactivity', key: 'onNodeMouseMove', - flavors: ['svg'], + flavors: allFlavors, type: '(node: ComputedNode, event: MouseEvent) => void', help: 'onMouseMove handler for nodes.', required: false, @@ -413,7 +488,7 @@ const props: ChartProperty[] = [ { group: 'Interactivity', key: 'onNodeMouseLeave', - flavors: ['svg'], + flavors: allFlavors, type: '(node: ComputedNode, event: MouseEvent) => void', help: 'onMouseLeave handler for nodes.', required: false, @@ -421,7 +496,7 @@ const props: ChartProperty[] = [ { group: 'Interactivity', key: 'onNodeClick', - flavors: ['svg'], + flavors: allFlavors, type: '(node: ComputedNode, event: MouseEvent) => void', help: 'onClick handler for nodes.', required: false, @@ -429,7 +504,7 @@ const props: ChartProperty[] = [ { group: 'Interactivity', key: 'nodeTooltip', - flavors: ['svg'], + flavors: allFlavors, type: 'NodeTooltip', help: 'Tooltip component for nodes.', required: false, diff --git a/website/src/data/nav.ts b/website/src/data/nav.ts index 567935afd..fd72ef357 100644 --- a/website/src/data/nav.ts +++ b/website/src/data/nav.ts @@ -260,6 +260,7 @@ export const components: ChartNavData[] = [ tags: tree.Tree.tags, flavors: { svg: true, + canvas: true, }, }, { diff --git a/website/src/pages/tree/canvas.tsx b/website/src/pages/tree/canvas.tsx new file mode 100644 index 000000000..3475715ec --- /dev/null +++ b/website/src/pages/tree/canvas.tsx @@ -0,0 +1,157 @@ +import React from 'react' +import { graphql, useStaticQuery } from 'gatsby' +import { + ResponsiveTreeCanvas, + TreeSvgProps, + svgDefaultProps as defaults, + ComputedLink, + ComputedNode, +} from '@nivo/tree' +import { ComponentTemplate } from '../../components/components/ComponentTemplate' +import meta from '../../data/components/tree/meta.yml' +import mapper from '../../data/components/treemap/mapper' +import { groups } from '../../data/components/tree/props' +import { generateLightDataSet } from '../../data/components/treemap/generator' + +type Datum = ReturnType + +const initialProperties: Pick< + TreeSvgProps, + | 'identity' + | 'mode' + | 'layout' + | 'nodeSize' + | 'activeNodeSize' + | 'inactiveNodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkCurve' + | 'linkThickness' + | 'activeLinkThickness' + | 'inactiveLinkThickness' + | 'linkColor' + | 'enableLabel' + | 'labelsPosition' + | 'orientLabel' + | 'labelOffset' + | 'margin' + | 'animate' + | 'motionConfig' + | 'isInteractive' + | 'meshDetectionThreshold' + | 'debugMesh' + | 'highlightAncestorNodes' + | 'highlightDescendantNodes' + | 'highlightAncestorLinks' + | 'highlightDescendantLinks' +> = { + identity: 'name', + mode: defaults.mode, + layout: defaults.layout, + nodeSize: 12, + activeNodeSize: 24, + inactiveNodeSize: 12, + nodeColor: { scheme: 'tableau10' }, + fixNodeColorAtDepth: 1, + linkCurve: defaults.linkCurve, + linkThickness: 2, + activeLinkThickness: 8, + inactiveLinkThickness: 2, + linkColor: { from: 'target.color', modifiers: [['opacity', 0.4]] }, + + enableLabel: defaults.enableLabel, + labelsPosition: defaults.labelsPosition, + orientLabel: defaults.orientLabel, + labelOffset: defaults.labelOffset, + + margin: { + top: 90, + right: 90, + bottom: 90, + left: 90, + }, + + animate: defaults.animate, + motionConfig: 'stiff', + + isInteractive: defaults.isInteractive, + meshDetectionThreshold: 80, + debugMesh: defaults.debugMesh, + highlightAncestorNodes: defaults.highlightAncestorNodes, + highlightDescendantNodes: defaults.highlightDescendantNodes, + highlightAncestorLinks: defaults.highlightAncestorLinks, + highlightDescendantLinks: defaults.highlightDescendantLinks, + + pixelRatio: + typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1, +} + +const TreeCanvas = () => { + const { + image: { + childImageSharp: { gatsbyImageData: image }, + }, + } = useStaticQuery(graphql` + query { + image: file(absolutePath: { glob: "**/src/assets/captures/tree.png" }) { + childImageSharp { + gatsbyImageData(layout: FIXED, width: 700, quality: 100) + } + } + } + `) + + return ( + + {(properties, data, theme, logAction) => { + return ( + + data={data} + {...properties} + theme={{ + ...theme, + labels: { + ...theme.labels, + text: { + ...theme.labels?.text, + outlineWidth: 2, + outlineColor: theme.background, + }, + }, + }} + onNodeClick={(node: ComputedNode) => { + logAction({ + type: 'click', + label: `[node] ${node.path.join(' / ')}`, + data: node, + color: node.color, + }) + }} + onLinkClick={(link: ComputedLink) => { + logAction({ + type: 'click', + label: `[link] ${link.source.id} > ${link.target.id}`, + data: link, + }) + }} + /> + ) + }} + + ) +} + +export default TreeCanvas diff --git a/website/src/pages/tree/index.tsx b/website/src/pages/tree/index.tsx index f9c1217a1..fe155c55e 100644 --- a/website/src/pages/tree/index.tsx +++ b/website/src/pages/tree/index.tsx @@ -126,7 +126,7 @@ const Tree = () => { ...theme.labels, text: { ...theme.labels?.text, - outlineWidth: 4, + outlineWidth: 2, outlineColor: theme.background, }, },