diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b213e92a..706733437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,7 +54,7 @@ jobs: - name: Build nivo packages run: make pkgs-build - name: Cypress run - uses: cypress-io/github-action@v5 + uses: cypress-io/github-action@v6 with: install: false component: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee4ab4f04..07b4666a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,11 +28,12 @@ the various packages, please execute the following: make init ``` -> please note that it will take a while as this project uses a lot of dependencies…' +> please note that it will take a while as this project uses a lot of dependencies… ### Windows -If you want to build this project on Windows, it is recommended to use either WSL 2, or Git bash + `choco install make`. +If you want to build this project on Windows, it is recommended to use either WSL 2, +or Git bash + `choco install make`. ## Development @@ -77,6 +78,13 @@ You can also build the packages without running a watcher, you have two options: ### Testing +#### Unit tests + +Unit tests for each package are located in the `packages//tests` folder, we're using jest +as a test running and `react-test-renderer` as a testing library, some tests are still using +`enzyme`, but this lib is not maintained anymore and doesn't support newer versions of React, +so those should eventually be migrated. + To run unit tests on all packages, run the following command: ``` @@ -92,6 +100,14 @@ make pkg-test-bar where `bar` is the name of the targeted nivo package. +#### End-to-end tests + +Sometimes it's difficult to test certain things in unit tests, from our experience, animations +and interactions can be tricky to test via unit tests only, so we also try to have end-to-end tests. + +We're using `cypress` for writing such tests, and those are located in the `cypress/src/components` +folder, as we introduced end-to-end tests later, not all packages have some. + ### Formatting Nivo uses prettier in order to provide a consistent code style. diff --git a/Makefile b/Makefile index cd1ed93c1..7a323a8cc 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,8 @@ fmt: ##@0 global format code using prettier (js, css, md) "storybook/.storybook/*.{js,ts,tsx}" \ "storybook/stories/**/*.{js,ts,tsx}" \ "cypress/src/**/*.{js,ts,tsx}" \ + "scripts/*.{js,mjs}" \ + "cypress/src/**/*.{js,tsx}" \ "README.md" fmt-check: ##@0 global check if files were all formatted using prettier @@ -74,6 +76,8 @@ fmt-check: ##@0 global check if files were all formatted using prettier "storybook/.storybook/*.{js,ts,tsx}" \ "storybook/stories/**/*.{js,ts,tsx}" \ "cypress/src/**/*.{js,ts,tsx}" \ + "scripts/*.{js,mjs}" \ + "cypress/src/**/*.{js,tsx}" \ "README.md" test: ##@0 global run all checks/tests (packages, website) @@ -203,11 +207,22 @@ pkgs-publish-next: ##@1 packages publish all packages for @next npm tag @echo "${YELLOW}Publishing packages${RESET}" @pnpm lerna publish --exact --npm-tag=next -pkg-dev-%: ##@1 packages build package (es flavor) on change, eg. `package-watch-bar` +pkg-dev-%: ##@1 packages build package (es flavor) on change, eg. `pkg-dev-bar` @echo "${YELLOW}Running build watcher for package ${WHITE}@nivo/${*}${RESET}" @rm -rf ./packages/${*}/cjs @export PACKAGE=${*}; NODE_ENV=development BABEL_ENV=development ./node_modules/.bin/rollup -c conf/rollup.config.mjs -w +pkg-icons-%: ##@1 capture packages icons for the website, eg. `pkg-icons-bar` + ./scripts/capture.mjs icons --pkg ${*} + @$(MAKE) website-sprites + +pkg-previews-%: ##@1 capture packages previews for readmes, eg. `pkg-previews-bar` + ./scripts/capture.mjs charts --pkg ${*} + +pkg-capture-%: ##@1 capture packages previews and icons, eg. `pkg-capture-bar` + ./scripts/capture.mjs all --pkg ${*} + @$(MAKE) website-sprites + ######################################################################################################################## # # 2. WEBSITE diff --git a/api/package.json b/api/package.json index 2bbe8b603..e350edf01 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ }, "engineStrict": true, "engines": { - "node": ">=16" + "node": ">=18" }, "devDependencies": { "nodemon": "^2.0.22" @@ -39,7 +39,7 @@ "winston": "3.3.3" }, "scripts": { - "start": "yarn build && node app.js", + "start": "npm run build && node app.js", "dev": "nodemon app.ts", "build": "tsc" }, diff --git a/conf/base.yaml b/conf/base.yaml index 39581a5ee..34b07f3e9 100644 --- a/conf/base.yaml +++ b/conf/base.yaml @@ -1,4 +1,4 @@ -baseUrl: http://localhost:9000 +baseUrl: http://localhost:8000 capture: pages: - id: home @@ -57,6 +57,10 @@ capture: chart: bar flavors: [svg, canvas] + - pkg: bullet + chart: bullet + flavors: [ svg ] + - pkg: calendar chart: calendar flavors: [svg] @@ -71,10 +75,6 @@ capture: # html is broken for now # flavors: [svg, html, canvas] - - pkg: bullet - chart: bullet - flavors: [svg] - - pkg: heatmap chart: heatmap flavors: [svg, canvas] @@ -121,6 +121,11 @@ capture: chart: sunburst flavors: [svg] + - pkg: tree + chart: tree + theme: dark + flavors: [svg] + - pkg: treemap chart: treemap flavors: [svg, html, canvas] diff --git a/cypress/cypress.config.ts b/cypress/cypress.config.ts index 2a0d40c7e..f90b5622f 100644 --- a/cypress/cypress.config.ts +++ b/cypress/cypress.config.ts @@ -1,11 +1,13 @@ -import { defineConfig } from 'cypress' +import { defineConfig } from "cypress"; export default defineConfig({ + viewportWidth: 600, + viewportHeight: 600, component: { devServer: { - framework: 'create-react-app', - bundler: 'webpack', + framework: "create-react-app", + bundler: "webpack", }, video: false, }, -}) +}); diff --git a/cypress/package.json b/cypress/package.json index 8abc4343d..72fff2fdc 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -33,6 +33,7 @@ "@nivo/stream": "workspace:*", "@nivo/sunburst": "workspace:*", "@nivo/swarmplot": "workspace:*", + "@nivo/tree": "workspace:*", "@nivo/treemap": "workspace:*", "@nivo/voronoi": "workspace:*", "@nivo/waffle": "workspace:*" @@ -45,9 +46,9 @@ "node": ">=18" }, "devDependencies": { - "cypress": "^12.11.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "cypress": "^13.8.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-scripts": "^5.0.1", "typescript": "^4.9.5" }, diff --git a/cypress/src/components/tree/Tree.cy.tsx b/cypress/src/components/tree/Tree.cy.tsx new file mode 100644 index 000000000..cbb58f8ba --- /dev/null +++ b/cypress/src/components/tree/Tree.cy.tsx @@ -0,0 +1,174 @@ +import { Tree, TreeSvgProps } from '@nivo/tree' +import { before } from 'lodash' + +interface Datum { + id: string + children?: Datum[] +} + +const sampleData: Datum = { + id: 'A', + children: [ + { id: '0' }, + { + id: '1', + children: [{ id: 'A' }, { id: 'B' }], + }, + { id: '2' }, + ], +} + +const defaultProps: Pick< + TreeSvgProps, + | 'data' + | 'width' + | 'height' + | 'margin' + | 'nodeSize' + | 'activeNodeSize' + | 'inactiveNodeSize' + | 'linkThickness' + | 'activeLinkThickness' + | 'inactiveLinkThickness' + | 'animate' +> = { + data: sampleData, + width: 640, + height: 640, + margin: { + top: 20, + right: 20, + bottom: 20, + left: 20, + }, + nodeSize: 12, + activeNodeSize: 24, + inactiveNodeSize: 8, + linkThickness: 2, + activeLinkThickness: 12, + inactiveLinkThickness: 1, + animate: false, +} + +describe('', () => { + beforeEach(() => { + cy.viewport( + defaultProps.margin.left + defaultProps.width + defaultProps.margin.right, + defaultProps.margin.top + defaultProps.height + defaultProps.margin.bottom + ) + }) + + it('should render a tree graph', () => { + cy.mount( {...defaultProps} />) + + cy.get('[data-testid="node.A"]').should('exist') + cy.get('[data-testid="node.A.0"]').should('exist') + cy.get('[data-testid="node.A.1"]').should('exist') + cy.get('[data-testid="node.A.1.A"]').should('exist') + cy.get('[data-testid="node.A.1.B"]').should('exist') + cy.get('[data-testid="node.A.2"]').should('exist') + }) + + it('should highlight ancestor nodes and links', () => { + cy.mount( + + {...defaultProps} + useMesh={false} + highlightAncestorNodes={true} + highlightAncestorLinks={true} + /> + ) + + const expectations = [ + { uid: 'node.A', nodes: ['node.A'], links: [] }, + { uid: 'node.A.0', nodes: ['node.A', 'node.A.0'], links: ['link.A:A.0'] }, + { uid: 'node.A.1', nodes: ['node.A', 'node.A.1'], links: ['link.A:A.1'] }, + { + uid: 'node.A.1.A', + nodes: ['node.A', 'node.A.1', 'node.A.1.A'], + links: ['link.A:A.1', 'link.A.1:A.1.A'], + }, + { + uid: 'node.A.1.B', + nodes: ['node.A', 'node.A.1', 'node.A.1.B'], + links: ['link.A:A.1', 'link.A.1:A.1.B'], + }, + { uid: 'node.A.2', nodes: ['node.A', 'node.A.2'], links: ['link.A:A.2'] }, + ] + + for (const expectation of expectations) { + cy.get(`[data-testid="${expectation.uid}"]`).trigger('mouseover') + cy.wait(100) + + cy.get('[data-testid^="node."]').each($node => { + cy.wrap($node) + .invoke('attr', 'data-testid') + .then(testId => { + const size = expectation.nodes.includes(testId!) + ? defaultProps.activeNodeSize + : defaultProps.inactiveNodeSize + cy.wrap($node) + .invoke('attr', 'r') + .should('equal', `${size / 2}`) + }) + }) + + cy.get('[data-testid^="link."]').each($link => { + cy.wrap($link) + .invoke('attr', 'data-testid') + .then(testId => { + const thickness = expectation.links.includes(testId!) + ? defaultProps.activeLinkThickness + : defaultProps.inactiveLinkThickness + cy.wrap($link) + .invoke('attr', 'stroke-width') + .should('equal', `${thickness}`) + }) + }) + } + }) + + it('should highlight descendant nodes and links', () => { + cy.mount( + + {...defaultProps} + useMesh={false} + highlightAncestorNodes={false} + highlightAncestorLinks={false} + highlightDescendantNodes={true} + highlightDescendantLinks={true} + /> + ) + + const expectations = [ + { + uid: 'node.A', + nodes: ['node.A', 'node.A.0', 'node.A.1', 'node.A.1.A', 'node.A.1.B', 'node.A.2'], + links: [], + }, + { uid: 'node.A.0', nodes: ['node.A.0'], links: [] }, + { uid: 'node.A.1', nodes: ['node.A.1', 'node.A.1.A', 'node.A.1.B'], links: [] }, + { uid: 'node.A.1.A', nodes: ['node.A.1.A'], links: [] }, + { uid: 'node.A.1.B', nodes: ['node.A.1.B'], links: [] }, + { uid: 'node.A.2', nodes: ['node.A.2'], links: [] }, + ] + + for (const expectation of expectations) { + cy.get(`[data-testid="${expectation.uid}"]`).trigger('mouseover') + cy.wait(100) + + cy.get('[data-testid^="node."]').each($node => { + cy.wrap($node) + .invoke('attr', 'data-testid') + .then(testId => { + const size = expectation.nodes.includes(testId!) + ? defaultProps.activeNodeSize + : defaultProps.inactiveNodeSize + cy.wrap($node) + .invoke('attr', 'r') + .should('equal', `${size / 2}`) + }) + }) + } + }) +}) diff --git a/package.json b/package.json index 656599980..99c72166b 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ "rollup-plugin-strip-banner": "^3.0.0", "rollup-plugin-visualizer": "^5.5.2", "serve": "^13.0.2", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "yargs": "^17.7.2" }, "resolutions": { "@types/react": "^18.2.0", diff --git a/packages/arcs/package.json b/packages/arcs/package.json index 9d66748b9..3e5e14537 100644 --- a/packages/arcs/package.json +++ b/packages/arcs/package.json @@ -31,8 +31,8 @@ "@nivo/colors": "workspace:*", "@nivo/core": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", - "@types/d3-shape": "^2.0.0", - "d3-shape": "^1.3.5" + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/axes/src/components/AxisTick.tsx b/packages/axes/src/components/AxisTick.tsx index bbd887d24..4d0d58d0c 100644 --- a/packages/axes/src/components/AxisTick.tsx +++ b/packages/axes/src/components/AxisTick.tsx @@ -1,7 +1,7 @@ import { useMemo, memo } from 'react' import * as React from 'react' import { animated } from '@react-spring/web' -import { useTheme } from '@nivo/core' +import { useTheme, sanitizeSvgTextStyle } from '@nivo/core' import { ScaleValue } from '@nivo/scales' import { AxisTickProps } from '../types' @@ -54,7 +54,7 @@ const AxisTick = ({ dominantBaseline={textBaseline} textAnchor={textAnchor} transform={animatedProps.textTransform} - style={textStyle} + style={sanitizeSvgTextStyle(textStyle)} > {`${value}`} diff --git a/packages/bar/package.json b/packages/bar/package.json index 4ec40a5e5..047eee877 100644 --- a/packages/bar/package.json +++ b/packages/bar/package.json @@ -38,9 +38,9 @@ "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5", + "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/packages/boxplot/package.json b/packages/boxplot/package.json index d15d1315a..e97424c80 100644 --- a/packages/boxplot/package.json +++ b/packages/boxplot/package.json @@ -43,9 +43,9 @@ "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5", + "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/packages/bump/package.json b/packages/bump/package.json index 6d00986b7..6bf648588 100644 --- a/packages/bump/package.json +++ b/packages/bump/package.json @@ -39,9 +39,9 @@ "@nivo/voronoi": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5" + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/chord/package.json b/packages/chord/package.json index 8a18acace..85e6fce15 100644 --- a/packages/chord/package.json +++ b/packages/chord/package.json @@ -36,9 +36,9 @@ "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-chord": "^3.0.1", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-chord": "^1.0.6", - "d3-shape": "^1.3.5" + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/colors/src/props.ts b/packages/colors/src/props.ts index b533d0866..b2203b617 100644 --- a/packages/colors/src/props.ts +++ b/packages/colors/src/props.ts @@ -1,18 +1,4 @@ import PropTypes from 'prop-types' -import { colorSchemeIds } from './schemes' - -export const ordinalColorsPropType = PropTypes.oneOfType([ - PropTypes.func, - PropTypes.arrayOf(PropTypes.string), - PropTypes.shape({ - scheme: PropTypes.oneOf(colorSchemeIds).isRequired, - size: PropTypes.number, - }), - PropTypes.shape({ - datum: PropTypes.string.isRequired, - }), - PropTypes.string, -]) export const inheritedColorPropType = PropTypes.oneOfType([ PropTypes.string, diff --git a/packages/colors/src/schemes/categorical.ts b/packages/colors/src/schemes/categorical.ts index eacb46cd0..a41d338b9 100644 --- a/packages/colors/src/schemes/categorical.ts +++ b/packages/colors/src/schemes/categorical.ts @@ -8,6 +8,7 @@ import { schemeSet1, schemeSet2, schemeSet3, + schemeTableau10, } from 'd3-scale-chromatic' export const categoricalColorSchemes = { @@ -21,6 +22,7 @@ export const categoricalColorSchemes = { set1: schemeSet1, set2: schemeSet2, set3: schemeSet3, + tableau10: schemeTableau10, } export type CategoricalColorSchemeId = keyof typeof categoricalColorSchemes diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 6df3710cb..daf343f18 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -23,6 +23,7 @@ export type Margin = { right: number top: number } +export type Padding = Margin export type Box = Partial export type BoxAlign = @@ -54,8 +55,11 @@ export type TextStyle = { fill: string outlineWidth: number outlineColor: string + outlineOpacity: number } & Partial +export function sanitizeSvgTextStyle(style: TextStyle): any + export type CompleteTheme = { background: string text: TextStyle @@ -100,7 +104,7 @@ export type CompleteTheme = { } } labels: { - text: Partial + text: TextStyle } markers: { lineColor: string @@ -265,6 +269,10 @@ export type Theme = Partial<{ export function useTheme(): CompleteTheme export function usePartialTheme(theme?: Theme): CompleteTheme +export function extendDefaultTheme( + defaultTheme: ThemeWithoutInheritance, + customTheme: Theme +): CompleteTheme export type MotionProps = Partial<{ animate: boolean diff --git a/packages/core/package.json b/packages/core/package.json index f514fd297..d834626af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,13 +23,13 @@ "dependencies": { "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", - "d3-shape": "^1.3.5", + "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "prop-types": "^15.7.2" diff --git a/packages/core/src/components/dots/DotsItem.js b/packages/core/src/components/dots/DotsItem.js index 9c4faaad8..b8fa0e23f 100644 --- a/packages/core/src/components/dots/DotsItem.js +++ b/packages/core/src/components/dots/DotsItem.js @@ -1,7 +1,7 @@ import { createElement, memo } from 'react' import PropTypes from 'prop-types' import { useSpring, animated } from '@react-spring/web' -import { useTheme } from '../../theming' +import { useTheme, sanitizeSvgTextStyle } from '../../theming' import { useMotionConfig } from '../../motion' import DotsItemSymbol from './DotsItemSymbol' @@ -37,7 +37,11 @@ const DotsItem = ({ borderColor, })} {label && ( - + {label} )} diff --git a/packages/core/src/props/curve.js b/packages/core/src/props/curve.js index 81befadb9..a858ce1f8 100644 --- a/packages/core/src/props/curve.js +++ b/packages/core/src/props/curve.js @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types' import without from 'lodash/without' import { curveBasis, @@ -44,8 +43,6 @@ export const curvePropMapping = { export const curvePropKeys = Object.keys(curvePropMapping) -export const curvePropType = PropTypes.oneOf(curvePropKeys) - export const closedCurvePropKeys = curvePropKeys.filter(c => c.endsWith('Closed')) // Safe curves to be used with d3 area shape generator @@ -74,8 +71,6 @@ export const lineCurvePropKeys = without( 'linearClosed' ) -export const lineCurvePropType = PropTypes.oneOf(lineCurvePropKeys) - /** * Returns curve interpolator from given identifier. * diff --git a/packages/core/src/props/defs.js b/packages/core/src/props/defs.js deleted file mode 100644 index dc37b4129..000000000 --- a/packages/core/src/props/defs.js +++ /dev/null @@ -1,16 +0,0 @@ -import PropTypes from 'prop-types' - -export const defsPropTypes = { - defs: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - }) - ).isRequired, - fill: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - match: PropTypes.oneOfType([PropTypes.oneOf(['*']), PropTypes.object, PropTypes.func]) - .isRequired, - }) - ).isRequired, -} diff --git a/packages/core/src/props/index.js b/packages/core/src/props/index.js index 207a9de46..8f3a826db 100644 --- a/packages/core/src/props/index.js +++ b/packages/core/src/props/index.js @@ -30,5 +30,4 @@ export const blendModePropType = PropTypes.oneOf(blendModes) export * from './colors' export * from './curve' -export * from './defs' export * from './stack' diff --git a/packages/core/src/props/stack.js b/packages/core/src/props/stack.js index 7a540a959..d02e32f59 100644 --- a/packages/core/src/props/stack.js +++ b/packages/core/src/props/stack.js @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types' import { // order stackOrderAscending, @@ -24,8 +23,6 @@ export const stackOrderPropMapping = { export const stackOrderPropKeys = Object.keys(stackOrderPropMapping) -export const stackOrderPropType = PropTypes.oneOf(stackOrderPropKeys) - export const stackOrderFromProp = prop => stackOrderPropMapping[prop] export const stackOffsetPropMapping = { @@ -38,6 +35,4 @@ export const stackOffsetPropMapping = { export const stackOffsetPropKeys = Object.keys(stackOffsetPropMapping) -export const stackOffsetPropType = PropTypes.oneOf(stackOffsetPropKeys) - export const stackOffsetFromProp = prop => stackOffsetPropMapping[prop] diff --git a/packages/core/src/theming/defaultTheme.js b/packages/core/src/theming/defaultTheme.js index 293a2b82b..386974850 100644 --- a/packages/core/src/theming/defaultTheme.js +++ b/packages/core/src/theming/defaultTheme.js @@ -18,6 +18,7 @@ export const defaultTheme = { fill: '#333333', outlineWidth: 0, outlineColor: 'transparent', + outlineOpacity: 1, }, axis: { domain: { diff --git a/packages/core/src/theming/extend.js b/packages/core/src/theming/extend.js index 4dfa6eb4d..4b4d915db 100644 --- a/packages/core/src/theming/extend.js +++ b/packages/core/src/theming/extend.js @@ -10,7 +10,7 @@ import merge from 'lodash/merge' import get from 'lodash/get' import set from 'lodash/set' -const textProps = [ +const textPropsWithInheritance = [ 'axis.ticks.text', 'axis.legend.text', 'legends.title.text', @@ -23,6 +23,16 @@ const textProps = [ 'annotations.text', ] +/** + * @param {Partial} partialStyle + * @param {TextStyle} rootStyle + * @returns {TextStyle} + */ +export const inheritRootThemeText = (partialStyle, rootStyle) => ({ + ...rootStyle, + ...partialStyle, +}) + /** * @param {ThemeWithoutInheritance} defaultTheme * @param {Theme} customTheme @@ -31,22 +41,8 @@ const textProps = [ export const extendDefaultTheme = (defaultTheme, customTheme) => { const theme = merge({}, defaultTheme, customTheme) - textProps.forEach(prop => { - if (get(theme, `${prop}.fontFamily`) === undefined) { - set(theme, `${prop}.fontFamily`, theme.text.fontFamily) - } - if (get(theme, `${prop}.fontSize`) === undefined) { - set(theme, `${prop}.fontSize`, theme.text.fontSize) - } - if (get(theme, `${prop}.fill`) === undefined) { - set(theme, `${prop}.fill`, theme.text.fill) - } - if (get(theme, `${prop}.outlineWidth`) === undefined) { - set(theme, `${prop}.outlineWidth`, theme.text.outlineWidth) - } - if (get(theme, `${prop}.outlineColor`) === undefined) { - set(theme, `${prop}.outlineColor`, theme.text.outlineColor) - } + textPropsWithInheritance.forEach(prop => { + set(theme, prop, inheritRootThemeText(get(theme, prop), theme.text)) }) return theme diff --git a/packages/core/src/theming/helpers.js b/packages/core/src/theming/helpers.js new file mode 100644 index 000000000..079e5d39f --- /dev/null +++ b/packages/core/src/theming/helpers.js @@ -0,0 +1,11 @@ +/** + * Cleanup theme text style so that all properties + * are valid for an SVG text element. + * + * @param {TextStyle} style + */ +export const sanitizeSvgTextStyle = style => { + const { outlineWidth, outlineColor, outlineOpacity, ...sanitized } = style + + return sanitized +} diff --git a/packages/core/src/theming/index.js b/packages/core/src/theming/index.js index 0aeec6f4b..ec4780e91 100644 --- a/packages/core/src/theming/index.js +++ b/packages/core/src/theming/index.js @@ -6,7 +6,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -export * from './propTypes' export * from './defaultTheme' export * from './extend' export * from './context' +export * from './helpers' diff --git a/packages/core/src/theming/propTypes.js b/packages/core/src/theming/propTypes.js deleted file mode 100644 index 631a35090..000000000 --- a/packages/core/src/theming/propTypes.js +++ /dev/null @@ -1,115 +0,0 @@ -/* - * This file is part of the nivo project. - * - * Copyright 2016-present, Raphaël Benitte. - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ -import PropTypes from 'prop-types' - -const textProps = { - fill: PropTypes.string, - fontSize: PropTypes.number, - fontFamily: PropTypes.string, -} - -export const axisThemePropType = PropTypes.shape({ - domain: PropTypes.shape({ - line: PropTypes.shape({ - stroke: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired, - strokeDasharray: PropTypes.string, - }).isRequired, - }).isRequired, - ticks: PropTypes.shape({ - line: PropTypes.shape({ - stroke: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired, - strokeDasharray: PropTypes.string, - }).isRequired, - text: PropTypes.shape({ ...textProps }).isRequired, - }).isRequired, - legend: PropTypes.shape({ - text: PropTypes.shape({ ...textProps }).isRequired, - }).isRequired, -}) - -export const gridThemePropType = PropTypes.shape({ - line: PropTypes.shape({ - stroke: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired, - strokeDasharray: PropTypes.string, - }).isRequired, -}) - -export const legendsThemePropType = PropTypes.shape({ - hidden: PropTypes.shape({ - symbol: PropTypes.shape({ - fill: PropTypes.string.isRequired, - opacity: PropTypes.number, - }).isRequired, - text: PropTypes.shape({ ...textProps, opacity: PropTypes.number }).isRequired, - }).isRequired, - text: PropTypes.shape({ ...textProps }).isRequired, -}) - -export const labelsThemePropType = PropTypes.shape({ - text: PropTypes.shape({ ...textProps }).isRequired, -}) - -export const dotsThemePropType = PropTypes.shape({ - text: PropTypes.shape({ ...textProps }).isRequired, -}) - -export const markersThemePropType = PropTypes.shape({ - text: PropTypes.shape({ ...textProps }).isRequired, -}) - -export const crosshairPropType = PropTypes.shape({ - line: PropTypes.shape({ - stroke: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired, - strokeDasharray: PropTypes.string, - }).isRequired, -}) - -export const annotationsPropType = PropTypes.shape({ - text: PropTypes.shape({ - ...textProps, - outlineWidth: PropTypes.number.isRequired, - outlineColor: PropTypes.string.isRequired, - }).isRequired, - link: PropTypes.shape({ - stroke: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired, - outlineWidth: PropTypes.number.isRequired, - outlineColor: PropTypes.string.isRequired, - }).isRequired, - outline: PropTypes.shape({ - stroke: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired, - outlineWidth: PropTypes.number.isRequired, - outlineColor: PropTypes.string.isRequired, - }).isRequired, - symbol: PropTypes.shape({ - fill: PropTypes.string.isRequired, - outlineWidth: PropTypes.number.isRequired, - outlineColor: PropTypes.string.isRequired, - }).isRequired, -}) - -export const themePropType = PropTypes.shape({ - background: PropTypes.string.isRequired, - fontFamily: PropTypes.string.isRequired, - fontSize: PropTypes.number.isRequired, - textColor: PropTypes.string.isRequired, - axis: axisThemePropType.isRequired, - grid: gridThemePropType.isRequired, - legends: legendsThemePropType.isRequired, - labels: labelsThemePropType.isRequired, - dots: dotsThemePropType.isRequired, - markers: markersThemePropType, - crosshair: crosshairPropType.isRequired, - annotations: annotationsPropType.isRequired, -}) diff --git a/packages/funnel/package.json b/packages/funnel/package.json index 13cac7008..9734bee64 100644 --- a/packages/funnel/package.json +++ b/packages/funnel/package.json @@ -35,9 +35,9 @@ "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5" + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/legends/src/svg/LegendSvgItem.tsx b/packages/legends/src/svg/LegendSvgItem.tsx index 4aaa7fe2e..6ddf8c9f2 100644 --- a/packages/legends/src/svg/LegendSvgItem.tsx +++ b/packages/legends/src/svg/LegendSvgItem.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from 'react' import * as React from 'react' -import { useTheme } from '@nivo/core' +import { useTheme, sanitizeSvgTextStyle } from '@nivo/core' import { LegendSvgItemProps } from '../types' import { computeItemLayout } from '../compute' import { SymbolCircle, SymbolDiamond, SymbolSquare, SymbolTriangle } from './symbols' @@ -135,7 +135,7 @@ export const LegendSvgItem = ({ = 16.14.0 < 19.0.0" diff --git a/packages/line/src/Mesh.js b/packages/line/src/Mesh.js index 2b222fff6..4163fd5a0 100644 --- a/packages/line/src/Mesh.js +++ b/packages/line/src/Mesh.js @@ -28,10 +28,9 @@ const Mesh = ({ [point.x + margin.left, point.y + margin.top], 'top' ) - setCurrent(point) onMouseEnter && onMouseEnter(point, event) }, - [setCurrent, showTooltipAt, tooltip, onMouseEnter, margin] + [showTooltipAt, tooltip, onMouseEnter, margin] ) const handleMouseMove = useCallback( @@ -41,19 +40,17 @@ const Mesh = ({ [point.x + margin.left, point.y + margin.top], 'top' ) - setCurrent(point) onMouseMove && onMouseMove(point, event) }, - [showTooltipAt, tooltip, margin.left, margin.top, setCurrent, onMouseMove] + [showTooltipAt, tooltip, margin.left, margin.top, onMouseMove] ) const handleMouseLeave = useCallback( (point, event) => { hideTooltip() - setCurrent(null) onMouseLeave && onMouseLeave(point, event) }, - [hideTooltip, setCurrent, onMouseLeave] + [hideTooltip, onMouseLeave] ) const handleClick = useCallback( @@ -70,10 +67,9 @@ const Mesh = ({ [point.x + margin.left, point.y + margin.top], 'top' ) - setCurrent(point) onTouchStart && onTouchStart(point, event) }, - [margin.left, margin.top, onTouchStart, setCurrent, showTooltipAt, tooltip] + [margin.left, margin.top, onTouchStart, showTooltipAt, tooltip] ) const handleTouchMove = useCallback( @@ -83,19 +79,17 @@ const Mesh = ({ [point.x + margin.left, point.y + margin.top], 'top' ) - setCurrent(point) onTouchMove && onTouchMove(point, event) }, - [margin.left, margin.top, onTouchMove, setCurrent, showTooltipAt, tooltip] + [margin.left, margin.top, onTouchMove, showTooltipAt, tooltip] ) const handleTouchEnd = useCallback( (point, event) => { hideTooltip() - setCurrent(null) onTouchEnd && onTouchEnd(point, event) }, - [onTouchEnd, hideTooltip, setCurrent] + [onTouchEnd, hideTooltip] ) return ( @@ -103,6 +97,7 @@ const Mesh = ({ nodes={points} width={width} height={height} + setCurrent={setCurrent} onMouseEnter={handleMouseEnter} onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} diff --git a/packages/line/tests/Line.test.js b/packages/line/tests/Line.test.js index 5794d868b..f46777cbb 100644 --- a/packages/line/tests/Line.test.js +++ b/packages/line/tests/Line.test.js @@ -372,6 +372,7 @@ describe('touch events with slices', () => { height: 300, data: data, animate: false, + useMesh: false, enableSlices: 'x', } diff --git a/packages/line/tests/__snapshots__/Line.test.js.snap b/packages/line/tests/__snapshots__/Line.test.js.snap index 570ac4fbf..0ee2a68da 100644 --- a/packages/line/tests/__snapshots__/Line.test.js.snap +++ b/packages/line/tests/__snapshots__/Line.test.js.snap @@ -52,8 +52,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="middle" @@ -89,8 +87,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="middle" @@ -126,8 +122,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="middle" @@ -163,8 +157,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="middle" @@ -200,8 +192,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="middle" @@ -253,8 +243,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -290,8 +278,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -327,8 +313,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -364,8 +348,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -401,8 +383,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -438,8 +418,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -475,8 +453,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -512,8 +488,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -549,8 +523,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -586,8 +558,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -623,8 +593,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -660,8 +628,6 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` "fill": "#333333", "fontFamily": "sans-serif", "fontSize": 11, - "outlineColor": "transparent", - "outlineWidth": 0, } } textAnchor="end" @@ -684,7 +650,7 @@ exports[`curve interpolation should support basis curve interpolation 1`] = ` /> = 16.14.0 < 19.0.0" diff --git a/packages/pie/package.json b/packages/pie/package.json index 01a0df75e..e7570be10 100644 --- a/packages/pie/package.json +++ b/packages/pie/package.json @@ -34,8 +34,8 @@ "@nivo/core": "workspace:*", "@nivo/legends": "workspace:*", "@nivo/tooltip": "workspace:*", - "@types/d3-shape": "^2.0.0", - "d3-shape": "^1.3.5" + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/radar/package.json b/packages/radar/package.json index 2bf9591af..9ec9c54d8 100644 --- a/packages/radar/package.json +++ b/packages/radar/package.json @@ -35,9 +35,9 @@ "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5" + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/radial-bar/package.json b/packages/radial-bar/package.json index 3f81380b3..0ff88d6d4 100644 --- a/packages/radial-bar/package.json +++ b/packages/radial-bar/package.json @@ -38,9 +38,9 @@ "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5" + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/sankey/package.json b/packages/sankey/package.json index 3d46e7fae..82769281b 100644 --- a/packages/sankey/package.json +++ b/packages/sankey/package.json @@ -35,9 +35,9 @@ "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-sankey": "^0.11.2", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-sankey": "^0.12.3", - "d3-shape": "^1.3.5", + "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/packages/scatterplot/package.json b/packages/scatterplot/package.json index 305ce5177..6bc664824 100644 --- a/packages/scatterplot/package.json +++ b/packages/scatterplot/package.json @@ -39,9 +39,9 @@ "@nivo/voronoi": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5", + "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/packages/stream/package.json b/packages/stream/package.json index f9f0fc3a2..36effb8e8 100644 --- a/packages/stream/package.json +++ b/packages/stream/package.json @@ -36,8 +36,8 @@ "@nivo/scales": "workspace:*", "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", - "@types/d3-shape": "^2.0.0", - "d3-shape": "^1.3.5" + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/packages/text/LICENSE.md b/packages/text/LICENSE.md new file mode 100644 index 000000000..faa45389e --- /dev/null +++ b/packages/text/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/text/README.md b/packages/text/README.md new file mode 100644 index 000000000..25d9d723d --- /dev/null +++ b/packages/text/README.md @@ -0,0 +1,6 @@ +nivo + +# `@nivo/text` + +[![version](https://img.shields.io/npm/v/@nivo/text?style=for-the-badge)](https://www.npmjs.com/package/@nivo/text) +[![downloads](https://img.shields.io/npm/dm/@nivo/text?style=for-the-badge)](https://www.npmjs.com/package/@nivo/text) diff --git a/packages/text/package.json b/packages/text/package.json new file mode 100644 index 000000000..4e2957c3e --- /dev/null +++ b/packages/text/package.json @@ -0,0 +1,34 @@ +{ + "name": "@nivo/text", + "version": "0.86.0", + "license": "MIT", + "author": { + "name": "Raphaël Benitte", + "url": "https://github.com/plouc" + }, + "repository": { + "type": "git", + "url": "https://github.com/plouc/nivo.git", + "directory": "packages/text" + }, + "sideEffects": false, + "main": "./dist/nivo-text.cjs.js", + "module": "./dist/nivo-text.es.js", + "types": "./dist/types/index.d.ts", + "files": [ + "README.md", + "LICENSE.md", + "dist/", + "!dist/tsconfig.tsbuildinfo" + ], + "dependencies": { + "@nivo/core": "workspace:*", + "@react-spring/web": "9.4.5 || ^9.7.2" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/packages/text/src/Text.tsx b/packages/text/src/Text.tsx new file mode 100644 index 000000000..b109ce9dc --- /dev/null +++ b/packages/text/src/Text.tsx @@ -0,0 +1,39 @@ +import { PropsWithChildren, ComponentType } from 'react' +import { animated } from '@react-spring/web' +import { TextStyle as ThemeStyle } from '@nivo/core' + +type GetComponentProps = T extends ComponentType ? P : never +type AnimatedComponentProps = GetComponentProps<(typeof animated)['text']> + +type TextProps = PropsWithChildren< + Omit & { + style: AnimatedComponentProps['style'] & + Pick + } +> + +export const Text = ({ style: fullStyle, children, ...attributes }: TextProps) => { + const { outlineWidth, outlineColor, outlineOpacity, ...style } = fullStyle + + return ( + <> + {outlineWidth > 0 && ( + + {children} + + )} + + {children} + + + ) +} diff --git a/packages/text/src/canvas.ts b/packages/text/src/canvas.ts new file mode 100644 index 000000000..93fe220c5 --- /dev/null +++ b/packages/text/src/canvas.ts @@ -0,0 +1,25 @@ +import { TextStyle } from '@nivo/core' + +export const setCanvasFont = (ctx: CanvasRenderingContext2D, style: TextStyle) => { + ctx.font = `${style.fontWeight ? `${style.fontWeight} ` : ''}${style.fontSize}px ${ + style.fontFamily + }` +} + +export const drawCanvasText = ( + ctx: CanvasRenderingContext2D, + style: TextStyle, + text: string, + x = 0, + y = 0 +) => { + if (style.outlineWidth > 0) { + ctx.strokeStyle = style.outlineColor + ctx.lineWidth = style.outlineWidth * 2 + ctx.lineJoin = 'round' + ctx.strokeText(text, x, y) + } + + ctx.fillStyle = style.fill + ctx.fillText(text, x, y) +} diff --git a/packages/text/src/index.ts b/packages/text/src/index.ts new file mode 100644 index 000000000..b0fb9c886 --- /dev/null +++ b/packages/text/src/index.ts @@ -0,0 +1,2 @@ +export * from './canvas' +export * from './Text' diff --git a/packages/text/tsconfig.json b/packages/text/tsconfig.json new file mode 100644 index 000000000..569f591b4 --- /dev/null +++ b/packages/text/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.types.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist/types", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/packages/tooltip/src/types.ts b/packages/tooltip/src/types.ts index b82c76b26..d0ed8a4a1 100644 --- a/packages/tooltip/src/types.ts +++ b/packages/tooltip/src/types.ts @@ -1,3 +1,4 @@ +export type TooltipPosition = 'cursor' | 'fixed' export type TooltipAnchor = 'top' | 'right' | 'bottom' | 'left' | 'center' export type CrosshairType = diff --git a/packages/tree/LICENSE.md b/packages/tree/LICENSE.md new file mode 100644 index 000000000..faa45389e --- /dev/null +++ b/packages/tree/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/tree/README.md b/packages/tree/README.md new file mode 100644 index 000000000..5388d1968 --- /dev/null +++ b/packages/tree/README.md @@ -0,0 +1,12 @@ +nivo + +# `@nivo/tree` + +[![version](https://img.shields.io/npm/v/@nivo/tree?style=for-the-badge)](https://www.npmjs.com/package/@nivo/tree) +[![downloads](https://img.shields.io/npm/dm/@nivo/tree?style=for-the-badge)](https://www.npmjs.com/package/@nivo/tree) + +## Tree + +[documentation](http://nivo.rocks/tree/) + +![Tree](https://raw.githubusercontent.com/plouc/nivo/master/website/src/assets/captures/tree.png) diff --git a/packages/tree/package.json b/packages/tree/package.json new file mode 100644 index 000000000..e1a97c663 --- /dev/null +++ b/packages/tree/package.json @@ -0,0 +1,52 @@ +{ + "name": "@nivo/tree", + "version": "0.86.0", + "license": "MIT", + "author": { + "name": "Raphaël Benitte", + "url": "https://github.com/plouc" + }, + "repository": { + "type": "git", + "url": "https://github.com/plouc/nivo.git", + "directory": "packages/tree" + }, + "keywords": [ + "nivo", + "dataviz", + "react", + "d3", + "charts", + "hierarchy", + "tree" + ], + "main": "./dist/nivo-tree.cjs.js", + "module": "./dist/nivo-tree.es.js", + "types": "./dist/types/index.d.ts", + "files": [ + "README.md", + "LICENSE.md", + "dist/", + "!dist/tsconfig.tsbuildinfo" + ], + "dependencies": { + "@nivo/colors": "workspace:*", + "@nivo/core": "workspace:*", + "@nivo/text": "workspace:*", + "@nivo/tooltip": "workspace:*", + "@nivo/voronoi": "workspace:*", + "@react-spring/web": "9.4.5 || ^9.7.2", + "@types/d3-hierarchy": "^3.1.7", + "@types/d3-scale": "^4.0.8", + "@types/d3-shape": "^3.1.6", + "d3-hierarchy": "^3.1.2", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": ">= 16.14.0 < 19.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/tree/src/Label.tsx b/packages/tree/src/Label.tsx new file mode 100644 index 000000000..a8c22fc84 --- /dev/null +++ b/packages/tree/src/Label.tsx @@ -0,0 +1,40 @@ +import { animated, to } from '@react-spring/web' +import { useTheme } from '@nivo/core' +import { LabelComponentProps } from './types' + +export const Label = ({ label, animatedProps }: LabelComponentProps) => { + const theme = useTheme() + + return ( + `translate(${x},${y})`)} + > + `rotate(${rotation})`)}> + {theme.labels.text.outlineWidth > 0 && ( + + {label.label} + + )} + + {label.label} + + + + ) +} diff --git a/packages/tree/src/Labels.tsx b/packages/tree/src/Labels.tsx new file mode 100644 index 000000000..e56d9f69f --- /dev/null +++ b/packages/tree/src/Labels.tsx @@ -0,0 +1,73 @@ +import { createElement } from 'react' +import { useTransition } from '@react-spring/web' +import { useMotionConfig } from '@nivo/core' +import { + CommonProps, + ComputedLabel, + ComputedNode, + LabelAnimatedProps, + LabelComponent, + LabelsPosition, + Layout, +} from './types' +import { useLabels } from './labelsHooks' + +interface LabelsProps { + nodes: readonly ComputedNode[] + label: Exclude['label'], undefined> + layout: Layout + labelsPosition: LabelsPosition + orientLabel: boolean + labelOffset: number + labelComponent: LabelComponent +} + +const regularTransition = (label: ComputedLabel): LabelAnimatedProps => ({ + x: label.x, + y: label.y, + rotation: label.rotation, +}) +const leaveTransition = (label: ComputedLabel): LabelAnimatedProps => ({ + x: label.x, + y: label.y, + rotation: label.rotation, +}) + +export const Labels = ({ + nodes, + label, + layout, + labelsPosition, + orientLabel, + labelOffset, + labelComponent, +}: LabelsProps) => { + const labels = useLabels({ nodes, label, layout, labelsPosition, orientLabel, labelOffset }) + + const { animate, config: springConfig } = useMotionConfig() + + const transition = useTransition, LabelAnimatedProps>(labels, { + keys: label => label.id, + from: regularTransition, + enter: regularTransition, + update: regularTransition, + leave: leaveTransition, + config: springConfig, + immediate: !animate, + }) + + return ( + + {transition((animatedProps, label) => + createElement(labelComponent, { + label, + animatedProps, + }) + )} + + ) +} diff --git a/packages/tree/src/Link.tsx b/packages/tree/src/Link.tsx new file mode 100644 index 000000000..71277e50e --- /dev/null +++ b/packages/tree/src/Link.tsx @@ -0,0 +1,50 @@ +import { animated, to } from '@react-spring/web' +import { LinkComponentProps } from './types' +import { useLinkMouseEventHandlers } from './hooks' + +export const Link = ({ + link, + linkGenerator, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + tooltipAnchor, + animatedProps, +}: LinkComponentProps) => { + const eventHandlers = useLinkMouseEventHandlers(link, { + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + tooltipAnchor, + }) + + return ( + { + return linkGenerator({ + source: [sourceX, sourceY], + target: [targetX, targetY], + }) + } + )} + fill="none" + strokeWidth={animatedProps.thickness} + stroke={animatedProps.color} + {...eventHandlers} + /> + ) +} diff --git a/packages/tree/src/Links.tsx b/packages/tree/src/Links.tsx new file mode 100644 index 000000000..e0cbafec1 --- /dev/null +++ b/packages/tree/src/Links.tsx @@ -0,0 +1,86 @@ +import { createElement } from 'react' +import { useTransition } from '@react-spring/web' +import { useMotionConfig } from '@nivo/core' +import { TooltipAnchor } from '@nivo/tooltip' +import { + ComputedLink, + LinkComponent, + LinkMouseEventHandler, + LinkTooltip, + LinkAnimatedProps, + LinkGenerator, +} from './types' + +interface LinksProps { + links: ComputedLink[] + linkComponent: LinkComponent + linkGenerator: LinkGenerator + isInteractive: boolean + onMouseEnter?: LinkMouseEventHandler + onMouseMove?: LinkMouseEventHandler + onMouseLeave?: LinkMouseEventHandler + onClick?: LinkMouseEventHandler + tooltip?: LinkTooltip + tooltipAnchor: TooltipAnchor +} + +const regularTransition = (link: ComputedLink): LinkAnimatedProps => ({ + sourceX: link.source.x, + sourceY: link.source.y, + targetX: link.target.x, + targetY: link.target.y, + thickness: link.thickness, + color: link.color, +}) +const leaveTransition = (link: ComputedLink): LinkAnimatedProps => ({ + sourceX: link.source.x, + sourceY: link.source.y, + targetX: link.target.x, + targetY: link.target.y, + thickness: link.thickness, + color: link.color, +}) + +export const Links = ({ + links, + linkComponent, + linkGenerator, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + tooltipAnchor, +}: LinksProps) => { + const { animate, config: springConfig } = useMotionConfig() + + const transition = useTransition, LinkAnimatedProps>(links, { + keys: link => link.id, + from: regularTransition, + enter: regularTransition, + update: regularTransition, + leave: leaveTransition, + config: springConfig, + immediate: !animate, + }) + + return ( + <> + {transition((animatedProps, link) => + createElement(linkComponent, { + link, + linkGenerator, + animatedProps, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + tooltipAnchor, + }) + )} + + ) +} diff --git a/packages/tree/src/Mesh.tsx b/packages/tree/src/Mesh.tsx new file mode 100644 index 000000000..f1d30f3df --- /dev/null +++ b/packages/tree/src/Mesh.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react' +import { createElement, memo } from 'react' +import { Margin } from '@nivo/core' +import { TooltipAnchor, TooltipPosition } from '@nivo/tooltip' +import { Mesh as BaseMesh } from '@nivo/voronoi' +import { ComputedNode, CurrentNodeSetter, NodeMouseEventHandler, NodeTooltip } from './types' + +interface MeshProps { + nodes: ComputedNode[] + width: number + height: number + margin: Margin + onMouseEnter?: NodeMouseEventHandler + onMouseMove?: NodeMouseEventHandler + onMouseLeave?: NodeMouseEventHandler + onClick?: NodeMouseEventHandler + setCurrentNode: CurrentNodeSetter + tooltip?: NodeTooltip + tooltipPosition?: TooltipPosition + tooltipAnchor?: TooltipAnchor + detectionRadius: number + debug: boolean +} + +const NonMemoizedMesh = ({ + nodes, + width, + height, + margin, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + setCurrentNode, + tooltip, + tooltipPosition, + tooltipAnchor, + detectionRadius, + debug, +}: MeshProps) => { + const renderTooltip = useMemo(() => { + if (!tooltip) return undefined + return (node: ComputedNode) => createElement(tooltip, { node }) + }, [tooltip]) + + /* + const handleMouseEnter = useCallback( + (node: ComputedNode, event: MouseEvent) => { + setCurrentNode(node) + if (tooltip !== undefined) { + showTooltipAt( + createElement(tooltip, { node }), + [node.x + margin.left, node.y ?? 0 + margin.top], + 'top' + ) + } + onMouseEnter && onMouseEnter(node, event) + }, + [showTooltipAt, tooltip, margin.left, margin.top, setCurrentNode, onMouseEnter] + ) + + const handleMouseMove = useCallback( + (node: ComputedNode, event: MouseEvent) => { + setCurrentNode(node) + if (tooltip !== undefined) { + showTooltipAt( + createElement(tooltip, { node }), + [node.x + margin.left, node.y ?? 0 + margin.top], + 'top' + ) + } + onMouseMove && onMouseMove(node, event) + }, + [showTooltipAt, tooltip, margin.left, margin.top, setCurrentNode, onMouseMove] + ) + + const handleMouseLeave = useCallback( + (node: ComputedNode, event: MouseEvent) => { + setCurrentNode(null) + hideTooltip() + onMouseLeave && onMouseLeave(node, event) + }, + [hideTooltip, setCurrentNode, onMouseLeave] + ) + + const handleClick = useCallback( + (node: ComputedNode, event: MouseEvent) => { + onClick && onClick(node, event) + }, + [onClick] + ) + */ + + return ( + > + nodes={nodes} + width={width} + height={height} + margin={margin} + detectionRadius={detectionRadius} + setCurrent={setCurrentNode} + onMouseEnter={onMouseEnter} + onMouseMove={onMouseMove} + onMouseLeave={onMouseLeave} + onClick={onClick} + tooltip={renderTooltip} + tooltipPosition={tooltipPosition} + tooltipAnchor={tooltipAnchor} + debug={debug} + /> + ) +} + +export const Mesh = memo(NonMemoizedMesh) as typeof NonMemoizedMesh diff --git a/packages/tree/src/Node.tsx b/packages/tree/src/Node.tsx new file mode 100644 index 000000000..6ea95616c --- /dev/null +++ b/packages/tree/src/Node.tsx @@ -0,0 +1,42 @@ +import { animated } from '@react-spring/web' +import { NodeComponentProps } from './types' +import { useNodeMouseEventHandlers } from './hooks' + +export const Node = ({ + node, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + setCurrentNode, + tooltip, + tooltipPosition, + tooltipAnchor, + margin, + animatedProps, +}: NodeComponentProps) => { + const eventHandlers = useNodeMouseEventHandlers(node, { + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + setCurrentNode, + tooltip, + tooltipPosition, + tooltipAnchor, + margin, + }) + + return ( + size / 2)} + fill={animatedProps.color} + cx={animatedProps.x} + cy={animatedProps.y} + {...eventHandlers} + /> + ) +} diff --git a/packages/tree/src/Nodes.tsx b/packages/tree/src/Nodes.tsx new file mode 100644 index 000000000..833316718 --- /dev/null +++ b/packages/tree/src/Nodes.tsx @@ -0,0 +1,88 @@ +import { createElement } from 'react' +import { useTransition } from '@react-spring/web' +import { Margin, useMotionConfig } from '@nivo/core' +import { TooltipAnchor, TooltipPosition } from '@nivo/tooltip' +import { + ComputedNode, + CurrentNodeSetter, + NodeComponent, + NodeMouseEventHandler, + NodeTooltip, + NodeAnimatedProps, +} from './types' + +interface NodesProps { + nodes: ComputedNode[] + nodeComponent: NodeComponent + isInteractive: boolean + onMouseEnter?: NodeMouseEventHandler + onMouseMove?: NodeMouseEventHandler + onMouseLeave?: NodeMouseEventHandler + onClick?: NodeMouseEventHandler + setCurrentNode: CurrentNodeSetter + tooltip?: NodeTooltip + tooltipPosition: TooltipPosition + tooltipAnchor: TooltipAnchor + margin: Margin +} + +const regularTransition = (node: ComputedNode): NodeAnimatedProps => ({ + x: node.x, + y: node.y, + size: node.size, + color: node.color, +}) +const leaveTransition = (node: ComputedNode): NodeAnimatedProps => ({ + x: node.x, + y: node.y, + size: 0, + color: node.color, +}) + +export const Nodes = ({ + nodes, + nodeComponent, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + setCurrentNode, + tooltip, + tooltipPosition, + tooltipAnchor, + margin, +}: NodesProps) => { + const { animate, config: springConfig } = useMotionConfig() + + const transition = useTransition, NodeAnimatedProps>(nodes, { + keys: node => node.uid, + from: regularTransition, + enter: regularTransition, + update: regularTransition, + leave: leaveTransition, + config: springConfig, + immediate: !animate, + }) + + return ( + <> + {transition((animatedProps, node) => + createElement(nodeComponent, { + node, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + setCurrentNode, + tooltip, + tooltipPosition, + tooltipAnchor, + margin, + animatedProps, + }) + )} + + ) +} diff --git a/packages/tree/src/ResponsiveTree.tsx b/packages/tree/src/ResponsiveTree.tsx new file mode 100644 index 000000000..fc6d20109 --- /dev/null +++ b/packages/tree/src/ResponsiveTree.tsx @@ -0,0 +1,9 @@ +import { ResponsiveWrapper } from '@nivo/core' +import { ResponsiveTreeSvgProps, DefaultDatum } from './types' +import { Tree } from './Tree' + +export const ResponsiveTree = (props: ResponsiveTreeSvgProps) => ( + + {({ width, height }) => width={width} height={height} {...props} />} + +) diff --git a/packages/tree/src/ResponsiveTreeCanvas.tsx b/packages/tree/src/ResponsiveTreeCanvas.tsx new file mode 100644 index 000000000..6af30d03e --- /dev/null +++ b/packages/tree/src/ResponsiveTreeCanvas.tsx @@ -0,0 +1,11 @@ +import { ResponsiveWrapper } from '@nivo/core' +import { ResponsiveTreeCanvasProps, DefaultDatum } from './types' +import { TreeCanvas } from './TreeCanvas' + +export const ResponsiveTreeCanvas = ( + props: ResponsiveTreeCanvasProps +) => ( + + {({ width, height }) => width={width} height={height} {...props} />} + +) diff --git a/packages/tree/src/Tree.tsx b/packages/tree/src/Tree.tsx new file mode 100644 index 000000000..6dea7f5d2 --- /dev/null +++ b/packages/tree/src/Tree.tsx @@ -0,0 +1,233 @@ +import { createElement, Fragment, ReactNode, useMemo } from 'react' +import { Container, useDimensions, SvgWrapper } from '@nivo/core' +import { DefaultDatum, LayerId, TreeSvgProps, CustomSvgLayerProps } from './types' +import { svgDefaultProps } from './defaults' +import { useTree } from './hooks' +import { Links } from './Links' +import { Nodes } from './Nodes' +import { Labels } from './Labels' +import { Mesh } from './Mesh' + +type InnerTreeProps = Omit< + TreeSvgProps, + 'animate' | 'motionConfig' | 'renderWrapper' | 'theme' +> + +const InnerTree = ({ + width, + height, + margin: partialMargin, + data, + identity, + mode = svgDefaultProps.mode, + layout = svgDefaultProps.layout, + nodeSize = svgDefaultProps.nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor = svgDefaultProps.nodeColor, + fixNodeColorAtDepth = svgDefaultProps.fixNodeColorAtDepth, + nodeComponent = svgDefaultProps.nodeComponent, + linkCurve = svgDefaultProps.linkCurve, + linkThickness = svgDefaultProps.linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor = svgDefaultProps.linkColor, + linkComponent = svgDefaultProps.linkComponent, + enableLabel = svgDefaultProps.enableLabel, + label = svgDefaultProps.label, + labelsPosition = svgDefaultProps.labelsPosition, + orientLabel = svgDefaultProps.orientLabel, + labelOffset = svgDefaultProps.labelOffset, + labelComponent = svgDefaultProps.labelComponent, + layers = svgDefaultProps.layers, + isInteractive = svgDefaultProps.isInteractive, + useMesh = svgDefaultProps.useMesh, + meshDetectionRadius = svgDefaultProps.meshDetectionRadius, + debugMesh = svgDefaultProps.debugMesh, + highlightAncestorNodes = svgDefaultProps.highlightAncestorNodes, + highlightDescendantNodes = svgDefaultProps.highlightDescendantNodes, + highlightAncestorLinks = svgDefaultProps.highlightAncestorLinks, + highlightDescendantLinks = svgDefaultProps.highlightDescendantLinks, + onNodeMouseEnter, + onNodeMouseMove, + onNodeMouseLeave, + onNodeClick, + nodeTooltip, + nodeTooltipPosition = svgDefaultProps.nodeTooltipPosition, + nodeTooltipAnchor = svgDefaultProps.nodeTooltipAnchor, + onLinkMouseEnter, + onLinkMouseMove, + onLinkMouseLeave, + onLinkClick, + linkTooltip, + linkTooltipAnchor = svgDefaultProps.linkTooltipAnchor, + role = svgDefaultProps.role, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: InnerTreeProps) => { + const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions( + width, + height, + partialMargin + ) + + const { nodes, nodeByUid, links, linkGenerator, setCurrentNode } = useTree({ + data, + identity, + layout, + mode, + width: innerWidth, + height: innerHeight, + nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor, + fixNodeColorAtDepth, + highlightAncestorNodes, + highlightDescendantNodes, + linkCurve, + linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor, + highlightAncestorLinks, + highlightDescendantLinks, + }) + + const layerById: Record = { + links: null, + nodes: null, + labels: null, + mesh: null, + } + + if (layers.includes('links')) { + layerById.links = ( + + key="links" + links={links} + linkComponent={linkComponent} + linkGenerator={linkGenerator} + isInteractive={isInteractive} + onMouseEnter={onLinkMouseEnter} + onMouseMove={onLinkMouseMove} + onMouseLeave={onLinkMouseLeave} + onClick={onLinkClick} + tooltip={linkTooltip} + tooltipAnchor={linkTooltipAnchor} + /> + ) + } + + if (layers.includes('nodes')) { + layerById.nodes = ( + + key="nodes" + nodes={nodes} + nodeComponent={nodeComponent} + isInteractive={isInteractive} + onMouseEnter={onNodeMouseEnter} + onMouseMove={onNodeMouseMove} + onMouseLeave={onNodeMouseLeave} + onClick={onNodeClick} + setCurrentNode={setCurrentNode} + tooltip={nodeTooltip} + tooltipPosition={nodeTooltipPosition} + tooltipAnchor={nodeTooltipAnchor} + margin={margin} + /> + ) + } + + if (layers.includes('labels') && enableLabel) { + layerById.labels = ( + + key="labels" + label={label} + nodes={nodes} + layout={layout} + labelsPosition={labelsPosition} + orientLabel={orientLabel} + labelOffset={labelOffset} + labelComponent={labelComponent} + /> + ) + } + + if (layers.includes('mesh') && isInteractive && useMesh) { + layerById.mesh = ( + + key="mesh" + nodes={nodes} + width={innerWidth} + height={innerHeight} + margin={margin} + detectionRadius={meshDetectionRadius} + debug={debugMesh} + onMouseEnter={onNodeMouseEnter} + onMouseMove={onNodeMouseMove} + onMouseLeave={onNodeMouseLeave} + onClick={onNodeClick} + tooltip={nodeTooltip} + tooltipPosition={nodeTooltipPosition} + tooltipAnchor={nodeTooltipAnchor} + setCurrentNode={setCurrentNode} + /> + ) + } + + const customLayerProps: CustomSvgLayerProps = useMemo( + () => ({ + nodes, + nodeByUid, + links, + innerWidth, + innerHeight, + linkGenerator, + setCurrentNode, + }), + [nodes, nodeByUid, links, innerWidth, innerHeight, linkGenerator, setCurrentNode] + ) + + return ( + + {layers.map((layer, i) => { + if (typeof layer === 'function') { + return {createElement(layer, customLayerProps)} + } + + return layerById?.[layer] ?? null + })} + + ) +} + +export const Tree = ({ + isInteractive = svgDefaultProps.isInteractive, + animate = svgDefaultProps.animate, + motionConfig = svgDefaultProps.motionConfig, + theme, + renderWrapper, + ...otherProps +}: TreeSvgProps) => ( + + isInteractive={isInteractive} {...otherProps} /> + +) diff --git a/packages/tree/src/TreeCanvas.tsx b/packages/tree/src/TreeCanvas.tsx new file mode 100644 index 000000000..e92fe06a2 --- /dev/null +++ b/packages/tree/src/TreeCanvas.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, createElement } from 'react' +import { Container, useDimensions, useTheme } from '@nivo/core' +import { setCanvasFont } from '@nivo/text' +import { useMesh, renderDebugToCanvas } from '@nivo/voronoi' +import { DefaultDatum, TreeCanvasProps, CustomCanvasLayerProps, ComputedNode } from './types' +import { canvasDefaultProps } from './defaults' +import { useTree } from './hooks' +import { useLabels } from './labelsHooks' + +type InnerTreeCanvasProps = Omit< + TreeCanvasProps, + 'animate' | 'motionConfig' | 'renderWrapper' | 'theme' +> + +const InnerTreeCanvas = ({ + width, + height, + pixelRatio = canvasDefaultProps.pixelRatio, + margin: partialMargin, + data, + identity, + mode = canvasDefaultProps.mode, + layout = canvasDefaultProps.layout, + nodeSize = canvasDefaultProps.nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor = canvasDefaultProps.nodeColor, + fixNodeColorAtDepth = canvasDefaultProps.fixNodeColorAtDepth, + renderNode = canvasDefaultProps.renderNode, + linkCurve = canvasDefaultProps.linkCurve, + linkThickness = canvasDefaultProps.linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor = canvasDefaultProps.linkColor, + renderLink = canvasDefaultProps.renderLink, + enableLabel = canvasDefaultProps.enableLabel, + label = canvasDefaultProps.label, + labelsPosition = canvasDefaultProps.labelsPosition, + orientLabel = canvasDefaultProps.orientLabel, + labelOffset = canvasDefaultProps.labelOffset, + renderLabel = canvasDefaultProps.renderLabel, + layers = canvasDefaultProps.layers, + isInteractive = canvasDefaultProps.isInteractive, + meshDetectionRadius = canvasDefaultProps.meshDetectionRadius, + debugMesh = canvasDefaultProps.debugMesh, + highlightAncestorNodes = canvasDefaultProps.highlightAncestorNodes, + highlightDescendantNodes = canvasDefaultProps.highlightDescendantNodes, + highlightAncestorLinks = canvasDefaultProps.highlightAncestorLinks, + highlightDescendantLinks = canvasDefaultProps.highlightDescendantLinks, + onNodeMouseEnter, + onNodeMouseMove, + onNodeMouseLeave, + onNodeClick, + nodeTooltip, + nodeTooltipPosition = canvasDefaultProps.nodeTooltipPosition, + nodeTooltipAnchor = canvasDefaultProps.nodeTooltipAnchor, + role = canvasDefaultProps.role, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, +}: InnerTreeCanvasProps) => { + const canvasEl = useRef(null) + + const { outerWidth, outerHeight, margin, innerWidth, innerHeight } = useDimensions( + width, + height, + partialMargin + ) + + const theme = useTheme() + + const { nodes, nodeByUid, links, linkGenerator, setCurrentNode } = useTree({ + data, + identity, + layout, + mode, + width: innerWidth, + height: innerHeight, + nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor, + fixNodeColorAtDepth, + highlightAncestorNodes, + highlightDescendantNodes, + linkCurve, + linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor, + highlightAncestorLinks, + highlightDescendantLinks, + }) + + const labels = useLabels({ + nodes, + label, + layout, + labelsPosition, + orientLabel, + labelOffset, + }) + + const renderTooltip = useMemo(() => { + if (!nodeTooltip) return undefined + return (node: ComputedNode) => createElement(nodeTooltip, { node }) + }, [nodeTooltip]) + + const { + delaunay, + voronoi, + handleMouseEnter, + handleMouseMove, + handleMouseLeave, + handleClick, + current, + } = useMesh, HTMLCanvasElement>({ + elementRef: canvasEl, + nodes, + width: innerWidth, + height: innerHeight, + margin, + detectionRadius: meshDetectionRadius, + isInteractive, + setCurrent: setCurrentNode, + onMouseEnter: onNodeMouseEnter, + onMouseMove: onNodeMouseMove, + onMouseLeave: onNodeMouseLeave, + onClick: onNodeClick, + tooltip: renderTooltip, + tooltipPosition: nodeTooltipPosition, + tooltipAnchor: nodeTooltipAnchor, + debug: debugMesh, + }) + + const customLayerProps: CustomCanvasLayerProps = useMemo( + () => ({ + nodes, + nodeByUid, + links, + innerWidth, + innerHeight, + linkGenerator, + }), + [nodes, nodeByUid, links, innerWidth, innerHeight, linkGenerator] + ) + + useEffect(() => { + if (canvasEl.current === null) return + + canvasEl.current.width = outerWidth * pixelRatio + canvasEl.current.height = outerHeight * pixelRatio + + const ctx = canvasEl.current.getContext('2d')! + + ctx.scale(pixelRatio, pixelRatio) + + ctx.fillStyle = theme.background + ctx.fillRect(0, 0, outerWidth, outerHeight) + + ctx.translate(margin.left, margin.top) + + layers.forEach(layer => { + if (layer === 'links') { + linkGenerator.context(ctx) + + links.forEach(link => { + renderLink(ctx, { link, linkGenerator }) + }) + } else if (layer === 'nodes') { + nodes.forEach(node => { + renderNode(ctx, { node }) + }) + } else if (layer === 'labels' && enableLabel) { + setCanvasFont(ctx, theme.labels.text) + + labels.forEach(label => { + renderLabel(ctx, { label, theme }) + }) + } else if (layer === 'mesh' && debugMesh && voronoi) { + ctx.save() + // The mesh should cover the whole chart, including margins. + ctx.translate(-margin.left, -margin.top) + + renderDebugToCanvas(ctx, { + delaunay, + voronoi, + detectionRadius: meshDetectionRadius, + index: current !== null ? current[0] : null, + }) + + ctx.restore() + } else if (typeof layer === 'function') { + layer(ctx, customLayerProps) + } + }) + }, [ + canvasEl, + outerWidth, + outerHeight, + pixelRatio, + margin.left, + margin.top, + theme, + layers, + nodes, + nodeByUid, + renderNode, + links, + renderLink, + linkGenerator, + labels, + enableLabel, + renderLabel, + delaunay, + voronoi, + meshDetectionRadius, + debugMesh, + current, + customLayerProps, + ]) + + return ( + + ) +} + +export const TreeCanvas = ({ + isInteractive = canvasDefaultProps.isInteractive, + animate = canvasDefaultProps.animate, + motionConfig = canvasDefaultProps.motionConfig, + theme, + renderWrapper, + ...otherProps +}: TreeCanvasProps) => ( + + isInteractive={isInteractive} {...otherProps} /> + +) diff --git a/packages/tree/src/canvas.ts b/packages/tree/src/canvas.ts new file mode 100644 index 000000000..910d8f906 --- /dev/null +++ b/packages/tree/src/canvas.ts @@ -0,0 +1,45 @@ +import { degreesToRadians } from '@nivo/core' +import { drawCanvasText } from '@nivo/text' +import { LinkCanvasRendererProps, NodeCanvasRendererProps, LabelCanvasRendererProps } from './types' + +export const renderNode = ( + ctx: CanvasRenderingContext2D, + { node }: NodeCanvasRendererProps +) => { + ctx.beginPath() + ctx.arc(node.x, node.y, node.size / 2, 0, 2 * Math.PI) + ctx.fillStyle = node.color + ctx.fill() +} + +export const renderLink = ( + ctx: CanvasRenderingContext2D, + { link, linkGenerator }: LinkCanvasRendererProps +) => { + ctx.strokeStyle = link.color + ctx.lineWidth = link.thickness + ctx.beginPath() + linkGenerator({ + source: [link.source.x, link.source.y], + target: [link.target.x, link.target.y], + }) + ctx.stroke() +} + +export const renderLabel = ( + ctx: CanvasRenderingContext2D, + { label, theme }: LabelCanvasRendererProps +) => { + ctx.save() + + ctx.translate(label.x, label.y) + ctx.rotate(degreesToRadians(label.rotation)) + + ctx.textBaseline = 'middle' + ctx.textAlign = label.textAnchor === 'middle' ? 'center' : label.textAnchor + ctx.fillStyle = '#000' + + drawCanvasText(ctx, theme.labels.text, label.label) + + ctx.restore() +} diff --git a/packages/tree/src/defaults.ts b/packages/tree/src/defaults.ts new file mode 100644 index 000000000..b00be0f46 --- /dev/null +++ b/packages/tree/src/defaults.ts @@ -0,0 +1,94 @@ +import { CommonProps, TreeCanvasProps, TreeSvgProps } from './types' +import { Node } from './Node' +import { Link } from './Link' +import { Label } from './Label' +import { renderNode, renderLink, renderLabel } from './canvas' + +export const commonDefaultProps: Pick< + CommonProps, + | 'identity' + | 'mode' + | 'layout' + | 'nodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkCurve' + | 'linkThickness' + | 'linkColor' + | 'enableLabel' + | 'label' + | 'labelsPosition' + | 'orientLabel' + | 'labelOffset' + | 'isInteractive' + | 'useMesh' + | 'meshDetectionRadius' + | 'debugMesh' + | 'highlightAncestorNodes' + | 'highlightDescendantNodes' + | 'highlightAncestorLinks' + | 'highlightDescendantLinks' + | 'nodeTooltipPosition' + | 'nodeTooltipAnchor' + | 'role' + | 'animate' + | 'motionConfig' +> = { + identity: 'id', + mode: 'dendogram', + layout: 'top-to-bottom', + nodeSize: 12, + nodeColor: { scheme: 'nivo' }, + fixNodeColorAtDepth: Infinity, + linkCurve: 'bump', + linkThickness: 1, + linkColor: { from: 'source.color', modifiers: [['opacity', 0.4]] }, + enableLabel: true, + label: 'id', + labelsPosition: 'outward', + orientLabel: true, + labelOffset: 6, + isInteractive: true, + useMesh: true, + meshDetectionRadius: Infinity, + debugMesh: false, + highlightAncestorNodes: true, + highlightDescendantNodes: false, + highlightAncestorLinks: true, + highlightDescendantLinks: false, + nodeTooltipPosition: 'fixed', + nodeTooltipAnchor: 'top', + role: 'img', + animate: true, + motionConfig: 'gentle', +} + +export const svgDefaultProps: typeof commonDefaultProps & + Required< + Pick< + TreeSvgProps, + 'layers' | 'nodeComponent' | 'linkComponent' | 'labelComponent' | 'linkTooltipAnchor' + > + > = { + ...commonDefaultProps, + layers: ['links', 'nodes', 'labels', 'mesh'], + nodeComponent: Node, + linkComponent: Link, + labelComponent: Label, + linkTooltipAnchor: 'top', +} + +export const canvasDefaultProps: typeof commonDefaultProps & + Required< + Pick< + TreeCanvasProps, + 'layers' | 'renderNode' | 'renderLink' | 'renderLabel' | 'pixelRatio' + > + > = { + ...commonDefaultProps, + layers: ['links', 'nodes', 'labels', 'mesh'], + renderNode, + renderLink, + renderLabel, + pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, +} diff --git a/packages/tree/src/hooks.ts b/packages/tree/src/hooks.ts new file mode 100644 index 000000000..cda34ebb9 --- /dev/null +++ b/packages/tree/src/hooks.ts @@ -0,0 +1,698 @@ +import { createElement, MouseEvent, useCallback, useMemo, useState } from 'react' +import { hierarchy as d3Hierarchy, cluster as d3Cluster, tree as d3Tree } from 'd3-hierarchy' +import { scaleLinear, ScaleLinear } from 'd3-scale' +import { + link as d3Link, + CurveFactory, + curveLinear, + curveBumpX, + curveBumpY, + curveStep, + curveStepBefore, + curveStepAfter, +} from 'd3-shape' +import { Margin, usePropertyAccessor, useTheme } from '@nivo/core' +import { TooltipAnchor, TooltipPosition, useTooltip } from '@nivo/tooltip' +import { useOrdinalColorScale, useInheritedColor } from '@nivo/colors' +import { + DefaultDatum, + HierarchyTreeNode, + HierarchyTreeLink, + TreeDataProps, + CommonProps, + Layout, + ComputedNode, + ComputedLink, + NodeMouseEventHandler, + NodeTooltip, + IntermediateComputedLink, + LinkThicknessFunction, + LinkMouseEventHandler, + LinkTooltip, + IntermediateComputedNode, + CurrentNodeSetter, + NodeSizeModifierFunction, + LinkThicknessModifierFunction, + TreeMode, + LinkCurve, +} from './types' +import { commonDefaultProps } from './defaults' + +export const useRoot = ({ + data, + mode, + getIdentity, +}: { + data: TreeDataProps['data'] + mode: TreeMode + getIdentity: (node: Datum) => string +}) => + useMemo(() => { + const root = d3Hierarchy(data) as HierarchyTreeNode + const cluster = mode === 'tree' ? d3Tree() : d3Cluster() + + root.eachBefore(node => { + const ancestors = node + .ancestors() + .filter(ancestor => ancestor !== node) + .reverse() + const ancestorIds = ancestors.map(ancestor => getIdentity(ancestor.data)) + + node.ancestorIds = ancestorIds + node.uid = [...ancestorIds, getIdentity(node.data)].join('.') + node.ancestorUids = ancestors.map(ancestor => ancestor.uid!) + }) + + root.each(node => { + node.descendantUids = node + .descendants() + .filter(descendant => descendant !== node) + .map(descendant => descendant.uid!) + }) + + cluster(root) + + return root + }, [data, mode, getIdentity]) + +/** + * By default, the x/y positions are computed for a 0~1 range, + * so that we can easily change the layout without having to + * recompute the nodes. + */ +const useCartesianScales = ({ + width, + height, + layout, +}: { + width: number + height: number + layout: Layout +}) => + useMemo(() => { + const xScale = scaleLinear().domain([0, 1]) + const yScale = scaleLinear().domain([0, 1]) + + if (layout === 'top-to-bottom') { + xScale.range([0, width]) + yScale.range([0, height]) + } else if (layout === 'right-to-left') { + xScale.range([width, 0]) + yScale.range([0, height]) + } else if (layout === 'bottom-to-top') { + xScale.range([width, 0]) + yScale.range([height, 0]) + } else if (layout === 'left-to-right') { + xScale.range([0, width]) + yScale.range([height, 0]) + } + + return { + xScale, + yScale, + } + }, [width, height, layout]) + +const useNodeSize = (size: Exclude['nodeSize'], undefined>) => + useMemo(() => { + if (typeof size === 'function') return size + return () => size + }, [size]) + +const useNodeSizeModifier = (size?: NodeSizeModifierFunction | number) => + useMemo(() => { + if (size === undefined) return (node: ComputedNode) => node.size + if (typeof size === 'function') return size + return () => size + }, [size]) + +const useNodes = ({ + root, + xScale, + yScale, + layout, + getIdentity, + nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor, + fixNodeColorAtDepth, +}: { + root: HierarchyTreeNode + xScale: ScaleLinear + yScale: ScaleLinear + layout: Layout + getIdentity: (node: Datum) => string + nodeSize: Exclude['nodeSize'], undefined> + activeNodeSize?: CommonProps['activeNodeSize'] + inactiveNodeSize?: CommonProps['inactiveNodeSize'] + nodeColor: Exclude['nodeColor'], undefined> + fixNodeColorAtDepth: number +}) => { + const intermediateNodes = useMemo[]>(() => { + return root.descendants().map(node => { + let x: number + let y: number + if (layout === 'top-to-bottom' || layout === 'bottom-to-top') { + x = xScale(node.x!) + y = yScale(node.y!) + } else { + x = xScale(node.y!) + y = yScale(node.x!) + } + + const id = getIdentity(node.data) + + return { + path: [...node.ancestorIds!, id], + uid: node.uid!, + isRoot: node.depth === 0, + isLeaf: node.height === 0, + ancestorIds: node.ancestorIds!, + ancestorUids: node.ancestorUids!, + descendantUids: node.descendantUids!, + id, + data: node.data, + depth: node.depth, + height: node.height, + x, + y, + } + }) + }, [root, getIdentity, layout, xScale, yScale]) + + const getNodeSize = useNodeSize(nodeSize) + const getActiveNodeSize = useNodeSizeModifier(activeNodeSize) + const getInactiveNodeSize = useNodeSizeModifier(inactiveNodeSize) + + const getNodeColorBase = useOrdinalColorScale(nodeColor, 'uid') + // Wrap the default color function to support `getNodeColorAtDepth`. + const getNodeColor = useMemo(() => { + if (fixNodeColorAtDepth === Infinity) return getNodeColorBase + + return ( + node: IntermediateComputedNode, + nodeByUid: Record> + ) => { + if ( + node.depth <= 0 || + node.depth <= fixNodeColorAtDepth || + node.ancestorUids.length === 0 + ) + return getNodeColorBase(node) + + const parentUid = node.ancestorUids[node.ancestorUids.length - 1] + const parent = nodeByUid[parentUid] + if (parent === undefined) return getNodeColorBase(node) + + return parent.color + } + }, [getNodeColorBase, fixNodeColorAtDepth]) + + const [activeNodeUids, setActiveNodeUids] = useState([]) + + const computed = useMemo(() => { + const nodeByUid: Record> = {} + + const nodes: ComputedNode[] = intermediateNodes.map(intermediateNode => { + const computedNode: ComputedNode = { + ...intermediateNode, + size: getNodeSize(intermediateNode), + color: getNodeColor(intermediateNode, nodeByUid), + isActive: null, + } + + if (activeNodeUids.length > 0) { + computedNode.isActive = activeNodeUids.includes(computedNode.uid) + if (computedNode.isActive) { + computedNode.size = getActiveNodeSize(computedNode) + } else { + computedNode.size = getInactiveNodeSize(computedNode) + } + } + + nodeByUid[computedNode.uid] = computedNode + + return computedNode + }) + + return { nodes, nodeByUid } + }, [ + intermediateNodes, + getNodeSize, + getActiveNodeSize, + getInactiveNodeSize, + getNodeColor, + activeNodeUids, + ]) + + return { ...computed, activeNodeUids, setActiveNodeUids } +} + +const useLinkThicknessModifier = ( + thickness?: LinkThicknessModifierFunction | number +) => + useMemo(() => { + if (thickness === undefined) return (link: ComputedLink) => link.thickness + if (typeof thickness === 'function') return thickness + return () => thickness + }, [thickness]) + +const useLinks = ({ + root, + nodeByUid, + activeNodeUids, + linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor, +}: { + root: HierarchyTreeNode + nodeByUid: Record> + activeNodeUids: readonly string[] + linkThickness: Exclude['linkThickness'], undefined> + activeLinkThickness?: CommonProps['activeLinkThickness'] + inactiveLinkThickness?: CommonProps['inactiveLinkThickness'] + linkColor: Exclude['linkColor'], undefined> +}) => { + const intermediateLinks = useMemo[]>(() => { + return (root.links() as HierarchyTreeLink[]).map(link => { + return { + id: `${link.source.uid}:${link.target.uid}`, + // Replace with computed nodes. + source: nodeByUid[link.source.uid!], + target: nodeByUid[link.target.uid!], + } + }) + }, [root, nodeByUid]) + + const getLinkThickness: LinkThicknessFunction = useMemo(() => { + if (typeof linkThickness === 'function') return linkThickness + return () => linkThickness + }, [linkThickness]) + const getActiveLinkThickness = useLinkThicknessModifier(activeLinkThickness) + const getInactiveLinkThickness = useLinkThicknessModifier(inactiveLinkThickness) + + const theme = useTheme() + const getLinkColor = useInheritedColor(linkColor, theme) + + const [activeLinkIds, setActiveLinkIds] = useState([]) + + const links = useMemo(() => { + return intermediateLinks.map(intermediateLink => { + const computedLink: ComputedLink = { + ...intermediateLink, + thickness: getLinkThickness(intermediateLink), + color: getLinkColor(intermediateLink), + isActive: null, + } + + if (activeNodeUids.length > 0) { + computedLink.isActive = activeLinkIds.includes(computedLink.id) + if (computedLink.isActive) { + computedLink.thickness = getActiveLinkThickness(computedLink) + } else { + computedLink.thickness = getInactiveLinkThickness(computedLink) + } + } + + return computedLink + }) + }, [ + intermediateLinks, + getLinkThickness, + getActiveLinkThickness, + getInactiveLinkThickness, + getLinkColor, + activeNodeUids.length, + activeLinkIds, + ]) + + return { + links, + setActiveLinkIds, + } +} + +const useLinkGenerator = ({ layout, curve }: { layout: Layout; curve: LinkCurve }) => + useMemo(() => { + let curveFactory: CurveFactory = curveLinear + + if (curve === 'bump') { + if (layout === 'top-to-bottom' || layout === 'bottom-to-top') { + curveFactory = curveBumpY + } else { + curveFactory = curveBumpX + } + } else if (curve === 'step') { + curveFactory = curveStep + } else if (curve === 'step-before') { + curveFactory = curveStepBefore + } else if (curve === 'step-after') { + curveFactory = curveStepAfter + } + + return d3Link(curveFactory) + }, [layout, curve]) + +const useSetCurrentNode = ({ + setActiveNodeUids, + highlightAncestorNodes, + highlightDescendantNodes, + links, + setActiveLinkIds, + highlightAncestorLinks, + highlightDescendantLinks, +}: { + setActiveNodeUids: (uids: string[]) => void + highlightAncestorNodes: boolean + highlightDescendantNodes: boolean + links: readonly ComputedLink[] + setActiveLinkIds: (ids: string[]) => void + highlightAncestorLinks: boolean + highlightDescendantLinks: boolean +}) => + useCallback( + (node: ComputedNode | null) => { + if (node === null) { + setActiveNodeUids([]) + setActiveLinkIds([]) + } else { + let nodeUids: string[] = [node.uid] + if (highlightAncestorNodes) { + nodeUids = [...nodeUids, ...node.ancestorUids] + } + if (highlightDescendantNodes) { + nodeUids = [...nodeUids, ...node.descendantUids] + } + setActiveNodeUids(nodeUids) + + const linkIds: string[] = [] + if (highlightAncestorLinks) { + links + .filter(link => { + return ( + link.target.uid === node.uid || + node.ancestorUids.includes(link.target.uid) + ) + }) + .forEach(link => { + linkIds.push(link.id) + }) + } + if (highlightDescendantLinks) { + links + .filter(link => { + return ( + link.source.uid === node.uid || + node.descendantUids.includes(link.source.uid) + ) + }) + .forEach(link => { + linkIds.push(link.id) + }) + } + setActiveLinkIds(linkIds) + } + }, + [ + setActiveNodeUids, + highlightAncestorNodes, + highlightDescendantNodes, + links, + setActiveLinkIds, + highlightAncestorLinks, + highlightDescendantLinks, + ] + ) + +export const useTree = ({ + data, + width, + height, + identity = commonDefaultProps.identity, + mode = commonDefaultProps.mode, + layout = commonDefaultProps.layout, + nodeSize = commonDefaultProps.nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor = commonDefaultProps.nodeColor, + fixNodeColorAtDepth = commonDefaultProps.fixNodeColorAtDepth, + highlightAncestorNodes = commonDefaultProps.highlightAncestorNodes, + highlightDescendantNodes = commonDefaultProps.highlightDescendantNodes, + linkCurve = commonDefaultProps.linkCurve, + linkThickness = commonDefaultProps.linkThickness, + linkColor = commonDefaultProps.linkColor, + activeLinkThickness, + inactiveLinkThickness, + highlightAncestorLinks = commonDefaultProps.highlightAncestorLinks, + highlightDescendantLinks = commonDefaultProps.highlightDescendantLinks, +}: { + data: TreeDataProps['data'] + width: number + height: number + identity?: CommonProps['identity'] + mode?: TreeMode + layout?: Layout + nodeSize?: CommonProps['nodeSize'] + activeNodeSize?: CommonProps['activeNodeSize'] + inactiveNodeSize?: CommonProps['inactiveNodeSize'] + nodeColor?: CommonProps['nodeColor'] + fixNodeColorAtDepth?: number + highlightAncestorNodes?: boolean + highlightDescendantNodes?: boolean + linkCurve?: LinkCurve + linkThickness?: CommonProps['linkThickness'] + activeLinkThickness?: CommonProps['activeLinkThickness'] + inactiveLinkThickness?: CommonProps['inactiveLinkThickness'] + linkColor?: CommonProps['linkColor'] + highlightAncestorLinks?: boolean + highlightDescendantLinks?: boolean +}) => { + const getIdentity = usePropertyAccessor(identity) + const root = useRoot({ data, mode, getIdentity }) + + const { xScale, yScale } = useCartesianScales({ width, height, layout }) + const { nodes, nodeByUid, activeNodeUids, setActiveNodeUids } = useNodes({ + root, + xScale, + yScale, + layout, + getIdentity, + nodeSize, + activeNodeSize, + inactiveNodeSize, + nodeColor, + fixNodeColorAtDepth, + }) + + const linkGenerator = useLinkGenerator({ layout, curve: linkCurve }) + const { links, setActiveLinkIds } = useLinks({ + root, + nodeByUid, + activeNodeUids, + linkThickness, + activeLinkThickness, + inactiveLinkThickness, + linkColor, + }) + + const setCurrentNode = useSetCurrentNode({ + setActiveNodeUids, + highlightAncestorNodes, + highlightDescendantNodes, + links, + setActiveLinkIds, + highlightAncestorLinks, + highlightDescendantLinks, + }) + + return { + nodes, + nodeByUid, + links, + linkGenerator, + setCurrentNode, + } +} + +/** + * This hook may generates mouse event handlers for a node according to the main chart props. + * It's used for the default `Node` component and may be used for custom nodes + * to simplify their implementation. + */ +export const useNodeMouseEventHandlers = ( + node: ComputedNode, + { + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + setCurrentNode, + tooltip, + tooltipPosition, + tooltipAnchor, + margin, + }: { + isInteractive: boolean + onMouseEnter?: NodeMouseEventHandler + onMouseMove?: NodeMouseEventHandler + onMouseLeave?: NodeMouseEventHandler + onClick?: NodeMouseEventHandler + setCurrentNode: CurrentNodeSetter + tooltip?: NodeTooltip + tooltipPosition: TooltipPosition + tooltipAnchor: TooltipAnchor + margin: Margin + } +) => { + const { showTooltipFromEvent, showTooltipAt, hideTooltip } = useTooltip() + + const showTooltip = useMemo(() => { + if (!tooltip) return undefined + + if (tooltipPosition === 'fixed') { + return () => { + const { x, y } = node + showTooltipAt( + createElement(tooltip, { + node, + }), + [x + margin.left, y + margin.top], + tooltipAnchor + ) + } + } + + return (event: MouseEvent) => { + showTooltipFromEvent( + createElement(tooltip, { + node, + }), + event, + tooltipAnchor + ) + } + }, [node, tooltip, showTooltipFromEvent, showTooltipAt, tooltipPosition, tooltipAnchor, margin]) + + const handleMouseEnter = useCallback( + (event: MouseEvent) => { + setCurrentNode(node) + showTooltip?.(event) + onMouseEnter?.(node, event) + }, + [node, showTooltip, setCurrentNode, onMouseEnter] + ) + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + showTooltip?.(event) + onMouseMove?.(node, event) + }, + [node, showTooltip, onMouseMove] + ) + + const handleMouseLeave = useCallback( + (event: MouseEvent) => { + setCurrentNode(null) + hideTooltip() + onMouseLeave?.(node, event) + }, + [node, hideTooltip, setCurrentNode, onMouseLeave] + ) + + const handleClick = useCallback( + (event: MouseEvent) => { + onClick?.(node, event) + }, + [node, onClick] + ) + + return { + onMouseEnter: isInteractive ? handleMouseEnter : undefined, + onMouseMove: isInteractive ? handleMouseMove : undefined, + onMouseLeave: isInteractive ? handleMouseLeave : undefined, + onClick: isInteractive ? handleClick : undefined, + } +} + +/** + * This hook may generates mouse event handlers for a node according to the main chart props. + * It's used for the default `Node` component and may be used for custom nodes + * to simplify their implementation. + */ +export const useLinkMouseEventHandlers = ( + link: ComputedLink, + { + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + tooltipAnchor, + }: { + isInteractive: boolean + onMouseEnter?: LinkMouseEventHandler + onMouseMove?: LinkMouseEventHandler + onMouseLeave?: LinkMouseEventHandler + onClick?: LinkMouseEventHandler + tooltip?: LinkTooltip + tooltipAnchor: TooltipAnchor + } +) => { + const { showTooltipFromEvent, hideTooltip } = useTooltip() + + const showTooltip = useMemo(() => { + if (!tooltip) return undefined + + return (event: MouseEvent) => { + showTooltipFromEvent( + createElement(tooltip, { + link, + }), + event, + tooltipAnchor + ) + } + }, [link, tooltip, showTooltipFromEvent, tooltipAnchor]) + + const handleMouseEnter = useCallback( + (event: MouseEvent) => { + showTooltip?.(event) + onMouseEnter?.(link, event) + }, + [link, showTooltip, onMouseEnter] + ) + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + showTooltip?.(event) + onMouseMove?.(link, event) + }, + [link, showTooltip, onMouseMove] + ) + + const handleMouseLeave = useCallback( + (event: MouseEvent) => { + hideTooltip() + onMouseLeave?.(link, event) + }, + [link, hideTooltip, onMouseLeave] + ) + + const handleClick = useCallback( + (event: MouseEvent) => { + onClick?.(link, event) + }, + [link, onClick] + ) + + return { + onMouseEnter: isInteractive ? handleMouseEnter : undefined, + onMouseMove: isInteractive ? handleMouseMove : undefined, + onMouseLeave: isInteractive ? handleMouseLeave : undefined, + onClick: isInteractive ? handleClick : undefined, + } +} diff --git a/packages/tree/src/index.ts b/packages/tree/src/index.ts new file mode 100644 index 000000000..a071a4726 --- /dev/null +++ b/packages/tree/src/index.ts @@ -0,0 +1,8 @@ +export * from './Tree' +export * from './ResponsiveTree' +export * from './TreeCanvas' +export * from './ResponsiveTreeCanvas' +export * from './hooks' +export * from './labelsHooks' +export * from './types' +export * from './defaults' diff --git a/packages/tree/src/labelsHooks.ts b/packages/tree/src/labelsHooks.ts new file mode 100644 index 000000000..6b16f01de --- /dev/null +++ b/packages/tree/src/labelsHooks.ts @@ -0,0 +1,256 @@ +import { useMemo } from 'react' +import { usePropertyAccessor } from '@nivo/core' +import { + CommonProps, + Layout, + ComputedNode, + ComputedLabel, + LabelsPosition, + LabelTextAnchor, + LabelBaseline, +} from './types' + +interface LabelPositionResult { + x: number + y: number + rotation: number + textAnchor: LabelTextAnchor + baseline: LabelBaseline +} + +type GetLabelPosition = (node: ComputedNode) => LabelPositionResult + +interface LabelPositionFactoryProps { + orient: boolean + offset: number +} + +const horizontalLabelBefore = (x: number, y: number, offset: number): LabelPositionResult => ({ + x: x - offset, + y: y, + rotation: 0, + textAnchor: 'end', + baseline: 'middle', +}) + +const horizontalLabelAfter = (x: number, y: number, offset: number): LabelPositionResult => ({ + x: x + offset, + y: y, + rotation: 0, + textAnchor: 'start', + baseline: 'middle', +}) + +const verticalLabelBefore = (x: number, y: number, offset: number): LabelPositionResult => ({ + x: x, + y: y - offset, + rotation: 0, + textAnchor: 'middle', + baseline: 'auto', +}) + +const verticalLabelBeforeOriented = ( + x: number, + y: number, + offset: number +): LabelPositionResult => ({ + x: x, + y: y - offset, + rotation: -90, + textAnchor: 'start', + baseline: 'middle', +}) + +const verticalLabelAfter = (x: number, y: number, offset: number): LabelPositionResult => ({ + x: x, + y: y + offset, + rotation: 0, + textAnchor: 'middle', + baseline: 'hanging', +}) + +const verticalLabelAfterOriented = (x: number, y: number, offset: number): LabelPositionResult => ({ + x: x, + y: y + offset, + rotation: -90, + textAnchor: 'end', + baseline: 'middle', +}) + +const verticalLeavesBeforeOthersAfter = + ({ orient, offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + const spacing = node.size / 2 + offset + if (node.isLeaf) { + if (orient) return verticalLabelBeforeOriented(node.x, node.y, spacing) + else return verticalLabelBefore(node.x, node.y, spacing) + } else { + if (orient) return verticalLabelAfterOriented(node.x, node.y, spacing) + else return verticalLabelAfter(node.x, node.y, spacing) + } + } + +const verticalLeavesAfterOthersBefore = + ({ orient, offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + const spacing = node.size / 2 + offset + if (node.isLeaf) { + if (orient) return verticalLabelAfterOriented(node.x, node.y, spacing) + else return verticalLabelAfter(node.x, node.y, spacing) + } else { + if (orient) return verticalLabelBeforeOriented(node.x, node.y, spacing) + else return verticalLabelBefore(node.x, node.y, spacing) + } + } + +const verticalAllBefore = + ({ orient, offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + const spacing = node.size / 2 + offset + if (orient) return verticalLabelBeforeOriented(node.x, node.y, spacing) + else return verticalLabelBefore(node.x, node.y, spacing) + } + +const verticalAllAfter = + ({ orient, offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + const spacing = node.size / 2 + offset + if (orient) return verticalLabelAfterOriented(node.x, node.y, spacing) + else return verticalLabelAfter(node.x, node.y, spacing) + } + +const horizontalLeavesBeforeOthersAfter = + ({ offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + const spacing = node.size / 2 + offset + if (node.isLeaf) return horizontalLabelBefore(node.x, node.y, spacing) + else return horizontalLabelAfter(node.x, node.y, spacing) + } + +const horizontalLeavesAfterOthersBefore = + ({ offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + const spacing = node.size / 2 + offset + if (node.isLeaf) return horizontalLabelAfter(node.x, node.y, spacing) + return horizontalLabelBefore(node.x, node.y, spacing) + } + +const horizontalAllBefore = + ({ offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + return horizontalLabelBefore(node.x, node.y, node.size / 2 + offset) + } + +const horizontalAllAfter = + ({ offset }: LabelPositionFactoryProps): GetLabelPosition => + (node: ComputedNode) => { + return horizontalLabelAfter(node.x, node.y, node.size / 2 + offset) + } + +const useGetLabelPosition = ({ + layout, + labelsPosition, + orientLabel, + labelOffset, +}: { + layout: Layout + labelsPosition: LabelsPosition + orientLabel: boolean + labelOffset: number +}) => + useMemo(() => { + const options: LabelPositionFactoryProps = { + orient: orientLabel, + offset: labelOffset, + } + + if (layout === 'top-to-bottom') { + if (labelsPosition === 'outward') { + return verticalLeavesAfterOthersBefore(options) + } else if (labelsPosition === 'inward') { + return verticalLeavesBeforeOthersAfter(options) + } else if (labelsPosition === 'layout') { + return verticalAllAfter(options) + } else if (labelsPosition === 'layout-opposite') { + return verticalAllBefore(options) + } + } + + if (layout === 'bottom-to-top') { + if (labelsPosition === 'outward') { + return verticalLeavesBeforeOthersAfter(options) + } else if (labelsPosition === 'inward') { + return verticalLeavesAfterOthersBefore(options) + } else if (labelsPosition === 'layout') { + return verticalAllBefore(options) + } else if (labelsPosition === 'layout-opposite') { + return verticalAllAfter(options) + } + } + + if (layout === 'right-to-left') { + if (labelsPosition === 'outward') { + return horizontalLeavesBeforeOthersAfter(options) + } else if (labelsPosition === 'inward') { + return horizontalLeavesAfterOthersBefore(options) + } else if (labelsPosition === 'layout') { + return horizontalAllBefore(options) + } else if (labelsPosition === 'layout-opposite') { + return horizontalAllAfter(options) + } + } + + if (layout === 'left-to-right') { + if (labelsPosition === 'outward') { + return horizontalLeavesAfterOthersBefore(options) + } else if (labelsPosition === 'inward') { + return horizontalLeavesBeforeOthersAfter(options) + } else if (labelsPosition === 'layout') { + return horizontalAllAfter(options) + } else if (labelsPosition === 'layout-opposite') { + return horizontalAllBefore(options) + } + } + }, [layout, labelsPosition, orientLabel, labelOffset]) + +export const useLabels = ({ + nodes, + label, + layout, + labelsPosition, + orientLabel, + labelOffset, +}: { + nodes: readonly ComputedNode[] + label: Exclude['label'], undefined> + layout: Layout + labelsPosition: LabelsPosition + orientLabel: boolean + labelOffset: number +}) => { + const getLabel = usePropertyAccessor(label) + const getPosition = useGetLabelPosition({ + layout, + labelsPosition, + orientLabel, + labelOffset, + }) + + if (getPosition === undefined) { + throw new Error('Unable to determine the logic to compute labels position') + } + + return useMemo( + () => + nodes.map( + node => + ({ + id: node.uid, + node: node, + label: getLabel(node), + ...getPosition(node), + } as ComputedLabel) + ), + [nodes, getLabel, getPosition] + ) +} diff --git a/packages/tree/src/types.ts b/packages/tree/src/types.ts new file mode 100644 index 000000000..dee40d2d0 --- /dev/null +++ b/packages/tree/src/types.ts @@ -0,0 +1,296 @@ +import { AriaAttributes, FunctionComponent, MouseEvent } from 'react' +import { HierarchyNode } from 'd3-hierarchy' +import { Link as LinkShape, DefaultLinkObject } from 'd3-shape' +import { SpringValues } from '@react-spring/web' +import { + Box, + Dimensions, + MotionProps, + Theme, + PropertyAccessor, + CompleteTheme, + Margin, +} from '@nivo/core' +import { OrdinalColorScaleConfig, InheritedColorConfig } from '@nivo/colors' +import { TooltipAnchor, TooltipPosition } from '@nivo/tooltip' + +export type TreeMode = 'tree' | 'dendogram' +export type Layout = 'top-to-bottom' | 'right-to-left' | 'bottom-to-top' | 'left-to-right' +export type LayerId = 'links' | 'nodes' | 'labels' | 'mesh' +export type LinkCurve = 'bump' | 'linear' | 'step' | 'step-before' | 'step-after' +export type LabelsPosition = 'outward' | 'inward' | 'layout' | 'layout-opposite' + +export interface DefaultDatum { + id: string + value?: number + children?: readonly DefaultDatum[] +} + +export interface HierarchyTreeNode extends HierarchyNode { + uid: string | undefined + ancestorIds: readonly string[] | undefined + ancestorUids: readonly string[] | undefined + descendantUids: readonly string[] | undefined +} + +export interface HierarchyTreeLink { + source: HierarchyTreeNode + target: HierarchyTreeNode +} + +export interface IntermediateComputedNode { + path: readonly string[] + uid: string + isRoot: boolean + isLeaf: boolean + ancestorIds: readonly string[] + ancestorUids: readonly string[] + descendantUids: readonly string[] + id: string + data: Datum + depth: number + height: number + x: number + y: number +} + +export interface ComputedNode extends IntermediateComputedNode { + size: number + color: string + isActive: boolean | null +} + +export type CurrentNodeSetter = (node: ComputedNode | null) => void + +export interface IntermediateComputedLink { + id: string + source: ComputedNode + target: ComputedNode +} + +export interface ComputedLink extends IntermediateComputedLink { + thickness: number + color: string + isActive: boolean | null +} + +export type NodeSizeFunction = (node: IntermediateComputedNode) => number + +export type NodeSizeModifierFunction = (node: ComputedNode) => number + +export type NodeAnimatedProps = { + x: number + y: number + size: number + color: string +} + +export interface NodeComponentProps { + node: ComputedNode + isInteractive: boolean + onMouseEnter?: NodeMouseEventHandler + onMouseMove?: NodeMouseEventHandler + onMouseLeave?: NodeMouseEventHandler + onClick?: NodeMouseEventHandler + setCurrentNode: CurrentNodeSetter + tooltip?: NodeTooltip + tooltipPosition: TooltipPosition + tooltipAnchor: TooltipAnchor + margin: Margin + animatedProps: SpringValues +} +export type NodeComponent = FunctionComponent> + +export interface NodeCanvasRendererProps { + node: ComputedNode +} +export type NodeCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: NodeCanvasRendererProps +) => void + +export interface NodeTooltipProps { + node: ComputedNode +} +export type NodeTooltip = FunctionComponent> + +export type NodeMouseEventHandler = (node: ComputedNode, event: MouseEvent) => void + +export type LinkThicknessFunction = (link: IntermediateComputedLink) => number + +export type LinkThicknessModifierFunction = (link: ComputedLink) => number + +export type LinkAnimatedProps = { + sourceX: number + sourceY: number + targetX: number + targetY: number + thickness: number + color: string +} + +export type LinkGenerator = LinkShape + +export interface LinkComponentProps { + link: ComputedLink + linkGenerator: LinkGenerator + isInteractive: boolean + onMouseEnter?: LinkMouseEventHandler + onMouseMove?: LinkMouseEventHandler + onMouseLeave?: LinkMouseEventHandler + onClick?: LinkMouseEventHandler + tooltip?: LinkTooltip + tooltipAnchor: TooltipAnchor + animatedProps: SpringValues +} +export type LinkComponent = FunctionComponent> + +export interface LinkCanvasRendererProps { + link: ComputedLink + linkGenerator: LinkGenerator +} +export type LinkCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: LinkCanvasRendererProps +) => void + +export type LinkMouseEventHandler = (node: ComputedLink, event: MouseEvent) => void + +export interface LinkTooltipProps { + link: ComputedLink +} +export type LinkTooltip = FunctionComponent> + +export type LabelTextAnchor = 'start' | 'middle' | 'end' +export type LabelBaseline = 'auto' | 'middle' | 'hanging' + +export interface ComputedLabel { + id: string // node.uid + label: string + node: ComputedNode + x: number + y: number + rotation: number + textAnchor: LabelTextAnchor + baseline: LabelBaseline +} + +export type LabelAnimatedProps = { + x: number + y: number + rotation: number +} + +export interface LabelComponentProps { + label: ComputedLabel + animatedProps: SpringValues +} +export type LabelComponent = FunctionComponent> + +export interface LabelCanvasRendererProps { + label: ComputedLabel + theme: CompleteTheme +} +export type LabelCanvasRenderer = ( + ctx: CanvasRenderingContext2D, + props: LabelCanvasRendererProps +) => void + +export interface CustomSvgLayerProps { + nodes: readonly ComputedNode[] + nodeByUid: Record> + links: readonly ComputedLink[] + innerWidth: number + innerHeight: number + linkGenerator: LinkGenerator + setCurrentNode: (node: ComputedNode | null) => void +} +export type CustomSvgLayer = FunctionComponent> + +export type CustomCanvasLayerProps = Omit, 'setCurrentNode'> +export type CustomCanvasLayer = ( + ctx: CanvasRenderingContext2D, + props: CustomCanvasLayerProps +) => void + +export interface TreeDataProps { + data: Datum +} + +export interface CommonProps extends MotionProps { + margin: Box + + mode: TreeMode + layout: Layout + identity: PropertyAccessor + + theme: Theme + nodeSize: number | NodeSizeFunction + activeNodeSize: number | NodeSizeModifierFunction + inactiveNodeSize: number | NodeSizeModifierFunction + nodeColor: OrdinalColorScaleConfig> + fixNodeColorAtDepth: number + linkCurve: LinkCurve + linkThickness: number | LinkThicknessFunction + activeLinkThickness: number | LinkThicknessModifierFunction + inactiveLinkThickness: number | LinkThicknessModifierFunction + linkColor: InheritedColorConfig> + + enableLabel: boolean + label: PropertyAccessor, string> + labelsPosition: LabelsPosition + orientLabel: boolean + labelOffset: number + + isInteractive: boolean + useMesh: boolean + meshDetectionRadius: number + debugMesh: boolean + highlightAncestorNodes: boolean + highlightDescendantNodes: boolean + highlightAncestorLinks: boolean + highlightDescendantLinks: boolean + onNodeMouseEnter: NodeMouseEventHandler + onNodeMouseMove: NodeMouseEventHandler + onNodeMouseLeave: NodeMouseEventHandler + onNodeClick: NodeMouseEventHandler + nodeTooltip: NodeTooltip + nodeTooltipPosition: TooltipPosition + nodeTooltipAnchor: TooltipAnchor + + role: string + ariaLabel: AriaAttributes['aria-label'] + ariaLabelledBy: AriaAttributes['aria-labelledby'] + ariaDescribedBy: AriaAttributes['aria-describedby'] + + renderWrapper: boolean +} + +export type TreeSvgProps = TreeDataProps & + Dimensions & + Partial> & { + layers?: (LayerId | CustomSvgLayer)[] + nodeComponent?: NodeComponent + linkComponent?: LinkComponent + labelComponent?: LabelComponent + onLinkMouseEnter: LinkMouseEventHandler + onLinkMouseMove: LinkMouseEventHandler + onLinkMouseLeave: LinkMouseEventHandler + onLinkClick: LinkMouseEventHandler + linkTooltip: LinkTooltip + linkTooltipAnchor: TooltipAnchor + } + +export type ResponsiveTreeSvgProps = Omit, 'height' | 'width'> + +export type TreeCanvasProps = TreeDataProps & + Dimensions & + Partial> & { + layers?: (LayerId | CustomCanvasLayer)[] + renderNode?: NodeCanvasRenderer + renderLink?: LinkCanvasRenderer + renderLabel?: LabelCanvasRenderer + pixelRatio?: number + } + +export type ResponsiveTreeCanvasProps = Omit, 'height' | 'width'> diff --git a/packages/tree/tests/.eslintrc.yml b/packages/tree/tests/.eslintrc.yml new file mode 100644 index 000000000..2f8de9aea --- /dev/null +++ b/packages/tree/tests/.eslintrc.yml @@ -0,0 +1,2 @@ +env: + jest: true diff --git a/packages/tree/tests/Tree.test.tsx b/packages/tree/tests/Tree.test.tsx new file mode 100644 index 000000000..5a33f29cc --- /dev/null +++ b/packages/tree/tests/Tree.test.tsx @@ -0,0 +1,417 @@ +import { create, ReactTestInstance } from 'react-test-renderer' +import { Globals } from '@react-spring/web' +import { Tree, TreeSvgProps, Layout, LabelsPosition } from '../src' +import { Node } from '../src/Node' +import { Label } from '../src/Label' + +interface Datum { + id: string + children?: Datum[] +} + +const sampleData: Datum = { + id: '0', + children: [ + { id: 'A' }, + { + id: 'B', + children: [{ id: '0' }], + }, + { id: 'C' }, + ], +} + +const baseProps: TreeSvgProps = { + width: 300, + height: 300, + data: sampleData, + nodeSize: 10, + labelOffset: 10, + useMesh: false, + animate: false, +} + +describe('Tree', () => { + beforeAll(() => { + Globals.assign({ skipAnimation: true }) + }) + + afterAll(() => { + Globals.assign({ skipAnimation: false }) + }) + + it('should render a tree graph', () => { + const root = create( {...baseProps} />).root + + const nodes = root.findAllByType(Node) + expect(nodes).toHaveLength(5) + + expect(nodes[0].props.node.uid).toEqual('0') + expect(nodes[1].props.node.uid).toEqual('0.A') + expect(nodes[2].props.node.uid).toEqual('0.B') + expect(nodes[3].props.node.uid).toEqual('0.C') + expect(nodes[4].props.node.uid).toEqual('0.B.0') + }) + + describe('labels', () => { + it('should skip labels if disabled', () => { + const root = create( {...baseProps} enableLabel={false} />).root + + expect(root.findAllByType(Label)).toHaveLength(0) + }) + + it('should skip labels if the layer is not configured', () => { + const root = create( {...baseProps} layers={['links', 'nodes']} />).root + + expect(root.findAllByType(Label)).toHaveLength(0) + }) + + // Positioning tests involve quite some logic to ease checking the various + // combination we can have, depending on `layout`, `labelsPosition` and `orienLabel`, + // we should normally avoid having too much logic in tests, as it's more fragile + // and harder to debug, but it would really be too redundant otherwise. + describe('positioning', () => { + type LabelPositionSpec = [x: number, y: number, rotation: number] + + // nodeSize: 10 + // labelOffset: 10 + // So the total offset should be 15, half the node size + label offset. + // Also note that the center of the graph is 150, as the width and height are 300. + const labelsPositionTestCases: { + layout: Layout + labelsPosition: LabelsPosition + orientLabel: boolean + expected: [ + root: LabelPositionSpec, + intermediate: LabelPositionSpec, + leaf: LabelPositionSpec + ] + }[] = [ + { + layout: 'top-to-bottom', + labelsPosition: 'outward', + orientLabel: false, + expected: [ + [150, -15, 0], + [150, 150 - 15, 0], + [150, 300 + 15, 0], + ], + }, + { + layout: 'top-to-bottom', + labelsPosition: 'outward', + orientLabel: true, + expected: [ + [150, -15, -90], + [150, 150 - 15, -90], + [150, 300 + 15, -90], + ], + }, + { + layout: 'top-to-bottom', + labelsPosition: 'inward', + orientLabel: false, + expected: [ + [150, 15, 0], + [150, 150 + 15, 0], + [150, 300 - 15, 0], + ], + }, + { + layout: 'top-to-bottom', + labelsPosition: 'inward', + orientLabel: true, + expected: [ + [150, 15, -90], + [150, 150 + 15, -90], + [150, 300 - 15, -90], + ], + }, + { + layout: 'top-to-bottom', + labelsPosition: 'layout', + orientLabel: false, + expected: [ + [150, 15, 0], + [150, 150 + 15, 0], + [150, 300 + 15, 0], + ], + }, + { + layout: 'top-to-bottom', + labelsPosition: 'layout', + orientLabel: true, + expected: [ + [150, 15, -90], + [150, 150 + 15, -90], + [150, 300 + 15, -90], + ], + }, + { + layout: 'top-to-bottom', + labelsPosition: 'layout-opposite', + orientLabel: false, + expected: [ + [150, -15, 0], + [150, 150 - 15, 0], + [150, 300 - 15, 0], + ], + }, + { + layout: 'top-to-bottom', + labelsPosition: 'layout-opposite', + orientLabel: true, + expected: [ + [150, -15, -90], + [150, 150 - 15, -90], + [150, 300 - 15, -90], + ], + }, + { + layout: 'right-to-left', + labelsPosition: 'outward', + orientLabel: true, + expected: [ + [300 + 15, 150, 0], + [150 + 15, 150, 0], + [-15, 150, 0], + ], + }, + { + layout: 'right-to-left', + labelsPosition: 'inward', + orientLabel: true, + expected: [ + [300 - 15, 150, 0], + [150 - 15, 150, 0], + [15, 150, 0], + ], + }, + { + layout: 'right-to-left', + labelsPosition: 'layout', + orientLabel: true, + expected: [ + [300 - 15, 150, 0], + [150 - 15, 150, 0], + [-15, 150, 0], + ], + }, + { + layout: 'right-to-left', + labelsPosition: 'layout-opposite', + orientLabel: true, + expected: [ + [300 + 15, 150, 0], + [150 + 15, 150, 0], + [15, 150, 0], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'outward', + orientLabel: false, + expected: [ + [150, 300 + 15, 0], + [150, 150 + 15, 0], + [150, -15, 0], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'outward', + orientLabel: true, + expected: [ + [150, 300 + 15, -90], + [150, 150 + 15, -90], + [150, -15, -90], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'inward', + orientLabel: false, + expected: [ + [150, 300 - 15, 0], + [150, 150 - 15, 0], + [150, 15, 0], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'inward', + orientLabel: true, + expected: [ + [150, 300 - 15, -90], + [150, 150 - 15, -90], + [150, 15, -90], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'layout', + orientLabel: false, + expected: [ + [150, 300 - 15, 0], + [150, 150 - 15, 0], + [150, -15, 0], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'layout', + orientLabel: true, + expected: [ + [150, 300 - 15, -90], + [150, 150 - 15, -90], + [150, -15, -90], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'layout-opposite', + orientLabel: false, + expected: [ + [150, 300 + 15, 0], + [150, 150 + 15, 0], + [150, 15, 0], + ], + }, + { + layout: 'bottom-to-top', + labelsPosition: 'layout-opposite', + orientLabel: true, + expected: [ + [150, 300 + 15, -90], + [150, 150 + 15, -90], + [150, 15, -90], + ], + }, + { + layout: 'left-to-right', + labelsPosition: 'outward', + orientLabel: true, + expected: [ + [-15, 150, 0], + [150 - 15, 150, 0], + [300 + 15, 150, 0], + ], + }, + { + layout: 'left-to-right', + labelsPosition: 'inward', + orientLabel: true, + expected: [ + [15, 150, 0], + [150 + 15, 150, 0], + [300 - 15, 150, 0], + ], + }, + { + layout: 'left-to-right', + labelsPosition: 'layout', + orientLabel: true, + expected: [ + [15, 150, 0], + [150 + 15, 150, 0], + [300 + 15, 150, 0], + ], + }, + { + layout: 'left-to-right', + labelsPosition: 'layout-opposite', + orientLabel: true, + expected: [ + [-15, 150, 0], + [150 - 15, 150, 0], + [300 - 15, 150, 0], + ], + }, + ] + + const extractLabelPosition = (label: ReactTestInstance) => { + // element defining the label translation. + // We have to go 2 level down because of react-spring. + const topGroup = (label.children[0] as ReactTestInstance) + .children[0] as ReactTestInstance + expect(topGroup).toBeDefined() + const translationTransform: string = topGroup.props.transform + + const translationRegExp = /^translate\((?[-0-9.]+),(?[-0-9.]+)\)$/ + const translationMatch = translationRegExp.exec(translationTransform)! + expect(translationMatch).not.toBeNull() + const x = Number(translationMatch.groups!.x) + const y = Number(translationMatch.groups!.y) + + // element defining the label rotation. + // We have to go 2 level down because of react-spring. + const nestedGroup = (topGroup.children[0] as ReactTestInstance) + .children[0] as ReactTestInstance + expect(nestedGroup).toBeDefined() + const rotationTransform: string = nestedGroup.props.transform + + const rotationRegExp = /^rotate\((?[-0-9.]+)\)$/ + const rotationMatch = rotationRegExp.exec(rotationTransform)! + expect(rotationMatch).not.toBeNull() + const rotation = Number(rotationMatch.groups!.r) + + return { x, y, rotation } + } + + labelsPositionTestCases.forEach(testCase => { + const description = [ + `layout: ${testCase.layout}`, + `labelsPosition: ${testCase.labelsPosition}`, + `orientLabel: ${testCase.orientLabel ? 'true' : 'false'}`, + ].join(', ') + + it(description, () => { + const root = create( + + {...baseProps} + layout={testCase.layout} + labelsPosition={testCase.labelsPosition} + orientLabel={testCase.orientLabel} + /> + ).root + + // We're going to test 3 nodes, a root node, an intermediate one, and a leaf, + // it should be enough to assess that things are working as expected. + // Also note that nodes are in the middle, so that it's easier + // to check their position. + const labels = root.findAllByType(Label) + + const [ + [rootX, rootY, rootRotation], + [intermediateX, intermediateY, intermediateRotation], + [leafX, leafY, leafRotation], + ] = testCase.expected + + const rootLabel = labels.find(label => label.props.label.id === '0')! + expect(rootLabel).toBeDefined() + + const rootPosition = extractLabelPosition(rootLabel) + expect(rootPosition.x).toEqual(rootX) + expect(rootPosition.y).toEqual(rootY) + expect(rootPosition.rotation).toEqual(rootRotation) + + const intermediateLabel = labels.find(label => label.props.label.id === '0.B')! + expect(intermediateLabel).toBeDefined() + + const intermediatePosition = extractLabelPosition(intermediateLabel) + expect(intermediatePosition.x).toEqual(intermediateX) + expect(intermediatePosition.y).toEqual(intermediateY) + expect(intermediatePosition.rotation).toEqual(intermediateRotation) + + const leafLabel = labels.find(label => label.props.label.id === '0.B.0')! + expect(leafLabel).toBeDefined() + + const leafPosition = extractLabelPosition(leafLabel) + expect(leafPosition.x).toEqual(leafX) + expect(leafPosition.y).toEqual(leafY) + expect(leafPosition.rotation).toEqual(leafRotation) + }) + }) + }) + }) +}) diff --git a/packages/tree/tsconfig.json b/packages/tree/tsconfig.json new file mode 100644 index 000000000..569f591b4 --- /dev/null +++ b/packages/tree/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.types.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "./dist/types", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/packages/voronoi/package.json b/packages/voronoi/package.json index 5ecc330fe..30386b809 100644 --- a/packages/voronoi/package.json +++ b/packages/voronoi/package.json @@ -30,9 +30,10 @@ ], "dependencies": { "@nivo/core": "workspace:*", - "@types/d3-delaunay": "^5.3.0", + "@nivo/tooltip": "workspace: *", + "@types/d3-delaunay": "^6.0.4", "@types/d3-scale": "^4.0.8", - "d3-delaunay": "^5.3.0", + "d3-delaunay": "^6.0.4", "d3-scale": "^4.0.2" }, "peerDependencies": { diff --git a/packages/voronoi/src/Mesh.tsx b/packages/voronoi/src/Mesh.tsx index 9ff8f431a..9e0baaf77 100644 --- a/packages/voronoi/src/Mesh.tsx +++ b/packages/voronoi/src/Mesh.tsx @@ -1,34 +1,44 @@ -import { useRef, useState, useCallback, useMemo, MouseEvent, TouchEvent } from 'react' -import { getRelativeCursor } from '@nivo/core' -import { useVoronoiMesh } from './hooks' -import { XYAccessor } from './computeMesh' - -type MouseHandler = (datum: Datum, event: MouseEvent) => void -type TouchHandler = (datum: Datum, event: TouchEvent) => void - -interface MeshProps { - nodes: Datum[] +import { useMemo, useRef } from 'react' +import { Margin } from '@nivo/core' +import { TooltipAnchor, TooltipPosition } from '@nivo/tooltip' +import { useVoronoiMesh, useMeshEvents } from './hooks' +import { NodeMouseHandler, NodePositionAccessor, NodeTouchHandler } from './types' +import { defaultMargin, defaultTooltipAnchor, defaultTooltipPosition } from './defaults' + +interface MeshProps { + nodes: Node[] width: number height: number - x?: XYAccessor - y?: XYAccessor - onMouseEnter?: MouseHandler - onMouseMove?: MouseHandler - onMouseLeave?: MouseHandler - onClick?: MouseHandler - onTouchStart?: TouchHandler - onTouchMove?: TouchHandler - onTouchEnd?: TouchHandler + margin?: Margin + getNodePosition?: NodePositionAccessor + // Can be used in case you want to keep track of the current node externally, + // the current node being the last hovered node. + setCurrent?: (node: Node | null) => void + onMouseEnter?: NodeMouseHandler + onMouseMove?: NodeMouseHandler + onMouseLeave?: NodeMouseHandler + onClick?: NodeMouseHandler + onTouchStart?: NodeTouchHandler + onTouchMove?: NodeTouchHandler + onTouchEnd?: NodeTouchHandler enableTouchCrosshair?: boolean + // Restrict the node detection to a given radius, default to `Infinity`. + detectionRadius?: number + // If specified, tooltips are going to be handled automatically. + tooltip?: (node: Node) => JSX.Element + tooltipPosition?: TooltipPosition + tooltipAnchor?: TooltipAnchor + // Display the voronoi mesh for debugging purpose. debug?: boolean } -export const Mesh = ({ +export const Mesh = ({ nodes, width, height, - x, - y, + margin = defaultMargin, + getNodePosition, + setCurrent, onMouseEnter, onMouseMove, onMouseLeave, @@ -37,161 +47,81 @@ export const Mesh = ({ onTouchMove, onTouchEnd, enableTouchCrosshair = false, + detectionRadius = Infinity, + tooltip, + tooltipPosition = defaultTooltipPosition, + tooltipAnchor = defaultTooltipAnchor, debug, -}: MeshProps) => { - const elementRef = useRef(null) - const [currentIndex, setCurrentIndex] = useState(null) +}: MeshProps) => { + const elementRef = useRef(null) - const { delaunay, voronoi } = useVoronoiMesh({ + const { delaunay, voronoi } = useVoronoiMesh({ points: nodes, - x, - y, + getNodePosition, width, height, + margin, debug, }) - const voronoiPath = useMemo(() => { - if (debug && voronoi) { - return voronoi.render() - } + const { + current, + handleMouseEnter, + handleMouseMove, + handleMouseLeave, + handleClick, + handleTouchStart, + handleTouchMove, + handleTouchEnd, + } = useMeshEvents({ + elementRef, + nodes, + delaunay, + margin, + detectionRadius, + setCurrent, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + onTouchStart, + onTouchMove, + onTouchEnd, + enableTouchCrosshair, + tooltip, + tooltipPosition, + tooltipAnchor, + }) + const voronoiPath = useMemo(() => { + if (debug && voronoi) return voronoi.render() return undefined }, [debug, voronoi]) - const getIndexAndNodeFromMouseEvent = useCallback( - (event: MouseEvent) => { - if (!elementRef.current) { - return [null, null] - } - - const [x, y] = getRelativeCursor(elementRef.current, event) - const index = delaunay.find(x, y) - - return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null] - }, - [delaunay, nodes] - ) - - const getIndexAndNodeFromTouchEvent = useCallback( - (event: TouchEvent) => { - if (!elementRef.current) { - return [null, null] - } - - const [x, y] = getRelativeCursor(elementRef.current, event) - const index = delaunay.find(x, y) - - return [index, index !== undefined ? nodes[index] : null] as [number, Datum | null] - }, - [delaunay, nodes] - ) - - const handleMouseEnter = useCallback( - (event: MouseEvent) => { - const [index, node] = getIndexAndNodeFromMouseEvent(event) - setCurrentIndex(index) - if (node) { - onMouseEnter?.(node, event) - } - }, - [getIndexAndNodeFromMouseEvent, setCurrentIndex, onMouseEnter] - ) - - const handleMouseMove = useCallback( - (event: MouseEvent) => { - const [index, node] = getIndexAndNodeFromMouseEvent(event) - setCurrentIndex(index) - if (node) { - onMouseMove?.(node, event) - } - }, - [getIndexAndNodeFromMouseEvent, setCurrentIndex, onMouseMove] - ) - - const handleMouseLeave = useCallback( - (event: MouseEvent) => { - setCurrentIndex(null) - if (onMouseLeave) { - let previousNode: Datum | undefined = undefined - if (currentIndex !== null) { - previousNode = nodes[currentIndex] - } - previousNode && onMouseLeave(previousNode, event) - } - }, - [setCurrentIndex, currentIndex, onMouseLeave, nodes] - ) - - const handleClick = useCallback( - (event: MouseEvent) => { - const [index, node] = getIndexAndNodeFromMouseEvent(event) - setCurrentIndex(index) - if (node) { - onClick?.(node, event) - } - }, - [getIndexAndNodeFromMouseEvent, setCurrentIndex, onClick] - ) - - const handleTouchStart = useCallback( - (event: TouchEvent) => { - const [index, node] = getIndexAndNodeFromTouchEvent(event) - if (enableTouchCrosshair) { - setCurrentIndex(index) - } - if (node) { - onTouchStart?.(node, event) - } - }, - [getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchStart] - ) - - const handleTouchMove = useCallback( - (event: TouchEvent) => { - const [index, node] = getIndexAndNodeFromTouchEvent(event) - if (enableTouchCrosshair) { - setCurrentIndex(index) - } - if (node) { - onTouchMove?.(node, event) - } - }, - [getIndexAndNodeFromTouchEvent, enableTouchCrosshair, onTouchMove] - ) - - const handleTouchEnd = useCallback( - (event: TouchEvent) => { - if (enableTouchCrosshair) { - setCurrentIndex(null) - } - if (onTouchEnd) { - let previousNode: Datum | undefined = undefined - if (currentIndex !== null) { - previousNode = nodes[currentIndex] - } - previousNode && onTouchEnd(previousNode, event) - } - }, - [enableTouchCrosshair, onTouchEnd, currentIndex, nodes] - ) - return ( - + {debug && voronoi && ( <> - {/* highlight current cell */} - {currentIndex !== null && ( - + {detectionRadius < Infinity && ( + + )} + {/* highlight the current cell */} + {current && ( + )} )} {/* transparent rect to intercept mouse events */} = { - [K in keyof T]: T[K] extends number ? K : never -}[keyof T] - -export type XYAccessor = NumberPropertyNames | ((datum: Datum) => number) - -const getAccessor = (directive: XYAccessor) => - typeof directive === 'function' ? directive : (datum: Datum) => datum[directive] +import { Margin } from '@nivo/core' +import { NodePositionAccessor } from './types' +import { defaultNodePositionAccessor, defaultMargin } from './defaults' /** * The delaunay generator requires an array @@ -17,34 +11,44 @@ const getAccessor = (directive: XYAccessor) => * Points represent the raw input data * and x/y represent accessors to x & y. */ -export const computeMeshPoints = ({ +export const computeMeshPoints = ({ points, - x = 'x' as NumberPropertyNames, - y = 'y' as NumberPropertyNames, + getNodePosition = defaultNodePositionAccessor as NodePositionAccessor, + margin = defaultMargin, }: { - points: Datum[] - x?: XYAccessor - y?: XYAccessor + points: readonly Node[] + getNodePosition?: NodePositionAccessor + margin?: Margin }): [number, number][] => { - const getX = getAccessor(x) - const getY = getAccessor(y) + return points.map(node => { + const [x, y] = getNodePosition(node) - return points.map(point => [getX(point) as number, getY(point) as number]) + return [x + margin.left, y + margin.top] + }) } export const computeMesh = ({ points, width, height, + margin = defaultMargin, debug, }: { - points: [number, number][] + points: readonly [number, number][] width: number height: number + margin?: Margin debug?: boolean }) => { const delaunay = Delaunay.from(points) - const voronoi = debug ? delaunay.voronoi([0, 0, width, height]) : undefined + const voronoi = debug + ? delaunay.voronoi([ + 0, + 0, + margin.left + width + margin.right, + margin.top + height + margin.bottom, + ]) + : undefined - return { delaunay, voronoi } + return { points, delaunay, voronoi } } diff --git a/packages/voronoi/src/defaults.ts b/packages/voronoi/src/defaults.ts new file mode 100644 index 000000000..314cce6ce --- /dev/null +++ b/packages/voronoi/src/defaults.ts @@ -0,0 +1,12 @@ +import { Margin, defaultMargin as coreDefaultMargin } from '@nivo/core' +import { TooltipAnchor, TooltipPosition } from '@nivo/tooltip' + +export const defaultNodePositionAccessor = (node: { + x: number + y: number +}): [x: number, y: number] => [node.x, node.y] + +export const defaultMargin: Margin = coreDefaultMargin + +export const defaultTooltipPosition: TooltipPosition = 'cursor' +export const defaultTooltipAnchor: TooltipAnchor = 'top' diff --git a/packages/voronoi/src/hooks.ts b/packages/voronoi/src/hooks.ts index a13c92aa7..b6ef38cc6 100644 --- a/packages/voronoi/src/hooks.ts +++ b/packages/voronoi/src/hooks.ts @@ -1,31 +1,63 @@ -import { useMemo } from 'react' +import { + MouseEvent, + MutableRefObject, + TouchEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { scaleLinear } from 'd3-scale' import { Delaunay } from 'd3-delaunay' -import { computeMeshPoints, computeMesh, XYAccessor } from './computeMesh' -import { VoronoiCommonProps, VoronoiDatum, VoronoiCustomLayerProps } from './types' +import { getDistance, getRelativeCursor, Margin } from '@nivo/core' +import { TooltipAnchor, TooltipPosition, useTooltip } from '@nivo/tooltip' +import { computeMeshPoints, computeMesh } from './computeMesh' +import { + VoronoiCommonProps, + VoronoiDatum, + VoronoiCustomLayerProps, + NodeMouseHandler, + // DatumTouchHandler, + NodePositionAccessor, + NodeTouchHandler, +} from './types' +import { + defaultMargin, + defaultNodePositionAccessor, + defaultTooltipPosition, + defaultTooltipAnchor, +} from './defaults' -export const useVoronoiMesh = ({ +export const useVoronoiMesh = ({ points, - x, - y, + getNodePosition = defaultNodePositionAccessor as NodePositionAccessor, width, height, + margin = defaultMargin, debug, }: { - points: Datum[] - x?: XYAccessor - y?: XYAccessor + points: readonly Node[] + getNodePosition?: NodePositionAccessor + // Margins are added to the chart's dimensions, so that mouse detection + // also works inside the margins, omit if that's not what you want. + // When including the margins, we recommend to set a `detectionRadius` as well. + margin?: Margin width: number height: number debug?: boolean -}) => { - const points2d = useMemo(() => computeMeshPoints({ points, x, y }), [points, x, y]) - - return useMemo( - () => computeMesh({ points: points2d, width, height, debug }), - [points2d, width, height, debug] +}) => + useMemo( + () => + computeMesh({ + points: computeMeshPoints({ points, margin, getNodePosition }), + width, + height, + margin, + debug, + }), + [points, width, height, margin, debug] ) -} export const useVoronoi = ({ data, @@ -84,3 +116,330 @@ export const useVoronoiLayerContext = ({ }), [points, delaunay, voronoi] ) + +export const useMeshEvents = ({ + elementRef, + nodes, + getNodePosition = defaultNodePositionAccessor as NodePositionAccessor, + delaunay, + setCurrent: setCurrentNode, + margin = defaultMargin, + detectionRadius = Infinity, + isInteractive = true, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + onTouchStart, + onTouchMove, + onTouchEnd, + enableTouchCrosshair = false, + tooltip, + tooltipPosition = defaultTooltipPosition, + tooltipAnchor = defaultTooltipAnchor, +}: { + elementRef: MutableRefObject + nodes: readonly Node[] + getNodePosition?: NodePositionAccessor + delaunay: Delaunay + setCurrent?: (node: Node | null) => void + margin?: Margin + detectionRadius?: number + isInteractive?: boolean + onMouseEnter?: NodeMouseHandler + onMouseMove?: NodeMouseHandler + onMouseLeave?: NodeMouseHandler + onClick?: NodeMouseHandler + onTouchStart?: NodeTouchHandler + onTouchMove?: NodeTouchHandler + onTouchEnd?: NodeTouchHandler + enableTouchCrosshair?: boolean + tooltip?: (node: Node) => JSX.Element + tooltipPosition?: TooltipPosition + tooltipAnchor?: TooltipAnchor +}) => { + // Store the index of the current point and the current node. + const [current, setCurrent] = useState<[number, Node] | null>(null) + + // Keep track of the previous index and node, this is needed as we don't have enter/leave events + // for each node because we use a single rect element to capture events. + const previous = useRef<[number, Node] | null>(null) + + useEffect(() => { + previous.current = current + }, [previous, current]) + + const findNode = useCallback( + (event: MouseEvent | TouchEvent): null | [number, Node] => { + if (!elementRef.current) return null + + const [x, y] = getRelativeCursor(elementRef.current, event) + + let index: number | null = delaunay.find(x, y) + let node = index !== undefined ? nodes[index] : null + + if (node && detectionRadius !== Infinity) { + const [nodeX, nodeY] = getNodePosition(node) + if (getDistance(x, y, nodeX + margin.left, nodeY + margin.top) > detectionRadius) { + index = null + node = null + } + } + + if (index === null || node === null) return null + + return [index, node] + }, + [elementRef, delaunay, nodes, getNodePosition, margin, detectionRadius] + ) + + const { showTooltipAt, showTooltipFromEvent, hideTooltip } = useTooltip() + const showTooltip = useMemo(() => { + if (!tooltip) return undefined + + if (tooltipPosition === 'cursor') { + // Following the cursor. + return (node: Node, event: MouseEvent) => { + showTooltipFromEvent(tooltip(node), event, tooltipAnchor) + } + } + + // Fixed at the node's position. + return (node: Node) => { + const [x, y] = getNodePosition(node) + showTooltipAt(tooltip(node), [x + margin.left, y + margin.top], tooltipAnchor) + } + }, [ + showTooltipAt, + showTooltipFromEvent, + tooltip, + tooltipPosition, + tooltipAnchor, + getNodePosition, + margin, + ]) + + // Mouse enter only occurs when entering the main element, + // not for each node. + const handleMouseEnter = useCallback( + (event: MouseEvent) => { + const match = findNode(event) + + setCurrent(match) + setCurrentNode?.(match ? match[1] : null) + + if (match) { + const node = match[1] + + showTooltip?.(node, event) + onMouseEnter?.(match[1], event) + } + }, + [findNode, setCurrent, setCurrentNode, showTooltip, onMouseEnter] + ) + + // Handle mouse enter/move/leave, relying on `previous` to simulate events. + const handleMouseMove = useCallback( + (event: MouseEvent) => { + const match = findNode(event) + + setCurrent(match) + + if (match) { + const [index, node] = match + + setCurrentNode?.(node) + showTooltip?.(node, event) + + if (previous.current) { + const [previousIndex, previousNode] = previous.current + if (index !== previousIndex) { + // Simulate an enter event if the previous index is different. + onMouseLeave?.(previousNode, event) + } else { + // If it's the same, trigger a regular move event. + onMouseMove?.(node, event) + } + } else { + onMouseEnter?.(node, event) + } + } else { + setCurrentNode?.(null) + hideTooltip?.() + + if (previous.current) { + // Simulate a leave event if there's a previous node. + onMouseLeave?.(previous.current[1], event) + } + } + }, + [ + findNode, + setCurrent, + previous, + onMouseEnter, + onMouseMove, + onMouseLeave, + showTooltip, + hideTooltip, + ] + ) + + // Mouse leave only occurs when leaving the main element, + // not for each node. + const handleMouseLeave = useCallback( + (event: MouseEvent) => { + setCurrent(null) + setCurrentNode?.(null) + + hideTooltip() + + if (onMouseLeave && previous.current) { + onMouseLeave(previous.current[1], event) + } + }, + [setCurrent, setCurrentNode, previous, hideTooltip, onMouseLeave] + ) + + const handleClick = useCallback( + (event: MouseEvent) => { + const match = findNode(event) + + setCurrent(match) + + match && onClick?.(match[1], event) + }, + [findNode, setCurrent, onClick] + ) + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + const match = findNode(event) + + if (enableTouchCrosshair) { + setCurrent(match) + setCurrentNode?.(match ? match[1] : null) + } + + match && onTouchStart?.(match[1], event) + }, + [findNode, setCurrent, setCurrentNode, enableTouchCrosshair, onTouchStart] + ) + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + const match = findNode(event) + + if (enableTouchCrosshair) { + setCurrent(match) + setCurrentNode?.(match ? match[1] : null) + } + + match && onTouchMove?.(match[1], event) + }, + [findNode, setCurrent, setCurrentNode, enableTouchCrosshair, onTouchMove] + ) + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + if (enableTouchCrosshair) { + setCurrent(null) + setCurrentNode?.(null) + } + + if (onTouchEnd && previous.current) { + onTouchEnd(previous.current[1], event) + } + }, + [enableTouchCrosshair, setCurrent, setCurrentNode, onTouchEnd, previous] + ) + + return { + current, + handleMouseEnter: isInteractive ? handleMouseEnter : undefined, + handleMouseMove: isInteractive ? handleMouseMove : undefined, + handleMouseLeave: isInteractive ? handleMouseLeave : undefined, + handleClick: isInteractive ? handleClick : undefined, + handleTouchStart: isInteractive ? handleTouchStart : undefined, + handleTouchMove: isInteractive ? handleTouchMove : undefined, + handleTouchEnd: isInteractive ? handleTouchEnd : undefined, + } +} + +/** + * Compute a voronoi mesh and corresponding events. + */ +export const useMesh = ({ + elementRef, + nodes, + getNodePosition, + width, + height, + margin = defaultMargin, + isInteractive = true, + detectionRadius = Infinity, + setCurrent, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + tooltipPosition = defaultTooltipPosition, + tooltipAnchor = defaultTooltipAnchor, + debug = false, +}: { + elementRef: MutableRefObject + nodes: readonly Node[] + getNodePosition?: NodePositionAccessor + width: number + height: number + margin?: Margin + isInteractive?: boolean + detectionRadius?: number + setCurrent?: (node: Node | null) => void + onMouseEnter?: NodeMouseHandler + onMouseMove?: NodeMouseHandler + onMouseLeave?: NodeMouseHandler + onClick?: NodeMouseHandler + tooltip?: (node: Node) => JSX.Element + tooltipPosition?: TooltipPosition + tooltipAnchor?: TooltipAnchor + debug?: boolean +}) => { + const { delaunay, voronoi } = useVoronoiMesh({ + points: nodes, + getNodePosition, + width, + height, + margin, + debug, + }) + + const { handleMouseEnter, handleMouseMove, handleMouseLeave, handleClick, current } = + useMeshEvents({ + elementRef, + nodes, + margin, + setCurrent, + delaunay, + detectionRadius, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + tooltipPosition, + tooltipAnchor, + }) + + return { + delaunay, + voronoi, + current, + handleMouseEnter, + handleMouseMove, + handleMouseLeave, + handleClick, + } +} diff --git a/packages/voronoi/src/meshCanvas.ts b/packages/voronoi/src/meshCanvas.ts index 3c0610922..381a08236 100644 --- a/packages/voronoi/src/meshCanvas.ts +++ b/packages/voronoi/src/meshCanvas.ts @@ -16,6 +16,23 @@ export const renderVoronoiToCanvas = ( ctx.restore() } +export const renderDelaunayPointsToCanvas = ( + ctx: CanvasRenderingContext2D, + delaunay: Delaunay, + radius: number +) => { + ctx.save() + + ctx.globalAlpha = 0.15 + ctx.beginPath() + delaunay.renderPoints(ctx, radius) + ctx.strokeStyle = 'red' + ctx.lineWidth = 1 + ctx.stroke() + + ctx.restore() +} + export const renderVoronoiCellToCanvas = ( ctx: CanvasRenderingContext2D, voronoi: Voronoi, @@ -26,8 +43,33 @@ export const renderVoronoiCellToCanvas = ( ctx.globalAlpha = 0.35 ctx.beginPath() voronoi.renderCell(index, ctx) - ctx.fillStyle = 'red' + ctx.fillStyle = 'pink' ctx.fill() ctx.restore() } + +export const renderDebugToCanvas = ( + ctx: CanvasRenderingContext2D, + { + delaunay, + voronoi, + detectionRadius, + index, + }: { + delaunay: Delaunay + voronoi: Voronoi + detectionRadius: number + index: number | null + } +) => { + renderVoronoiToCanvas(ctx, voronoi) + + if (detectionRadius < Infinity) { + renderDelaunayPointsToCanvas(ctx, delaunay, detectionRadius) + } + + if (index !== null) { + renderVoronoiCellToCanvas(ctx, voronoi, index) + } +} diff --git a/packages/voronoi/src/types.ts b/packages/voronoi/src/types.ts index 8f2f181ae..d72ab7810 100644 --- a/packages/voronoi/src/types.ts +++ b/packages/voronoi/src/types.ts @@ -1,7 +1,12 @@ -import * as React from 'react' +import { MouseEvent, TouchEvent, FunctionComponent } from 'react' import { Theme, Box } from '@nivo/core' import { Delaunay, Voronoi } from 'd3-delaunay' +export type NodeMouseHandler = (node: Node, Node: MouseEvent) => void +export type NodeTouchHandler = (node: Node, event: TouchEvent) => void + +export type NodePositionAccessor = (node: Node) => [number, number] + export type VoronoiDatum = { id: string | number x: number @@ -22,7 +27,7 @@ export interface VoronoiCustomLayerProps { voronoi: Voronoi } -export type VoronoiCustomLayer = React.FC +export type VoronoiCustomLayer = FunctionComponent export type VoronoiLayer = VoronoiLayerId | VoronoiCustomLayer diff --git a/packages/waffle/package.json b/packages/waffle/package.json index 61e306925..bacdbc9da 100644 --- a/packages/waffle/package.json +++ b/packages/waffle/package.json @@ -37,8 +37,8 @@ "@nivo/legends": "workspace:*", "@nivo/tooltip": "workspace:*", "@react-spring/web": "9.4.5 || ^9.7.2", - "@types/d3-shape": "^2.0.0", - "d3-shape": "^1.3.5" + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" }, "peerDependencies": { "react": ">= 16.14.0 < 19.0.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60d6a2df3..7bc6e673b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: typescript: specifier: ^4.9.5 version: 4.9.5 + yargs: + specifier: ^17.7.2 + version: 17.7.2 api: dependencies: @@ -316,6 +319,9 @@ importers: '@nivo/swarmplot': specifier: workspace:* version: link:../packages/swarmplot + '@nivo/tree': + specifier: workspace:* + version: link:../packages/tree '@nivo/treemap': specifier: workspace:* version: link:../packages/treemap @@ -327,8 +333,8 @@ importers: version: link:../packages/waffle devDependencies: cypress: - specifier: ^12.11.0 - version: 12.11.0 + specifier: ^13.8.1 + version: 13.8.1 react: specifier: 18.2.0 version: 18.2.0 @@ -372,11 +378,11 @@ importers: specifier: 9.4.5 || ^9.7.2 version: 9.7.2(react-dom@18.2.0)(react@18.2.0) '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -438,14 +444,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -483,14 +489,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -555,14 +561,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -629,14 +635,14 @@ importers: specifier: ^3.0.1 version: 3.0.1 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-chord: specifier: ^1.0.6 version: 1.0.6 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -713,8 +719,8 @@ importers: specifier: 9.4.5 || ^9.7.2 version: 9.7.2(react-dom@18.2.0)(react@18.2.0) '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-color: specifier: ^3.1.0 version: 3.1.0 @@ -731,8 +737,8 @@ importers: specifier: ^3.0.0 version: 3.0.0 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 d3-time-format: specifier: ^3.0.0 version: 3.0.0 @@ -797,14 +803,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -949,8 +955,8 @@ importers: specifier: 9.4.5 || ^9.7.2 version: 9.7.2(react-dom@18.2.0)(react@18.2.0) d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -979,11 +985,11 @@ importers: specifier: 9.4.5 || ^9.7.2 version: 9.7.2(react-dom@18.2.0)(react@18.2.0) '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -1045,14 +1051,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -1075,11 +1081,11 @@ importers: specifier: workspace:* version: link:../tooltip '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -1123,14 +1129,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -1165,14 +1171,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -1198,14 +1204,14 @@ importers: specifier: ^0.11.2 version: 0.11.2 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-sankey: specifier: ^0.12.3 version: 0.12.3 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -1270,14 +1276,14 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-scale: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 lodash: specifier: ^4.17.21 version: 4.17.21 @@ -1372,11 +1378,11 @@ importers: specifier: 9.4.5 || ^9.7.2 version: 9.7.2(react-dom@18.2.0)(react@18.2.0) '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -1456,6 +1462,18 @@ importers: specifier: 18.2.0 version: 18.2.0 + packages/text: + dependencies: + '@nivo/core': + specifier: workspace:* + version: link:../core + '@react-spring/web': + specifier: 9.4.5 || ^9.7.2 + version: 9.7.2(react-dom@18.2.0)(react@18.2.0) + react: + specifier: 18.2.0 + version: 18.2.0 + packages/tooltip: dependencies: '@nivo/core': @@ -1468,6 +1486,48 @@ importers: specifier: 18.2.0 version: 18.2.0 + packages/tree: + dependencies: + '@nivo/colors': + specifier: workspace:* + version: link:../colors + '@nivo/core': + specifier: workspace:* + version: link:../core + '@nivo/text': + specifier: workspace:* + version: link:../text + '@nivo/tooltip': + specifier: workspace:* + version: link:../tooltip + '@nivo/voronoi': + specifier: workspace:* + version: link:../voronoi + '@react-spring/web': + specifier: 9.4.5 || ^9.7.2 + version: 9.7.2(react-dom@18.2.0)(react@18.2.0) + '@types/d3-hierarchy': + specifier: ^3.1.7 + version: 3.1.7 + '@types/d3-scale': + specifier: ^4.0.8 + version: 4.0.8 + '@types/d3-shape': + specifier: ^3.1.6 + version: 3.1.6 + d3-hierarchy: + specifier: ^3.1.2 + version: 3.1.2 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 + d3-shape: + specifier: ^3.2.0 + version: 3.2.0 + react: + specifier: 18.2.0 + version: 18.2.0 + packages/treemap: dependencies: '@nivo/colors': @@ -1500,15 +1560,18 @@ importers: '@nivo/core': specifier: workspace:* version: link:../core + '@nivo/tooltip': + specifier: 'workspace: *' + version: link:../tooltip '@types/d3-delaunay': - specifier: ^5.3.0 - version: 5.3.1 + specifier: ^6.0.4 + version: 6.0.4 '@types/d3-scale': specifier: ^4.0.8 version: 4.0.8 d3-delaunay: - specifier: ^5.3.0 - version: 5.3.0 + specifier: ^6.0.4 + version: 6.0.4 d3-scale: specifier: ^4.0.2 version: 4.0.2 @@ -1540,11 +1603,11 @@ importers: specifier: 9.4.5 || ^9.7.2 version: 9.7.2(react-dom@18.2.0)(react@18.2.0) '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 react: specifier: 18.2.0 version: 18.2.0 @@ -1638,6 +1701,9 @@ importers: '@nivo/tooltip': specifier: workspace:* version: link:../packages/tooltip + '@nivo/tree': + specifier: workspace:* + version: link:../packages/tree '@nivo/treemap': specifier: workspace:* version: link:../packages/treemap @@ -1681,8 +1747,8 @@ importers: specifier: ^4.0.8 version: 4.0.8 '@types/d3-shape': - specifier: ^2.0.0 - version: 2.1.3 + specifier: ^3.1.6 + version: 3.1.6 d3-random: specifier: ^1.1.2 version: 1.1.2 @@ -1693,8 +1759,8 @@ importers: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 prop-types: specifier: ^15.8.1 version: 15.8.1 @@ -1800,6 +1866,9 @@ importers: '@nivo/swarmplot': specifier: workspace:* version: link:../packages/swarmplot + '@nivo/tree': + specifier: workspace:* + version: link:../packages/tree '@nivo/treemap': specifier: workspace:* version: link:../packages/treemap @@ -1843,8 +1912,8 @@ importers: specifier: ^4.0.2 version: 4.0.2 d3-shape: - specifier: ^1.3.5 - version: 1.3.7 + specifier: ^3.2.0 + version: 3.2.0 dedent-js: specifier: ^1.0.1 version: 1.0.1 @@ -3481,6 +3550,30 @@ packages: uuid: 8.3.2 dev: true + /@cypress/request@3.0.1: + resolution: {integrity: sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==} + engines: {node: '>= 6'} + dependencies: + aws-sign2: 0.7.0 + aws4: 1.11.0 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + http-signature: 1.3.6 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.10.4 + safe-buffer: 5.2.1 + tough-cookie: 4.1.4 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + dev: true + /@cypress/xvfb@1.2.4(supports-color@8.1.1): resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} dependencies: @@ -7621,8 +7714,8 @@ packages: resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} dev: false - /@types/d3-delaunay@5.3.1: - resolution: {integrity: sha512-F6itHi2DxdatHil1rJ2yEFUNhejj8+0Acd55LZ6Ggwbdoks0+DxVY2cawNj16sjCBiWvubVlh6eBMVsYRNGLew==} + /@types/d3-delaunay@6.0.4: + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} dev: false /@types/d3-force@2.1.4: @@ -7637,6 +7730,10 @@ packages: resolution: {integrity: sha512-AbStKxNyWiMDQPGDguG2Kuhlq1Sv539pZSxYbx4UZeYkutpPwXCcgyiRrlV4YH64nIOsKx7XVnOMy9O7rJsXkg==} dev: false + /@types/d3-hierarchy@3.1.7: + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + dev: false + /@types/d3-path@1.0.9: resolution: {integrity: sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==} @@ -7669,6 +7766,12 @@ packages: resolution: {integrity: sha512-HAhCel3wP93kh4/rq+7atLdybcESZ5bRHDEZUojClyZWsRuEMo3A52NGYJSh48SxfxEU6RZIVbZL2YFZ2OAlzQ==} dependencies: '@types/d3-path': 2.0.1 + dev: false + + /@types/d3-shape@3.1.6: + resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} + dependencies: + '@types/d3-path': 2.0.1 /@types/d3-time-format@2.3.1: resolution: {integrity: sha512-fck0Z9RGfIQn3GJIEKVrp15h9m6Vlg0d5XXeiE/6+CQiBmMDZxfR21XtjEPuDeg7gC3bBM0SdieA5XF3GW1wKA==} @@ -11186,6 +11289,56 @@ packages: yauzl: 2.10.0 dev: true + /cypress@13.8.1: + resolution: {integrity: sha512-Uk6ovhRbTg6FmXjeZW/TkbRM07KPtvM5gah1BIMp4Y2s+i/NMxgaLw0+PbYTOdw1+egE0FP3mWRiGcRkjjmhzA==} + engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0} + hasBin: true + requiresBuild: true + dependencies: + '@cypress/request': 3.0.1 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.3 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.3.0 + chalk: 4.1.2 + check-more-types: 2.24.0 + cli-cursor: 3.1.0 + cli-table3: 0.6.3 + commander: 6.2.1 + common-tags: 1.8.2 + dayjs: 1.11.7 + debug: 4.3.4(supports-color@8.1.1) + enquirer: 2.3.6 + eventemitter2: 6.4.7 + execa: 4.1.0 + executable: 4.1.1 + extract-zip: 2.0.1(supports-color@8.1.1) + figures: 3.2.0 + fs-extra: 9.1.0 + getos: 3.2.1 + is-ci: 3.0.1 + is-installed-globally: 0.4.0 + lazy-ass: 1.6.0 + listr2: 3.14.0(enquirer@2.3.6) + lodash: 4.17.21 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + process: 0.11.10 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + semver: 7.6.0 + supports-color: 8.1.1 + tmp: 0.2.1 + untildify: 4.0.0 + yauzl: 2.10.0 + dev: true + /d3-array@1.2.4: resolution: {integrity: sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==} dev: false @@ -11206,10 +11359,11 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} - /d3-delaunay@5.3.0: - resolution: {integrity: sha512-amALSrOllWVLaHTnDLHwMIiz0d1bBu9gZXd1FiLfXf8sHcX9jrcj81TVZOqD4UX7MgBZZ07c8GxzEgBpJqc74w==} + /d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} dependencies: - delaunator: 4.0.1 + delaunator: 5.0.1 dev: false /d3-dispatch@2.0.0: @@ -11237,6 +11391,11 @@ packages: resolution: {integrity: sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==} dev: false + /d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + dev: false + /d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} @@ -11246,6 +11405,10 @@ packages: /d3-path@1.0.9: resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + /d3-quadtree@2.0.0: resolution: {integrity: sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw==} dev: false @@ -11282,6 +11445,12 @@ packages: dependencies: d3-path: 1.0.9 + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + /d3-time-format@3.0.0: resolution: {integrity: sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==} dependencies: @@ -11556,8 +11725,10 @@ packages: slash: 3.0.0 dev: true - /delaunator@4.0.1: - resolution: {integrity: sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==} + /delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + dependencies: + robust-predicates: 3.0.2 dev: false /delayed-stream@1.0.0: @@ -16251,7 +16422,7 @@ packages: jest-util: 29.5.0 jest-validate: 29.5.0 prompts: 2.4.2 - yargs: 17.7.1 + yargs: 17.7.2 transitivePeerDependencies: - '@types/node' - supports-color @@ -19341,7 +19512,7 @@ packages: tsconfig-paths: 4.2.0 tslib: 2.5.0 v8-compile-cache: 2.3.0 - yargs: 17.7.1 + yargs: 17.7.2 yargs-parser: 21.1.1 optionalDependencies: '@nrwl/nx-darwin-arm64': 15.9.3 @@ -22287,6 +22458,10 @@ packages: glob: 9.3.5 dev: true + /robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + dev: false + /rollup-plugin-cleanup@3.2.1(rollup@3.21.0): resolution: {integrity: sha512-zuv8EhoO3TpnrU8MX8W7YxSbO4gmOR0ny06Lm3nkFfq0IVKdBUtHwhVzY1OAJyNCIAdLiyPnOrU0KnO0Fri1GQ==} engines: {node: ^10.14.2 || >=12.0.0} @@ -22575,6 +22750,14 @@ packages: dependencies: lru-cache: 6.0.0 + /semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + /send@0.17.2: resolution: {integrity: sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==} engines: {node: '>= 0.8.0'} @@ -23970,6 +24153,16 @@ packages: url-parse: 1.5.10 dev: true + /tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + dependencies: + psl: 1.8.0 + punycode: 2.3.0 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -25703,6 +25896,19 @@ packages: yargs-parser: 21.1.1 dev: true + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + /yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} dependencies: diff --git a/scripts/capture.mjs b/scripts/capture.mjs old mode 100644 new mode 100755 index 2f46ef706..bcf0826a1 --- a/scripts/capture.mjs +++ b/scripts/capture.mjs @@ -1,11 +1,13 @@ +#!/usr/bin/env node import Path from 'path' -import fs from 'fs/promises' +import yargs from 'yargs' +import { hideBin } from 'yargs/helpers' import puppeteer from 'puppeteer' import chalk from 'chalk-template' import _ from 'lodash' import config from '@ekino/config' -const HEADLESS = false // 'new' +const HEADLESS = 'new' // 'new' const DEFAULT_FLAVOR = 'svg' const CHART_SELECTOR = '#chart' const VIEWPORT = { @@ -13,12 +15,7 @@ const VIEWPORT = { icons: { width: 1400, height: 4000 }, homeDemos: { width: 1800, height: 6000 }, } -const ICON_VARIANTS = [ - 'light-neutral', - 'light-colored', - 'dark-neutral', - 'dark-colored', -] +const ICON_VARIANTS = ['light-neutral', 'light-colored', 'dark-neutral', 'dark-colored'] const projectDir = process.cwd() const websiteDir = Path.join(projectDir, 'website') @@ -26,14 +23,11 @@ const websiteCapturesDir = Path.join(websiteDir, 'src', 'assets', 'captures') const websiteIconsDir = Path.join(websiteDir, 'src', 'assets', 'icons') const websiteHomeDemosDir = Path.join(websiteCapturesDir, 'home') const websitePagesDir = Path.join(websiteCapturesDir, 'pages') -const getChartFileName = (chart, flavor) => `${chart}${flavor !== DEFAULT_FLAVOR ? `-${flavor}` : ''}.png` -const getChartWebsiteFilePath = (chart, flavor) => Path.join( - websiteCapturesDir, - getChartFileName(chart, flavor) -) -const getChartUrl = (chart, flavor) => { - const baseUrl = config.get('baseUrl') - +const getChartFileName = (chart, flavor) => + `${chart}${flavor !== DEFAULT_FLAVOR ? `-${flavor}` : ''}.png` +const getChartWebsiteFilePath = (chart, flavor) => + Path.join(websiteCapturesDir, getChartFileName(chart, flavor)) +const getChartUrl = (baseUrl, chart, flavor) => { const chunks = [baseUrl, chart] if (flavor !== DEFAULT_FLAVOR) { chunks.push(flavor) @@ -47,15 +41,20 @@ const getHomeDemoFilePath = id => Path.join(websiteHomeDemosDir, `${id}.png`) const getPageUrl = path => `${Path.join(config.get('baseUrl'), path)}/?capture=1` const getPageFilePath = id => Path.join(websitePagesDir, `${id}.png`) +const allPackages = _.uniq(config.get('capture.charts').map(chart => chart.pkg)) +const allCharts = config.get('capture.charts') + const delay = time => new Promise(resolve => { setTimeout(resolve, time) }) -const captureChart = async (page, { pkg, chart, flavor, theme }) => { - const url = getChartUrl(chart, flavor) +const captureChart = async (baseUrl, page, { pkg, chart, flavor, theme }) => { + const url = getChartUrl(baseUrl, chart, flavor) - console.log(chalk`{yellow Capturing chart {white ${chart}}} {dim (package: @nivo/${pkg}, flavor: ${flavor}, url: ${url})}`) + console.log( + chalk`{yellow Capturing chart {white ${chart}}} {dim (package: @nivo/${pkg}, flavor: ${flavor}, url: ${url})}` + ) await page.setViewport(VIEWPORT.chart) await page.goto(url) @@ -73,25 +72,21 @@ const captureChart = async (page, { pkg, chart, flavor, theme }) => { throw new Error(`Unable to find element matching selector: ${CHART_SELECTOR} (url: ${url})`) } - const clip = await element.boundingBox() const websiteFilePath = getChartWebsiteFilePath(chart, flavor) + const clip = await element.boundingBox() - // initially saved to the package doc dir await page.screenshot({ path: websiteFilePath, clip, omitBackground: true, }) - // also save a copy in the website, for social images - await fs.copyFile(docFilePath, websiteFilePath) - console.log(chalk`{green saved to {white ${websiteFilePath}}}`) console.log('') } -const captureCharts = async () => { - const charts = config.get('capture').charts +const captureCharts = async ({ baseUrl, packages }) => { + const charts = allCharts.filter(chart => packages.includes(chart.pkg)) console.log(chalk`{yellow Starting capture for ${charts.length} chart(s)}`) console.log('') @@ -104,7 +99,7 @@ const captureCharts = async () => { for (let chart of charts) { for (let flavor of chart.flavors) { - await captureChart(page, { ...chart, flavor }) + await captureChart(baseUrl, page, { ...chart, flavor }) } } @@ -120,12 +115,13 @@ const captureCharts = async () => { } } -const captureIcons = async () => { - const charts = config.get('capture').charts - const icons = charts.map(chart => chart.chart) +const captureIcons = async ({ baseUrl, packages }) => { + const icons = allCharts.filter(chart => packages.includes(chart.pkg)).map(chart => chart.chart) - console.log(chalk`{yellow Starting capture for ${icons.length} chart icon(s)}`) - console.log('') + const iconsUrl = `${Path.join(baseUrl, 'internal', 'icons')}/?capture=1` + console.log( + chalk`{yellow Starting capture for ${icons.length} chart icon(s)} {dim url: ${iconsUrl}}` + ) try { const browser = await puppeteer.launch({ @@ -133,10 +129,11 @@ const captureIcons = async () => { }) const page = await browser.newPage() await page.setViewport(VIEWPORT.icons) - await page.goto(`${Path.join(config.get('baseUrl'), 'internal', 'icons')}/?capture=1`) + await page.goto(iconsUrl) for (let icon of icons) { console.log(chalk`{yellow Capturing {white ${icon}} chart icons}`) + for (let variant of ICON_VARIANTS) { const selector = `#${icon}-${_.camelCase(variant)}` console.log(chalk`{dim variant: ${variant}, selector: ${selector}}`) @@ -237,7 +234,9 @@ const capturePages = async () => { const url = getPageUrl(pageConfig.path) const selector = pageConfig.selector - console.log(chalk`{yellow Capturing page {white ${pageConfig.id}}} {dim (url: ${url}, selector: ${selector})}`) + console.log( + chalk`{yellow Capturing page {white ${pageConfig.id}}} {dim (url: ${url}, selector: ${selector})}` + ) await page.setViewport({ width: pageConfig.viewport[0], @@ -248,7 +247,9 @@ const capturePages = async () => { await page.waitForSelector(selector) const element = await page.$(selector) if (element === null) { - throw new Error(`Unable to find element matching selector: ${selector} (url: ${url})`) + throw new Error( + `Unable to find element matching selector: ${selector} (url: ${url})` + ) } const clip = await element.boundingBox() @@ -276,10 +277,87 @@ const capturePages = async () => { } const run = async () => { + // const argv = yargs(hideBin(process.argv)).parse() + + // console.log(argv) // await capturePages() // await captureHomeDemos() - // await captureCharts() - await captureIcons() + + yargs(hideBin(process.argv)) + .command( + 'all', + 'capture everything', + yargs => { + return yargs + }, + async argv => { + await captureIcons({ + baseUrl: argv.baseUrl, + packages: argv.pkg, + }) + await captureCharts({ + baseUrl: argv.baseUrl, + packages: argv.pkg, + }) + } + ) + .command( + 'icons', + 'capture icons for the website', + yargs => { + return yargs + }, + async argv => { + await captureIcons({ + baseUrl: argv.baseUrl, + packages: argv.pkg, + }) + } + ) + .command( + 'charts', + 'capture charts for package readmes', + yargs => { + return yargs + }, + async argv => { + await captureCharts({ + baseUrl: argv.baseUrl, + packages: argv.pkg, + }) + } + ) + .command( + 'home', + 'capture charts for the website home page', + yargs => { + return yargs.positional('port', { + describe: 'port to bind on', + default: 5000, + }) + }, + argv => { + if (argv.verbose) console.info(`start server on :${argv.port}`) + serve(argv.port) + } + ) + .option('baseUrl', { + alias: 'u', + type: 'string', + default: config.get('baseUrl'), + }) + .option('pkg', { + alias: 'p', + describe: 'only capture icons for specific packages', + choices: allPackages, + default: allPackages, + }) + .coerce('pkg', arg => { + if (Array.isArray(arg)) return arg + return [arg] + }) + .demandCommand(1) + .parse() } run() diff --git a/storybook/package.json b/storybook/package.json index 9d6310079..19e9178fb 100644 --- a/storybook/package.json +++ b/storybook/package.json @@ -42,6 +42,7 @@ "@nivo/sunburst": "workspace:*", "@nivo/swarmplot": "workspace:*", "@nivo/tooltip": "workspace:*", + "@nivo/tree": "workspace:*", "@nivo/treemap": "workspace:*", "@nivo/voronoi": "workspace:*", "@nivo/waffle": "workspace:*", @@ -56,11 +57,11 @@ "@types/d3-random": "^1.1.3", "@types/d3-sankey": "^0.11.2", "@types/d3-scale": "^4.0.8", - "@types/d3-shape": "^2.0.0", + "@types/d3-shape": "^3.1.6", "d3-random": "^1.1.2", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5", + "d3-shape": "^3.2.0", "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/storybook/stories/tree/Tree.stories.tsx b/storybook/stories/tree/Tree.stories.tsx new file mode 100644 index 000000000..2b12eac98 --- /dev/null +++ b/storybook/stories/tree/Tree.stories.tsx @@ -0,0 +1,383 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { generateLibTree } from '@nivo/generators' +import { useTheme } from '@nivo/core' +import { + Tree, + useNodeMouseEventHandlers, + NodeComponentProps, + NodeTooltipProps, + LinkTooltipProps, + TreeSvgProps, + Layout, + LabelsPosition, + TreeMode, +} from '@nivo/tree' + +const meta: Meta = { + title: 'Tree', + component: Tree, + tags: ['autodocs'], + argTypes: { + mode: { + control: 'select', + options: ['tree', 'dendogram'], + }, + layout: { + control: 'select', + options: ['top-to-bottom', 'right-to-left', 'bottom-to-top', 'left-to-right'], + }, + onNodeMouseEnter: { action: 'node mouse enter' }, + onNodeMouseMove: { action: 'node mouse move' }, + onNodeMouseLeave: { action: 'node mouse leave' }, + onNodeClick: { action: 'node clicked' }, + onLinkMouseEnter: { action: 'link mouse enter' }, + onLinkMouseMove: { action: 'link mouse move' }, + onLinkMouseLeave: { action: 'link mouse leave' }, + onLinkClick: { action: 'link clicked' }, + }, + args: { + mode: 'dendogram', + layout: 'top-to-bottom', + }, +} + +export default meta +type Story = StoryObj + +const generateData = () => { + const data = generateLibTree() + + return { data } +} + +const minimalData = { + id: 'R', + children: [ + { + id: 'A', + children: [{ id: '00' }, { id: '01' }, { id: '02' }], + }, + { + id: 'B', + }, + { + id: 'C', + children: [{ id: '00' }, { id: '01' }, { id: '02' }], + }, + ], +} + +const commonProperties: Pick< + TreeSvgProps, + | 'width' + | 'height' + | 'margin' + | 'data' + | 'identity' + | 'activeNodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkThickness' + | 'activeLinkThickness' + | 'linkColor' + | 'theme' +> = { + width: 900, + height: 600, + margin: { top: 70, right: 70, bottom: 70, left: 70 }, + ...generateData(), + identity: 'name', + activeNodeSize: 20, + nodeColor: { scheme: 'tableau10' }, + fixNodeColorAtDepth: 1, + linkThickness: 2, + activeLinkThickness: 6, + linkColor: { from: 'target.color', modifiers: [['opacity', 0.4]] }, + theme: { + labels: { + text: { + outlineWidth: 2, + outlineColor: '#ffffff', + }, + }, + }, +} + +const NodeTooltip = ({ node }: NodeTooltipProps) => { + const theme = useTheme() + + return ( +
+ id: {node.id} +
+ path:{' '} + + {node.ancestorIds.join(' > ')} > {node.id} + +
+ uid: {node.uid} +
+ ) +} + +const LinkTooltip = ({ link }: LinkTooltipProps) => { + const theme = useTheme() + + return ( +
+ id: {link.id} +
+ source: {link.source.id} +
+ target: {link.target.id} +
+ ) +} + +export const Basic: Story = { + render: args => ( + + ), +} + +export const WithNodeTooltip: Story = { + render: args => ( + + ), +} + +export const WithLinkTooltip: Story = { + render: args => ( + + ), +} + +const CUSTOM_NODE_SIZE = 32 +const CustomNode = ({ + node, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + setCurrentNode, + tooltip, +}: NodeComponentProps) => { + const eventHandlers = useNodeMouseEventHandlers(node, { + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, + tooltip, + setCurrentNode, + }) + + return ( + + + + + {node.id[0].toUpperCase()} + + + ) +} + +export const CustomNodeComponent: Story = { + render: args => ( + + ), +} + +interface LabelsPositionConfig { + layout: Layout + labelsPosition: LabelsPosition + orientLabel: boolean +} + +const LabelsPositionDemo = ({ config, mode }: { config: LabelsPositionConfig; mode: TreeMode }) => { + return ( +
+
+ layout + {config.layout} + labelsPosition + {config.labelsPosition} + orientLabel + {config.orientLabel ? 'true' : 'false'} +
+ +
+ ) +} + +const layouts: Layout[] = ['top-to-bottom', 'bottom-to-top', 'left-to-right', 'right-to-left'] +const labelsPositions: LabelsPosition[] = ['outward', 'inward', 'layout', 'layout-opposite'] + +const getLabelsPositionConfigs = (): LabelsPositionConfig[] => { + const configs: LabelsPositionConfig[] = [] + + layouts.forEach(layout => { + const isVertical = layout.includes('top') + + labelsPositions.forEach(labelsPosition => { + const config: LabelsPositionConfig = { + layout, + labelsPosition, + orientLabel: false, + } + configs.push(config) + + // Orienting labels only affects vertical layouts. + if (isVertical) configs.push({ ...config, orientLabel: true }) + }) + }) + + return configs +} + +export const LabelsPositionDemos: Story = { + render: args => { + const labelsPositionConfigs = getLabelsPositionConfigs() + + return ( +
+ {labelsPositionConfigs.map((config, index) => ( + + ))} +
+ ) + }, +} diff --git a/storybook/stories/tree/TreeCanvas.stories.tsx b/storybook/stories/tree/TreeCanvas.stories.tsx new file mode 100644 index 000000000..e907b093c --- /dev/null +++ b/storybook/stories/tree/TreeCanvas.stories.tsx @@ -0,0 +1,314 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { generateLibTree } from '@nivo/generators' +import { useTheme } from '@nivo/core' +import { + Tree, + TreeCanvas, + NodeTooltipProps, + TreeCanvasProps, + Layout, + LabelsPosition, + TreeMode, + NodeCanvasRendererProps, +} from '@nivo/tree' + +const meta: Meta = { + title: 'TreeCanvas', + component: TreeCanvas, + tags: ['autodocs'], + argTypes: { + mode: { + control: 'select', + options: ['tree', 'dendogram'], + }, + layout: { + control: 'select', + options: ['top-to-bottom', 'right-to-left', 'bottom-to-top', 'left-to-right'], + }, + onNodeMouseEnter: { action: 'node mouse enter' }, + onNodeMouseMove: { action: 'node mouse move' }, + onNodeMouseLeave: { action: 'node mouse leave' }, + onNodeClick: { action: 'node clicked' }, + }, + args: { + mode: 'dendogram', + layout: 'top-to-bottom', + }, +} + +export default meta +type Story = StoryObj + +const generateData = () => { + const data = generateLibTree() + + return { data } +} + +const minimalData = { + id: 'R', + children: [ + { + id: 'A', + children: [{ id: '00' }, { id: '01' }, { id: '02' }], + }, + { + id: 'B', + }, + { + id: 'C', + children: [{ id: '00' }, { id: '01' }, { id: '02' }], + }, + ], +} + +const commonProperties: Pick< + TreeCanvasProps, + | 'width' + | 'height' + | 'margin' + | 'data' + | 'identity' + | 'activeNodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkThickness' + | 'activeLinkThickness' + | 'linkColor' + | 'theme' +> = { + width: 900, + height: 600, + margin: { top: 70, right: 70, bottom: 70, left: 70 }, + ...generateData(), + identity: 'name', + activeNodeSize: 20, + nodeColor: { scheme: 'tableau10' }, + fixNodeColorAtDepth: 1, + linkThickness: 2, + activeLinkThickness: 6, + linkColor: { from: 'target.color', modifiers: [['opacity', 0.4]] }, + theme: { + labels: { + text: { + outlineWidth: 2, + outlineColor: '#ffffff', + }, + }, + }, +} + +const NodeTooltip = ({ node }: NodeTooltipProps) => { + const theme = useTheme() + + return ( +
+ id: {node.id} +
+ path:{' '} + + {node.ancestorIds.join(' > ')} > {node.id} + +
+ uid: {node.uid} +
+ ) +} + +export const Basic: Story = { + render: args => ( + + ), +} + +export const WithNodeTooltip: Story = { + render: args => ( + + ), +} + +const renderNodeCustom = ( + ctx: CanvasRenderingContext2D, + { node }: NodeCanvasRendererProps +) => { + ctx.save() + + ctx.translate(node.x, node.y) + + if (node.isActive) { + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = node.color + } else { + ctx.fillStyle = node.color + } + + ctx.beginPath() + ctx.arc(0, 0, node.size / 2, 0, 2 * Math.PI) + ctx.fill() + if (node.isActive) { + ctx.stroke() + } + + if (node.isActive) { + } + + ctx.restore() +} + +export const CustomNodeRendering: Story = { + render: args => ( + + ), +} + +interface LabelsPositionConfig { + layout: Layout + labelsPosition: LabelsPosition + orientLabel: boolean +} + +const LabelsPositionDemo = ({ config, mode }: { config: LabelsPositionConfig; mode: TreeMode }) => { + return ( +
+
+ layout + {config.layout} + labelsPosition + {config.labelsPosition} + orientLabel + {config.orientLabel ? 'true' : 'false'} +
+ +
+ ) +} + +const layouts: Layout[] = ['top-to-bottom', 'bottom-to-top', 'left-to-right', 'right-to-left'] +const labelsPositions: LabelsPosition[] = ['outward', 'inward', 'layout', 'layout-opposite'] + +const getLabelsPositionConfigs = (): LabelsPositionConfig[] => { + const configs: LabelsPositionConfig[] = [] + + layouts.forEach(layout => { + const isVertical = layout.includes('top') + + labelsPositions.forEach(labelsPosition => { + const config: LabelsPositionConfig = { + layout, + labelsPosition, + orientLabel: false, + } + configs.push(config) + + // Orienting labels only affects vertical layouts. + if (isVertical) configs.push({ ...config, orientLabel: true }) + }) + }) + + return configs +} + +export const LabelsPositionDemos: Story = { + render: args => { + const labelsPositionConfigs = getLabelsPositionConfigs() + + return ( +
+ {labelsPositionConfigs.map((config, index) => ( + + ))} +
+ ) + }, +} diff --git a/tsconfig.monorepo.json b/tsconfig.monorepo.json index 738a551ef..4f2446a69 100644 --- a/tsconfig.monorepo.json +++ b/tsconfig.monorepo.json @@ -5,6 +5,7 @@ // { "path": "./packages/core" }, // Shared next because charts need them + { "path": "./packages/text" }, { "path": "./packages/canvas" }, { "path": "./packages/annotations" }, { "path": "./packages/scales" }, @@ -40,6 +41,7 @@ { "path": "./packages/sunburst" }, { "path": "./packages/stream" }, { "path": "./packages/swarmplot" }, + { "path": "./packages/tree" }, { "path": "./packages/treemap" }, { "path": "./packages/waffle" }, diff --git a/website/package.json b/website/package.json index 11dec5f38..72de5de14 100644 --- a/website/package.json +++ b/website/package.json @@ -33,6 +33,7 @@ "@nivo/stream": "workspace:*", "@nivo/sunburst": "workspace:*", "@nivo/swarmplot": "workspace:*", + "@nivo/tree": "workspace:*", "@nivo/treemap": "workspace:*", "@nivo/voronoi": "workspace:*", "@nivo/waffle": "workspace:*", @@ -47,7 +48,7 @@ "d3-format": "^1.4.4", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", - "d3-shape": "^1.3.5", + "d3-shape": "^3.2.0", "dedent-js": "^1.0.1", "gatsby": "^5.9.0", "gatsby-image": "^3.11.0", diff --git a/website/src/@types/file_types.d.ts b/website/src/@types/file_types.d.ts index 47049a68f..921476239 100644 --- a/website/src/@types/file_types.d.ts +++ b/website/src/@types/file_types.d.ts @@ -61,16 +61,6 @@ declare module '*calendar/meta.yml' { export default meta } -declare module '*calendar/meta.yml' { - const meta: { - flavors: ChartMetaFlavors - Calendar: ChartMeta - CalendarCanvas: ChartMeta - } - - export default meta -} - declare module '*chord/meta.yml' { const meta: { flavors: ChartMetaFlavors @@ -244,6 +234,16 @@ declare module '*time-range/meta.yml' { export default meta } +declare module '*tree/meta.yml' { + const meta: { + flavors: ChartMetaFlavors + Tree: ChartMeta + TreeCanvas: ChartMeta + } + + export default meta +} + declare module '*treemap/meta.yml' { const meta: { flavors: ChartMetaFlavors diff --git a/website/src/assets/captures/tree.png b/website/src/assets/captures/tree.png new file mode 100644 index 000000000..0974e2cdf Binary files /dev/null and b/website/src/assets/captures/tree.png differ diff --git a/website/src/assets/icons.png b/website/src/assets/icons.png index b8349a12a..73557858f 100644 Binary files a/website/src/assets/icons.png and b/website/src/assets/icons.png differ diff --git a/website/src/assets/icons/tree-dark-colored.png b/website/src/assets/icons/tree-dark-colored.png new file mode 100644 index 000000000..a648c960c Binary files /dev/null and b/website/src/assets/icons/tree-dark-colored.png differ diff --git a/website/src/assets/icons/tree-dark-neutral.png b/website/src/assets/icons/tree-dark-neutral.png new file mode 100644 index 000000000..1b0addfd0 Binary files /dev/null and b/website/src/assets/icons/tree-dark-neutral.png differ diff --git a/website/src/assets/icons/tree-light-colored.png b/website/src/assets/icons/tree-light-colored.png new file mode 100644 index 000000000..d1acc2790 Binary files /dev/null and b/website/src/assets/icons/tree-light-colored.png differ diff --git a/website/src/assets/icons/tree-light-neutral.png b/website/src/assets/icons/tree-light-neutral.png new file mode 100644 index 000000000..a22881ec2 Binary files /dev/null and b/website/src/assets/icons/tree-light-neutral.png differ diff --git a/website/src/assets/icons@2x.png b/website/src/assets/icons@2x.png index cad7ea737..2f5220a4c 100644 Binary files a/website/src/assets/icons@2x.png and b/website/src/assets/icons@2x.png differ diff --git a/website/src/components/components/ComponentTemplate.tsx b/website/src/components/components/ComponentTemplate.tsx index 4bf0e9baf..1e64694f5 100644 --- a/website/src/components/components/ComponentTemplate.tsx +++ b/website/src/components/components/ComponentTemplate.tsx @@ -41,6 +41,7 @@ interface ComponentTemplateProps< propertiesMapper?: (props: UnmappedProps, data: Data) => MappedProps codePropertiesMapper?: (props: MappedProps, data: Data) => MappedProps generateData: (previousData?: Data | null) => Data + enableDiceRoll?: boolean dataKey?: string getDataSize?: (data: Data) => number getTabData?: (data: Data) => Data @@ -70,6 +71,7 @@ export const ComponentTemplate = < propertiesMapper, codePropertiesMapper, generateData, + enableDiceRoll = true, dataKey = 'data', getDataSize, getTabData = data => data, @@ -129,7 +131,9 @@ export const ComponentTemplate = < data={tabData} dataKey={dataKey} nodeCount={getDataSize !== undefined ? getDataSize(data) : undefined} - diceRoll={data !== undefined ? diceRoll : undefined} + diceRoll={ + enableDiceRoll ? (data !== undefined ? diceRoll : undefined) : undefined + } > {children(mappedProperties, data, theme.nivo, logAction)} diff --git a/website/src/components/controls/generics/PropertyDocumentation.tsx b/website/src/components/controls/generics/PropertyDocumentation.tsx index 3dc820f4a..ae8907025 100644 --- a/website/src/components/controls/generics/PropertyDocumentation.tsx +++ b/website/src/components/controls/generics/PropertyDocumentation.tsx @@ -26,7 +26,7 @@ export const PropertyDocumentation = ({ currentFlavor={currentFlavor} supportedFlavors={property.flavors} > - + {property.help} ) diff --git a/website/src/components/controls/ui/Control.tsx b/website/src/components/controls/ui/Control.tsx index 3e7e9e697..988736885 100644 --- a/website/src/components/controls/ui/Control.tsx +++ b/website/src/components/controls/ui/Control.tsx @@ -2,14 +2,14 @@ import React, { useState, useCallback, PropsWithChildren } from 'react' import intersection from 'lodash/intersection' import styled from 'styled-components' import { MdKeyboardArrowRight, MdKeyboardArrowDown } from 'react-icons/md' -import { Flavor } from '../../../types' +import { Flavor, FlavorAwareChartPropertyAttribute } from '../../../types' import { PropertyDescription } from './PropertyDescription' import { PropertyFlavors } from './PropertyFlavors' import { Cell } from './styled' interface ControlProps { id: string - description?: string + description?: FlavorAwareChartPropertyAttribute flavors: Flavor[] currentFlavor: Flavor supportedFlavors?: Flavor[] @@ -17,7 +17,7 @@ interface ControlProps { export const Control = ({ id, - description, + description: _description, flavors, currentFlavor, supportedFlavors, @@ -37,6 +37,14 @@ export const Control = ({ } } + let description: string | undefined = undefined + if (typeof _description === 'string') { + description = _description + } else if (typeof _description === 'object') { + // If an object is provided, it means it depends on the current flavor. + description = _description[currentFlavor] + } + return ( {description !== undefined && ( diff --git a/website/src/components/controls/ui/PropertyHeader.tsx b/website/src/components/controls/ui/PropertyHeader.tsx index c27933441..c485999d3 100644 --- a/website/src/components/controls/ui/PropertyHeader.tsx +++ b/website/src/components/controls/ui/PropertyHeader.tsx @@ -6,7 +6,7 @@ import isNumber from 'lodash/isNumber' import isBoolean from 'lodash/isBoolean' import isFunction from 'lodash/isFunction' import styled from 'styled-components' -import { ChartProperty } from '../../../types' +import { ChartProperty, Flavor } from '../../../types' import { ControlContext } from '../types' const getDefaultValue = (value: any) => { @@ -39,6 +39,7 @@ type PropertyHeaderProps = ChartProperty & { id?: string name?: string context?: ControlContext + currentFlavor?: Flavor } export const PropertyHeader = ({ @@ -48,6 +49,7 @@ export const PropertyHeader = ({ required, defaultValue, context, + currentFlavor, }: PropertyHeaderProps) => { let label: ReactNode = name if (context) { @@ -59,10 +61,20 @@ export const PropertyHeader = ({ ) } + let propertyType: string | undefined = undefined + if (type !== undefined) { + if (typeof type === 'string') { + propertyType = type + } else if (typeof type === 'object' && currentFlavor) { + // If an object is provided, it means it depends on the current flavor. + propertyType = type[currentFlavor] + } + } + return ( - {type !== undefined && {type}} + {propertyType && {propertyType}} {required && required} {!required && optional} {defaultValue !== undefined && ( diff --git a/website/src/components/icons/Icons.tsx b/website/src/components/icons/Icons.tsx index 4861e6071..0b60d0a37 100644 --- a/website/src/components/icons/Icons.tsx +++ b/website/src/components/icons/Icons.tsx @@ -27,6 +27,7 @@ import { StreamIcon } from './StreamIcon' import { SunburstIcon } from './SunburstIcon' import { SwarmPlotIcon } from './SwarmPlotIcon' import { TimeRangeIcon } from './TimeRangeIcon' +import { TreeIcon } from './TreeIcon' import { TreeMapIcon } from './TreeMapIcon' import { VoronoiIcon } from './VoronoiIcon' import { WaffleIcon } from './WaffleIcon' @@ -60,6 +61,7 @@ const ColorsDemo = ({ type }: { type: IconType }) => { export const Icons = () => ( + diff --git a/website/src/components/icons/TreeIcon.tsx b/website/src/components/icons/TreeIcon.tsx new file mode 100644 index 000000000..1c8d5a01a --- /dev/null +++ b/website/src/components/icons/TreeIcon.tsx @@ -0,0 +1,97 @@ +import React, { useMemo } from 'react' +import { Theme } from '@nivo/core' +import { Tree, TreeSvgProps } from '@nivo/tree' +import treeLightNeutralImg from '../../assets/icons/tree-light-neutral.png' +import treeLightColoredImg from '../../assets/icons/tree-light-colored.png' +import treeDarkNeutralImg from '../../assets/icons/tree-dark-neutral.png' +import treeDarkColoredImg from '../../assets/icons/tree-dark-colored.png' +import { ICON_SIZE, Icon, colors, IconImg } from './styled' +import { IconType } from './types' + +type Datum = { + id: string + children?: Datum[] +} + +const chartProps: TreeSvgProps = { + width: ICON_SIZE, + height: ICON_SIZE, + data: { + id: 'X', + children: [ + { + id: 'A', + children: [{ id: '0' }], + }, + { + id: 'B', + children: [{ id: '0' }], + }, + { + id: 'C', + children: [{ id: '0' }], + }, + ], + }, + margin: { + top: 8, + right: 8, + bottom: 8, + left: 8, + }, + mode: 'dendogram', + nodeSize: 14, + linkThickness: 5, + enableLabel: false, + isInteractive: false, +} + +const TreeIconItem = ({ type }: { type: IconType }) => { + const currentColors = colors[type].colors + + const theme: Theme = useMemo( + () => ({ + grid: { + line: { + stroke: currentColors[1], + strokeWidth: 2, + }, + }, + }), + [type] + ) + + let nodeColor: string + let linkColor: string + if (type.startsWith('light')) { + nodeColor = currentColors[4] + linkColor = currentColors[1] + } else { + nodeColor = currentColors[0] + linkColor = currentColors[2] + } + + return ( + + + {...chartProps} + nodeColor={nodeColor} + linkColor={linkColor} + theme={theme} + /> + + ) +} + +export const TreeIcon = () => ( + <> + + + + + + + + + +) diff --git a/website/src/data/components/tree/meta.yml b/website/src/data/components/tree/meta.yml new file mode 100644 index 000000000..2c0d8b543 --- /dev/null +++ b/website/src/data/components/tree/meta.yml @@ -0,0 +1,45 @@ +flavors: + - flavor: svg + path: /tree/ + - flavor: canvas + path: /tree/canvas/ + +Tree: + package: '@nivo/tree' + tags: + - experimental + - hierarchy + - tree + stories: + - label: Labels position demos + link: tree--labels-position-demos + - label: Custom node component + link: tree--custom-node-component + - label: With node tooltip + link: tree--with-node-tooltip + - label: With link tooltip + link: tree--with-link-tooltip + description: | + Nivo tree graph, supporting dendograms as well. + + While it's part of the nivo internals, and not formally documented, + you should be able to use the `useTree` hook directly in order to build + a fully custom graph, this hook takes a config object which + is very close to the component's props. + +TreeCanvas: + package: '@nivo/tree' + tags: + - experimental + - hierarchy + - tree + - canvas + stories: + - label: Custom node rendering + link: treecanvas--custom-node-rendering + - label: With node tooltip + link: treecanvas--with-node-tooltip + description: | + A canvas alternative to the [Tree](self:/tree) component. + Well suited for large data sets as it does not impact DOM tree depth, + however you'll lose the isomorphic ability and transitions. diff --git a/website/src/data/components/tree/props.ts b/website/src/data/components/tree/props.ts new file mode 100644 index 000000000..254eba6d8 --- /dev/null +++ b/website/src/data/components/tree/props.ts @@ -0,0 +1,569 @@ +import { commonDefaultProps as defaults, svgDefaultProps as svgDefaults } from '@nivo/tree' +import { + motionProperties, + groupProperties, + themeProperty, + tooltipPositionProperty, + tooltipAnchorProperty, +} from '../../../lib/componentProperties' +import { + chartDimensions, + isInteractive, + commonAccessibilityProps, + ordinalColors, +} from '../../../lib/chart-properties' +import { ChartProperty, Flavor } from '../../../types' + +const allFlavors: Flavor[] = ['svg', 'canvas'] + +const props: ChartProperty[] = [ + { + group: 'Base', + key: 'data', + flavors: allFlavors, + help: 'The hierarchical data object.', + description: ` + A typical data object should look like this: + + \`\`\` + { + id: '0', + children: [ + { id: 'A', children: [{ id: '0' }, { id: '1' }] }, + { id: 'B', children: [{ id: '0' }] }, + { id: 'C' }, + ], + } + \`\`\` + + Please note that you should **never** mutate the data object, + because otherwise nivo won't know that it changed, we make heavy + use of memoization internally via React hooks, so if you want + to update the data, the reference should change, meaning you + should pass a new object. + `, + type: 'object', + required: true, + }, + { + group: 'Base', + key: 'identity', + flavors: allFlavors, + help: 'The key or function to use to retrieve nodes identity.', + description: ` + The identity of each node in a group of siblings must be unique, + however it's fine to have the same ID for several nodes in the tree, + as long as they're not siblings. + + Internally, nivo computes the full path of the nodes to generate a unique ID, + accessible via \`uid\`, you can also get the path components via \`pathComponents\`. + `, + type: 'string | Function', + required: false, + defaultValue: defaults.identity, + }, + { + group: 'Base', + key: 'mode', + help: `Type of tree diagram.`, + type: `'tree' | 'dendogram'`, + flavors: allFlavors, + required: false, + defaultValue: defaults.mode, + control: { + type: 'radio', + choices: [ + { label: 'tree', value: 'tree' }, + { label: 'dendogram', value: 'dendogram' }, + ], + }, + }, + { + group: 'Base', + key: 'layout', + help: 'Defines the diagram layout.', + flavors: allFlavors, + type: `'top-to-bottom' | 'right-to-left' | 'bottom-to-top' | 'left-to-right'`, + required: false, + defaultValue: defaults.layout, + control: { + type: 'choices', + choices: ['top-to-bottom', 'right-to-left', 'bottom-to-top', 'left-to-right'].map( + choice => ({ + label: choice, + value: choice, + }) + ), + }, + }, + ...chartDimensions(allFlavors), + // Style + themeProperty(allFlavors), + { + group: 'Style', + key: 'nodeSize', + type: 'number | (node: IntermediateComputedNode) => number', + control: { type: 'lineWidth' }, + help: 'Defines the size of the nodes, statically or dynamically.', + required: false, + defaultValue: defaults.nodeSize, + flavors: allFlavors, + }, + { + group: 'Style', + key: 'activeNodeSize', + type: 'number | (node: ComputedNode) => number', + control: { type: 'range', min: 0, max: 40, unit: 'px' }, + help: 'Defines the size of active nodes, statically or dynamically.', + required: false, + defaultValue: defaults.activeNodeSize, + flavors: allFlavors, + }, + { + group: 'Style', + key: 'inactiveNodeSize', + type: 'number | (node: ComputedNode) => number', + control: { type: 'range', min: 0, max: 40, unit: 'px' }, + help: 'Defines the size of inactive nodes, statically or dynamically.', + required: false, + defaultValue: defaults.activeNodeSize, + flavors: allFlavors, + }, + ordinalColors({ + key: 'nodeColor', + help: 'Defines the color of the nodes, statically or dynamically.', + flavors: allFlavors, + defaultValue: defaults.nodeColor, + genericType: 'IntermediateComputedNode', + }), + { + group: 'Style', + key: 'fixNodeColorAtDepth', + type: 'number', + help: ` + Fix the node color past a certain depth, meaning descendant nodes + are going to inherit the parent color defined at that depth. + Use \`Infinity\` to disable. + `, + flavors: allFlavors, + required: false, + defaultValue: defaults.fixNodeColorAtDepth, + control: { type: 'range', min: 0, max: 5 }, + }, + { + group: 'Style', + key: 'linkCurve', + help: 'Defines the type of curve to use to draw links.', + flavors: allFlavors, + type: `'bump' | 'linear' | 'step' | 'step-before' | 'step-after'`, + required: false, + defaultValue: defaults.linkCurve, + control: { + type: 'choices', + choices: ['bump', 'linear', 'step', 'step-before', 'step-after'].map(choice => ({ + label: choice, + value: choice, + })), + }, + }, + { + group: 'Style', + key: 'linkThickness', + type: 'number | (link: IntermediateComputedLink) => number', + control: { type: 'lineWidth' }, + help: 'Defines the thickness of the links, statically or dynamically.', + required: false, + defaultValue: defaults.linkThickness, + flavors: allFlavors, + }, + { + group: 'Style', + key: 'activeLinkThickness', + type: 'number | (link: ComputedLink) => number', + control: { type: 'lineWidth' }, + help: 'Defines the size of active links, statically or dynamically.', + required: false, + defaultValue: defaults.activeLinkThickness, + flavors: allFlavors, + }, + { + group: 'Style', + key: 'inactiveLinkThickness', + type: 'number | (link: ComputedLink) => number', + control: { type: 'lineWidth' }, + help: 'Defines the thickness of inactive links, statically or dynamically.', + required: false, + defaultValue: defaults.inactiveLinkThickness, + flavors: allFlavors, + }, + { + group: 'Style', + key: 'linkColor', + type: 'InheritedColorConfig', + control: { + type: 'inheritedColor', + inheritableProperties: ['source.color', 'target.color'], + defaultFrom: 'source.color', + defaultThemeProperty: 'labels.text.fill', + }, + help: 'Defines the color of the links.', + description: ` + How to compute the links color, + [see dedicated documentation](self:/guides/colors). + `, + required: false, + defaultValue: defaults.linkColor, + flavors: allFlavors, + }, + // Labels + { + group: 'Labels', + key: 'enableLabel', + flavors: allFlavors, + help: 'Show labels for nodes.', + description: ` + If you want to adjust the labels styles you should + use the \`theme\` property, labels use the \`theme.labels.text\` + styles. + `, + type: 'boolean', + required: false, + defaultValue: defaults.enableLabel, + control: { type: 'switch' }, + }, + { + group: 'Labels', + key: 'label', + help: `Define what to use as a node label, if a string is provided it's used as a property path to access the value.`, + type: 'string | (node: ComputedNode) => string', + flavors: allFlavors, + required: false, + defaultValue: defaults.label, + }, + { + group: 'Labels', + key: 'labelsPosition', + help: 'Defines how to position the nodes labels.', + description: ` + Please note that labels don't affect the margins, meaning + that if you're using the \`outward\` mode for example, + you'll probably have to adjust the margins so that + the labels fit within the chart. + `, + flavors: allFlavors, + type: `'outward' | 'inward' | 'layout' | 'layout-opposite'`, + required: false, + defaultValue: defaults.labelsPosition, + control: { + type: 'choices', + choices: ['outward', 'inward', 'layout', 'layout-opposite'].map(choice => ({ + label: choice, + value: choice, + })), + }, + }, + { + group: 'Labels', + key: 'orientLabel', + flavors: allFlavors, + help: 'Automatically orient labels according to the selected `layout`.', + description: ` + If enabled, this is going to only affect vertical layouts + (\`top-to-bottom\`, \`bottom-to-top\`), rotating labels + so that they match the direction of the selected \`layout\`. + `, + type: 'boolean', + required: false, + defaultValue: defaults.orientLabel, + control: { type: 'switch' }, + }, + { + group: 'Labels', + key: 'labelOffset', + type: 'number', + help: 'Prevent nodes from being detected if the cursor is too far away from the node.', + flavors: allFlavors, + required: false, + defaultValue: Infinity, + control: { type: 'range', min: 0, max: 60, unit: 'px' }, + }, + { + group: 'Labels', + key: 'labelComponent', + type: 'LabelComponent', + help: 'Override the default label component.', + description: ` + When providing your own component, some features are disabled, + such as animations, you should have a look at the default \`Label\` + component if you plan on restoring these. + `, + flavors: ['svg'], + required: false, + }, + { + group: 'Labels', + key: 'renderLabel', + type: 'LabelCanvasRenderer', + help: 'Override the default label canvas rendering.', + description: ` + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + flavors: ['canvas'], + required: false, + }, + // Customization + { + group: 'Customization', + key: 'layers', + type: { + svg: `('links' | 'nodes' | 'mesh' | CustomSvgLayer)[]`, + canvas: `('links' | 'nodes' | 'mesh' | CustomCanvasLayer)[]`, + }, + help: 'Defines the order of layers and add custom layers.', + description: { + svg: ` + You can also use this property to insert extra layers to the chart, + the extra layer must be a function component. + + This component is going to get the chart's context and computed data + as props and should return a valid SVG element. + `, + canvas: ` + You can also use this property to insert extra layers to the chart, + the extra layer must be a function. + + The function is going to get the canvas 2d context as first argument + and the chart's context and computed data as second. + + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + }, + defaultValue: svgDefaults.layers, + flavors: allFlavors, + }, + { + group: 'Customization', + key: 'nodeComponent', + type: 'NodeComponent', + help: 'Override the default node component.', + description: ` + When providing your own component, some features are disabled, + such as animations and interactions, you should have a look at + the default \`Node\` component if you plan on restoring these. + `, + flavors: ['svg'], + required: false, + }, + { + group: 'Customization', + key: 'renderNode', + type: 'NodeCanvasRenderer', + help: 'Override the default node canvas rendering.', + description: ` + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + flavors: ['canvas'], + required: false, + }, + { + group: 'Customization', + key: 'linkComponent', + type: 'LinkComponent', + help: 'Override the default link component.', + description: ` + When providing your own component, some features are disabled, + such as animations and interactions, you should have a look at + the default \`Link\` component if you plan on restoring these. + `, + flavors: ['svg'], + required: false, + }, + { + group: 'Customization', + key: 'renderLink', + type: 'LinkCanvasRenderer', + help: 'Override the default link canvas rendering.', + description: ` + Please make sure to use \`context.save()\` and \`context.restore()\` + if you make some global modifications to the 2d context inside this + function to avoid side effects. + `, + flavors: ['canvas'], + required: false, + }, + // Interactivity + isInteractive({ + flavors: allFlavors, + defaultValue: defaults.isInteractive, + }), + { + group: 'Interactivity', + key: 'useMesh', + flavors: ['svg'], + help: 'Use a voronoi mesh to detect mouse interactions. Always `true` for the canvas implementation', + description: ` + Use a voronoi mesh to detect mouse interactions, this can be useful + when the tree is dense, or if the nodes are small and you want to + facilitate user interactions. + + Please note that you won't be able to capture link events when using this feature. + `, + type: 'boolean', + required: false, + defaultValue: defaults.useMesh, + control: { type: 'switch' }, + }, + { + group: 'Interactivity', + key: 'meshDetectionRadius', + type: 'number', + help: 'Prevent nodes from being detected if the cursor is too far away from the node.', + flavors: allFlavors, + required: false, + defaultValue: defaults.meshDetectionRadius, + control: { type: 'range', min: 0, max: 200, step: 10, unit: 'px' }, + }, + { + group: 'Interactivity', + key: 'debugMesh', + flavors: allFlavors, + help: 'Display mesh used to detect mouse interactions (voronoi cells).', + type: 'boolean', + required: false, + defaultValue: defaults.debugMesh, + control: { type: 'switch' }, + }, + { + group: 'Interactivity', + key: 'highlightAncestorNodes', + flavors: allFlavors, + type: 'boolean', + help: 'Highlight active node ancestor nodes.', + required: false, + control: { type: 'switch' }, + }, + { + key: 'highlightDescendantNodes', + flavors: allFlavors, + group: 'Interactivity', + type: 'boolean', + help: 'Highlight active node descendant nodes.', + required: false, + control: { type: 'switch' }, + }, + { + group: 'Interactivity', + key: 'highlightAncestorLinks', + flavors: allFlavors, + type: 'boolean', + help: 'Highlight active node ancestor links.', + required: false, + control: { type: 'switch' }, + }, + { + group: 'Interactivity', + key: 'highlightDescendantLinks', + flavors: allFlavors, + type: 'boolean', + help: 'Highlight active node descendant links.', + required: false, + control: { type: 'switch' }, + }, + { + group: 'Interactivity', + key: 'onNodeMouseEnter', + flavors: allFlavors, + type: '(node: ComputedNode, event: MouseEvent) => void', + help: 'onMouseEnter handler for nodes.', + required: false, + }, + { + group: 'Interactivity', + key: 'onNodeMouseMove', + flavors: allFlavors, + type: '(node: ComputedNode, event: MouseEvent) => void', + help: 'onMouseMove handler for nodes.', + required: false, + }, + { + group: 'Interactivity', + key: 'onNodeMouseLeave', + flavors: allFlavors, + type: '(node: ComputedNode, event: MouseEvent) => void', + help: 'onMouseLeave handler for nodes.', + required: false, + }, + { + group: 'Interactivity', + key: 'onNodeClick', + flavors: allFlavors, + type: '(node: ComputedNode, event: MouseEvent) => void', + help: 'onClick handler for nodes.', + required: false, + }, + tooltipPositionProperty({ + key: 'nodeTooltipPosition', + flavors: allFlavors, + defaultValue: svgDefaults.TooltipPosition, + }), + tooltipAnchorProperty({ + key: 'nodeTooltipAnchor', + flavors: allFlavors, + defaultValue: defaults.nodeTooltipAnchor, + }), + { + group: 'Interactivity', + key: 'onLinkMouseEnter', + flavors: ['svg'], + type: '(node: ComputedLink, event: MouseEvent) => void', + help: 'onMouseEnter handler for links (`useMesh` must be `false`).', + required: false, + }, + { + group: 'Interactivity', + key: 'onLinkMouseMove', + flavors: ['svg'], + type: '(node: ComputedLink, event: MouseEvent) => void', + help: 'onMouseMove handler for links (`useMesh` must be `false`).', + required: false, + }, + { + group: 'Interactivity', + key: 'onLinkMouseLeave', + flavors: ['svg'], + type: '(node: ComputedLink, event: MouseEvent) => void', + help: 'onMouseLeave handler for links (`useMesh` must be `false`).', + required: false, + }, + { + group: 'Interactivity', + key: 'onLinkClick', + flavors: ['svg'], + type: '(node: ComputedLink, event: MouseEvent) => void', + help: 'onClick handler for links (`useMesh` must be `false`).', + required: false, + }, + { + group: 'Interactivity', + key: 'linkTooltip', + flavors: ['svg'], + type: 'LinkTooltip', + help: 'Tooltip component for links (`useMesh` must be `false`).', + required: false, + }, + tooltipAnchorProperty({ + key: 'linkTooltipAnchor', + flavors: ['svg'], + defaultValue: svgDefaults.linkTooltipAnchor, + }), + ...commonAccessibilityProps(allFlavors), + ...motionProperties(allFlavors, defaults), +] + +export const groups = groupProperties(props) diff --git a/website/src/data/nav.ts b/website/src/data/nav.ts index 3f6b1434d..fd72ef357 100644 --- a/website/src/data/nav.ts +++ b/website/src/data/nav.ts @@ -23,6 +23,7 @@ import stream from './components/stream/meta.yml' import sunburst from './components/sunburst/meta.yml' import swarmplot from './components/swarmplot/meta.yml' import timeRange from './components/time-range/meta.yml' +import tree from './components/tree/meta.yml' import treemap from './components/treemap/meta.yml' import voronoi from './components/voronoi/meta.yml' import waffle from './components/waffle/meta.yml' @@ -253,6 +254,15 @@ export const components: ChartNavData[] = [ svg: true, }, }, + { + name: 'Tree', + id: 'tree', + tags: tree.Tree.tags, + flavors: { + svg: true, + canvas: true, + }, + }, { name: 'TreeMap', id: 'treemap', diff --git a/website/src/lib/chart-properties/colors.ts b/website/src/lib/chart-properties/colors.ts index f1564fca2..3994d830b 100644 --- a/website/src/lib/chart-properties/colors.ts +++ b/website/src/lib/chart-properties/colors.ts @@ -9,6 +9,7 @@ export const ordinalColors = ({ help = `Define chart's colors.`, description = `Please see the [dedicated documentation](self:/guides/colors) for colors.`, defaultValue, + genericType, }: { key?: string group?: string @@ -16,17 +17,25 @@ export const ordinalColors = ({ help?: string description?: string defaultValue: OrdinalColorScaleConfig -}): ChartProperty => ({ - key, - group, - type: 'OrdinalColorScaleConfig', - help, - description, - required: false, - defaultValue, - flavors, - control: { type: 'ordinalColors' }, -}) + genericType?: string +}): ChartProperty => { + let type: string = `OrdinalColorScaleConfig` + if (genericType !== undefined) { + type = `${type}<${genericType}>` + } + + return { + key, + group, + type, + help, + description, + required: false, + defaultValue, + flavors, + control: { type: 'ordinalColors' }, + } +} export const blendMode = ({ key = 'blendMode', diff --git a/website/src/lib/componentProperties.ts b/website/src/lib/componentProperties.ts index 3433c7f91..7b152b2b2 100644 --- a/website/src/lib/componentProperties.ts +++ b/website/src/lib/componentProperties.ts @@ -1,5 +1,6 @@ import uniq from 'lodash/uniq' import { defaultAnimate } from '@nivo/core' +import { TooltipPosition, TooltipAnchor } from '@nivo/tooltip' import { Flavor, ChartProperty } from '../types' export const themeProperty = (flavors: Flavor[]): ChartProperty => ({ @@ -236,6 +237,92 @@ export const groupProperties = ( return grouped } +export const tooltipPositionProperty = ({ + group = 'Interactivity', + key = 'tooltipPosition', + help = `Define the tooltip positioning behavior.`, + flavors, + defaultValue, +}: { + group?: string + key?: string + help?: string + flavors: Flavor[] + defaultValue?: TooltipPosition +}): ChartProperty => { + return { + key, + group, + help, + description: ` + For the \`cursor\` mode, the tooltip is going to follow the cursor position, + while for the \`fixed\` mode, the tooltip stays at the element's position. + `, + type: `'cursor' | 'fixed'`, + required: false, + flavors, + defaultValue, + control: { + type: 'radio', + choices: [ + { + label: 'cursor', + value: 'cursor', + }, + { + label: 'fixed', + value: 'fixed', + }, + ], + }, + } +} + +export const tooltipAnchorProperty = ({ + group = 'Interactivity', + key = 'tooltipAnchor', + help = `Define the tooltip anchor.`, + flavors, + defaultValue, +}: { + group?: string + key?: string + help?: string + flavors: Flavor[] + defaultValue?: TooltipAnchor +}): ChartProperty => { + return { + key, + group, + help, + type: `'top' | 'right' | 'bottom' | 'left'`, + required: false, + flavors, + defaultValue, + control: { + type: 'choices', + choices: [ + { + label: 'top', + value: 'top', + }, + { + label: 'right', + value: 'right', + }, + { + label: 'bottom', + value: 'bottom', + }, + { + label: 'left', + value: 'left', + }, + ], + }, + } +} + export const polarAxisProperty = ({ key, flavors, diff --git a/website/src/pages/tree/canvas.tsx b/website/src/pages/tree/canvas.tsx new file mode 100644 index 000000000..14e75a183 --- /dev/null +++ b/website/src/pages/tree/canvas.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import { graphql, useStaticQuery } from 'gatsby' +import { + ResponsiveTreeCanvas, + TreeSvgProps, + svgDefaultProps as defaults, + ComputedNode, +} from '@nivo/tree' +import { ComponentTemplate } from '../../components/components/ComponentTemplate' +import meta from '../../data/components/tree/meta.yml' +import mapper from '../../data/components/treemap/mapper' +import { groups } from '../../data/components/tree/props' +import { generateLightDataSet } from '../../data/components/treemap/generator' + +type Datum = ReturnType + +const initialProperties: Pick< + TreeSvgProps, + | 'identity' + | 'mode' + | 'layout' + | 'nodeSize' + | 'activeNodeSize' + | 'inactiveNodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkCurve' + | 'linkThickness' + | 'activeLinkThickness' + | 'inactiveLinkThickness' + | 'linkColor' + | 'enableLabel' + | 'labelsPosition' + | 'orientLabel' + | 'labelOffset' + | 'margin' + | 'animate' + | 'motionConfig' + | 'isInteractive' + | 'meshDetectionRadius' + | 'debugMesh' + | 'highlightAncestorNodes' + | 'highlightDescendantNodes' + | 'highlightAncestorLinks' + | 'highlightDescendantLinks' + | 'nodeTooltipPosition' + | 'nodeTooltipAnchor' +> = { + identity: 'name', + mode: defaults.mode, + layout: defaults.layout, + nodeSize: 12, + activeNodeSize: 24, + inactiveNodeSize: 12, + nodeColor: { scheme: 'tableau10' }, + fixNodeColorAtDepth: 1, + linkCurve: defaults.linkCurve, + linkThickness: 2, + activeLinkThickness: 8, + inactiveLinkThickness: 2, + linkColor: { from: 'target.color', modifiers: [['opacity', 0.4]] }, + + enableLabel: defaults.enableLabel, + labelsPosition: defaults.labelsPosition, + orientLabel: defaults.orientLabel, + labelOffset: defaults.labelOffset, + + margin: { + top: 90, + right: 90, + bottom: 90, + left: 90, + }, + + animate: defaults.animate, + motionConfig: 'stiff', + + isInteractive: defaults.isInteractive, + meshDetectionRadius: 80, + debugMesh: defaults.debugMesh, + highlightAncestorNodes: defaults.highlightAncestorNodes, + highlightDescendantNodes: defaults.highlightDescendantNodes, + highlightAncestorLinks: defaults.highlightAncestorLinks, + highlightDescendantLinks: defaults.highlightDescendantLinks, + nodeTooltipPosition: defaults.nodeTooltipPosition, + nodeTooltipAnchor: defaults.nodeTooltipAnchor, + + pixelRatio: + typeof window !== 'undefined' && window.devicePixelRatio ? window.devicePixelRatio : 1, +} + +const TreeCanvas = () => { + const { + image: { + childImageSharp: { gatsbyImageData: image }, + }, + } = useStaticQuery(graphql` + query { + image: file(absolutePath: { glob: "**/src/assets/captures/tree.png" }) { + childImageSharp { + gatsbyImageData(layout: FIXED, width: 700, quality: 100) + } + } + } + `) + + return ( + + {(properties, data, theme, logAction) => { + return ( + + data={data} + {...properties} + theme={{ + ...theme, + labels: { + ...theme.labels, + text: { + ...theme.labels?.text, + outlineWidth: 2, + outlineColor: theme.background, + }, + }, + }} + onNodeClick={(node: ComputedNode) => { + logAction({ + type: 'click', + label: `[node] ${node.path.join(' / ')}`, + data: node, + color: node.color, + }) + }} + /> + ) + }} + + ) +} + +export default TreeCanvas diff --git a/website/src/pages/tree/index.tsx b/website/src/pages/tree/index.tsx new file mode 100644 index 000000000..39e8bf8f4 --- /dev/null +++ b/website/src/pages/tree/index.tsx @@ -0,0 +1,163 @@ +import React from 'react' +import { graphql, useStaticQuery } from 'gatsby' +import { + ResponsiveTree, + TreeSvgProps, + svgDefaultProps as defaults, + ComputedLink, + ComputedNode, +} from '@nivo/tree' +import { ComponentTemplate } from '../../components/components/ComponentTemplate' +import meta from '../../data/components/tree/meta.yml' +import mapper from '../../data/components/treemap/mapper' +import { groups } from '../../data/components/tree/props' +import { generateLightDataSet } from '../../data/components/treemap/generator' + +type Datum = ReturnType + +const initialProperties: Pick< + TreeSvgProps, + | 'identity' + | 'mode' + | 'layout' + | 'nodeSize' + | 'activeNodeSize' + | 'inactiveNodeSize' + | 'nodeColor' + | 'fixNodeColorAtDepth' + | 'linkCurve' + | 'linkThickness' + | 'activeLinkThickness' + | 'inactiveLinkThickness' + | 'linkColor' + | 'enableLabel' + | 'labelsPosition' + | 'orientLabel' + | 'labelOffset' + | 'margin' + | 'animate' + | 'motionConfig' + | 'isInteractive' + | 'useMesh' + | 'meshDetectionRadius' + | 'debugMesh' + | 'highlightAncestorNodes' + | 'highlightDescendantNodes' + | 'highlightAncestorLinks' + | 'highlightDescendantLinks' + | 'nodeTooltipAnchor' + | 'nodeTooltipPosition' + | 'linkTooltipAnchor' +> = { + identity: 'name', + mode: defaults.mode, + layout: defaults.layout, + nodeSize: 12, + activeNodeSize: 24, + inactiveNodeSize: 12, + nodeColor: { scheme: 'tableau10' }, + fixNodeColorAtDepth: 1, + linkCurve: defaults.linkCurve, + linkThickness: 2, + activeLinkThickness: 8, + inactiveLinkThickness: 2, + linkColor: { from: 'target.color', modifiers: [['opacity', 0.4]] }, + + enableLabel: defaults.enableLabel, + labelsPosition: defaults.labelsPosition, + orientLabel: defaults.orientLabel, + labelOffset: defaults.labelOffset, + + margin: { + top: 90, + right: 90, + bottom: 90, + left: 90, + }, + + animate: defaults.animate, + motionConfig: 'stiff', + + isInteractive: defaults.isInteractive, + useMesh: true, + meshDetectionRadius: 80, + debugMesh: defaults.debugMesh, + highlightAncestorNodes: defaults.highlightAncestorNodes, + highlightDescendantNodes: defaults.highlightDescendantNodes, + highlightAncestorLinks: defaults.highlightAncestorLinks, + highlightDescendantLinks: defaults.highlightDescendantLinks, + nodeTooltipPosition: defaults.nodeTooltipPosition, + nodeTooltipAnchor: defaults.nodeTooltipAnchor, + linkTooltipAnchor: defaults.linkTooltipAnchor, +} + +const Tree = () => { + const { + image: { + childImageSharp: { gatsbyImageData: image }, + }, + } = useStaticQuery(graphql` + query { + image: file(absolutePath: { glob: "**/src/assets/captures/tree.png" }) { + childImageSharp { + gatsbyImageData(layout: FIXED, width: 700, quality: 100) + } + } + } + `) + + return ( + + {(properties, data, theme, logAction) => { + return ( + + data={data} + {...properties} + theme={{ + ...theme, + labels: { + ...theme.labels, + text: { + ...theme.labels?.text, + outlineWidth: 2, + outlineColor: theme.background, + }, + }, + }} + onNodeClick={(node: ComputedNode) => { + logAction({ + type: 'click', + label: `[node] ${node.path.join(' / ')}`, + data: node, + color: node.color, + }) + }} + onLinkClick={(link: ComputedLink) => { + logAction({ + type: 'click', + label: `[link] ${link.source.id} > ${link.target.id}`, + data: link, + color: link.color, + }) + }} + /> + ) + }} + + ) +} + +export default Tree diff --git a/website/src/styles/icons.css b/website/src/styles/icons.css index 91ed46303..62fba90ec 100644 --- a/website/src/styles/icons.css +++ b/website/src/styles/icons.css @@ -1,4 +1,4 @@ -/* glue: 0.13 hash: 78b58a59dc */ +/* glue: 0.13 hash: 3b0ea47a6c */ .sprite-icons-waffle-light-neutral, .sprite-icons-waffle-light-colored, .sprite-icons-waffle-dark-neutral, @@ -15,6 +15,10 @@ .sprite-icons-treemap-grey, .sprite-icons-treemap-dark-neutral, .sprite-icons-treemap-dark-colored, +.sprite-icons-tree-light-neutral, +.sprite-icons-tree-light-colored, +.sprite-icons-tree-dark-neutral, +.sprite-icons-tree-dark-colored, .sprite-icons-time-range-light-neutral, .sprite-icons-time-range-light-colored, .sprite-icons-time-range-dark-neutral, @@ -228,684 +232,708 @@ height: 52px; } -.sprite-icons-time-range-light-neutral { +.sprite-icons-tree-light-neutral { background-position: -244px -4px; width: 52px; height: 52px; } -.sprite-icons-time-range-light-colored { +.sprite-icons-tree-light-colored { background-position: -244px -64px; width: 52px; height: 52px; } -.sprite-icons-time-range-dark-neutral { +.sprite-icons-tree-dark-neutral { background-position: -244px -124px; width: 52px; height: 52px; } -.sprite-icons-time-range-dark-colored { +.sprite-icons-tree-dark-colored { background-position: -244px -184px; width: 52px; height: 52px; } -.sprite-icons-swarmplot-light-neutral { +.sprite-icons-time-range-light-neutral { background-position: -4px -244px; width: 52px; height: 52px; } -.sprite-icons-swarmplot-light-colored { +.sprite-icons-time-range-light-colored { background-position: -64px -244px; width: 52px; height: 52px; } -.sprite-icons-swarmplot-dark-neutral { +.sprite-icons-time-range-dark-neutral { background-position: -124px -244px; width: 52px; height: 52px; } -.sprite-icons-swarmplot-dark-colored { +.sprite-icons-time-range-dark-colored { background-position: -184px -244px; width: 52px; height: 52px; } -.sprite-icons-sunburst-red { +.sprite-icons-swarmplot-light-neutral { background-position: -244px -244px; width: 52px; height: 52px; } -.sprite-icons-sunburst-light-neutral { +.sprite-icons-swarmplot-light-colored { background-position: -304px -4px; width: 52px; height: 52px; } -.sprite-icons-sunburst-light-colored { +.sprite-icons-swarmplot-dark-neutral { background-position: -304px -64px; width: 52px; height: 52px; } -.sprite-icons-sunburst-grey { +.sprite-icons-swarmplot-dark-colored { background-position: -304px -124px; width: 52px; height: 52px; } -.sprite-icons-sunburst-dark-neutral { +.sprite-icons-sunburst-red { background-position: -304px -184px; width: 52px; height: 52px; } -.sprite-icons-sunburst-dark-colored { +.sprite-icons-sunburst-light-neutral { background-position: -304px -244px; width: 52px; height: 52px; } -.sprite-icons-stream-light-neutral { +.sprite-icons-sunburst-light-colored { background-position: -4px -304px; width: 52px; height: 52px; } -.sprite-icons-stream-light-colored { +.sprite-icons-sunburst-grey { background-position: -64px -304px; width: 52px; height: 52px; } -.sprite-icons-stream-dark-neutral { +.sprite-icons-sunburst-dark-neutral { background-position: -124px -304px; width: 52px; height: 52px; } -.sprite-icons-stream-dark-colored { +.sprite-icons-sunburst-dark-colored { background-position: -184px -304px; width: 52px; height: 52px; } -.sprite-icons-scatterplot-light-neutral { +.sprite-icons-stream-light-neutral { background-position: -244px -304px; width: 52px; height: 52px; } -.sprite-icons-scatterplot-light-colored { +.sprite-icons-stream-light-colored { background-position: -304px -304px; width: 52px; height: 52px; } -.sprite-icons-scatterplot-dark-neutral { +.sprite-icons-stream-dark-neutral { background-position: -364px -4px; width: 52px; height: 52px; } -.sprite-icons-scatterplot-dark-colored { +.sprite-icons-stream-dark-colored { background-position: -364px -64px; width: 52px; height: 52px; } -.sprite-icons-sankey-red { +.sprite-icons-scatterplot-light-neutral { background-position: -364px -124px; width: 52px; height: 52px; } -.sprite-icons-sankey-light-neutral { +.sprite-icons-scatterplot-light-colored { background-position: -364px -184px; width: 52px; height: 52px; } -.sprite-icons-sankey-light-colored { +.sprite-icons-scatterplot-dark-neutral { background-position: -364px -244px; width: 52px; height: 52px; } -.sprite-icons-sankey-grey { +.sprite-icons-scatterplot-dark-colored { background-position: -364px -304px; width: 52px; height: 52px; } -.sprite-icons-sankey-dark-neutral { +.sprite-icons-sankey-red { background-position: -4px -364px; width: 52px; height: 52px; } -.sprite-icons-sankey-dark-colored { +.sprite-icons-sankey-light-neutral { background-position: -64px -364px; width: 52px; height: 52px; } -.sprite-icons-radial-bar-light-neutral { +.sprite-icons-sankey-light-colored { background-position: -124px -364px; width: 52px; height: 52px; } -.sprite-icons-radial-bar-light-colored { +.sprite-icons-sankey-grey { background-position: -184px -364px; width: 52px; height: 52px; } -.sprite-icons-radial-bar-dark-neutral { +.sprite-icons-sankey-dark-neutral { background-position: -244px -364px; width: 52px; height: 52px; } -.sprite-icons-radial-bar-dark-colored { +.sprite-icons-sankey-dark-colored { background-position: -304px -364px; width: 52px; height: 52px; } -.sprite-icons-radar-light-neutral { +.sprite-icons-radial-bar-light-neutral { background-position: -364px -364px; width: 52px; height: 52px; } -.sprite-icons-radar-light-colored { +.sprite-icons-radial-bar-light-colored { background-position: -424px -4px; width: 52px; height: 52px; } -.sprite-icons-radar-dark-neutral { +.sprite-icons-radial-bar-dark-neutral { background-position: -424px -64px; width: 52px; height: 52px; } -.sprite-icons-radar-dark-colored { +.sprite-icons-radial-bar-dark-colored { background-position: -424px -124px; width: 52px; height: 52px; } -.sprite-icons-pie-light-neutral { +.sprite-icons-radar-light-neutral { background-position: -424px -184px; width: 52px; height: 52px; } -.sprite-icons-pie-light-colored { +.sprite-icons-radar-light-colored { background-position: -424px -244px; width: 52px; height: 52px; } -.sprite-icons-pie-dark-neutral { +.sprite-icons-radar-dark-neutral { background-position: -424px -304px; width: 52px; height: 52px; } -.sprite-icons-pie-dark-colored { +.sprite-icons-radar-dark-colored { background-position: -424px -364px; width: 52px; height: 52px; } -.sprite-icons-parallel-coordinates-light-neutral { +.sprite-icons-pie-light-neutral { background-position: -4px -424px; width: 52px; height: 52px; } -.sprite-icons-parallel-coordinates-light-colored { +.sprite-icons-pie-light-colored { background-position: -64px -424px; width: 52px; height: 52px; } -.sprite-icons-parallel-coordinates-dark-neutral { +.sprite-icons-pie-dark-neutral { background-position: -124px -424px; width: 52px; height: 52px; } -.sprite-icons-parallel-coordinates-dark-colored { +.sprite-icons-pie-dark-colored { background-position: -184px -424px; width: 52px; height: 52px; } -.sprite-icons-nivo-icon { +.sprite-icons-parallel-coordinates-light-neutral { background-position: -244px -424px; width: 52px; height: 52px; } -.sprite-icons-network-light-neutral { +.sprite-icons-parallel-coordinates-light-colored { background-position: -304px -424px; width: 52px; height: 52px; } -.sprite-icons-network-light-colored { +.sprite-icons-parallel-coordinates-dark-neutral { background-position: -364px -424px; width: 52px; height: 52px; } -.sprite-icons-network-dark-neutral { +.sprite-icons-parallel-coordinates-dark-colored { background-position: -424px -424px; width: 52px; height: 52px; } -.sprite-icons-network-dark-colored { +.sprite-icons-nivo-icon { background-position: -484px -4px; width: 52px; height: 52px; } -.sprite-icons-marimekko-light-neutral { +.sprite-icons-network-light-neutral { background-position: -484px -64px; width: 52px; height: 52px; } -.sprite-icons-marimekko-light-colored { +.sprite-icons-network-light-colored { background-position: -484px -124px; width: 52px; height: 52px; } -.sprite-icons-marimekko-dark-neutral { +.sprite-icons-network-dark-neutral { background-position: -484px -184px; width: 52px; height: 52px; } -.sprite-icons-marimekko-dark-colored { +.sprite-icons-network-dark-colored { background-position: -484px -244px; width: 52px; height: 52px; } -.sprite-icons-line-light-neutral { +.sprite-icons-marimekko-light-neutral { background-position: -484px -304px; width: 52px; height: 52px; } -.sprite-icons-line-light-colored { +.sprite-icons-marimekko-light-colored { background-position: -484px -364px; width: 52px; height: 52px; } -.sprite-icons-line-dark-neutral { +.sprite-icons-marimekko-dark-neutral { background-position: -484px -424px; width: 52px; height: 52px; } -.sprite-icons-line-dark-colored { +.sprite-icons-marimekko-dark-colored { background-position: -4px -484px; width: 52px; height: 52px; } -.sprite-icons-heatmap-light-neutral { +.sprite-icons-line-light-neutral { background-position: -64px -484px; width: 52px; height: 52px; } -.sprite-icons-heatmap-light-colored { +.sprite-icons-line-light-colored { background-position: -124px -484px; width: 52px; height: 52px; } -.sprite-icons-heatmap-dark-neutral { +.sprite-icons-line-dark-neutral { background-position: -184px -484px; width: 52px; height: 52px; } -.sprite-icons-heatmap-dark-colored { +.sprite-icons-line-dark-colored { background-position: -244px -484px; width: 52px; height: 52px; } -.sprite-icons-geomap-light-neutral { +.sprite-icons-heatmap-light-neutral { background-position: -304px -484px; width: 52px; height: 52px; } -.sprite-icons-geomap-light-colored { +.sprite-icons-heatmap-light-colored { background-position: -364px -484px; width: 52px; height: 52px; } -.sprite-icons-geomap-dark-neutral { +.sprite-icons-heatmap-dark-neutral { background-position: -424px -484px; width: 52px; height: 52px; } -.sprite-icons-geomap-dark-colored { +.sprite-icons-heatmap-dark-colored { background-position: -484px -484px; width: 52px; height: 52px; } -.sprite-icons-funnel-light-neutral { +.sprite-icons-geomap-light-neutral { background-position: -544px -4px; width: 52px; height: 52px; } -.sprite-icons-funnel-light-colored { +.sprite-icons-geomap-light-colored { background-position: -544px -64px; width: 52px; height: 52px; } -.sprite-icons-funnel-dark-neutral { +.sprite-icons-geomap-dark-neutral { background-position: -544px -124px; width: 52px; height: 52px; } -.sprite-icons-funnel-dark-colored { +.sprite-icons-geomap-dark-colored { background-position: -544px -184px; width: 52px; height: 52px; } -.sprite-icons-data-light-neutral { +.sprite-icons-funnel-light-neutral { background-position: -544px -244px; width: 52px; height: 52px; } -.sprite-icons-data-light-colored { +.sprite-icons-funnel-light-colored { background-position: -544px -304px; width: 52px; height: 52px; } -.sprite-icons-data-dark-neutral { +.sprite-icons-funnel-dark-neutral { background-position: -544px -364px; width: 52px; height: 52px; } -.sprite-icons-data-dark-colored { +.sprite-icons-funnel-dark-colored { background-position: -544px -424px; width: 52px; height: 52px; } -.sprite-icons-code-light-neutral { +.sprite-icons-data-light-neutral { background-position: -544px -484px; width: 52px; height: 52px; } -.sprite-icons-code-light-colored { +.sprite-icons-data-light-colored { background-position: -4px -544px; width: 52px; height: 52px; } -.sprite-icons-code-dark-neutral { +.sprite-icons-data-dark-neutral { background-position: -64px -544px; width: 52px; height: 52px; } -.sprite-icons-code-dark-colored { +.sprite-icons-data-dark-colored { background-position: -124px -544px; width: 52px; height: 52px; } -.sprite-icons-circle-packing-light-neutral { +.sprite-icons-code-light-neutral { background-position: -184px -544px; width: 52px; height: 52px; } -.sprite-icons-circle-packing-light-colored { +.sprite-icons-code-light-colored { background-position: -244px -544px; width: 52px; height: 52px; } -.sprite-icons-circle-packing-dark-neutral { +.sprite-icons-code-dark-neutral { background-position: -304px -544px; width: 52px; height: 52px; } -.sprite-icons-circle-packing-dark-colored { +.sprite-icons-code-dark-colored { background-position: -364px -544px; width: 52px; height: 52px; } -.sprite-icons-choropleth-light-neutral { +.sprite-icons-circle-packing-light-neutral { background-position: -424px -544px; width: 52px; height: 52px; } -.sprite-icons-choropleth-light-colored { +.sprite-icons-circle-packing-light-colored { background-position: -484px -544px; width: 52px; height: 52px; } -.sprite-icons-choropleth-dark-neutral { +.sprite-icons-circle-packing-dark-neutral { background-position: -544px -544px; width: 52px; height: 52px; } -.sprite-icons-choropleth-dark-colored { +.sprite-icons-circle-packing-dark-colored { background-position: -604px -4px; width: 52px; height: 52px; } -.sprite-icons-chord-light-neutral { +.sprite-icons-choropleth-light-neutral { background-position: -604px -64px; width: 52px; height: 52px; } -.sprite-icons-chord-light-colored { +.sprite-icons-choropleth-light-colored { background-position: -604px -124px; width: 52px; height: 52px; } -.sprite-icons-chord-dark-neutral { +.sprite-icons-choropleth-dark-neutral { background-position: -604px -184px; width: 52px; height: 52px; } -.sprite-icons-chord-dark-colored { +.sprite-icons-choropleth-dark-colored { background-position: -604px -244px; width: 52px; height: 52px; } -.sprite-icons-calendar-light-neutral { +.sprite-icons-chord-light-neutral { background-position: -604px -304px; width: 52px; height: 52px; } -.sprite-icons-calendar-light-colored { +.sprite-icons-chord-light-colored { background-position: -604px -364px; width: 52px; height: 52px; } -.sprite-icons-calendar-dark-neutral { +.sprite-icons-chord-dark-neutral { background-position: -604px -424px; width: 52px; height: 52px; } -.sprite-icons-calendar-dark-colored { +.sprite-icons-chord-dark-colored { background-position: -604px -484px; width: 52px; height: 52px; } -.sprite-icons-bump-light-neutral { +.sprite-icons-calendar-light-neutral { background-position: -604px -544px; width: 52px; height: 52px; } -.sprite-icons-bump-light-colored { +.sprite-icons-calendar-light-colored { background-position: -4px -604px; width: 52px; height: 52px; } -.sprite-icons-bump-dark-neutral { +.sprite-icons-calendar-dark-neutral { background-position: -64px -604px; width: 52px; height: 52px; } -.sprite-icons-bump-dark-colored { +.sprite-icons-calendar-dark-colored { background-position: -124px -604px; width: 52px; height: 52px; } -.sprite-icons-bullet-light-neutral { +.sprite-icons-bump-light-neutral { background-position: -184px -604px; width: 52px; height: 52px; } -.sprite-icons-bullet-light-colored { +.sprite-icons-bump-light-colored { background-position: -244px -604px; width: 52px; height: 52px; } -.sprite-icons-bullet-dark-neutral { +.sprite-icons-bump-dark-neutral { background-position: -304px -604px; width: 52px; height: 52px; } -.sprite-icons-bullet-dark-colored { +.sprite-icons-bump-dark-colored { background-position: -364px -604px; width: 52px; height: 52px; } -.sprite-icons-boxplot-light-neutral { +.sprite-icons-bullet-light-neutral { background-position: -424px -604px; width: 52px; height: 52px; } -.sprite-icons-boxplot-light-colored { +.sprite-icons-bullet-light-colored { background-position: -484px -604px; width: 52px; height: 52px; } -.sprite-icons-boxplot-dark-neutral { +.sprite-icons-bullet-dark-neutral { background-position: -544px -604px; width: 52px; height: 52px; } -.sprite-icons-boxplot-dark-colored { +.sprite-icons-bullet-dark-colored { background-position: -604px -604px; width: 52px; height: 52px; } -.sprite-icons-bar-light-neutral { +.sprite-icons-boxplot-light-neutral { background-position: -664px -4px; width: 52px; height: 52px; } -.sprite-icons-bar-light-colored { +.sprite-icons-boxplot-light-colored { background-position: -664px -64px; width: 52px; height: 52px; } -.sprite-icons-bar-dark-neutral { +.sprite-icons-boxplot-dark-neutral { background-position: -664px -124px; width: 52px; height: 52px; } -.sprite-icons-bar-dark-colored { +.sprite-icons-boxplot-dark-colored { background-position: -664px -184px; width: 52px; height: 52px; } -.sprite-icons-area-bump-light-neutral { +.sprite-icons-bar-light-neutral { background-position: -664px -244px; width: 52px; height: 52px; } -.sprite-icons-area-bump-light-colored { +.sprite-icons-bar-light-colored { background-position: -664px -304px; width: 52px; height: 52px; } -.sprite-icons-area-bump-dark-neutral { +.sprite-icons-bar-dark-neutral { background-position: -664px -364px; width: 52px; height: 52px; } -.sprite-icons-area-bump-dark-colored { +.sprite-icons-bar-dark-colored { background-position: -664px -424px; width: 52px; height: 52px; } +.sprite-icons-area-bump-light-neutral { + background-position: -664px -484px; + width: 52px; + height: 52px; +} + +.sprite-icons-area-bump-light-colored { + background-position: -664px -544px; + width: 52px; + height: 52px; +} + +.sprite-icons-area-bump-dark-neutral { + background-position: -664px -604px; + width: 52px; + height: 52px; +} + +.sprite-icons-area-bump-dark-colored { + background-position: -4px -664px; + width: 52px; + height: 52px; +} + @media screen and (-webkit-min-device-pixel-ratio: 1), screen and (min--moz-device-pixel-ratio: 1), screen and (-o-min-device-pixel-ratio: 100/100), @@ -927,6 +955,10 @@ .sprite-icons-treemap-grey, .sprite-icons-treemap-dark-neutral, .sprite-icons-treemap-dark-colored, + .sprite-icons-tree-light-neutral, + .sprite-icons-tree-light-colored, + .sprite-icons-tree-dark-neutral, + .sprite-icons-tree-dark-colored, .sprite-icons-time-range-light-neutral, .sprite-icons-time-range-light-colored, .sprite-icons-time-range-dark-neutral, @@ -1041,9 +1073,9 @@ .sprite-icons-area-bump-dark-neutral, .sprite-icons-area-bump-dark-colored { background-image: url('../assets/icons.png'); - -webkit-background-size: 720px 660px; - -moz-background-size: 720px 660px; - background-size: 720px 660px; + -webkit-background-size: 720px 720px; + -moz-background-size: 720px 720px; + background-size: 720px 720px; } } @@ -1068,6 +1100,10 @@ .sprite-icons-treemap-grey, .sprite-icons-treemap-dark-neutral, .sprite-icons-treemap-dark-colored, + .sprite-icons-tree-light-neutral, + .sprite-icons-tree-light-colored, + .sprite-icons-tree-dark-neutral, + .sprite-icons-tree-dark-colored, .sprite-icons-time-range-light-neutral, .sprite-icons-time-range-light-colored, .sprite-icons-time-range-dark-neutral, @@ -1182,8 +1218,8 @@ .sprite-icons-area-bump-dark-neutral, .sprite-icons-area-bump-dark-colored { background-image: url('../assets/icons@2x.png'); - -webkit-background-size: 720px 660px; - -moz-background-size: 720px 660px; - background-size: 720px 660px; + -webkit-background-size: 720px 720px; + -moz-background-size: 720px 720px; + background-size: 720px 720px; } } diff --git a/website/src/types.ts b/website/src/types.ts index 742f2ee97..0425c59b1 100644 --- a/website/src/types.ts +++ b/website/src/types.ts @@ -11,16 +11,18 @@ export interface ChartMeta { }[] } +export type FlavorAwareChartPropertyAttribute = T | Partial> + export interface ChartProperty { key: string name?: string group: string // type of the property, preferably expressed with TypeScript notation - type: string + type: FlavorAwareChartPropertyAttribute // will be parsed in Markdown and supports links help?: string // will be parsed in Markdown and supports links - description?: string + description?: FlavorAwareChartPropertyAttribute // assumed to be optional by default required?: boolean // default property value as defined for the component,