From e02ca8b9143f34648b852f4eb72b65bbfac42dec Mon Sep 17 00:00:00 2001 From: Lilian Saget-Lethias Date: Wed, 20 Apr 2022 22:32:22 +0200 Subject: [PATCH 1/3] feat(icicles): add rects and icicles --- packages/icicles/LICENSE.md | 19 + packages/icicles/README.md | 12 + packages/icicles/package.json | 53 ++ packages/icicles/src/Icicles.tsx | 149 ++++ packages/icicles/src/IciclesTooltip.tsx | 10 + packages/icicles/src/Rects.tsx | 78 +++ packages/icicles/src/ResponsiveIcicles.tsx | 16 + packages/icicles/src/hooks.ts | 190 +++++ packages/icicles/src/index.ts | 6 + packages/icicles/src/props.ts | 26 + packages/icicles/src/types.ts | 98 +++ packages/icicles/stories/icicles.stories.tsx | 210 ++++++ packages/icicles/tests/.eslintrc.yml | 2 + packages/icicles/tests/Icicles.test.tsx | 654 ++++++++++++++++++ packages/icicles/tsconfig.json | 8 + packages/rects/LICENSE.md | 19 + packages/rects/README.md | 9 + packages/rects/package.json | 48 ++ packages/rects/src/RectShape.tsx | 67 ++ packages/rects/src/RectsLayer.tsx | 82 +++ packages/rects/src/index.ts | 5 + packages/rects/src/rect_labels/RectLabel.tsx | 39 ++ .../rects/src/rect_labels/RectLabelsLayer.tsx | 51 ++ packages/rects/src/rect_labels/index.ts | 3 + packages/rects/src/rect_labels/props.ts | 10 + packages/rects/src/types.ts | 20 + packages/rects/src/useRectsTransition.ts | 51 ++ packages/rects/tsconfig.json | 8 + packages/static/package.json | 3 +- packages/static/src/mappings/icicles.ts | 41 ++ packages/static/src/mappings/index.ts | 2 + packages/static/src/samples/index.ts | 32 +- tsconfig.monorepo.json | 140 +++- website/package.json | 4 +- website/src/@types/file_types.d.ts | 72 +- website/src/components/home/Home.tsx | 3 +- .../src/components/home/HomeIciclesDemo.tsx | 28 + website/src/components/home/index.ts | 1 + website/src/components/icons/IciclesIcon.tsx | 132 ++++ website/src/components/icons/Icons.tsx | 2 + .../src/data/components/icicles/mapper.tsx | 72 ++ website/src/data/components/icicles/meta.yml | 31 + website/src/data/components/icicles/props.ts | 332 +++++++++ website/src/data/nav.ts | 10 + website/src/pages/icicles/api.tsx | 77 +++ website/src/pages/icicles/index.js | 109 +++ website/src/pages/internal/home-demos.tsx | 4 +- 47 files changed, 2960 insertions(+), 78 deletions(-) create mode 100644 packages/icicles/LICENSE.md create mode 100644 packages/icicles/README.md create mode 100644 packages/icicles/package.json create mode 100644 packages/icicles/src/Icicles.tsx create mode 100644 packages/icicles/src/IciclesTooltip.tsx create mode 100644 packages/icicles/src/Rects.tsx create mode 100644 packages/icicles/src/ResponsiveIcicles.tsx create mode 100644 packages/icicles/src/hooks.ts create mode 100644 packages/icicles/src/index.ts create mode 100644 packages/icicles/src/props.ts create mode 100644 packages/icicles/src/types.ts create mode 100644 packages/icicles/stories/icicles.stories.tsx create mode 100644 packages/icicles/tests/.eslintrc.yml create mode 100644 packages/icicles/tests/Icicles.test.tsx create mode 100644 packages/icicles/tsconfig.json create mode 100644 packages/rects/LICENSE.md create mode 100644 packages/rects/README.md create mode 100644 packages/rects/package.json create mode 100644 packages/rects/src/RectShape.tsx create mode 100644 packages/rects/src/RectsLayer.tsx create mode 100644 packages/rects/src/index.ts create mode 100644 packages/rects/src/rect_labels/RectLabel.tsx create mode 100644 packages/rects/src/rect_labels/RectLabelsLayer.tsx create mode 100644 packages/rects/src/rect_labels/index.ts create mode 100644 packages/rects/src/rect_labels/props.ts create mode 100644 packages/rects/src/types.ts create mode 100644 packages/rects/src/useRectsTransition.ts create mode 100644 packages/rects/tsconfig.json create mode 100644 packages/static/src/mappings/icicles.ts create mode 100644 website/src/components/home/HomeIciclesDemo.tsx create mode 100644 website/src/components/icons/IciclesIcon.tsx create mode 100644 website/src/data/components/icicles/mapper.tsx create mode 100644 website/src/data/components/icicles/meta.yml create mode 100644 website/src/data/components/icicles/props.ts create mode 100644 website/src/pages/icicles/api.tsx create mode 100644 website/src/pages/icicles/index.js diff --git a/packages/icicles/LICENSE.md b/packages/icicles/LICENSE.md new file mode 100644 index 000000000..faa45389e --- /dev/null +++ b/packages/icicles/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) Raphaël Benitte + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/icicles/README.md b/packages/icicles/README.md new file mode 100644 index 000000000..82132a0d9 --- /dev/null +++ b/packages/icicles/README.md @@ -0,0 +1,12 @@ +nivo + +# `@nivo/icicles` + +[![version](https://img.shields.io/npm/v/@nivo/icicles?style=for-the-badge)](https://www.npmjs.com/package/@nivo/icicles) +[![downloads](https://img.shields.io/npm/dm/@nivo/icicles?style=for-the-badge)](https://www.npmjs.com/package/@nivo/icicles) + +## Icicles + +[documentation](http://nivo.rocks/icicles/) + +![Icicles](https://raw.githubusercontent.com/plouc/nivo/master/website/src/assets/captures/icicles.png) diff --git a/packages/icicles/package.json b/packages/icicles/package.json new file mode 100644 index 000000000..7595f752c --- /dev/null +++ b/packages/icicles/package.json @@ -0,0 +1,53 @@ +{ + "name": "@nivo/icicles", + "version": "0.79.1", + "license": "MIT", + "author": { + "name": "Raphaël Benitte", + "url": "https://github.com/plouc" + }, + "contributors": [ + "Lilian Saget-Lethias ", + "Mehdi Louraoui " + ], + "repository": { + "type": "git", + "url": "https://github.com/plouc/nivo.git", + "directory": "packages/icicles" + }, + "keywords": [ + "nivo", + "dataviz", + "react", + "d3", + "charts", + "icicles-chart" + ], + "main": "./dist/nivo-icicles.cjs.js", + "module": "./dist/nivo-icicles.es.js", + "typings": "./dist/types/index.d.ts", + "files": [ + "README.md", + "LICENSE.md", + "dist/", + "!dist/tsconfig.tsbuildinfo" + ], + "dependencies": { + "@nivo/rects": "0.79.1", + "@nivo/colors": "0.79.1", + "@nivo/tooltip": "0.79.0", + "d3-hierarchy": "^1.1.8", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@nivo/core": "0.79.0", + "@types/d3-hierarchy": "^1.1.7" + }, + "peerDependencies": { + "@nivo/core": "0.79.0", + "react": ">= 16.14.0 < 18.0.0" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/packages/icicles/src/Icicles.tsx b/packages/icicles/src/Icicles.tsx new file mode 100644 index 000000000..b8eab8efb --- /dev/null +++ b/packages/icicles/src/Icicles.tsx @@ -0,0 +1,149 @@ +import { InheritedColorConfig } from '@nivo/colors' +import { + // @ts-ignore + bindDefs, + Container, + SvgWrapper, + useDimensions, +} from '@nivo/core' +import { Fragment, ReactNode, createElement } from 'react' +import { Rects } from './Rects' +import { useIcicles, useIciclesLayerContext } from './hooks' +import { RectLabelsLayer } from '@nivo/rects' +import { defaultProps } from './props' +import { IciclesSvgProps, IciclesLayerId, IciclesComputedDatum } from './types' + +type InnerIciclesProps = Partial< + Omit< + IciclesSvgProps, + 'data' | 'width' | 'height' | 'isInteractive' | 'animate' | 'motionConfig' + > +> & + Pick, 'data' | 'width' | 'height' | 'isInteractive'> + +const InnerIcicles = ({ + data, + id = defaultProps.id, + value = defaultProps.value, + valueFormat, + layers = ['rects', 'rectLabels'], + colors = defaultProps.colors, + colorBy = defaultProps.colorBy, + inheritColorFromParent = defaultProps.inheritColorFromParent, + childColor = defaultProps.childColor as InheritedColorConfig>, + borderWidth = defaultProps.borderWidth, + borderColor = defaultProps.borderColor, + margin: partialMargin, + width, + height, + enableRectLabels = defaultProps.enableRectLabels, + rectLabelsTextColor = defaultProps.rectLabelsTextColor, + defs = defaultProps.defs, + fill = defaultProps.fill, + isInteractive = defaultProps.isInteractive, + onClick, + onMouseEnter, + onMouseLeave, + onMouseMove, + tooltip = defaultProps.tooltip, + role = defaultProps.role, + rectLabel = defaultProps.rectLabel, + rectLabelsComponent, + direction = defaultProps.direction, +}: InnerIciclesProps) => { + const { margin, outerHeight, outerWidth } = useDimensions(width, height, partialMargin) + + const { nodes } = useIcicles({ + data, + id, + value, + valueFormat, + colors, + colorBy, + inheritColorFromParent, + childColor, + height, + width, + direction, + }) + + const boundDefs = bindDefs(defs, nodes, fill, { + dataKey: '.', + colorKey: 'color', + targetKey: 'fill', + }) + + const layerById: Record = { + rects: null, + rectLabels: null, + } + + if (layers.includes('rects')) { + layerById.rects = ( + + key="rects" + data={nodes} + borderWidth={borderWidth} + borderColor={borderColor} + isInteractive={isInteractive} + tooltip={tooltip} + onClick={onClick} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onMouseMove={onMouseMove} + /> + ) + } + + if (enableRectLabels && layers.includes('rectLabels')) { + layerById.rectLabels = ( + > + key="rectLabels" + data={nodes} + label={rectLabel} + textColor={rectLabelsTextColor} + component={rectLabelsComponent} + /> + ) + } + + const layerContext = useIciclesLayerContext({ + nodes, + }) + + return ( + + {layers?.map((layer, i) => { + if (layerById[layer as IciclesLayerId] !== undefined) { + return layerById[layer as IciclesLayerId] + } + + if (typeof layer === 'function') { + return {createElement(layer, layerContext)} + } + + return null + })} + + ) +} + +export const Icicles = ({ + isInteractive = defaultProps.isInteractive, + animate = defaultProps.animate, + motionConfig = defaultProps.motionConfig, + theme, + renderWrapper, + ...otherProps +}: Partial, 'data' | 'width' | 'height'>> & + Pick, 'data' | 'width' | 'height'>) => ( + + isInteractive={isInteractive} {...otherProps} /> + +) diff --git a/packages/icicles/src/IciclesTooltip.tsx b/packages/icicles/src/IciclesTooltip.tsx new file mode 100644 index 000000000..16b1dfecc --- /dev/null +++ b/packages/icicles/src/IciclesTooltip.tsx @@ -0,0 +1,10 @@ +import { BasicTooltip } from '@nivo/tooltip' +import { IciclesComputedDatum } from './types' + +export const IciclesTooltip = ({ + id, + formattedValue, + color, +}: IciclesComputedDatum) => ( + +) diff --git a/packages/icicles/src/Rects.tsx b/packages/icicles/src/Rects.tsx new file mode 100644 index 000000000..bdb647d85 --- /dev/null +++ b/packages/icicles/src/Rects.tsx @@ -0,0 +1,78 @@ +import { useTooltip } from '@nivo/tooltip' +import { createElement, useMemo } from 'react' +import * as React from 'react' +import { RectsLayer } from '@nivo/rects' +import { IciclesCommonProps, IciclesComputedDatum, IciclesMouseHandlers } from './types' + +interface RectsProps { + borderColor: IciclesCommonProps['borderColor'] + borderWidth: IciclesCommonProps['borderWidth'] + data: IciclesComputedDatum[] + isInteractive: IciclesCommonProps['isInteractive'] + onClick?: IciclesMouseHandlers['onClick'] + onMouseEnter?: IciclesMouseHandlers['onMouseEnter'] + onMouseLeave?: IciclesMouseHandlers['onMouseLeave'] + onMouseMove?: IciclesMouseHandlers['onMouseMove'] + tooltip: IciclesCommonProps['tooltip'] +} + +export const Rects = ({ + data, + borderWidth, + borderColor, + isInteractive, + onClick, + onMouseEnter, + onMouseMove, + onMouseLeave, + tooltip, +}: RectsProps) => { + const { showTooltipFromEvent, hideTooltip } = useTooltip() + + const handleClick = useMemo(() => { + if (!isInteractive) return undefined + + return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + onClick?.(datum, event) + } + }, [isInteractive, onClick]) + + const handleMouseEnter = useMemo(() => { + if (!isInteractive) return undefined + + return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + showTooltipFromEvent(createElement(tooltip, datum), event) + onMouseEnter?.(datum, event) + } + }, [isInteractive, showTooltipFromEvent, tooltip, onMouseEnter]) + + const handleMouseMove = useMemo(() => { + if (!isInteractive) return undefined + + return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + showTooltipFromEvent(createElement(tooltip, datum), event) + onMouseMove?.(datum, event) + } + }, [isInteractive, showTooltipFromEvent, tooltip, onMouseMove]) + + const handleMouseLeave = useMemo(() => { + if (!isInteractive) return undefined + + return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + hideTooltip() + onMouseLeave?.(datum, event) + } + }, [isInteractive, hideTooltip, onMouseLeave]) + + return ( + > + data={data} + borderWidth={borderWidth} + borderColor={borderColor} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + /> + ) +} diff --git a/packages/icicles/src/ResponsiveIcicles.tsx b/packages/icicles/src/ResponsiveIcicles.tsx new file mode 100644 index 000000000..de1d0972f --- /dev/null +++ b/packages/icicles/src/ResponsiveIcicles.tsx @@ -0,0 +1,16 @@ +import { ResponsiveWrapper } from '@nivo/core' +import { Icicles } from './Icicles' +import { IciclesSvgProps } from './types' + +type ResponsiveIciclesProps = Partial< + Omit, 'data' | 'width' | 'height'> +> & + Pick, 'data'> + +export const ResponsiveIcicles = (props: ResponsiveIciclesProps) => ( + + {({ width, height }: { height: number; width: number }) => ( + width={width} height={height} {...props} /> + )} + +) diff --git a/packages/icicles/src/hooks.ts b/packages/icicles/src/hooks.ts new file mode 100644 index 000000000..d973a0e94 --- /dev/null +++ b/packages/icicles/src/hooks.ts @@ -0,0 +1,190 @@ +import { useOrdinalColorScale, useInheritedColor, InheritedColorConfig } from '@nivo/colors' +import { usePropertyAccessor, useTheme, useValueFormatter } from '@nivo/core' +import { + partition as d3Partition, + hierarchy as d3Hierarchy, + HierarchyRectangularNode, +} from 'd3-hierarchy' +import cloneDeep from 'lodash/cloneDeep' +import sortBy from 'lodash/sortBy' +import { useMemo } from 'react' +import { Rect } from '@nivo/rects' +import { defaultProps } from './props' +import { + DataProps, + DatumId, + IciclesCommonProps, + IciclesComputedDatum, + IciclesCustomLayerProps, +} from './types' + +const hierarchyRectUseX = (d: HierarchyRectangularNode) => + d.x1 - d.x0 - Math.min(1, (d.x1 - d.x0) / 2) + +const hierarchyRectUseY = (d: HierarchyRectangularNode) => + d.y1 - d.y0 - Math.min(1, (d.y1 - d.y0) / 2) + +const widthHeight = (d: HierarchyRectangularNode) => ({ + topBottom: () => ({ + height: hierarchyRectUseY(d), + width: hierarchyRectUseX(d), + }), + leftRight: () => ({ + height: hierarchyRectUseX(d), + width: hierarchyRectUseY(d), + }), +}) + +export const useIcicles = ({ + data, + id = defaultProps.id, + value = defaultProps.value, + valueFormat, + colors = defaultProps.colors, + colorBy = defaultProps.colorBy, + inheritColorFromParent = defaultProps.inheritColorFromParent, + childColor = defaultProps.childColor as InheritedColorConfig>, + width, + height, + direction, +}: { + childColor?: IciclesCommonProps['childColor'] + colorBy?: IciclesCommonProps['colorBy'] + colors?: IciclesCommonProps['colors'] + data: DataProps['data'] + direction: IciclesCommonProps['direction'] + height: IciclesCommonProps['height'] + id?: DataProps['id'] + inheritColorFromParent?: IciclesCommonProps['inheritColorFromParent'] + value?: DataProps['value'] + valueFormat?: DataProps['valueFormat'] + width: IciclesCommonProps['width'] +}) => { + const theme = useTheme() + const getColor = useOrdinalColorScale, 'color' | 'fill'>>( + colors, + colorBy + ) + const getChildColor = useInheritedColor>(childColor, theme) + + const isLeftRight = direction === 'left' || direction === 'right' + + const getId = usePropertyAccessor(id) + const getValue = usePropertyAccessor(value) + const formatValue = useValueFormatter(valueFormat) + + // https://observablehq.com/@d3/zoomable-icicle + const nodes: IciclesComputedDatum[] = useMemo(() => { + // d3 mutates the data for performance reasons, + // however it does not work well with reactive programming, + // this ensures that we don't mutate the input data + const clonedData = cloneDeep(data) + + const hierarchy = d3Hierarchy(clonedData) + .sum(getValue) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .sort((a, b) => b.height - a.height || b.value! - a.value!) + + const partition = d3Partition().size( + isLeftRight ? [height, width] : [width, height] + ) + const descendants = partition(hierarchy).descendants() + + const total = hierarchy.value ?? 0 + + // It's important to sort node by depth, + // it ensures that we assign a parent node + // which has already been computed, because parent nodes + // are going to be computed first + const sortedNodes = sortBy(descendants, 'depth') + + const rootRect = { + ...widthHeight(sortedNodes[0])[isLeftRight ? 'leftRight' : 'topBottom'](), + } + + return sortedNodes.reduce[]>((acc, descendant) => { + const id = getId(descendant.data) + // d3 hierarchy node value is optional by default as it depends on + // a call to `count()` or `sum()`, and we previously called `sum()`, + // d3 typings could be improved and make it non optional when calling + // one of those. + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const value = descendant.value! + const percentage = (100 * value) / total + const path = descendant.ancestors()?.map(ancestor => getId(ancestor.data)) + + const transform = { + right: `translate(${descendant.y0}, ${descendant.x0})`, + left: `translate(${width - rootRect.width - descendant.y0}, ${descendant.x0})`, + top: `translate(${descendant.x0}, ${height - rootRect.height - descendant.y0})`, + bottom: `translate(${descendant.x0}, ${descendant.y0})`, + }[direction] + + const rect: Rect = { + ...widthHeight(descendant)[isLeftRight ? 'leftRight' : 'topBottom'](), + transform, + } + + let parent: IciclesComputedDatum | undefined + if (descendant.parent) { + // as the parent is defined by the input data, and we sorted the data + // by `depth`, we can safely assume it's defined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + parent = acc.find(node => node.id === getId(descendant.parent!.data)) + } + + const normalizedNode: IciclesComputedDatum = { + id, + path, + value, + percentage, + rect, + formattedValue: valueFormat ? formatValue(value) : `${percentage.toFixed(2)}%`, + color: '', + data: descendant.data, + depth: descendant.depth, + height: descendant.height, + transform, + } + + if (inheritColorFromParent && parent && normalizedNode.depth > 1) { + normalizedNode.color = getChildColor(parent, normalizedNode) + } else { + normalizedNode.color = getColor(normalizedNode) + } + + // normalizedNode.color = getColor(normalizedNode); + + return [...acc, normalizedNode] + }, []) + }, [ + data, + getValue, + getId, + valueFormat, + formatValue, + getColor, + inheritColorFromParent, + getChildColor, + width, + height, + direction, + isLeftRight, + ]) + + return { nodes } +} + +/** + * Memoize the context to pass to custom layers. + */ +export const useIciclesLayerContext = ({ + nodes, +}: IciclesCustomLayerProps): IciclesCustomLayerProps => + useMemo( + () => ({ + nodes, + }), + [nodes] + ) diff --git a/packages/icicles/src/index.ts b/packages/icicles/src/index.ts new file mode 100644 index 000000000..75ad484b4 --- /dev/null +++ b/packages/icicles/src/index.ts @@ -0,0 +1,6 @@ +export * from './Icicles' +export * from './Rects' +export * from './ResponsiveIcicles' +export * from './IciclesTooltip' +export * from './types' +export * from './props' diff --git a/packages/icicles/src/props.ts b/packages/icicles/src/props.ts new file mode 100644 index 000000000..90b6991f3 --- /dev/null +++ b/packages/icicles/src/props.ts @@ -0,0 +1,26 @@ +import { OrdinalColorScaleConfig } from '@nivo/colors' +import { IciclesTooltip } from './IciclesTooltip' +import { IciclesDirection, IciclesLayerId } from './types' + +export const defaultProps = { + id: 'id', + value: 'value', + layers: ['rect', 'rectLabels'] as IciclesLayerId[], + colors: { scheme: 'nivo' } as unknown as OrdinalColorScaleConfig, + colorBy: 'id' as const, + inheritColorFromParent: true, + childColor: { from: 'color' }, + borderWidth: 1, + borderColor: 'white', + enableRectLabels: false, + rectLabel: 'formattedValue', + rectLabelsTextColor: { theme: 'labels.text.fill' }, + animate: true, + motionConfig: 'gentle', + isInteractive: true, + defs: [], + fill: [], + tooltip: IciclesTooltip, + role: 'img', + direction: 'bottom' as IciclesDirection, +} diff --git a/packages/icicles/src/types.ts b/packages/icicles/src/types.ts new file mode 100644 index 000000000..6d825d073 --- /dev/null +++ b/packages/icicles/src/types.ts @@ -0,0 +1,98 @@ +import { OrdinalColorScaleConfig, InheritedColorConfig } from '@nivo/colors' +import { + Theme, + Box, + ValueFormat, + SvgDefsAndFill, + ModernMotionProps, + PropertyAccessor, +} from '@nivo/core' +import { type Rect, RectLabelsProps } from '@nivo/rects' + +export type DatumId = string | number + +export type IciclesLayerId = 'rects' | 'rectLabels' + +export interface IciclesCustomLayerProps { + nodes: IciclesComputedDatum[] +} + +export type IciclesCustomLayer = React.FC> + +export type IciclesLayer = IciclesLayerId | IciclesCustomLayer + +export interface DataProps { + data: RawDatum + id?: PropertyAccessor + value?: PropertyAccessor + valueFormat?: ValueFormat +} + +export interface ChildrenDatum { + children?: Array> +} + +export interface IciclesComputedDatum { + color: string + // contains the raw node's data + data: RawDatum + depth: number + // defined when using patterns or gradients + fill?: string + formattedValue: string + height: number + id: DatumId + parent?: IciclesComputedDatum + // contain own id plus all ancestor ids + path: DatumId[] + percentage: number + rect: Rect + transform: string + value: number +} + +export type IciclesDirection = 'top' | 'right' | 'bottom' | 'left' + +export type IciclesCommonProps = { + animate: boolean + borderColor: InheritedColorConfig> + borderWidth: number + // used if `inheritColorFromParent` is `true` + childColor: InheritedColorConfig> + colorBy: 'id' | 'depth' + colors: OrdinalColorScaleConfig, 'color' | 'fill'>> + data: RawDatum + direction: IciclesDirection + enableRectLabels: boolean + height: number + id: PropertyAccessor + inheritColorFromParent: boolean + isInteractive: boolean + layers: IciclesLayer[] + margin?: Box + motionConfig: ModernMotionProps['motionConfig'] + rectLabelsTextColor: InheritedColorConfig + renderWrapper: boolean + role: string + theme: Theme + tooltip: (props: IciclesComputedDatum) => JSX.Element + value: PropertyAccessor + valueFormat?: ValueFormat + width: number +} & RectLabelsProps> + +export type IciclesMouseHandler = ( + datum: IciclesComputedDatum, + event: React.MouseEvent +) => void + +export type IciclesMouseHandlers = Partial<{ + onClick: IciclesMouseHandler + onMouseEnter: IciclesMouseHandler + onMouseLeave: IciclesMouseHandler + onMouseMove: IciclesMouseHandler +}> + +export type IciclesSvgProps = IciclesCommonProps & + SvgDefsAndFill & + IciclesMouseHandlers diff --git a/packages/icicles/stories/icicles.stories.tsx b/packages/icicles/stories/icicles.stories.tsx new file mode 100644 index 000000000..226f47f0b --- /dev/null +++ b/packages/icicles/stories/icicles.stories.tsx @@ -0,0 +1,210 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useState } from 'react' +import { storiesOf } from '@storybook/react' +import { action } from '@storybook/addon-actions' +import { withKnobs, boolean, select } from '@storybook/addon-knobs' +// @ts-ignore +import { linearGradientDef, patternDotsDef, useTheme } from '@nivo/core' +// @ts-ignore +import { generateLibTree } from '@nivo/generators' +import { colorSchemes } from '@nivo/colors' +// @ts-ignore +import { Icicles, IciclesComputedDatum } from '../src' + +interface RawDatum { + name: string + loc: number +} + +const commonProperties = { + width: 900, + height: 500, + data: generateLibTree() as any, + id: 'name', + value: 'loc', +} + +const stories = storiesOf('Icicles', module) + +stories.addDecorator(withKnobs) + +stories.add('default', () => ) + +stories.add('with child color modifier', () => ( + + {...commonProperties} + childColor={{ from: 'color', modifiers: [['brighter', 0.13]] }} + /> +)) + +stories.add('with child colors independent of parent', () => ( + {...commonProperties} inheritColorFromParent={false} /> +)) + +const customPalette = ['#ffd700', '#ffb14e', '#fa8775', '#ea5f94', '#cd34b5', '#9d02d7', '#0000ff'] + +stories.add('with custom colors', () => ( + {...commonProperties} colors={customPalette} /> +)) + +stories.add('with custom child colors', () => ( + + {...commonProperties} + childColor={(parent, child) => { + // @ts-expect-error + return child.data.color + }} + /> +)) + +stories.add('with formatted tooltip value', () => ( + {...commonProperties} valueFormat=" >-$,.2f" /> +)) + +const CustomTooltip = ({ id, value, color }: IciclesComputedDatum) => { + const theme = useTheme() + + return ( + + {id}: {value} + + ) +} + +stories.add('custom tooltip', () => ( + + {...commonProperties} + tooltip={CustomTooltip} + theme={{ + tooltip: { + container: { + background: '#333', + }, + }, + }} + /> +)) + +stories.add('enter/leave (check actions)', () => ( + + {...commonProperties} + onMouseEnter={action('onMouseEnter')} + onMouseLeave={action('onMouseLeave')} + /> +)) + +stories.add('patterns & gradients', () => ( + + {...commonProperties} + defs={[ + linearGradientDef('gradient', [ + { offset: 0, color: '#ffffff' }, + { offset: 15, color: 'inherit' }, + { offset: 100, color: 'inherit' }, + ]), + patternDotsDef('pattern', { + background: 'inherit', + color: '#ffffff', + size: 2, + padding: 3, + stagger: true, + }), + ]} + fill={[ + { + match: node => + ['viz', 'text', 'utils'].includes( + (node as unknown as IciclesComputedDatum).id as string + ), + id: 'gradient', + }, + { + match: node => + ['set', 'generators', 'misc'].includes( + (node as unknown as IciclesComputedDatum).id as string + ), + id: 'pattern', + }, + ]} + /> +)) + +const flatten = (data: T[]): T[] => + data.reduce((acc, item) => { + if (item.children) { + return [...acc, item, ...flatten(item.children)] + } + + return [...acc, item] + }, []) + +const findObject = (data: T[], name: string): T | undefined => + data.find(searchedName => searchedName.name === name) + +const drillDownColors = colorSchemes.brown_blueGreen[7] +const drillDownColorMap = { + viz: drillDownColors[0], + colors: drillDownColors[1], + utils: drillDownColors[2], + generators: drillDownColors[3], + set: drillDownColors[4], + text: drillDownColors[5], + misc: drillDownColors[6], +} +const getDrillDownColor = (node: Omit, 'color' | 'fill'>) => { + const category = [...node.path].reverse()[1] as keyof typeof drillDownColorMap + + return drillDownColorMap[category] +} + +stories.add( + 'children drill down', + () => { + const [data, setData] = useState(commonProperties.data) + + return ( + <> + + + {...commonProperties} + colors={getDrillDownColor} + inheritColorFromParent={false} + borderWidth={1} + borderColor={{ + from: 'color', + modifiers: [['darker', 0.6]], + }} + animate={boolean('animate', true)} + motionConfig={select( + 'motion config', + ['default', 'gentle', 'wobbly', 'stiff', 'slow', 'molasses'], + 'gentle' + )} + enableRectLabels + rectLabelsTextColor={{ + from: 'color', + modifiers: [['darker', 3]], + }} + data={data} + onClick={clickedData => { + const foundObject = findObject( + flatten(data.children) as any, + clickedData.id as string + ) + if (foundObject && (foundObject as any).children) { + setData(foundObject) + } + }} + /> + + ) + }, + { + info: { + text: ` + You can drill down into individual children by clicking on them + `, + }, + } +) diff --git a/packages/icicles/tests/.eslintrc.yml b/packages/icicles/tests/.eslintrc.yml new file mode 100644 index 000000000..2f8de9aea --- /dev/null +++ b/packages/icicles/tests/.eslintrc.yml @@ -0,0 +1,2 @@ +env: + jest: true diff --git a/packages/icicles/tests/Icicles.test.tsx b/packages/icicles/tests/Icicles.test.tsx new file mode 100644 index 000000000..e8dab93a8 --- /dev/null +++ b/packages/icicles/tests/Icicles.test.tsx @@ -0,0 +1,654 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { mount } from 'enzyme' +// @ts-ignore +import { linearGradientDef, patternDotsDef } from '@nivo/core' +// @ts-ignore +import { Icicles } from '../src' +import { RectLabelsLayer, RectShape, RectLabelComponent as RectLabel } from '@nivo/rects' + +interface CustomSampleData { + name: string + color: string + attributes?: { + volume: number + } + children?: CustomSampleData[] +} + +const sampleData = { + id: 'root', + color: '#abcdef', + children: [ + { + id: 'A', + color: '#ff5500', + children: [ + { + id: 'A-1', + color: '#bbddcc', + children: [ + { + id: 'A-1-I', + value: 60, + color: '#ffaacc', + }, + ], + }, + { + id: 'A-2', + value: 50, + color: '#cc99dd', + }, + ], + }, + { + id: 'B', + value: 20, + color: '#ffdd00', + }, + ], +} + +const sampleDataWithCustomProps = { + name: 'root', + color: '#abcdef', + children: [ + { + name: 'A', + color: '#ff5500', + children: [ + { + name: 'A-1', + color: '#bbddcc', + children: [ + { + name: 'A-1-I', + attributes: { + volume: 60, + }, + color: '#ffaacc', + }, + ], + }, + { + name: 'A-2', + attributes: { + volume: 50, + }, + color: '#cc99dd', + }, + ], + }, + { + name: 'B', + attributes: { + volume: 20, + }, + color: '#ffdd00', + }, + ], +} + +describe('Icicles', () => { + describe('data', () => { + it('should use default id and value properties', () => { + const wrapper = mount() + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').value).toEqual(110) + + expect(rects.at(2).prop('datum').id).toEqual('B') + expect(rects.at(2).prop('datum').value).toEqual(20) + + expect(rects.at(3).prop('datum').id).toEqual('A-1') + expect(rects.at(3).prop('datum').value).toEqual(60) + + expect(rects.at(4).prop('datum').id).toEqual('A-2') + expect(rects.at(4).prop('datum').value).toEqual(50) + + expect(rects.at(5).prop('datum').id).toEqual('A-1-I') + expect(rects.at(5).prop('datum').value).toEqual(60) + }) + + it('should use custom id and value accessors expressed as path', () => { + const wrapper = mount( + + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').value).toEqual(110) + + expect(rects.at(2).prop('datum').id).toEqual('B') + expect(rects.at(2).prop('datum').value).toEqual(20) + + expect(rects.at(3).prop('datum').id).toEqual('A-1') + expect(rects.at(3).prop('datum').value).toEqual(60) + + expect(rects.at(4).prop('datum').id).toEqual('A-2') + expect(rects.at(4).prop('datum').value).toEqual(50) + + expect(rects.at(5).prop('datum').id).toEqual('A-1-I') + expect(rects.at(5).prop('datum').value).toEqual(60) + }) + + it('should use custom id and value accessors expressed as functions', () => { + const wrapper = mount( + + width={400} + height={400} + data={sampleDataWithCustomProps} + id={d => d.name} + value={d => d.attributes?.volume ?? 0} + /> + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').value).toEqual(110) + + expect(rects.at(2).prop('datum').id).toEqual('B') + expect(rects.at(2).prop('datum').value).toEqual(20) + + expect(rects.at(3).prop('datum').id).toEqual('A-1') + expect(rects.at(3).prop('datum').value).toEqual(60) + + expect(rects.at(4).prop('datum').id).toEqual('A-2') + expect(rects.at(4).prop('datum').value).toEqual(50) + + expect(rects.at(5).prop('datum').id).toEqual('A-1-I') + expect(rects.at(5).prop('datum').value).toEqual(60) + }) + }) + + describe('colors', () => { + it('should use colors from scheme', () => { + const wrapper = mount( + + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + expect(rects.at(0).prop('datum').color).toEqual('#7fc97f') + + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').color).toEqual('#beaed4') + + expect(rects.at(2).prop('datum').id).toEqual('B') + expect(rects.at(2).prop('datum').color).toEqual('#fdc086') + + expect(rects.at(3).prop('datum').id).toEqual('A-1') + expect(rects.at(3).prop('datum').color).toEqual('#beaed4') + + expect(rects.at(4).prop('datum').id).toEqual('A-2') + expect(rects.at(4).prop('datum').color).toEqual('#beaed4') + + expect(rects.at(5).prop('datum').id).toEqual('A-1-I') + expect(rects.at(5).prop('datum').color).toEqual('#beaed4') + }) + + it('should allow to use colors from data using a path', () => { + const wrapper = mount( + + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + expect(rects.at(0).prop('datum').color).toEqual('#abcdef') + + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').color).toEqual('#ff5500') + + expect(rects.at(2).prop('datum').id).toEqual('B') + expect(rects.at(2).prop('datum').color).toEqual('#ffdd00') + + expect(rects.at(3).prop('datum').id).toEqual('A-1') + expect(rects.at(3).prop('datum').color).toEqual('#ff5500') + + expect(rects.at(4).prop('datum').id).toEqual('A-2') + expect(rects.at(4).prop('datum').color).toEqual('#ff5500') + + expect(rects.at(5).prop('datum').id).toEqual('A-1-I') + expect(rects.at(5).prop('datum').color).toEqual('#ff5500') + }) + + it('should allow to use colors from data using a function', () => { + const wrapper = mount( + d.data.color} + inheritColorFromParent + /> + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + expect(rects.at(0).prop('datum').color).toEqual('#abcdef') + + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').color).toEqual('#ff5500') + + expect(rects.at(2).prop('datum').id).toEqual('B') + expect(rects.at(2).prop('datum').color).toEqual('#ffdd00') + + expect(rects.at(3).prop('datum').id).toEqual('A-1') + expect(rects.at(3).prop('datum').color).toEqual('#ff5500') + + expect(rects.at(4).prop('datum').id).toEqual('A-2') + expect(rects.at(4).prop('datum').color).toEqual('#ff5500') + + expect(rects.at(5).prop('datum').id).toEqual('A-1-I') + expect(rects.at(5).prop('datum').color).toEqual('#ff5500') + }) + + it('should allow to define colors according to depth', () => { + const wrapper = mount( + + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + expect(rects.at(0).prop('datum').color).toEqual('#e8c1a0') + + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').color).toEqual('#f47560') + + expect(rects.at(2).prop('datum').id).toEqual('B') + expect(rects.at(2).prop('datum').color).toEqual('#f47560') + + expect(rects.at(3).prop('datum').id).toEqual('A-1') + expect(rects.at(3).prop('datum').color).toEqual('#f1e15b') + + expect(rects.at(4).prop('datum').id).toEqual('A-2') + expect(rects.at(4).prop('datum').color).toEqual('#f1e15b') + + expect(rects.at(5).prop('datum').id).toEqual('A-1-I') + expect(rects.at(5).prop('datum').color).toEqual('#e8a838') + }) + }) + + describe('patterns & gradients', () => { + xit('should support patterns', () => { + const wrapper = mount( + + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').fill).toEqual('url(#pattern.bg.#e8c1a0)') + + const svg = wrapper.find('SvgWrapper') + expect(svg.at(0).prop('defs')).toEqual([ + { + background: 'inherit', + color: '#ffffff', + id: 'pattern', + padding: 4, + size: 1, + stagger: true, + type: 'patternDots', + }, + { + background: '#e8c1a0', + color: '#ffffff', + id: 'pattern.bg.#e8c1a0', + padding: 4, + size: 1, + stagger: true, + type: 'patternDots', + }, + ]) + }) + + xit('should support gradients', () => { + const wrapper = mount( + + ) + + const rects = wrapper.find(RectShape) + expect(rects).toHaveLength(6) + + expect(rects.at(0).prop('datum').id).toEqual('root') + expect(rects.at(1).prop('datum').id).toEqual('A') + expect(rects.at(1).prop('datum').fill).toEqual('url(#gradient.1.#e8c1a0.2.#e8c1a0)') + + const svg = wrapper.find('SvgWrapper') + expect(svg.at(0).prop('defs')).toEqual([ + { + colors: [ + { color: '#ffffff', offset: 0 }, + { color: 'inherit', offset: 15 }, + { color: 'inherit', offset: 100 }, + ], + id: 'gradient', + type: 'linearGradient', + }, + { + colors: [ + { color: '#ffffff', offset: 0 }, + { color: '#e8c1a0', offset: 15 }, + { color: '#e8c1a0', offset: 100 }, + ], + id: 'gradient.1.#e8c1a0.2.#e8c1a0', + type: 'linearGradient', + }, + ]) + }) + }) + + describe('slice labels', () => { + it('should be disabled by default', () => { + const wrapper = mount() + expect(wrapper.find(RectLabelsLayer).exists()).toBeFalsy() + }) + + it('should render labels when enabled', () => { + const wrapper = mount( + + ) + + const labels = wrapper.find(RectLabel) + expect(labels).toHaveLength(6) + + expect(labels.at(0).text()).toEqual('100.00%') + expect(labels.at(1).text()).toEqual('84.62%') + expect(labels.at(2).text()).toEqual('15.38%') + expect(labels.at(3).text()).toEqual('46.15%') + expect(labels.at(4).text()).toEqual('38.46%') + expect(labels.at(5).text()).toEqual('46.15%') + }) + + it('should use formattedValue', () => { + const wrapper = mount( + + ) + + const labels = wrapper.find(RectLabel) + expect(labels).toHaveLength(6) + + expect(labels.at(1).prop('datum').formattedValue).toEqual('$110.00') + expect(labels.at(1).find('text').text()).toEqual('$110.00') + + expect(labels.at(2).prop('datum').formattedValue).toEqual('$20.00') + expect(labels.at(2).find('text').text()).toEqual('$20.00') + + expect(labels.at(3).prop('datum').formattedValue).toEqual('$60.00') + expect(labels.at(3).find('text').text()).toEqual('$60.00') + + expect(labels.at(4).prop('datum').formattedValue).toEqual('$50.00') + expect(labels.at(4).find('text').text()).toEqual('$50.00') + + expect(labels.at(5).prop('datum').formattedValue).toEqual('$60.00') + expect(labels.at(5).find('text').text()).toEqual('$60.00') + }) + + it('should allow to change the label accessor using a path', () => { + const wrapper = mount( + + ) + + const labels = wrapper.find(RectLabel) + expect(labels).toHaveLength(6) + + expect(labels.at(0).text()).toEqual('root') + expect(labels.at(1).text()).toEqual('A') + expect(labels.at(2).text()).toEqual('B') + expect(labels.at(3).text()).toEqual('A-1') + expect(labels.at(4).text()).toEqual('A-2') + expect(labels.at(5).text()).toEqual('A-1-I') + }) + + it('should allow to change the label accessor using a function', () => { + const wrapper = mount( + `${datum.id} - ${datum.value}`} + /> + ) + + const labels = wrapper.find(RectLabel) + expect(labels).toHaveLength(6) + + expect(labels.at(1).find('text').text()).toEqual('A - 110') + expect(labels.at(2).find('text').text()).toEqual('B - 20') + }) + }) + + describe('interactivity', () => { + it('should support onClick handler', () => { + const onClick = jest.fn() + const wrapper = mount( + + ) + + wrapper.find(RectShape).at(0).simulate('click') + + expect(onClick).toHaveBeenCalledTimes(1) + const [datum] = onClick.mock.calls[0] + expect(datum.id).toEqual('root') + }) + + it('should support onMouseEnter handler', () => { + const onMouseEnter = jest.fn() + const wrapper = mount( + + ) + + wrapper.find(RectShape).at(1).simulate('mouseenter') + + expect(onMouseEnter).toHaveBeenCalledTimes(1) + const [datum] = onMouseEnter.mock.calls[0] + expect(datum.id).toEqual('A') + }) + + it('should support onMouseMove handler', () => { + const onMouseMove = jest.fn() + const wrapper = mount( + + ) + + wrapper.find(RectShape).at(2).simulate('mousemove') + + expect(onMouseMove).toHaveBeenCalledTimes(1) + const [datum] = onMouseMove.mock.calls[0] + expect(datum.id).toEqual('B') + }) + + it('should support onMouseLeave handler', () => { + const onMouseLeave = jest.fn() + const wrapper = mount( + + ) + + wrapper.find(RectShape).at(0).simulate('mouseleave') + + expect(onMouseLeave).toHaveBeenCalledTimes(1) + const [datum] = onMouseLeave.mock.calls[0] + expect(datum.id).toEqual('root') + }) + + it('should allow to completely disable interactivity', () => { + const onClick = jest.fn() + const onMouseEnter = jest.fn() + const onMouseMove = jest.fn() + const onMouseLeave = jest.fn() + + const wrapper = mount( + + ) + + wrapper.find(RectShape).at(0).simulate('click') + wrapper.find(RectShape).at(0).simulate('mouseenter') + wrapper.find(RectShape).at(0).simulate('mousemove') + wrapper.find(RectShape).at(0).simulate('mouseleave') + + expect(onClick).not.toHaveBeenCalled() + expect(onMouseEnter).not.toHaveBeenCalled() + expect(onMouseMove).not.toHaveBeenCalled() + expect(onMouseLeave).not.toHaveBeenCalled() + + wrapper.find(RectShape).forEach(rect => { + const shape = rect.find('rect') + expect(shape.prop('onClick')).toBeUndefined() + expect(shape.prop('onMouseEnter')).toBeUndefined() + expect(shape.prop('onMouseMove')).toBeUndefined() + expect(shape.prop('onMouseLeave')).toBeUndefined() + }) + }) + }) + + describe('tooltip', () => { + it('should render a tooltip when hovering a slice', () => { + const wrapper = mount() + + expect(wrapper.find('IciclesTooltip').exists()).toBeFalsy() + + wrapper.find(RectShape).at(2).simulate('mouseenter') + + const tooltip = wrapper.find('IciclesTooltip') + expect(tooltip.exists()).toBeTruthy() + expect(tooltip.text()).toEqual('B: 15.38%') + }) + + it('should allow to override the default tooltip', () => { + const CustomTooltip = ({ id }) => {id} + const wrapper = mount( + + ) + + wrapper.find(RectShape).at(2).simulate('mouseenter') + + const tooltip = wrapper.find(CustomTooltip) + expect(tooltip.exists()).toBeTruthy() + expect(tooltip.text()).toEqual('B') + }) + }) + + describe('layers', () => { + it('should support disabling a layer', () => { + const wrapper = mount() + expect(wrapper.find(RectShape)).toHaveLength(6) // root included + + wrapper.setProps({ layers: ['rectLabels'] }) + expect(wrapper.find(RectShape)).toHaveLength(0) + }) + + it('should support adding a custom layer', () => { + const CustomLayer = () => null + + const wrapper = mount( + + ) + + const customLayer = wrapper.find(CustomLayer) + + // root included + expect(customLayer.prop('nodes')).toHaveLength(6) + }) + }) +}) diff --git a/packages/icicles/tsconfig.json b/packages/icicles/tsconfig.json new file mode 100644 index 000000000..855b4b2b7 --- /dev/null +++ b/packages/icicles/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.types.json", + "compilerOptions": { + "outDir": "./dist/types", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/rects/LICENSE.md b/packages/rects/LICENSE.md new file mode 100644 index 000000000..faa45389e --- /dev/null +++ b/packages/rects/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) Raphaël Benitte + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/rects/README.md b/packages/rects/README.md new file mode 100644 index 000000000..d07d0c3d0 --- /dev/null +++ b/packages/rects/README.md @@ -0,0 +1,9 @@ +nivo + +# `@nivo/rects` + +[![version](https://img.shields.io/npm/v/@nivo/rects?style=for-the-badge)](https://www.npmjs.com/package/@nivo/rects) +[![downloads](https://img.shields.io/npm/dm/@nivo/rects?style=for-the-badge)](https://www.npmjs.com/package/@nivo/rects) + +This package is used internally by nivo packages dealing with rects +such as `@nivo/icicles`. diff --git a/packages/rects/package.json b/packages/rects/package.json new file mode 100644 index 000000000..618da04be --- /dev/null +++ b/packages/rects/package.json @@ -0,0 +1,48 @@ +{ + "name": "@nivo/rects", + "version": "0.79.1", + "license": "MIT", + "author": { + "name": "Raphaël Benitte", + "url": "https://github.com/plouc" + }, + "contributors": [ + "Lilian Saget-Lethias ", + "Mehdi Louraoui " + ], + "repository": { + "type": "git", + "url": "https://github.com/plouc/nivo.git", + "directory": "packages/rects" + }, + "keywords": [ + "nivo", + "dataviz", + "react", + "d3", + "rects" + ], + "main": "./dist/nivo-rects.cjs.js", + "module": "./dist/nivo-rects.es.js", + "typings": "./dist/types/index.d.ts", + "files": [ + "README.md", + "LICENSE.md", + "dist/", + "!dist/tsconfig.tsbuildinfo" + ], + "dependencies": { + "@nivo/colors": "0.79.1", + "@react-spring/web": "9.3.1" + }, + "devDependencies": { + "@nivo/core": "0.79.0" + }, + "peerDependencies": { + "@nivo/core": "0.79.0", + "react": ">= 16.14.0 < 18.0.0" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/packages/rects/src/RectShape.tsx b/packages/rects/src/RectShape.tsx new file mode 100644 index 000000000..f7310a617 --- /dev/null +++ b/packages/rects/src/RectShape.tsx @@ -0,0 +1,67 @@ +import { SpringValue, animated } from '@react-spring/web' +import { MouseEvent, useCallback } from 'react' +import { DatumWithRectAndColor } from './types' + +export type RectMouseHandler = ( + datum: TDatum, + event: MouseEvent +) => void + +export interface RectShapeProps { + datum: TDatum + onClick?: RectMouseHandler + onMouseEnter?: RectMouseHandler + onMouseLeave?: RectMouseHandler + onMouseMove?: RectMouseHandler + style: { + borderColor: SpringValue + borderWidth: number + color: SpringValue + height: number + // height: SpringValue; + opacity: SpringValue + transform: string + width: number + // width: SpringValue; + } +} + +export const RectShape = ({ + datum, + style, + onClick, + onMouseEnter, + onMouseLeave, + onMouseMove, +}: RectShapeProps) => { + const handleClick = useCallback(event => onClick?.(datum, event), [onClick, datum]) + + const handleMouseEnter = useCallback( + event => onMouseEnter?.(datum, event), + [onMouseEnter, datum] + ) + + const handleMouseMove = useCallback(event => onMouseMove?.(datum, event), [onMouseMove, datum]) + + const handleMouseLeave = useCallback( + event => onMouseLeave?.(datum, event), + [onMouseLeave, datum] + ) + + return ( + + ) +} diff --git a/packages/rects/src/RectsLayer.tsx b/packages/rects/src/RectsLayer.tsx new file mode 100644 index 000000000..97b8da645 --- /dev/null +++ b/packages/rects/src/RectsLayer.tsx @@ -0,0 +1,82 @@ +import { InheritedColorConfig } from '@nivo/colors' +import { createElement } from 'react' +import { RectMouseHandler, RectShape, RectShapeProps } from './RectShape' +import { DatumWithRectAndColor } from './types' +import { useRectsTransition } from './useRectsTransition' + +export type RectComponent = ( + props: RectShapeProps +) => JSX.Element + +interface RectsLayerProps { + borderColor: InheritedColorConfig + borderWidth: number + component?: RectComponent + data: TDatum[] + onClick?: RectMouseHandler + onMouseEnter?: RectMouseHandler + onMouseLeave?: RectMouseHandler + onMouseMove?: RectMouseHandler +} + +export const RectsLayer = ({ + // borderColor, + onMouseMove, + onMouseLeave, + onMouseEnter, + onClick, + borderWidth, + data, + component = RectShape, +}: RectsLayerProps) => { + // const theme = useTheme(); + // const getBorderColor = useInheritedColor(borderColor, theme); + + const { transition } = useRectsTransition< + TDatum, + { borderColor: string; color: string; opacity: number } + >(data, { + enter: datum => ({ + opacity: 0, + color: datum.color, + // borderColor: getBorderColor(datum), + borderColor: '#ccc', + }), + update: datum => ({ + opacity: 1, + color: datum.color, + // borderColor: getBorderColor(datum), + borderColor: '#ccc', + }), + leave: datum => ({ + opacity: 0, + color: datum.color, + // borderColor: getBorderColor(datum), + borderColor: '#ccc', + }), + }) + + const Rect: RectComponent = component + + return ( + + {transition((transitionProps, datum) => { + return createElement(Rect, { + key: datum.id, + datum, + style: { + ...transitionProps, + borderWidth, + transform: datum.rect.transform, + width: datum.rect.width, + height: datum.rect.height, + }, + onClick, + onMouseEnter, + onMouseMove, + onMouseLeave, + }) + })} + + ) +} diff --git a/packages/rects/src/index.ts b/packages/rects/src/index.ts new file mode 100644 index 000000000..b4991c2cf --- /dev/null +++ b/packages/rects/src/index.ts @@ -0,0 +1,5 @@ +export * from './rect_labels' +export * from './RectShape' +export * from './RectsLayer' +export * from './types' +export * from './useRectsTransition' diff --git a/packages/rects/src/rect_labels/RectLabel.tsx b/packages/rects/src/rect_labels/RectLabel.tsx new file mode 100644 index 000000000..2427c11b6 --- /dev/null +++ b/packages/rects/src/rect_labels/RectLabel.tsx @@ -0,0 +1,39 @@ +import { useTheme } from '@nivo/core' +import { animated, SpringValue } from '@react-spring/web' +import { CSSProperties } from 'react' +import { DatumWithRectAndColor } from '../types' + +const staticStyle: CSSProperties = { + pointerEvents: 'none', +} + +export interface RectLabelProps { + datum: TDatum + label: string + style: { + progress: SpringValue + textColor: string + } +} + +export const RectLabel = ({ + label, + style, +}: RectLabelProps) => { + const theme = useTheme() + + return ( + + + {label} + + + ) +} diff --git a/packages/rects/src/rect_labels/RectLabelsLayer.tsx b/packages/rects/src/rect_labels/RectLabelsLayer.tsx new file mode 100644 index 000000000..97bbc3790 --- /dev/null +++ b/packages/rects/src/rect_labels/RectLabelsLayer.tsx @@ -0,0 +1,51 @@ +import { useInheritedColor } from '@nivo/colors' +import { PropertyAccessor, usePropertyAccessor, useTheme } from '@nivo/core' +import { createElement } from 'react' +import { DatumWithRectAndColor } from '../types' +import { useRectsTransition } from '../useRectsTransition' +import { RectLabel, RectLabelProps } from './RectLabel' +import { RectLabelsProps } from './props' + +export type RectLabelComponent = ( + props: RectLabelProps +) => JSX.Element + +interface RectLabelsLayerProps { + component?: RectLabelsProps['rectLabelsComponent'] + data: TDatum[] + label: PropertyAccessor + textColor: RectLabelsProps['rectLabelsTextColor'] +} + +export const RectLabelsLayer = ({ + data, + label: labelAccessor, + textColor, + component = RectLabel, +}: RectLabelsLayerProps) => { + const getLabel = usePropertyAccessor(labelAccessor) + const theme = useTheme() + const getTextColor = useInheritedColor(textColor, theme) + + // const filteredData = useMemo(() => {}, []) + + const { transition } = useRectsTransition(data) + + const Label: RectLabelComponent = component + + return ( + + {transition((transitionProps, datum) => { + return createElement(Label, { + key: datum.id, + datum, + label: getLabel(datum), + style: { + ...transitionProps, + textColor: getTextColor(datum), + }, + }) + })} + + ) +} diff --git a/packages/rects/src/rect_labels/index.ts b/packages/rects/src/rect_labels/index.ts new file mode 100644 index 000000000..115a446fb --- /dev/null +++ b/packages/rects/src/rect_labels/index.ts @@ -0,0 +1,3 @@ +export { RectLabel as RectLabelComponent } from './RectLabel' +export * from './RectLabelsLayer' +export * from './props' diff --git a/packages/rects/src/rect_labels/props.ts b/packages/rects/src/rect_labels/props.ts new file mode 100644 index 000000000..20f8bfd45 --- /dev/null +++ b/packages/rects/src/rect_labels/props.ts @@ -0,0 +1,10 @@ +import { InheritedColorConfig } from '@nivo/colors' +import { PropertyAccessor } from '@nivo/core' +import { DatumWithRectAndColor } from '../types' +import { RectLabelComponent } from './RectLabelsLayer' + +export interface RectLabelsProps { + rectLabel: PropertyAccessor + rectLabelsComponent: RectLabelComponent + rectLabelsTextColor: InheritedColorConfig +} diff --git a/packages/rects/src/types.ts b/packages/rects/src/types.ts new file mode 100644 index 000000000..69933d0ef --- /dev/null +++ b/packages/rects/src/types.ts @@ -0,0 +1,20 @@ +export interface Point { + x: number + y: number +} + +export interface Rect { + height: number + transform: string + width: number +} + +export interface DatumWithRect { + id: string | number + rect: Rect +} + +export interface DatumWithRectAndColor extends DatumWithRect { + color: string + fill?: string +} diff --git a/packages/rects/src/useRectsTransition.ts b/packages/rects/src/useRectsTransition.ts new file mode 100644 index 000000000..adf927164 --- /dev/null +++ b/packages/rects/src/useRectsTransition.ts @@ -0,0 +1,51 @@ +import { useMotionConfig } from '@nivo/core' +import { useTransition } from '@react-spring/web' +import { useMemo } from 'react' +import { DatumWithRect, DatumWithRectAndColor } from './types' + +export interface TransitionExtra { + enter: (datum: TDatum) => ExtraProps + leave: (datum: TDatum) => ExtraProps + update: (datum: TDatum) => ExtraProps +} +const useRectExtraTransition = ( + extraTransition?: TransitionExtra +) => + useMemo( + () => ({ + enter: (datum: TDatum) => ({ + progress: 0, + ...(extraTransition?.enter(datum) ?? {}), + }), + update: (datum: TDatum) => ({ + progress: 1, + ...(extraTransition?.update(datum) ?? {}), + }), + leave: (datum: TDatum) => ({ + progress: 0, + ...(extraTransition?.leave(datum) ?? {}), + }), + }), + [extraTransition] + ) + +export const useRectsTransition = ( + data: TDatum[], + extra?: TransitionExtra +) => { + const { animate, config: springConfig } = useMotionConfig() + + const phases = useRectExtraTransition(extra) + + const transition = useTransition(data, { + keys: datum => datum.id, + initial: phases.update, + from: phases.enter, + enter: phases.update, + leave: phases.leave, + config: springConfig, + immediate: !animate, + }) + + return { transition } +} diff --git a/packages/rects/tsconfig.json b/packages/rects/tsconfig.json new file mode 100644 index 000000000..39c997d5e --- /dev/null +++ b/packages/rects/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.types.json", + "compilerOptions": { + "outDir": "./dist/types", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/packages/static/package.json b/packages/static/package.json index b5038779f..ae38f729f 100644 --- a/packages/static/package.json +++ b/packages/static/package.json @@ -34,6 +34,7 @@ "@nivo/radar": "0.79.1", "@nivo/sankey": "0.79.1", "@nivo/sunburst": "0.79.1", + "@nivo/icicles": "0.79.1", "@nivo/treemap": "0.79.1", "joi": "^17.5.0", "lodash": "^4.17.21" @@ -45,4 +46,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/static/src/mappings/icicles.ts b/packages/static/src/mappings/icicles.ts new file mode 100644 index 000000000..aa2544896 --- /dev/null +++ b/packages/static/src/mappings/icicles.ts @@ -0,0 +1,41 @@ +import { FunctionComponent } from 'react' +import Joi from 'joi' +import { Icicles, IciclesSvgProps } from '@nivo/icicles' +import { custom } from './common' +import { ordinalColors, inheritedColor } from './commons/colors' +import { dimensions } from './commons/dimensions' +import { OmitStrict } from '../types' + +export type IciclesApiProps = OmitStrict< + IciclesSvgProps, + 'isInteractive' | 'tooltip' | 'onClick' | 'animate' | 'motionConfig' | 'renderWrapper' +> + +export const iciclesMapping = { + component: Icicles as FunctionComponent, + schema: Joi.object().keys({ + data: custom.object().required(), + id: Joi.string(), + value: Joi.string(), + valueFormat: Joi.string(), + width: dimensions.width, + height: dimensions.height, + margin: dimensions.margin, + + colors: ordinalColors, + colorBy: Joi.any().valid('id', 'depth'), + direction: Joi.any().valid('top', 'right', 'bottom', 'left'), + inheritColorFromParent: Joi.boolean(), + childColor: inheritedColor, + borderWidth: Joi.number().min(0), + borderColor: inheritedColor, + + enableRectLabels: Joi.boolean(), + rectLabel: Joi.string(), + rectLabelsTextColor: inheritedColor, + }), + runtimeProps: ['width', 'height', 'colors'], + defaults: { + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }, +} diff --git a/packages/static/src/mappings/index.ts b/packages/static/src/mappings/index.ts index 889489288..2471f87c1 100644 --- a/packages/static/src/mappings/index.ts +++ b/packages/static/src/mappings/index.ts @@ -9,6 +9,7 @@ import { pieMapping } from './pie' import { radarMapping } from './radar' import { sankeyMapping } from './sankey' import { sunburstMapping } from './sunburst' +import { iciclesMapping } from './icicles' import { treemapMapping } from './treemap' export const chartsMapping = { @@ -17,6 +18,7 @@ export const chartsMapping = { calendar: calendarMapping, chord: chordMapping, heatmap: heatmapMapping, + icicles: iciclesMapping, line: lineMapping, pie: pieMapping, radar: radarMapping, diff --git a/packages/static/src/samples/index.ts b/packages/static/src/samples/index.ts index 8632b565a..2b6d052e5 100644 --- a/packages/static/src/samples/index.ts +++ b/packages/static/src/samples/index.ts @@ -11,13 +11,14 @@ import { ChartProps, ChartType, LineApiProps } from '../mappings' const keys = ['hot dogs', 'burgers', 'sandwich', 'kebab', 'fries', 'donut'] const moreKeys = [...keys, 'junk', 'sushi', 'ramen', 'curry', 'udon', 'bagel'] -export const samples: Record< - string, - { - type: ChartType - props: ChartProps +type Samples = { + [P in ChartType]?: { + type: P + props: ChartProps

} -> = { +} + +export const samples: Samples = { bar: { type: 'bar', props: { @@ -73,7 +74,7 @@ export const samples: Record< borderWidth: 2, borderColor: 'inherit:darker(0.4)', labelTextColor: 'inherit:darker(2.4)', - }, + } as ChartProps<'heatmap'>, }, line: { type: 'line', @@ -119,7 +120,7 @@ export const samples: Record< enableMarkersLabel: true, }, generateWinesTastes() - ), + ) as ChartProps<'radar'>, }, sankey: { type: 'sankey', @@ -148,7 +149,20 @@ export const samples: Record< enableArcLabels: true, arcLabelsSkipAngle: 10, arcLabelsTextColor: { from: 'color', modifiers: [['darker', 1.4]] }, - }, + } as ChartProps<'sunburst'>, + }, + icicles: { + type: 'icicles', + props: { + width: 800, + height: 800, + data: generateLibTree(), + id: 'name', + value: 'loc', + childColor: { from: 'color', modifiers: [['brighter', 0.1]] }, + enableRectLabels: true, + rectLabelsTextColor: { from: 'color', modifiers: [['darker', 1.4]] }, + } as ChartProps<'icicles'>, }, treemap: { type: 'treemap', diff --git a/tsconfig.monorepo.json b/tsconfig.monorepo.json index 99343d876..971365ed7 100644 --- a/tsconfig.monorepo.json +++ b/tsconfig.monorepo.json @@ -3,45 +3,111 @@ "references": [ // Core first because everything needs it // { "path": "./packages/core" }, - // Shared next because charts need them - { "path": "./packages/annotations" }, - { "path": "./packages/scales" }, - { "path": "./packages/axes" }, - { "path": "./packages/colors" }, - { "path": "./packages/legends" }, - { "path": "./packages/tooltip" }, - { "path": "./packages/arcs" }, - { "path": "./packages/polar-axes" }, - { "path": "./packages/voronoi" }, - + { + "path": "./packages/annotations" + }, + { + "path": "./packages/scales" + }, + { + "path": "./packages/axes" + }, + { + "path": "./packages/colors" + }, + { + "path": "./packages/legends" + }, + { + "path": "./packages/tooltip" + }, + { + "path": "./packages/arcs" + }, + { + "path": "./packages/rects" + }, + { + "path": "./packages/polar-axes" + }, + { + "path": "./packages/voronoi" + }, // Utility packages - { "path": "./packages/generators" }, - { "path": "./packages/recompose" }, - + { + "path": "./packages/generators" + }, + { + "path": "./packages/recompose" + }, // Charts now - { "path": "./packages/bar" }, - { "path": "./packages/bullet" }, - { "path": "./packages/bump" }, - { "path": "./packages/calendar" }, - { "path": "./packages/chord" }, - { "path": "./packages/circle-packing" }, - { "path": "./packages/funnel" }, - { "path": "./packages/heatmap" }, - { "path": "./packages/marimekko" }, - { "path": "./packages/network" }, - { "path": "./packages/pie" }, - { "path": "./packages/radar" }, - { "path": "./packages/radial-bar" }, - { "path": "./packages/sankey" }, - { "path": "./packages/scatterplot" }, - { "path": "./packages/sunburst" }, - { "path": "./packages/stream" }, - { "path": "./packages/swarmplot" }, - { "path": "./packages/treemap" }, - + { + "path": "./packages/bar" + }, + { + "path": "./packages/bullet" + }, + { + "path": "./packages/bump" + }, + { + "path": "./packages/calendar" + }, + { + "path": "./packages/chord" + }, + { + "path": "./packages/circle-packing" + }, + { + "path": "./packages/funnel" + }, + { + "path": "./packages/heatmap" + }, + { + "path": "./packages/icicles" + }, + { + "path": "./packages/marimekko" + }, + { + "path": "./packages/network" + }, + { + "path": "./packages/pie" + }, + { + "path": "./packages/radar" + }, + { + "path": "./packages/radial-bar" + }, + { + "path": "./packages/sankey" + }, + { + "path": "./packages/scatterplot" + }, + { + "path": "./packages/sunburst" + }, + { + "path": "./packages/stream" + }, + { + "path": "./packages/swarmplot" + }, + { + "path": "./packages/treemap" + }, // Static rendering and express middleware - { "path": "./packages/static" }, - { "path": "./packages/express" } + { + "path": "./packages/static" + }, + { + "path": "./packages/express" + } ] -} +} \ No newline at end of file diff --git a/website/package.json b/website/package.json index 88e136c84..9774fd4a4 100644 --- a/website/package.json +++ b/website/package.json @@ -17,6 +17,7 @@ "@nivo/generators": "0.79.0", "@nivo/geo": "0.79.1", "@nivo/heatmap": "0.79.1", + "@nivo/icicles": "0.79.1", "@nivo/legends": "0.79.1", "@nivo/line": "0.79.1", "@nivo/marimekko": "0.79.1", @@ -25,6 +26,7 @@ "@nivo/pie": "0.79.1", "@nivo/radar": "0.79.1", "@nivo/radial-bar": "0.79.1", + "@nivo/rects": "0.79.1", "@nivo/sankey": "0.79.1", "@nivo/scatterplot": "0.79.1", "@nivo/stream": "0.79.1", @@ -64,4 +66,4 @@ "engines": { "node": "16.x" } -} +} \ No newline at end of file diff --git a/website/src/@types/file_types.d.ts b/website/src/@types/file_types.d.ts index 96576d8f1..8f7de105c 100644 --- a/website/src/@types/file_types.d.ts +++ b/website/src/@types/file_types.d.ts @@ -5,7 +5,7 @@ type ChartMetaFlavors = { path: string }[] -declare module '*area-bump/meta.yml' { +declare module '*/area-bump/meta.yml' { const meta: { flavors: ChartMetaFlavors AreaBump: ChartMeta @@ -14,7 +14,7 @@ declare module '*area-bump/meta.yml' { export default meta } -declare module '*bar/meta.yml' { +declare module '*/bar/meta.yml' { const meta: { flavors: ChartMetaFlavors Bar: ChartMeta @@ -24,7 +24,7 @@ declare module '*bar/meta.yml' { export default meta } -declare module '*bullet/meta.yml' { +declare module '*/bullet/meta.yml' { const meta: { flavors: ChartMetaFlavors Bullet: ChartMeta @@ -33,7 +33,7 @@ declare module '*bullet/meta.yml' { export default meta } -declare module '*bump/meta.yml' { +declare module '*/bump/meta.yml' { const meta: { flavors: ChartMetaFlavors Bump: ChartMeta @@ -42,7 +42,7 @@ declare module '*bump/meta.yml' { export default meta } -declare module '*calendar/meta.yml' { +declare module '*/calendar/meta.yml' { const meta: { flavors: ChartMetaFlavors Calendar: ChartMeta @@ -52,7 +52,7 @@ declare module '*calendar/meta.yml' { export default meta } -declare module '*calendar/meta.yml' { +declare module '*/calendar/meta.yml' { const meta: { flavors: ChartMetaFlavors Calendar: ChartMeta @@ -62,7 +62,7 @@ declare module '*calendar/meta.yml' { export default meta } -declare module '*chord/meta.yml' { +declare module '*/chord/meta.yml' { const meta: { flavors: ChartMetaFlavors Chord: ChartMeta @@ -72,7 +72,7 @@ declare module '*chord/meta.yml' { export default meta } -declare module '*choropleth/meta.yml' { +declare module '*/choropleth/meta.yml' { const meta: { flavors: ChartMetaFlavors Choropleth: ChartMeta @@ -82,7 +82,7 @@ declare module '*choropleth/meta.yml' { export default meta } -declare module '*circle-packing/meta.yml' { +declare module '*/circle-packing/meta.yml' { const meta: { flavors: ChartMetaFlavors CirclePacking: ChartMeta @@ -93,7 +93,7 @@ declare module '*circle-packing/meta.yml' { export default meta } -declare module '*funnel/meta.yml' { +declare module '*/funnel/meta.yml' { const meta: { flavors: ChartMetaFlavors Funnel: ChartMeta @@ -102,7 +102,16 @@ declare module '*funnel/meta.yml' { export default meta } -declare module '*heatmap/meta.yml' { +declare module '*/geomap/meta.yml' { + const meta: { + flavors: ChartMetaFlavors + GeoMap: ChartMeta + } + + export default meta +} + +declare module '*/heatmap/meta.yml' { const meta: { flavors: ChartMetaFlavors HeatMap: ChartMeta @@ -112,7 +121,16 @@ declare module '*heatmap/meta.yml' { export default meta } -declare module '*line/meta.yml' { +declare module '*/icicles/meta.yml' { + const meta: { + flavors: ChartMetaFlavors + Icicles: ChartMeta + } + + export default meta +} + +declare module '*/line/meta.yml' { const meta: { flavors: ChartMetaFlavors Line: ChartMeta @@ -122,7 +140,7 @@ declare module '*line/meta.yml' { export default meta } -declare module '*marimekko/meta.yml' { +declare module '*/marimekko/meta.yml' { const meta: { flavors: ChartMetaFlavors Marimekko: ChartMeta @@ -131,7 +149,7 @@ declare module '*marimekko/meta.yml' { export default meta } -declare module '*network/meta.yml' { +declare module '*/network/meta.yml' { const meta: { flavors: ChartMetaFlavors Network: ChartMeta @@ -141,7 +159,7 @@ declare module '*network/meta.yml' { export default meta } -declare module '*parallel-coordinates/meta.yml' { +declare module '*/parallel-coordinates/meta.yml' { const meta: { flavors: ChartMetaFlavors ParallelCoordinates: ChartMeta @@ -151,7 +169,7 @@ declare module '*parallel-coordinates/meta.yml' { export default meta } -declare module '*pie/meta.yml' { +declare module '*/pie/meta.yml' { const meta: { flavors: ChartMetaFlavors Pie: ChartMeta @@ -161,7 +179,7 @@ declare module '*pie/meta.yml' { export default meta } -declare module '*radar/meta.yml' { +declare module '*/radar/meta.yml' { const meta: { flavors: ChartMetaFlavors Radar: ChartMeta @@ -170,7 +188,7 @@ declare module '*radar/meta.yml' { export default meta } -declare module '*radial-bar/meta.yml' { +declare module '*/radial-bar/meta.yml' { const meta: { flavors: ChartMetaFlavors RadialBar: ChartMeta @@ -179,7 +197,7 @@ declare module '*radial-bar/meta.yml' { export default meta } -declare module '*sankey/meta.yml' { +declare module '*/sankey/meta.yml' { const meta: { flavors: ChartMetaFlavors Sankey: ChartMeta @@ -188,7 +206,7 @@ declare module '*sankey/meta.yml' { export default meta } -declare module '*scatterplot/meta.yml' { +declare module '*/scatterplot/meta.yml' { const meta: { flavors: ChartMetaFlavors ScatterPlot: ChartMeta @@ -198,7 +216,7 @@ declare module '*scatterplot/meta.yml' { export default meta } -declare module '*stream/meta.yml' { +declare module '*/stream/meta.yml' { const meta: { flavors: ChartMetaFlavors Stream: ChartMeta @@ -207,7 +225,7 @@ declare module '*stream/meta.yml' { export default meta } -declare module '*sunburst/meta.yml' { +declare module '*/sunburst/meta.yml' { const meta: { flavors: ChartMetaFlavors Sunburst: ChartMeta @@ -216,7 +234,7 @@ declare module '*sunburst/meta.yml' { export default meta } -declare module '*swarmplot/meta.yml' { +declare module '*/swarmplot/meta.yml' { const meta: { flavors: ChartMetaFlavors SwarmPlot: ChartMeta @@ -226,7 +244,7 @@ declare module '*swarmplot/meta.yml' { export default meta } -declare module '*time-range/meta.yml' { +declare module '*/time-range/meta.yml' { const meta: { flavors: ChartMetaFlavors TimeRange: ChartMeta @@ -235,7 +253,7 @@ declare module '*time-range/meta.yml' { export default meta } -declare module '*treemap/meta.yml' { +declare module '*/treemap/meta.yml' { const meta: { flavors: ChartMetaFlavors TreeMap: ChartMeta @@ -246,7 +264,7 @@ declare module '*treemap/meta.yml' { export default meta } -declare module '*voronoi/meta.yml' { +declare module '*/voronoi/meta.yml' { const meta: { flavors: ChartMetaFlavors Voronoi: ChartMeta @@ -255,7 +273,7 @@ declare module '*voronoi/meta.yml' { export default meta } -declare module '*waffle/meta.yml' { +declare module '*/waffle/meta.yml' { const meta: { flavors: ChartMetaFlavors Waffle: ChartMeta diff --git a/website/src/components/home/Home.tsx b/website/src/components/home/Home.tsx index ed0ca8979..3c31c2372 100644 --- a/website/src/components/home/Home.tsx +++ b/website/src/components/home/Home.tsx @@ -19,6 +19,7 @@ import radialBar from '../../assets/captures/home/radial-bar.png' import voronoi from '../../assets/captures/home/voronoi.png' import treemap from '../../assets/captures/home/treemap.png' import sunburst from '../../assets/captures/home/sunburst.png' +import icicles from '../../assets/captures/home/sunburst.png' import sankey from '../../assets/captures/home/sankey.png' import swarmplot from '../../assets/captures/home/swarmplot.png' import marimekko from '../../assets/captures/home/marimekko.png' @@ -55,7 +56,7 @@ const Home = () => { - + ) diff --git a/website/src/components/home/HomeIciclesDemo.tsx b/website/src/components/home/HomeIciclesDemo.tsx new file mode 100644 index 000000000..e35301d82 --- /dev/null +++ b/website/src/components/home/HomeIciclesDemo.tsx @@ -0,0 +1,28 @@ +import React, { useMemo } from 'react' +import { generateLibTree } from '@nivo/generators' +import { Icicles } from '@nivo/icicles' +import { useHomeTheme } from './theme' +import { dimensions } from './dimensions' + +export const HomeIciclesDemo = () => { + const { colors, nivoTheme } = useHomeTheme() + const data = useMemo(() => generateLibTree(), []) + + return ( +

+ +
+ ) +} diff --git a/website/src/components/home/index.ts b/website/src/components/home/index.ts index 13dafeff0..d2420182a 100644 --- a/website/src/components/home/index.ts +++ b/website/src/components/home/index.ts @@ -12,6 +12,7 @@ export * from './HomeRadialBarDemo' export * from './HomeSankeyDemo' export * from './HomeStreamDemo' export * from './HomeSunburstDemo' +export * from './HomeIciclesDemo' export * from './HomeSwarmPlotDemo' export * from './HomeTreeMapDemo' export * from './HomeVoronoiDemo' diff --git a/website/src/components/icons/IciclesIcon.tsx b/website/src/components/icons/IciclesIcon.tsx new file mode 100644 index 000000000..40a24aa4d --- /dev/null +++ b/website/src/components/icons/IciclesIcon.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import { arc as Arc } from 'd3-shape' +import { degreesToRadians } from '@nivo/core' +import sunburstLightNeutralImg from '../../assets/icons/sunburst-grey.png' +import sunburstLightColoredImg from '../../assets/icons/sunburst-red.png' +import sunburstDarkNeutralImg from '../../assets/icons/sunburst-dark-neutral.png' +import sunburstDarkColoredImg from '../../assets/icons/sunburst-dark-colored.png' +import { ICON_SIZE, Icon, colors, IconImg } from './styled' +import { IconType } from './types' + +const arc = Arc().padAngle(degreesToRadians(2)) + +const IciclesIconItem = ({ type }: { type: IconType }) => ( + + + + + + + + + + + + + + + +) + +export const IciclesIcon = () => ( + <> + + + + + + + + + +) diff --git a/website/src/components/icons/Icons.tsx b/website/src/components/icons/Icons.tsx index 936a3559c..9d986c5d6 100644 --- a/website/src/components/icons/Icons.tsx +++ b/website/src/components/icons/Icons.tsx @@ -24,6 +24,7 @@ import { SankeyIcon } from './SankeyIcon' import { ScatterPlotIcon } from './ScatterPlotIcon' import { StreamIcon } from './StreamIcon' import { SunburstIcon } from './SunburstIcon' +import { IciclesIcon } from './IciclesIcon' import { SwarmPlotIcon } from './SwarmPlotIcon' import { TimeRangeIcon } from './TimeRangeIcon' import { TreeMapIcon } from './TreeMapIcon' @@ -87,6 +88,7 @@ export const Icons = () => ( + diff --git a/website/src/data/components/icicles/mapper.tsx b/website/src/data/components/icicles/mapper.tsx new file mode 100644 index 000000000..32807b6b2 --- /dev/null +++ b/website/src/data/components/icicles/mapper.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import styled from 'styled-components' +import { patternLinesDef } from '@nivo/core' +import { mapFormat, settingsMapper } from '../../../lib/settings' + +const TooltipWrapper = styled.div` + display: grid; + background: #fff; + grid-template-columns: 1fr 1fr; + grid-column-gap: 12px; + font-size: 12px; + border-radius: 2px; + box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.15); +` +const TooltipKey = styled.span` + font-weight: 600; +` +const TooltipValue = styled.span`` + +const CustomTooltip = node => { + return ( + + id + {node.id} + value + {node.value} + percentage + {Math.round(node.percentage * 100) / 100}% + color + {node.color} + + ) +} + +export default settingsMapper( + { + valueFormat: mapFormat, + rectLabel: value => { + if (value === `d => \`\${d.id} (\${d.value})\``) return d => `${d.id} (${d.value})` + return value + }, + tooltip: (value, values) => { + if (!values['custom tooltip example']) return undefined + + return CustomTooltip + }, + defs: (value, values) => { + if (!values['showcase pattern usage']) return + + return [ + patternLinesDef('lines', { + background: 'rgba(0, 0, 0, 0)', + color: 'inherit', + rotation: -45, + lineWidth: 4, + spacing: 8, + }), + ] + }, + fill: (value, values) => { + if (!values['showcase pattern usage']) return + + return [ + { match: { id: 'set' }, id: 'lines' }, + { match: { id: 'misc' }, id: 'lines' }, + ] + }, + }, + { + exclude: ['custom tooltip example', 'showcase pattern usage'], + } +) diff --git a/website/src/data/components/icicles/meta.yml b/website/src/data/components/icicles/meta.yml new file mode 100644 index 000000000..3f6fb8c45 --- /dev/null +++ b/website/src/data/components/icicles/meta.yml @@ -0,0 +1,31 @@ +flavors: + - flavor: svg + path: /icicles/ + - flavor: api + path: /icicles/api/ + +Icicles: + package: "@nivo/icicles" + tags: + - hierarchy + - flat + stories: + - label: with child color modifier + link: icicles--with-child-color-modifier + - label: with child color independent of parent + link: icicles--with-child-colors-independent-of-parent + - label: with custom colors + link: icicles--with-custom-colors + - label: with formatted tooltip value + link: icicles--with-formatted-tooltip-value + - label: with custom tooltip + link: icicles--custom-tooltip + - label: with enter and leave actions + link: icicles--enter-leave-check-actions + - label: with patterns and gradients + link: icicles--patterns-gradients + - label: drill down to children + link: icicles--children-drill-down + description: | + The responsive alternative of this component is + `ResponsiveIcicles`. diff --git a/website/src/data/components/icicles/props.ts b/website/src/data/components/icicles/props.ts new file mode 100644 index 000000000..9b5bf8167 --- /dev/null +++ b/website/src/data/components/icicles/props.ts @@ -0,0 +1,332 @@ +import { defaultProps, IciclesDirection } from '@nivo/icicles' +import { + groupProperties, + defsProperties, + motionProperties, + themeProperty, +} from '../../../lib/componentProperties' +import { chartDimensions, ordinalColors, isInteractive } from '../../../lib/chart-properties' +import { ChartProperty, Flavor } from '../../../types' + +const allFlavors: Flavor[] = ['svg', 'api'] + +const directions: IciclesDirection[] = ['top', 'right', 'bottom', 'left']; + +const props: ChartProperty[] = [ + { + key: 'data', + group: 'Base', + flavors: allFlavors, + help: 'Chart data, which should be immutable.', + description: ` + Chart data, which must conform to this structure + if using the default \`id\` and \`value\` accessors: + + \`\`\` + { + // must be unique for the whole dataset + id: string | number + value: number + children: { + id: string | number + value: number + children: ... + }[] + } + \`\`\` + + If using a different data structure, you must make sure + to adjust both \`id\` and \`value\`. Meaning you can provide + a completely different data structure as long as \`id\` and \`value\` + return the appropriate values. + + Immutability of the data is important as re-computations + depends on it. + `, + type: 'object', + required: true, + }, + { + key: 'id', + group: 'Base', + flavors: allFlavors, + help: 'Id accessor.', + description: ` + define id accessor, if string given, + will use \`node[value]\`, + if function given, it will be invoked + for each node and will receive the node as + first argument, it must return the node + id (string | number). + `, + type: 'string | Function', + required: false, + defaultValue: defaultProps.id, + }, + { + key: 'value', + group: 'Base', + flavors: allFlavors, + help: 'Value accessor', + description: ` + define value accessor, if string given, + will use \`node[value]\`, + if function given, it will be invoked + for each node and will receive the node as + first argument, it must return the node + value (number). + `, + type: 'string | Function', + required: false, + defaultValue: defaultProps.value, + }, + { + key: 'direction', + group: 'Base', + flavors: allFlavors, + help: 'Optional chart direction.', + description: ` + Change the reading direction of the chart. + `, + required: false, + type: directions.map(d => `'${d}'`).join(' | '), + control: { + type: 'radio', + choices: directions.map(d => ({ + label: d, + value: d + })) + }, + }, + { + key: 'valueFormat', + group: 'Base', + flavors: allFlavors, + help: 'Optional formatter for values.', + description: ` + The formatted value can then be used for labels & tooltips. + + Under the hood, nivo uses [d3-format](https://github.com/d3/d3-format), + please have a look at it for available formats, you can also pass a function + which will receive the raw value and should return the formatted one. + `, + required: false, + type: 'string | (value: number) => string | number', + control: { type: 'valueFormat' }, + }, + ...chartDimensions(allFlavors), + themeProperty(['svg', 'api']), + ordinalColors({ + flavors: allFlavors, + defaultValue: defaultProps.colors, + }), + { + key: 'colorBy', + help: `Define the property to use to assign a color to arcs.`, + flavors: allFlavors, + description: ` + When using \`id\`, each node will get a new color, + and when using \`depth\` the nodes' color will depend on their depth. + `, + type: `'id' | 'depth'`, + required: false, + defaultValue: defaultProps.colorBy, + group: 'Style', + control: { + type: 'radio', + choices: [ + { label: 'id', value: 'id' }, + { label: 'depth', value: 'depth' }, + ], + }, + }, + { + key: 'inheritColorFromParent', + help: 'Inherit color from parent node starting from 2nd level.', + flavors: allFlavors, + type: 'boolean', + required: false, + defaultValue: defaultProps.inheritColorFromParent, + control: { type: 'switch' }, + group: 'Style', + }, + { + key: 'childColor', + help: 'Defines how to compute child nodes color.', + flavors: allFlavors, + type: 'string | object | Function', + required: false, + defaultValue: defaultProps.childColor, + control: { type: 'inheritedColor' }, + group: 'Style', + }, + { + key: 'borderWidth', + help: 'Node border width.', + flavors: allFlavors, + type: 'number', + required: false, + defaultValue: defaultProps.borderWidth, + control: { type: 'lineWidth' }, + group: 'Style', + }, + { + key: 'borderColor', + help: 'Defines how to compute arcs color.', + flavors: allFlavors, + type: 'string | object | Function', + required: false, + defaultValue: defaultProps.borderColor, + control: { type: 'inheritedColor' }, + group: 'Style', + }, + ...defsProperties('Style', ['svg', 'api']), + { + key: 'showcase pattern usage', + flavors: ['svg'], + help: 'Patterns.', + description: ` + You can use \`defs\` and \`fill\` properties + to use patterns, see + [dedicated guide](self:/guides/patterns) + for further information. + `, + required: false, + type: 'boolean', + control: { type: 'switch' }, + group: 'Style', + }, + { + key: 'enableRectLabels', + help: 'Enable/disable rect labels.', + flavors: allFlavors, + type: 'boolean', + required: false, + defaultValue: defaultProps.enableRectLabels, + control: { type: 'switch' }, + group: 'Rect labels', + }, + { + key: 'rectLabel', + help: 'Defines how to get label text, can be a string (used to access current node data property) or a function which will receive the actual node data.', + flavors: allFlavors, + type: 'string | Function', + required: false, + defaultValue: defaultProps.rectLabel, + group: 'Rect labels', + control: { + type: 'choices', + choices: ['id', 'value', 'formattedValue', `d => \`\${d.id} (\${d.value})\``].map( + choice => ({ + label: choice, + value: choice, + }) + ), + }, + }, + { + key: 'rectLabelsTextColor', + help: 'Defines how to compute rect label text color.', + flavors: allFlavors, + type: 'string | object | Function', + required: false, + defaultValue: defaultProps.rectLabelsTextColor, + control: { type: 'inheritedColor' }, + group: 'Rect labels', + }, + { + key: 'layers', + group: 'Customization', + help: 'Defines the order of layers and add custom layers.', + flavors: ['svg'], + description: ` + You can also use this to insert extra layers + to the chart, the extra layer must be a function. + + The layer component which will receive the chart's + context & computed data and must return a valid SVG element + for the \`Icicles\` component. + + The context passed to layers has the following structure: + + \`\`\` + { + nodes: ComputedDatum[] + } + \`\`\` + `, + required: false, + type: 'Array', + defaultValue: defaultProps.layers, + }, + isInteractive({ + flavors: ['svg'], + defaultValue: defaultProps.isInteractive, + }), + ...motionProperties(['svg'], defaultProps, 'react-spring'), + { + key: 'tooltip', + flavors: ['svg'], + group: 'Interactivity', + type: 'Function', + required: false, + help: 'Tooltip custom component', + description: ` + A function allowing complete tooltip customisation, + it must return a valid HTML element and will receive + the following data: + \`\`\` + { + id: string | number, + value: number, + depth: number, + color: string, + name: string + loc: number + percentage: number + // the parent datum + ancestor: object + } + \`\`\` + You can also customize the style of the tooltip + using the \`theme.tooltip\` object. + `, + }, + { + key: 'custom tooltip example', + flavors: ['svg'], + group: 'Interactivity', + required: false, + help: 'Showcase custom tooltip component.', + type: 'boolean', + control: { type: 'switch' }, + }, + { + key: 'onClick', + flavors: ['svg'], + group: 'Interactivity', + type: 'Function', + required: false, + help: 'onClick handler', + description: ` + onClick handler, will receive node data as first argument + & event as second one. The node data has the following shape: + + \`\`\` + { + id: string | number, + value: number, + depth: number, + color: string, + name: string + loc: number + percentage: number + // the parent datum + ancestor: object + } + \`\`\` + `, + }, +] + +export const groups = groupProperties(props) diff --git a/website/src/data/nav.ts b/website/src/data/nav.ts index c15bdea4e..75f17266c 100644 --- a/website/src/data/nav.ts +++ b/website/src/data/nav.ts @@ -10,6 +10,7 @@ import funnel from './components/funnel/meta.yml' import geomap from './components/geomap/meta.yml' import heatmap from './components/heatmap/meta.yml' import line from './components/line/meta.yml' +import icicles from './components/icicles/meta.yml' import marimekko from './components/marimekko/meta.yml' import network from './components/network/meta.yml' import parallelCoordinates from './components/parallel-coordinates/meta.yml' @@ -129,6 +130,15 @@ export const components: ChartNavData[] = [ api: true, }, }, + { + name: 'Icicles', + id: 'icicles', + tags: icicles.Icicles.tags, + flavors: { + svg: true, + api: true, + }, + }, { name: 'Line', id: 'line', diff --git a/website/src/pages/icicles/api.tsx b/website/src/pages/icicles/api.tsx new file mode 100644 index 000000000..4ea69edfa --- /dev/null +++ b/website/src/pages/icicles/api.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { generateLibTree } from '@nivo/generators' +import { Seo } from '../../components/Seo' +import { ApiClient } from '../../components/components/api-client/ApiClient' +import { groups } from '../../data/components/icicles/props' +import mapper from '../../data/components/icicles/mapper' +import meta from '../../data/components/icicles/meta.yml' +import { graphql, useStaticQuery } from 'gatsby' + +const data = generateLibTree() + +const IciclesApi = () => { + const { + image: { + childImageSharp: { gatsbyImageData: image }, + }, + } = useStaticQuery(graphql` + query { + image: file(absolutePath: { glob: "**/src/assets/captures/icicles.png" }) { + childImageSharp { + gatsbyImageData(layout: FIXED, width: 700, quality: 100) + } + } + } + `) + + return ( + <> + + + + ) +} + +export default IciclesApi diff --git a/website/src/pages/icicles/index.js b/website/src/pages/icicles/index.js new file mode 100644 index 000000000..c06f81648 --- /dev/null +++ b/website/src/pages/icicles/index.js @@ -0,0 +1,109 @@ +import React from 'react' +import { defaultProps, ResponsiveIcicles } from '@nivo/icicles' +import { generateLibTree } from '@nivo/generators' +import { omit } from 'lodash' +import { ComponentTemplate } from '../../components/components/ComponentTemplate' +import meta from '../../data/components/icicles/meta.yml' +import mapper from '../../data/components/icicles/mapper' +import { groups } from '../../data/components/icicles/props' +import { graphql, useStaticQuery } from 'gatsby' + +const Tooltip = () => { + /* return custom tooltip */ +} + +const generateData = () => generateLibTree() + +const initialProperties = { + margin: { + top: 10, + right: 10, + bottom: 10, + left: 10, + }, + id: 'name', + value: 'loc', + valueFormat: { format: '', enabled: false }, + borderWidth: 1, + borderColor: { theme: 'background' }, + colors: { scheme: 'nivo' }, + colorBy: 'id', + inheritColorFromParent: true, + childColor: { + from: 'color', + modifiers: [['brighter', 0.1]], + }, + enableRectLabels: true, + rectLabel: 'formattedValue', + rectLabelsTextColor: { + from: 'color', + modifiers: [['darker', 1.4]], + }, + animate: defaultProps.animate, + motionConfig: defaultProps.motionConfig, + defs: [], + fill: [], + isInteractive: true, + 'custom tooltip example': false, + tooltip: null, + 'showcase pattern usage': false, + direction: 'bottom', +} + +const Icicles = () => { + const { + image: { + childImageSharp: { gatsbyImageData: image }, + }, + } = useStaticQuery(graphql` + query { + image: file(absolutePath: { glob: "**/src/assets/captures/sunburst.png" }) { + childImageSharp { + gatsbyImageData(layout: FIXED, width: 700, quality: 100) + } + } + } + `) + + return ( + + {(properties, data, theme, logAction) => { + return ( + + logAction({ + type: 'click', + label: `[icicles] ${node.id} - ${node.value}: ${ + Math.round(node.percentage * 100) / 100 + }%`, + color: node.color, + // prevent cyclic dependency + data: { + ...omit(node, ['parent']), + parent: omit(node.parent, ['data', 'parent', 'children']), + }, + }) + } + /> + ) + }} + + ) +} + +export default Icicles diff --git a/website/src/pages/internal/home-demos.tsx b/website/src/pages/internal/home-demos.tsx index 966f266f2..860e6b655 100644 --- a/website/src/pages/internal/home-demos.tsx +++ b/website/src/pages/internal/home-demos.tsx @@ -15,7 +15,7 @@ import { HomeRadialBarDemo, HomeSankeyDemo, HomeStreamDemo, - HomeSunburstDemo, + HomeIciclesDemo, HomeSwarmPlotDemo, HomeTreeMapDemo, HomeVoronoiDemo, @@ -38,7 +38,7 @@ const HomeDemosPage = () => ( - + From 748ecc72a0b45d7745b842618c683142ddb8c1b0 Mon Sep 17 00:00:00 2001 From: Lilian Saget-Lethias Date: Fri, 22 Apr 2022 18:51:58 +0200 Subject: [PATCH 2/3] feat(icicles): handle more interactivity + direction --- packages/icicles/src/Icicles.tsx | 42 ++++- packages/icicles/src/IciclesTooltip.tsx | 4 +- packages/icicles/src/Rects.tsx | 46 +++-- packages/icicles/src/hooks.ts | 169 ++++++++++-------- packages/icicles/src/props.ts | 20 ++- packages/icicles/src/types.ts | 45 +++-- packages/icicles/stories/icicles.stories.tsx | 27 ++- packages/rects/src/RectShape.tsx | 34 +++- packages/rects/src/RectsLayer.tsx | 41 +++-- packages/rects/src/centers.ts | 57 ++++++ packages/rects/src/index.ts | 1 + packages/rects/src/rect_labels/RectLabel.tsx | 5 +- .../rects/src/rect_labels/RectLabelsLayer.tsx | 25 ++- packages/rects/src/rect_labels/props.ts | 3 + packages/rects/src/types.ts | 15 +- packages/rects/src/useRectsTransition.ts | 40 ++++- 16 files changed, 416 insertions(+), 158 deletions(-) create mode 100644 packages/rects/src/centers.ts diff --git a/packages/icicles/src/Icicles.tsx b/packages/icicles/src/Icicles.tsx index b8eab8efb..0983d9004 100644 --- a/packages/icicles/src/Icicles.tsx +++ b/packages/icicles/src/Icicles.tsx @@ -1,17 +1,17 @@ import { InheritedColorConfig } from '@nivo/colors' import { - // @ts-ignore + // @ts-ignore -- internal function bindDefs, Container, SvgWrapper, useDimensions, } from '@nivo/core' -import { Fragment, ReactNode, createElement } from 'react' +import { Fragment, ReactNode, createElement, useMemo } from 'react' import { Rects } from './Rects' import { useIcicles, useIciclesLayerContext } from './hooks' import { RectLabelsLayer } from '@nivo/rects' import { defaultProps } from './props' -import { IciclesSvgProps, IciclesLayerId, IciclesComputedDatum } from './types' +import { IciclesSvgProps, IciclesLayerId, ComputedDatum } from './types' type InnerIciclesProps = Partial< Omit< @@ -30,7 +30,7 @@ const InnerIcicles = ({ colors = defaultProps.colors, colorBy = defaultProps.colorBy, inheritColorFromParent = defaultProps.inheritColorFromParent, - childColor = defaultProps.childColor as InheritedColorConfig>, + childColor = defaultProps.childColor as InheritedColorConfig>, borderWidth = defaultProps.borderWidth, borderColor = defaultProps.borderColor, margin: partialMargin, @@ -45,15 +45,20 @@ const InnerIcicles = ({ onMouseEnter, onMouseLeave, onMouseMove, + onWheel, + onContextMenu, tooltip = defaultProps.tooltip, role = defaultProps.role, rectLabel = defaultProps.rectLabel, rectLabelsComponent, + rectLabelsSkipLength = defaultProps.rectLabelsSkipLength, + rectLabelsSkipPercentage = defaultProps.rectLabelsSkipPercentage, direction = defaultProps.direction, + rectLabelsOffset = defaultProps.rectLabelsOffset, }: InnerIciclesProps) => { const { margin, outerHeight, outerWidth } = useDimensions(width, height, partialMargin) - const { nodes } = useIcicles({ + const { nodes, baseOffsetLeft, baseOffsetTop } = useIcicles({ data, id, value, @@ -62,8 +67,8 @@ const InnerIcicles = ({ colorBy, inheritColorFromParent, childColor, - height, - width, + height: outerHeight, + width: outerWidth, direction, }) @@ -91,24 +96,43 @@ const InnerIcicles = ({ onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onMouseMove={onMouseMove} + onWheel={onWheel} + onContextMenu={onContextMenu} /> ) } + const filteredData = useMemo( + () => + nodes.filter(datum => { + return ( + datum.rect.percentage >= rectLabelsSkipPercentage && + datum.rect[['left', 'right'].includes(direction) ? 'height' : 'width'] >= + rectLabelsSkipLength + ) + }), + [nodes, rectLabelsSkipPercentage, rectLabelsSkipLength, direction] + ) + if (enableRectLabels && layers.includes('rectLabels')) { layerById.rectLabels = ( - > + > key="rectLabels" - data={nodes} + data={filteredData} label={rectLabel} textColor={rectLabelsTextColor} component={rectLabelsComponent} + offset={rectLabelsOffset} + baseOffsetLeft={baseOffsetLeft} + baseOffsetTop={baseOffsetTop} /> ) } const layerContext = useIciclesLayerContext({ nodes, + baseOffsetLeft, + baseOffsetTop, }) return ( diff --git a/packages/icicles/src/IciclesTooltip.tsx b/packages/icicles/src/IciclesTooltip.tsx index 16b1dfecc..a22b2ea4f 100644 --- a/packages/icicles/src/IciclesTooltip.tsx +++ b/packages/icicles/src/IciclesTooltip.tsx @@ -1,10 +1,10 @@ import { BasicTooltip } from '@nivo/tooltip' -import { IciclesComputedDatum } from './types' +import { ComputedDatum } from './types' export const IciclesTooltip = ({ id, formattedValue, color, -}: IciclesComputedDatum) => ( +}: ComputedDatum) => ( ) diff --git a/packages/icicles/src/Rects.tsx b/packages/icicles/src/Rects.tsx index bdb647d85..1f1c03f54 100644 --- a/packages/icicles/src/Rects.tsx +++ b/packages/icicles/src/Rects.tsx @@ -2,17 +2,19 @@ import { useTooltip } from '@nivo/tooltip' import { createElement, useMemo } from 'react' import * as React from 'react' import { RectsLayer } from '@nivo/rects' -import { IciclesCommonProps, IciclesComputedDatum, IciclesMouseHandlers } from './types' +import { IciclesCommonProps, ComputedDatum, MouseHandlers } from './types' -interface RectsProps { +export interface RectsProps { borderColor: IciclesCommonProps['borderColor'] borderWidth: IciclesCommonProps['borderWidth'] - data: IciclesComputedDatum[] + data: ComputedDatum[] isInteractive: IciclesCommonProps['isInteractive'] - onClick?: IciclesMouseHandlers['onClick'] - onMouseEnter?: IciclesMouseHandlers['onMouseEnter'] - onMouseLeave?: IciclesMouseHandlers['onMouseLeave'] - onMouseMove?: IciclesMouseHandlers['onMouseMove'] + onClick?: MouseHandlers['onClick'] + onMouseEnter?: MouseHandlers['onMouseEnter'] + onMouseLeave?: MouseHandlers['onMouseLeave'] + onMouseMove?: MouseHandlers['onMouseMove'] + onWheel?: MouseHandlers['onWheel'] + onContextMenu?: MouseHandlers['onContextMenu'] tooltip: IciclesCommonProps['tooltip'] } @@ -25,6 +27,8 @@ export const Rects = ({ onMouseEnter, onMouseMove, onMouseLeave, + onWheel, + onContextMenu, tooltip, }: RectsProps) => { const { showTooltipFromEvent, hideTooltip } = useTooltip() @@ -32,7 +36,7 @@ export const Rects = ({ const handleClick = useMemo(() => { if (!isInteractive) return undefined - return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + return (datum: ComputedDatum, event: React.MouseEvent) => { onClick?.(datum, event) } }, [isInteractive, onClick]) @@ -40,7 +44,7 @@ export const Rects = ({ const handleMouseEnter = useMemo(() => { if (!isInteractive) return undefined - return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + return (datum: ComputedDatum, event: React.MouseEvent) => { showTooltipFromEvent(createElement(tooltip, datum), event) onMouseEnter?.(datum, event) } @@ -49,7 +53,7 @@ export const Rects = ({ const handleMouseMove = useMemo(() => { if (!isInteractive) return undefined - return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + return (datum: ComputedDatum, event: React.MouseEvent) => { showTooltipFromEvent(createElement(tooltip, datum), event) onMouseMove?.(datum, event) } @@ -58,14 +62,30 @@ export const Rects = ({ const handleMouseLeave = useMemo(() => { if (!isInteractive) return undefined - return (datum: IciclesComputedDatum, event: React.MouseEvent) => { + return (datum: ComputedDatum, event: React.MouseEvent) => { hideTooltip() onMouseLeave?.(datum, event) } }, [isInteractive, hideTooltip, onMouseLeave]) + const handleWheel = useMemo(() => { + if (!isInteractive) return undefined + + return (datum: ComputedDatum, event: React.WheelEvent) => { + onWheel?.(datum, event) + } + }, [isInteractive, onWheel]) + + const handleContextMenu = useMemo(() => { + if (!isInteractive) return undefined + + return (datum: ComputedDatum, event: React.MouseEvent) => { + onContextMenu?.(datum, event) + } + }, [isInteractive, onContextMenu]) + return ( - > + > data={data} borderWidth={borderWidth} borderColor={borderColor} @@ -73,6 +93,8 @@ export const Rects = ({ onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} + onWheel={handleWheel} + onContextMenu={handleContextMenu} /> ) } diff --git a/packages/icicles/src/hooks.ts b/packages/icicles/src/hooks.ts index d973a0e94..b7de16cae 100644 --- a/packages/icicles/src/hooks.ts +++ b/packages/icicles/src/hooks.ts @@ -14,24 +14,20 @@ import { DataProps, DatumId, IciclesCommonProps, - IciclesComputedDatum, + ComputedDatum, IciclesCustomLayerProps, } from './types' -const hierarchyRectUseX = (d: HierarchyRectangularNode) => - d.x1 - d.x0 - Math.min(1, (d.x1 - d.x0) / 2) - -const hierarchyRectUseY = (d: HierarchyRectangularNode) => - d.y1 - d.y0 - Math.min(1, (d.y1 - d.y0) / 2) +const computeLength = (a: number, b: number) => b - a - Math.min(1, (b - a) / 2) const widthHeight = (d: HierarchyRectangularNode) => ({ topBottom: () => ({ - height: hierarchyRectUseY(d), - width: hierarchyRectUseX(d), + height: computeLength(d.y0, d.y1), + width: computeLength(d.x0, d.x1), }), leftRight: () => ({ - height: hierarchyRectUseX(d), - width: hierarchyRectUseY(d), + height: computeLength(d.x0, d.x1), + width: computeLength(d.y0, d.y1), }), }) @@ -43,7 +39,7 @@ export const useIcicles = ({ colors = defaultProps.colors, colorBy = defaultProps.colorBy, inheritColorFromParent = defaultProps.inheritColorFromParent, - childColor = defaultProps.childColor as InheritedColorConfig>, + childColor = defaultProps.childColor as InheritedColorConfig>, width, height, direction, @@ -61,11 +57,11 @@ export const useIcicles = ({ width: IciclesCommonProps['width'] }) => { const theme = useTheme() - const getColor = useOrdinalColorScale, 'color' | 'fill'>>( + const getColor = useOrdinalColorScale, 'color' | 'fill'>>( colors, colorBy ) - const getChildColor = useInheritedColor>(childColor, theme) + const getChildColor = useInheritedColor>(childColor, theme) const isLeftRight = direction === 'left' || direction === 'right' @@ -73,8 +69,15 @@ export const useIcicles = ({ const getValue = usePropertyAccessor(value) const formatValue = useValueFormatter(valueFormat) - // https://observablehq.com/@d3/zoomable-icicle - const nodes: IciclesComputedDatum[] = useMemo(() => { + const { + nodes, + baseOffsetTop, + baseOffsetLeft, + }: { + baseOffsetLeft: number + baseOffsetTop: number + nodes: ComputedDatum[] + } = useMemo(() => { // d3 mutates the data for performance reasons, // however it does not work well with reactive programming, // this ensures that we don't mutate the input data @@ -102,62 +105,82 @@ export const useIcicles = ({ ...widthHeight(sortedNodes[0])[isLeftRight ? 'leftRight' : 'topBottom'](), } - return sortedNodes.reduce[]>((acc, descendant) => { - const id = getId(descendant.data) - // d3 hierarchy node value is optional by default as it depends on - // a call to `count()` or `sum()`, and we previously called `sum()`, - // d3 typings could be improved and make it non optional when calling - // one of those. + // we pre compute offsets relative from container + // and from root node. + // it will be used to later compute nodes and text positions + const baseOffsetLeft = direction === 'left' ? width : 0 + const baseOffsetTop = direction === 'top' ? height : 0 + const rectOffsetLeft = direction === 'left' ? baseOffsetLeft - rootRect.width : 0 + const rectOffsetTop = direction === 'top' ? baseOffsetTop - rootRect.height : 0 + + return { + baseOffsetLeft, + baseOffsetTop, + nodes: sortedNodes.reduce[]>((acc, descendant) => { + const id = getId(descendant.data) + // d3 hierarchy node value is optional by default as it depends on + // a call to `count()` or `sum()`, and we previously called `sum()`, + // d3 typings could be improved and make it non optional when calling + // one of those. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const value = descendant.value! - const percentage = (100 * value) / total - const path = descendant.ancestors()?.map(ancestor => getId(ancestor.data)) - - const transform = { - right: `translate(${descendant.y0}, ${descendant.x0})`, - left: `translate(${width - rootRect.width - descendant.y0}, ${descendant.x0})`, - top: `translate(${descendant.x0}, ${height - rootRect.height - descendant.y0})`, - bottom: `translate(${descendant.x0}, ${descendant.y0})`, - }[direction] - - const rect: Rect = { - ...widthHeight(descendant)[isLeftRight ? 'leftRight' : 'topBottom'](), - transform, - } - - let parent: IciclesComputedDatum | undefined - if (descendant.parent) { - // as the parent is defined by the input data, and we sorted the data - // by `depth`, we can safely assume it's defined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - parent = acc.find(node => node.id === getId(descendant.parent!.data)) - } - - const normalizedNode: IciclesComputedDatum = { - id, - path, - value, - percentage, - rect, - formattedValue: valueFormat ? formatValue(value) : `${percentage.toFixed(2)}%`, - color: '', - data: descendant.data, - depth: descendant.depth, - height: descendant.height, - transform, - } - - if (inheritColorFromParent && parent && normalizedNode.depth > 1) { - normalizedNode.color = getChildColor(parent, normalizedNode) - } else { - normalizedNode.color = getColor(normalizedNode) - } - - // normalizedNode.color = getColor(normalizedNode); - - return [...acc, normalizedNode] - }, []) + const value = descendant.value! + const percentage = (100 * value) / total + const path = descendant.ancestors()?.map(ancestor => getId(ancestor.data)) + + const descendantRect = + widthHeight(descendant)[isLeftRight ? 'leftRight' : 'topBottom']() + + // if we switch direction, we need to switch point values + const x0 = isLeftRight ? descendant.y0 : descendant.x0, + x1 = isLeftRight ? descendant.y1 : descendant.x1, + y0 = isLeftRight ? descendant.x0 : descendant.y0, + y1 = isLeftRight ? descendant.x1 : descendant.y1 + + const transformX = Math.abs(rectOffsetLeft - x0) + const transformY = Math.abs(rectOffsetTop - y0) + + const rect: Rect = { + ...descendantRect, + transformX, + transformY, + x0, + x1, + y0, + y1, + percentage, + } + + let parent: ComputedDatum | undefined + if (descendant.parent) { + // as the parent is defined by the input data, and we sorted the data + // by `depth`, we can safely assume it's defined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + parent = acc.find(node => node.id === getId(descendant.parent!.data)) + } + + const normalizedNode: ComputedDatum = { + id, + path, + value, + percentage, + rect, + formattedValue: valueFormat ? formatValue(value) : `${percentage.toFixed(2)}%`, + color: '', + data: descendant.data, + depth: descendant.depth, + height: descendant.height, + } + + if (inheritColorFromParent && parent && normalizedNode.depth > 1) { + normalizedNode.color = getChildColor(parent, normalizedNode) + } else { + normalizedNode.color = getColor(normalizedNode) + } + + return [...acc, normalizedNode] + }, []), + } }, [ data, getValue, @@ -173,7 +196,7 @@ export const useIcicles = ({ isLeftRight, ]) - return { nodes } + return { nodes, baseOffsetLeft, baseOffsetTop } } /** @@ -181,10 +204,14 @@ export const useIcicles = ({ */ export const useIciclesLayerContext = ({ nodes, + baseOffsetLeft, + baseOffsetTop, }: IciclesCustomLayerProps): IciclesCustomLayerProps => useMemo( () => ({ nodes, + baseOffsetLeft, + baseOffsetTop, }), - [nodes] + [nodes, baseOffsetLeft, baseOffsetTop] ) diff --git a/packages/icicles/src/props.ts b/packages/icicles/src/props.ts index 90b6991f3..fd847949f 100644 --- a/packages/icicles/src/props.ts +++ b/packages/icicles/src/props.ts @@ -1,17 +1,16 @@ -import { OrdinalColorScaleConfig } from '@nivo/colors' import { IciclesTooltip } from './IciclesTooltip' -import { IciclesDirection, IciclesLayerId } from './types' +import { IciclesSvgProps } from './types' -export const defaultProps = { +const _defaultProps: Partial> = { id: 'id', value: 'value', - layers: ['rect', 'rectLabels'] as IciclesLayerId[], - colors: { scheme: 'nivo' } as unknown as OrdinalColorScaleConfig, - colorBy: 'id' as const, + layers: ['rects', 'rectLabels'], + colors: { scheme: 'nivo' }, + colorBy: 'id', inheritColorFromParent: true, childColor: { from: 'color' }, borderWidth: 1, - borderColor: 'white', + borderColor: { from: 'color', modifiers: [['darker', 0.6]] }, enableRectLabels: false, rectLabel: 'formattedValue', rectLabelsTextColor: { theme: 'labels.text.fill' }, @@ -22,5 +21,10 @@ export const defaultProps = { fill: [], tooltip: IciclesTooltip, role: 'img', - direction: 'bottom' as IciclesDirection, + direction: 'bottom', + rectLabelsSkipLength: 0, + rectLabelsSkipPercentage: 0, + rectLabelsOffset: 1, } + +export const defaultProps = _defaultProps as IciclesSvgProps diff --git a/packages/icicles/src/types.ts b/packages/icicles/src/types.ts index 6d825d073..165615a0c 100644 --- a/packages/icicles/src/types.ts +++ b/packages/icicles/src/types.ts @@ -14,7 +14,9 @@ export type DatumId = string | number export type IciclesLayerId = 'rects' | 'rectLabels' export interface IciclesCustomLayerProps { - nodes: IciclesComputedDatum[] + nodes: ComputedDatum[] + baseOffsetLeft: number + baseOffsetTop: number } export type IciclesCustomLayer = React.FC> @@ -32,7 +34,7 @@ export interface ChildrenDatum { children?: Array> } -export interface IciclesComputedDatum { +export interface ComputedDatum { color: string // contains the raw node's data data: RawDatum @@ -42,12 +44,11 @@ export interface IciclesComputedDatum { formattedValue: string height: number id: DatumId - parent?: IciclesComputedDatum + parent?: ComputedDatum // contain own id plus all ancestor ids path: DatumId[] percentage: number rect: Rect - transform: string value: number } @@ -55,12 +56,12 @@ export type IciclesDirection = 'top' | 'right' | 'bottom' | 'left' export type IciclesCommonProps = { animate: boolean - borderColor: InheritedColorConfig> + borderColor: InheritedColorConfig> borderWidth: number // used if `inheritColorFromParent` is `true` - childColor: InheritedColorConfig> + childColor: InheritedColorConfig> colorBy: 'id' | 'depth' - colors: OrdinalColorScaleConfig, 'color' | 'fill'>> + colors: OrdinalColorScaleConfig, 'color' | 'fill'>> data: RawDatum direction: IciclesDirection enableRectLabels: boolean @@ -71,28 +72,38 @@ export type IciclesCommonProps = { layers: IciclesLayer[] margin?: Box motionConfig: ModernMotionProps['motionConfig'] + rectLabelsOffset: number + rectLabelsSkipLength: number + rectLabelsSkipPercentage: number rectLabelsTextColor: InheritedColorConfig renderWrapper: boolean role: string theme: Theme - tooltip: (props: IciclesComputedDatum) => JSX.Element + tooltip: (props: ComputedDatum) => JSX.Element value: PropertyAccessor valueFormat?: ValueFormat width: number -} & RectLabelsProps> +} & RectLabelsProps> -export type IciclesMouseHandler = ( - datum: IciclesComputedDatum, +export type MouseHandler = ( + datum: ComputedDatum, event: React.MouseEvent ) => void -export type IciclesMouseHandlers = Partial<{ - onClick: IciclesMouseHandler - onMouseEnter: IciclesMouseHandler - onMouseLeave: IciclesMouseHandler - onMouseMove: IciclesMouseHandler +export type WheelHandler = ( + datum: ComputedDatum, + event: React.WheelEvent +) => void + +export type MouseHandlers = Partial<{ + onClick: MouseHandler + onMouseEnter: MouseHandler + onMouseLeave: MouseHandler + onMouseMove: MouseHandler + onWheel: WheelHandler + onContextMenu: MouseHandler }> export type IciclesSvgProps = IciclesCommonProps & SvgDefsAndFill & - IciclesMouseHandlers + MouseHandlers diff --git a/packages/icicles/stories/icicles.stories.tsx b/packages/icicles/stories/icicles.stories.tsx index 226f47f0b..65b530197 100644 --- a/packages/icicles/stories/icicles.stories.tsx +++ b/packages/icicles/stories/icicles.stories.tsx @@ -10,7 +10,7 @@ import { linearGradientDef, patternDotsDef, useTheme } from '@nivo/core' import { generateLibTree } from '@nivo/generators' import { colorSchemes } from '@nivo/colors' // @ts-ignore -import { Icicles, IciclesComputedDatum } from '../src' +import { Icicles, IciclesComputedDatum, IciclesDirection } from '../src' interface RawDatum { name: string @@ -94,6 +94,14 @@ stories.add('enter/leave (check actions)', () => ( /> )) +stories.add('wheel/contextmenu (check actions)', () => ( + + {...commonProperties} + onWheel={action('onWheel')} + onContextMenu={action('onContextMenu')} + /> +)) + stories.add('patterns & gradients', () => ( {...commonProperties} @@ -208,3 +216,20 @@ stories.add( }, } ) + +stories.add('change direction', () => ( + + {...commonProperties} + direction={select( + 'direction', + ['top', 'right', 'bottom', 'left'], + 'bottom' + )} + animate={boolean('animate', true)} + motionConfig={select( + 'motion config', + ['default', 'gentle', 'wobbly', 'stiff', 'slow', 'molasses'], + 'gentle' + )} + /> +)) diff --git a/packages/rects/src/RectShape.tsx b/packages/rects/src/RectShape.tsx index f7310a617..0ff272230 100644 --- a/packages/rects/src/RectShape.tsx +++ b/packages/rects/src/RectShape.tsx @@ -1,10 +1,15 @@ -import { SpringValue, animated } from '@react-spring/web' -import { MouseEvent, useCallback } from 'react' +import { SpringValue, animated, Interpolation } from '@react-spring/web' +import { MouseEvent, useCallback, WheelEvent } from 'react' import { DatumWithRectAndColor } from './types' export type RectMouseHandler = ( datum: TDatum, - event: MouseEvent + event: MouseEvent +) => void + +export type RectWheelHandler = ( + datum: TDatum, + event: WheelEvent ) => void export interface RectShapeProps { @@ -13,19 +18,25 @@ export interface RectShapeProps { onMouseEnter?: RectMouseHandler onMouseLeave?: RectMouseHandler onMouseMove?: RectMouseHandler + onWheel?: RectWheelHandler + onContextMenu?: RectMouseHandler style: { borderColor: SpringValue borderWidth: number color: SpringValue height: number - // height: SpringValue; opacity: SpringValue - transform: string + transform: Interpolation width: number - // width: SpringValue; } } +/** + * A simple rect component to be used typically with an `RectsLayer`. + * + * Please note that the component accepts `SpringValue`s instead of + * regular values to support animations. + */ export const RectShape = ({ datum, style, @@ -33,6 +44,8 @@ export const RectShape = ({ onMouseEnter, onMouseLeave, onMouseMove, + onWheel, + onContextMenu, }: RectShapeProps) => { const handleClick = useCallback(event => onClick?.(datum, event), [onClick, datum]) @@ -48,6 +61,13 @@ export const RectShape = ({ [onMouseLeave, datum] ) + const handleWheel = useCallback(event => onWheel?.(datum, event), [onWheel, datum]) + + const handleContextMenu = useCallback( + event => onContextMenu?.(datum, event), + [onContextMenu, datum] + ) + return ( ({ onMouseEnter={onMouseEnter ? handleMouseEnter : undefined} onMouseMove={onMouseMove ? handleMouseMove : undefined} onMouseLeave={onMouseLeave ? handleMouseLeave : undefined} + onWheel={onWheel ? handleWheel : undefined} + onContextMenu={onContextMenu ? handleContextMenu : undefined} width={style.width} height={style.height} /> diff --git a/packages/rects/src/RectsLayer.tsx b/packages/rects/src/RectsLayer.tsx index 97b8da645..3fc2c1d23 100644 --- a/packages/rects/src/RectsLayer.tsx +++ b/packages/rects/src/RectsLayer.tsx @@ -1,6 +1,7 @@ -import { InheritedColorConfig } from '@nivo/colors' +import { InheritedColorConfig, useInheritedColor } from '@nivo/colors' +import { useTheme } from '@nivo/core' import { createElement } from 'react' -import { RectMouseHandler, RectShape, RectShapeProps } from './RectShape' +import { RectMouseHandler, RectShape, RectShapeProps, RectWheelHandler } from './RectShape' import { DatumWithRectAndColor } from './types' import { useRectsTransition } from './useRectsTransition' @@ -8,7 +9,7 @@ export type RectComponent = ( props: RectShapeProps ) => JSX.Element -interface RectsLayerProps { +export interface RectsLayerProps { borderColor: InheritedColorConfig borderWidth: number component?: RectComponent @@ -17,42 +18,47 @@ interface RectsLayerProps { onMouseEnter?: RectMouseHandler onMouseLeave?: RectMouseHandler onMouseMove?: RectMouseHandler + onWheel?: RectWheelHandler + onContextMenu?: RectMouseHandler } export const RectsLayer = ({ - // borderColor, onMouseMove, onMouseLeave, onMouseEnter, onClick, + onWheel, + onContextMenu, borderWidth, data, + borderColor, component = RectShape, }: RectsLayerProps) => { - // const theme = useTheme(); - // const getBorderColor = useInheritedColor(borderColor, theme); + const theme = useTheme() + const getBorderColor = useInheritedColor(borderColor, theme) - const { transition } = useRectsTransition< + const { transition, interpolate } = useRectsTransition< TDatum, - { borderColor: string; color: string; opacity: number } + { + borderColor: string + color: string + opacity: number + } >(data, { enter: datum => ({ opacity: 0, color: datum.color, - // borderColor: getBorderColor(datum), - borderColor: '#ccc', + borderColor: getBorderColor(datum), }), update: datum => ({ opacity: 1, color: datum.color, - // borderColor: getBorderColor(datum), - borderColor: '#ccc', + borderColor: getBorderColor(datum), }), leave: datum => ({ opacity: 0, color: datum.color, - // borderColor: getBorderColor(datum), - borderColor: '#ccc', + borderColor: getBorderColor(datum), }), }) @@ -67,7 +73,10 @@ export const RectsLayer = ({ style: { ...transitionProps, borderWidth, - transform: datum.rect.transform, + transform: interpolate( + transitionProps.transformX, + transitionProps.transformY + ), width: datum.rect.width, height: datum.rect.height, }, @@ -75,6 +84,8 @@ export const RectsLayer = ({ onMouseEnter, onMouseMove, onMouseLeave, + onWheel, + onContextMenu, }) })} diff --git a/packages/rects/src/centers.ts b/packages/rects/src/centers.ts new file mode 100644 index 000000000..604b8d1b2 --- /dev/null +++ b/packages/rects/src/centers.ts @@ -0,0 +1,57 @@ +import { useMotionConfig } from '@nivo/core' +import { SpringValue, to, useTransition } from '@react-spring/web' +import { DatumWithRect } from './types' +import { TransitionExtra, useRectExtraTransition } from './useRectsTransition' + +export const interpolateRectCenter = + (offset: number, baseOffsetLeft: number, baseOffsetTop: number) => + ( + x0Value: SpringValue, + y0Value: SpringValue, + widthValue: SpringValue, + heightValue: SpringValue + ) => + to( + [x0Value, y0Value, widthValue, heightValue], + (x0, y0, width, height) => + `translate(${Math.abs(baseOffsetLeft - (x0 + width / 2) * offset)}, ${Math.abs( + baseOffsetTop - (y0 + height / 2) * offset + )})` + ) + +export const useRectCentersTransition = ( + data: TDatum[], + offset = 1, + baseOffsetLeft = 0, + baseOffsetTop = 0, + extra?: TransitionExtra +) => { + const { animate, config: springConfig } = useMotionConfig() + + const phases = useRectExtraTransition(extra) + + const transition = useTransition< + TDatum, + { + height: number + progress: number + width: number + x0: number + y0: number + } & TExtraProps + >(data, { + keys: datum => datum.id, + initial: phases.update, + from: phases.enter, + enter: phases.update, + update: phases.update, + leave: phases.leave, + config: springConfig, + immediate: !animate, + }) + + return { + transition, + interpolate: interpolateRectCenter(offset, baseOffsetLeft, baseOffsetTop), + } +} diff --git a/packages/rects/src/index.ts b/packages/rects/src/index.ts index b4991c2cf..fcd952634 100644 --- a/packages/rects/src/index.ts +++ b/packages/rects/src/index.ts @@ -3,3 +3,4 @@ export * from './RectShape' export * from './RectsLayer' export * from './types' export * from './useRectsTransition' +export * from './centers' diff --git a/packages/rects/src/rect_labels/RectLabel.tsx b/packages/rects/src/rect_labels/RectLabel.tsx index 2427c11b6..252007ed0 100644 --- a/packages/rects/src/rect_labels/RectLabel.tsx +++ b/packages/rects/src/rect_labels/RectLabel.tsx @@ -1,5 +1,5 @@ import { useTheme } from '@nivo/core' -import { animated, SpringValue } from '@react-spring/web' +import { animated, Interpolation, SpringValue } from '@react-spring/web' import { CSSProperties } from 'react' import { DatumWithRectAndColor } from '../types' @@ -13,6 +13,7 @@ export interface RectLabelProps { style: { progress: SpringValue textColor: string + transform: Interpolation } } @@ -23,7 +24,7 @@ export const RectLabel = ({ const theme = useTheme() return ( - + = ( props: RectLabelProps ) => JSX.Element -interface RectLabelsLayerProps { +export interface RectLabelsLayerProps { + baseOffsetLeft: number + baseOffsetTop: number component?: RectLabelsProps['rectLabelsComponent'] data: TDatum[] label: PropertyAccessor + offset: RectLabelsProps['rectLabelsOffset'] textColor: RectLabelsProps['rectLabelsTextColor'] } @@ -21,15 +24,21 @@ export const RectLabelsLayer = ({ data, label: labelAccessor, textColor, + offset, + baseOffsetLeft, + baseOffsetTop, component = RectLabel, }: RectLabelsLayerProps) => { const getLabel = usePropertyAccessor(labelAccessor) const theme = useTheme() const getTextColor = useInheritedColor(textColor, theme) - // const filteredData = useMemo(() => {}, []) - - const { transition } = useRectsTransition(data) + const { transition, interpolate } = useRectCentersTransition( + data, + offset, + baseOffsetLeft, + baseOffsetTop + ) const Label: RectLabelComponent = component @@ -43,6 +52,12 @@ export const RectLabelsLayer = ({ style: { ...transitionProps, textColor: getTextColor(datum), + transform: interpolate( + transitionProps.x0, + transitionProps.y0, + transitionProps.width, + transitionProps.height + ), }, }) })} diff --git a/packages/rects/src/rect_labels/props.ts b/packages/rects/src/rect_labels/props.ts index 20f8bfd45..bf592c985 100644 --- a/packages/rects/src/rect_labels/props.ts +++ b/packages/rects/src/rect_labels/props.ts @@ -6,5 +6,8 @@ import { RectLabelComponent } from './RectLabelsLayer' export interface RectLabelsProps { rectLabel: PropertyAccessor rectLabelsComponent: RectLabelComponent + rectLabelsOffset: number + rectLabelsSkipLength: number + rectLabelsSkipPercentage: number rectLabelsTextColor: InheritedColorConfig } diff --git a/packages/rects/src/types.ts b/packages/rects/src/types.ts index 69933d0ef..d6506d814 100644 --- a/packages/rects/src/types.ts +++ b/packages/rects/src/types.ts @@ -1,12 +1,14 @@ -export interface Point { - x: number - y: number -} - export interface Rect { height: number - transform: string + /** size in percentage from the root node */ + percentage: number + transformX: number + transformY: number width: number + x0: number + x1: number + y0: number + y1: number } export interface DatumWithRect { @@ -16,5 +18,6 @@ export interface DatumWithRect { export interface DatumWithRectAndColor extends DatumWithRect { color: string + /** When using patterns/gradients */ fill?: string } diff --git a/packages/rects/src/useRectsTransition.ts b/packages/rects/src/useRectsTransition.ts index adf927164..0c6f6401a 100644 --- a/packages/rects/src/useRectsTransition.ts +++ b/packages/rects/src/useRectsTransition.ts @@ -1,5 +1,5 @@ import { useMotionConfig } from '@nivo/core' -import { useTransition } from '@react-spring/web' +import { SpringValue, to, useTransition } from '@react-spring/web' import { useMemo } from 'react' import { DatumWithRect, DatumWithRectAndColor } from './types' @@ -8,27 +8,42 @@ export interface TransitionExtra { leave: (datum: TDatum) => ExtraProps update: (datum: TDatum) => ExtraProps } -const useRectExtraTransition = ( + +// TODO: mode ? +export const useRectExtraTransition = ( extraTransition?: TransitionExtra ) => useMemo( () => ({ enter: (datum: TDatum) => ({ progress: 0, + ...datum.rect, ...(extraTransition?.enter(datum) ?? {}), }), update: (datum: TDatum) => ({ progress: 1, + ...datum.rect, ...(extraTransition?.update(datum) ?? {}), }), leave: (datum: TDatum) => ({ progress: 0, + ...datum.rect, ...(extraTransition?.leave(datum) ?? {}), }), }), [extraTransition] ) +export const interpolateRect = ( + transformXValue: SpringValue, + transformYValue: SpringValue +) => to([transformXValue, transformYValue], (x, y) => `translate(${x}, ${y})`) + +/** + * This hook can be used to animate a group of rects. + * + * @todo `useAnimatedRect` for single rect animation + */ export const useRectsTransition = ( data: TDatum[], extra?: TransitionExtra @@ -37,15 +52,32 @@ export const useRectsTransition = (extra) - const transition = useTransition(data, { + const transition = useTransition< + TDatum, + { + height: number + progress: number + transformX: number + transformY: number + width: number + x0: number + x1: number + y0: number + y1: number + } & ExtraProps + >(data, { keys: datum => datum.id, initial: phases.update, from: phases.enter, enter: phases.update, + update: phases.update, leave: phases.leave, config: springConfig, immediate: !animate, }) - return { transition } + return { + transition, + interpolate: interpolateRect, + } } From 5708279292adc19f9e13c49a5d3ab70df4999376 Mon Sep 17 00:00:00 2001 From: Lilian Saget-Lethias Date: Fri, 22 Apr 2022 20:18:52 +0200 Subject: [PATCH 3/3] feat(website): complete icicles page --- packages/rects/src/centers.ts | 2 +- packages/static/src/mappings/icicles.ts | 3 + website/src/data/components/icicles/meta.yml | 4 + website/src/data/components/icicles/props.ts | 115 ++++++++++++++++++- website/src/pages/icicles/api.tsx | 8 +- website/src/pages/icicles/index.js | 4 + 6 files changed, 131 insertions(+), 5 deletions(-) diff --git a/packages/rects/src/centers.ts b/packages/rects/src/centers.ts index 604b8d1b2..c1eac8fd7 100644 --- a/packages/rects/src/centers.ts +++ b/packages/rects/src/centers.ts @@ -14,7 +14,7 @@ export const interpolateRectCenter = to( [x0Value, y0Value, widthValue, heightValue], (x0, y0, width, height) => - `translate(${Math.abs(baseOffsetLeft - (x0 + width / 2) * offset)}, ${Math.abs( + `translate(${Math.abs(baseOffsetLeft - (x0 + width / 2))}, ${Math.abs( baseOffsetTop - (y0 + height / 2) * offset )})` ) diff --git a/packages/static/src/mappings/icicles.ts b/packages/static/src/mappings/icicles.ts index aa2544896..5a702b1a5 100644 --- a/packages/static/src/mappings/icicles.ts +++ b/packages/static/src/mappings/icicles.ts @@ -33,6 +33,9 @@ export const iciclesMapping = { enableRectLabels: Joi.boolean(), rectLabel: Joi.string(), rectLabelsTextColor: inheritedColor, + rectLabelsOffset: Joi.number(), + rectLabelsSkipLength: Joi.number().min(0), + rectLabelsSkipPercentage: Joi.number().min(0).max(100), }), runtimeProps: ['width', 'height', 'colors'], defaults: { diff --git a/website/src/data/components/icicles/meta.yml b/website/src/data/components/icicles/meta.yml index 3f6fb8c45..32866f6b4 100644 --- a/website/src/data/components/icicles/meta.yml +++ b/website/src/data/components/icicles/meta.yml @@ -22,10 +22,14 @@ Icicles: link: icicles--custom-tooltip - label: with enter and leave actions link: icicles--enter-leave-check-actions + - label: with wheel and contextmenu actions + link: icicles--wheel-contextmenu-check-actions - label: with patterns and gradients link: icicles--patterns-gradients - label: drill down to children link: icicles--children-drill-down + - label: with different direction + link: icicles--change-direction description: | The responsive alternative of this component is `ResponsiveIcicles`. diff --git a/website/src/data/components/icicles/props.ts b/website/src/data/components/icicles/props.ts index 9b5bf8167..1c6bded68 100644 --- a/website/src/data/components/icicles/props.ts +++ b/website/src/data/components/icicles/props.ts @@ -122,7 +122,7 @@ const props: ChartProperty[] = [ }), { key: 'colorBy', - help: `Define the property to use to assign a color to arcs.`, + help: `Define the property to use to assign a color to rects.`, flavors: allFlavors, description: ` When using \`id\`, each node will get a new color, @@ -172,7 +172,7 @@ const props: ChartProperty[] = [ }, { key: 'borderColor', - help: 'Defines how to compute arcs color.', + help: 'Defines how to compute rects border color.', flavors: allFlavors, type: 'string | object | Function', required: false, @@ -224,6 +224,63 @@ const props: ChartProperty[] = [ ), }, }, + { + key: 'rectLabelsOffset', + help: ` + Define the ratio offset when centering a label. + The offset affects the vertical postion. + `, + flavors: allFlavors, + type: 'number', + required: false, + defaultValue: defaultProps.rectLabelsOffset, + group: 'Rect labels', + control: { + type: 'range', + min: 0.5, + max: 2, + step: 0.05, + }, + }, + { + key: 'rectLabelsSkipLength', + help: ` + Skip label if corresponding rect's length is lower than provided value. + "Length" is determined by width when direction is top or bottom, + and by height when direction is left or right. + `, + flavors: allFlavors, + type: 'number', + required: false, + defaultValue: defaultProps.rectLabelsSkipLength, + group: 'Rect labels', + control: { + type: 'range', + unit: 'px', + min: 0, + max: 900, + step: 1, + }, + }, + { + key: 'rectLabelsSkipPercentage', + help: ` + Skip label if corresponding rect's relative size is lower than provided value. + The size is relative to the root node considered as 100%. + This value is a percentage. + `, + flavors: allFlavors, + type: 'number', + required: false, + defaultValue: defaultProps.rectLabelsSkipPercentage, + group: 'Rect labels', + control: { + type: 'range', + min: 0, + max: 100, + step: 1, + }, + }, { key: 'rectLabelsTextColor', help: 'Defines how to compute rect label text color.', @@ -252,6 +309,8 @@ const props: ChartProperty[] = [ \`\`\` { nodes: ComputedDatum[] + baseOffsetLeft: number + baseOffsetTop: number } \`\`\` `, @@ -312,6 +371,58 @@ const props: ChartProperty[] = [ onClick handler, will receive node data as first argument & event as second one. The node data has the following shape: + \`\`\` + { + id: string | number, + value: number, + depth: number, + color: string, + name: string + loc: number + percentage: number + // the parent datum + ancestor: object + } + \`\`\` + `, + }, + { + key: 'onWheel', + flavors: ['svg'], + group: 'Interactivity', + type: 'Function', + required: false, + help: 'onWheel handler', + description: ` + onWheel handler, will receive node data as first argument + & event as second one. The node data has the following shape: + + \`\`\` + { + id: string | number, + value: number, + depth: number, + color: string, + name: string + loc: number + percentage: number + // the parent datum + ancestor: object + } + \`\`\` + `, + }, + { + key: 'onContextMenu', + flavors: ['svg'], + group: 'Interactivity', + type: 'Function', + required: false, + help: 'onContextMenu handler', + description: ` + onContextMenu handler, will receive node data as first argument + & event as second one. The node data has the following shape: + \`\`\` { id: string | number, diff --git a/website/src/pages/icicles/api.tsx b/website/src/pages/icicles/api.tsx index 4ea69edfa..6b917b2de 100644 --- a/website/src/pages/icicles/api.tsx +++ b/website/src/pages/icicles/api.tsx @@ -14,9 +14,10 @@ const IciclesApi = () => { image: { childImageSharp: { gatsbyImageData: image }, }, + // TODO: change with icicles capture } = useStaticQuery(graphql` query { - image: file(absolutePath: { glob: "**/src/assets/captures/icicles.png" }) { + image: file(absolutePath: { glob: "**/src/assets/captures/sunburst.png" }) { childImageSharp { gatsbyImageData(layout: FIXED, width: 700, quality: 100) } @@ -55,7 +56,7 @@ const IciclesApi = () => { valueFormat: { format: '', enabled: false }, cornerRadius: 2, borderWidth: 1, - borderColor: 'white', + borderColor: '#fff', colors: { scheme: 'nivo' }, colorBy: 'id', inheritColorFromParent: true, @@ -68,6 +69,9 @@ const IciclesApi = () => { from: 'color', modifiers: [['darker', 1.4]], }, + rectLabelsSkipLength: 0, + rectLabelsSkipPercentage: 0, + rectLabelsOffset: 1, }} /> diff --git a/website/src/pages/icicles/index.js b/website/src/pages/icicles/index.js index c06f81648..d15f91f56 100644 --- a/website/src/pages/icicles/index.js +++ b/website/src/pages/icicles/index.js @@ -48,6 +48,9 @@ const initialProperties = { tooltip: null, 'showcase pattern usage': false, direction: 'bottom', + rectLabelsSkipLength: defaultProps.rectLabelsSkipLength, + rectLabelsSkipPercentage: defaultProps.rectLabelsSkipPercentage, + rectLabelsOffset: defaultProps.rectLabelsOffset, } const Icicles = () => { @@ -55,6 +58,7 @@ const Icicles = () => { image: { childImageSharp: { gatsbyImageData: image }, }, + // TODO: change with icicles capture } = useStaticQuery(graphql` query { image: file(absolutePath: { glob: "**/src/assets/captures/sunburst.png" }) {