├── .babelrc.js ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── LICENSE ├── README.md ├── index.html ├── jest.config.js ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── WordCloud.tsx ├── __tests__ │ └── WordCloud.spec.tsx └── index.ts ├── test └── jest-setup.ts ├── tsconfig.build.json └── tsconfig.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: 'defaults', 7 | modules: process.env.ESM === 'true' ? false : 'commonjs', 8 | }, 9 | ], 10 | '@babel/preset-react', 11 | '@babel/preset-typescript', 12 | ], 13 | plugins: ['babel-plugin-typescript-to-proptypes'], 14 | }; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{json,js,jsx,ts,tsx,yml,css,html}] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | examples 5 | !.babelrc.js 6 | !.eslintrc.js 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaFeatures: { 4 | jsx: true, 5 | }, 6 | }, 7 | parser: '@typescript-eslint/parser', 8 | extends: [ 9 | 'yoctol', 10 | 'plugin:import/typescript', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | env: { 14 | node: true, 15 | jest: true, 16 | browser: true, 17 | }, 18 | rules: { 19 | 'no-use-before-define': 'off', 20 | '@typescript-eslint/no-use-before-define': ['error'], 21 | 'import/extensions': [ 22 | 'error', 23 | 'ignorePackages', 24 | { 25 | js: 'never', 26 | jsx: 'never', 27 | ts: 'never', 28 | tsx: 'never', 29 | }, 30 | ], 31 | 'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }], 32 | }, 33 | settings: { 34 | 'import/resolver': { 35 | node: { 36 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 37 | }, 38 | }, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | env: 4 | NODE_VERSION_USED_FOR_DEVELOPMENT: 14 5 | jobs: 6 | lint: 7 | name: Lint source files 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repo 11 | uses: actions/checkout@v2 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ env.NODE_VERSION_USED_FOR_DEVELOPMENT }} 17 | 18 | - name: Cache Node.js modules 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/.npm 22 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 23 | restore-keys: | 24 | ${{ runner.OS }}-node- 25 | - name: Install Dependencies 26 | run: npm ci 27 | 28 | - name: Lint ESLint 29 | run: npm run lint 30 | 31 | - name: Type Check 32 | run: npm run check 33 | 34 | test: 35 | name: Run tests on Node v${{ matrix.node_version_to_setup }} 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | node_version_to_setup: [12, 14, 16] 40 | steps: 41 | - name: Checkout repo 42 | uses: actions/checkout@v2 43 | 44 | - name: Setup Node.js v${{ matrix.node_version_to_setup }} 45 | uses: actions/setup-node@v1 46 | with: 47 | node-version: ${{ matrix.node_version_to_setup }} 48 | 49 | - name: Cache Node.js modules 50 | uses: actions/cache@v2 51 | with: 52 | path: ~/.npm 53 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 54 | restore-keys: | 55 | ${{ runner.OS }}-node- 56 | - name: Install Dependencies 57 | run: npm ci 58 | 59 | - name: Run Tests 60 | run: npm run testonly 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | lib/ 40 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test/ 2 | .babelrc 3 | .editorconfig 4 | .eslintrc.js 5 | .eslintignore 6 | .gitignore 7 | demo 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Yoctol (github.com/Yoctol/react-d3-cloud) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-d3-cloud 2 | 3 | [![npm version](https://badge.fury.io/js/react-d3-cloud.svg)](https://badge.fury.io/js/react-d3-cloud) 4 | [![Build Status](https://github.com/Yoctol/react-d3-cloud/workflows/CI/badge.svg?branch=master)](https://github.com/Yoctol/react-d3-cloud/actions?query=branch%3Amaster) 5 | 6 | A word cloud react component built with [d3-cloud](https://github.com/jasondavies/d3-cloud). 7 | 8 | ![image](https://cloud.githubusercontent.com/assets/6868283/20619528/fa83334c-b32f-11e6-81dd-6fe4fa6c52d9.png) 9 | 10 | ## Installation 11 | 12 | ```sh 13 | npm install react-d3-cloud 14 | ``` 15 | 16 | ## Usage 17 | 18 | Simple: 19 | 20 | ```jsx 21 | import React from 'react'; 22 | import { render } from 'react-dom'; 23 | import WordCloud from 'react-d3-cloud'; 24 | 25 | const data = [ 26 | { text: 'Hey', value: 1000 }, 27 | { text: 'lol', value: 200 }, 28 | { text: 'first impression', value: 800 }, 29 | { text: 'very cool', value: 1000000 }, 30 | { text: 'duck', value: 10 }, 31 | ]; 32 | 33 | render(, document.getElementById('root')); 34 | ``` 35 | 36 | More configuration: 37 | 38 | ```jsx 39 | import React from 'react'; 40 | import { render } from 'react-dom'; 41 | import WordCloud from 'react-d3-cloud'; 42 | import { scaleOrdinal } from 'd3-scale'; 43 | import { schemeCategory10 } from 'd3-scale-chromatic'; 44 | 45 | const data = [ 46 | { text: 'Hey', value: 1000 }, 47 | { text: 'lol', value: 200 }, 48 | { text: 'first impression', value: 800 }, 49 | { text: 'very cool', value: 1000000 }, 50 | { text: 'duck', value: 10 }, 51 | ]; 52 | 53 | const schemeCategory10ScaleOrdinal = scaleOrdinal(schemeCategory10); 54 | 55 | render( 56 | Math.log2(word.value) * 5} 64 | spiral="rectangular" 65 | rotate={(word) => word.value % 360} 66 | padding={5} 67 | random={Math.random} 68 | fill={(d, i) => schemeCategory10ScaleOrdinal(i)} 69 | onWordClick={(event, d) => { 70 | console.log(`onWordClick: ${d.text}`); 71 | }} 72 | onWordMouseOver={(event, d) => { 73 | console.log(`onWordMouseOver: ${d.text}`); 74 | }} 75 | onWordMouseOut={(event, d) => { 76 | console.log(`onWordMouseOut: ${d.text}`); 77 | }} 78 | />, 79 | document.getElementById('root') 80 | ); 81 | ``` 82 | 83 | Please checkout [demo](https://codesandbox.io/embed/react-d3-cloud-demo-forked-50wzl) 84 | 85 | for more detailed props, please refer to below: 86 | 87 | ## Props 88 | 89 | | name | description | type | required | default | 90 | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -------- | ------------------------------------------- | 91 | | data | The words array | `{ text: string, value: number }>[]` | ✓ | 92 | | width | Width of the layout (px) | `number` | | `700` | 93 | | height | Height of the layout (px) | `number` | | `600` | 94 | | font | The font accessor function, which indicates the font face for each word. A constant may be specified instead of a function. | `string \| (d) => string` | | `'serif'` | 95 | | fontStyle | The fontStyle accessor function, which indicates the font style for each word. A constant may be specified instead of a function. | `string \| (d) => string` | | `'normal'` | 96 | | fontWeight | The fontWeight accessor function, which indicates the font weight for each word. A constant may be specified instead of a function. | `string \| number \| (d) => string \| number` | | `'normal'` | 97 | | fontSize | The fontSize accessor function, which indicates the numerical font size for each word. | `(d) => number` | | `(d) => Math.sqrt(d.value)` | 98 | | rotate | The rotate accessor function, which indicates the rotation angle (in degrees) for each word. | `(d) => number` | | `() => (~~(Math.random() * 6) - 3) * 30` | 99 | | spiral | The current type of spiral used for positioning words. This can either be one of the two built-in spirals, "archimedean" and "rectangular", or an arbitrary spiral generator can be used | `'archimedean' \| 'rectangular' \| ([width, height]) => t => [x, y]` | | `'archimedean'` | 100 | | padding | The padding accessor function, which indicates the numerical padding for each word. | `number \| (d) => number` | | `1` | 101 | | random | The internal random number generator, used for selecting the initial position of each word, and the clockwise/counterclockwise direction of the spiral for each word. This should return a number in the range `[0, 1)`. | `(d) => number` | | `Math.random` | 102 | | fill | The fill accessor function, which indicates the color for each word. | `(d, i) => string` | | `(d, i) => schemeCategory10ScaleOrdinal(i)` | 103 | | onWordClick | The function will be called when `click` event is triggered on a word | `(event, d) => {}` | | null | 104 | | onWordMouseOver | The function will be called when `mouseover` event is triggered on a word | `(event, d) => {}` | | null | 105 | | onWordMouseOut | The function will be called when `mouseout` event is triggered on a word | `(event, d) => {}` | | null | 106 | 107 | ## FAQ 108 | 109 | ### How to Use with Next.js/SSR 110 | 111 | To make `` work with Server-Side Rendering (SSR), you need to avoid rendering it on the server: 112 | 113 | ```js 114 | { 115 | typeof window !== 'undefined' && ; 116 | } 117 | ``` 118 | 119 | ### How to Avoid Unnecessary Re-render 120 | 121 | As of version 0.10.1, `` has been wrapped by `React.memo()` and deep equal comparison under the hood to avoid unnecessary re-render. All you need to do is to make your function props deep equal comparable using `useCallback()`: 122 | 123 | ```js 124 | import React, { useCallback } from 'react'; 125 | import { render } from 'react-dom'; 126 | import WordCloud from 'react-d3-cloud'; 127 | import { scaleOrdinal } from 'd3-scale'; 128 | import { schemeCategory10 } from 'd3-scale-chromatic'; 129 | 130 | function App() { 131 | const data = [ 132 | { text: 'Hey', value: 1000 }, 133 | { text: 'lol', value: 200 }, 134 | { text: 'first impression', value: 800 }, 135 | { text: 'very cool', value: 1000000 }, 136 | { text: 'duck', value: 10 }, 137 | ]; 138 | 139 | const fontSize = useCallback((word) => Math.log2(word.value) * 5, []); 140 | const rotate = useCallback((word) => word.value % 360, []); 141 | const fill = useCallback((d, i) => scaleOrdinal(schemeCategory10)(i), []); 142 | const onWordClick = useCallback((word) => { 143 | console.log(`onWordClick: ${word}`); 144 | }, []); 145 | const onWordMouseOver = useCallback((word) => { 146 | console.log(`onWordMouseOver: ${word}`); 147 | }, []); 148 | const onWordMouseOut = useCallback((word) => { 149 | console.log(`onWordMouseOut: ${word}`); 150 | }, []); 151 | 152 | return ( 153 | 170 | ); 171 | ); 172 | ``` 173 | 174 | ## Build 175 | 176 | ```sh 177 | npm run build 178 | ``` 179 | 180 | ## Test 181 | 182 | ### pre-install 183 | 184 | #### Mac OS X 185 | 186 | ```sh 187 | brew install pkg-config cairo pango libpng jpeg giflib librsvg 188 | npm install 189 | ``` 190 | 191 | #### Ubuntu and Other Debian Based Systems 192 | 193 | ```sh 194 | sudo apt-get update 195 | sudo apt-get install libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++ 196 | npm install 197 | ``` 198 | 199 | For more details, please check out [Installation guides](https://github.com/Automattic/node-canvas/wiki) at node-canvas wiki. 200 | 201 | ### Run Tests 202 | 203 | ```sh 204 | npm test 205 | ``` 206 | 207 | ## License 208 | 209 | MIT © [Yoctol](https://github.com/Yoctol/react-d3-cloud) 210 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React D3 Could demo 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | setupFilesAfterEnv: ['./test/jest-setup.ts'], 4 | testPathIgnorePatterns: ['/node_modules/', '/examples/', '/lib/'], 5 | timers: 'fake', 6 | resetModules: true, 7 | resetMocks: true, 8 | }; 9 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*package.json': ['prettier-package-json --write'], 3 | '*.(js|ts)': ['eslint --fix'], 4 | '*.(md|json|yml)': ['prettier --write'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-d3-cloud", 3 | "version": "1.0.6", 4 | "description": "A word cloud component using d3-cloud", 5 | "license": "MIT", 6 | "author": "cph", 7 | "homepage": "https://github.com/Yoctol/react-d3-cloud", 8 | "repository": "Yoctol/react-d3-cloud", 9 | "bugs": { 10 | "url": "https://github.com/Yoctol/react-d3-cloud/issues" 11 | }, 12 | "exports": { 13 | "import": "./lib/esm/index.js", 14 | "require": "./lib/index.js" 15 | }, 16 | "main": "lib/index.js", 17 | "scripts": { 18 | "build": "npm run clean && npm run build:cjs && npm run build:esm", 19 | "build:cjs": "babel src -d lib --extensions '.ts,.tsx' --ignore '**/__tests__/**' && tsc -p tsconfig.build.json -m commonjs --emitDeclarationOnly", 20 | "build:esm": "cross-env ESM=true babel src -d lib/esm --extensions '.ts,.tsx' --ignore '**/__tests__/**' && tsc -p tsconfig.build.json -m esnext --emitDeclarationOnly --outDir lib/esm", 21 | "check": "tsc --noEmit", 22 | "clean": "rimraf lib", 23 | "lint": "eslint .", 24 | "lint:fix": "npm run lint -- --fix", 25 | "lint:staged": "lint-staged", 26 | "prepare": "husky install", 27 | "prepublishOnly": "npm run build", 28 | "test": "npm run lint && npm run testonly", 29 | "testonly": "jest", 30 | "testonly:watch": "npm run testonly -- --watch", 31 | "prettier": "prettier --write --list-different .", 32 | "prettier:check": "prettier --check .", 33 | "preversion": "npm test" 34 | }, 35 | "types": "lib/index.d.ts", 36 | "dependencies": { 37 | "d3-cloud": "^1.2.5", 38 | "d3-scale": "^3.3.0", 39 | "d3-scale-chromatic": "^2.0.0", 40 | "d3-selection": "^2.0.0", 41 | "prop-types": "^15.7.2", 42 | "react-fast-compare": "^3.2.0", 43 | "react-faux-dom": "^4.5.0" 44 | }, 45 | "peerDependencies": { 46 | "react": "^16.8.0 || ^17.0.0-0 || ^18.0.0-0", 47 | "react-dom": "^16.8.0 || ^17.0.0-0 || ^18.0.0-0" 48 | }, 49 | "devDependencies": { 50 | "@babel/cli": "^7.14.8", 51 | "@babel/core": "^7.15.0", 52 | "@babel/preset-env": "^7.15.0", 53 | "@babel/preset-react": "^7.14.5", 54 | "@babel/preset-typescript": "^7.15.0", 55 | "@testing-library/jest-dom": "^5.14.1", 56 | "@testing-library/react": "^12.1.1", 57 | "@types/d3-cloud": "^1.2.5", 58 | "@types/d3-scale": "^4.0.1", 59 | "@types/d3-scale-chromatic": "^3.0.0", 60 | "@types/d3-selection": "^3.0.1", 61 | "@types/jest": "^27.0.1", 62 | "@types/react": "^17.0.19", 63 | "@types/react-faux-dom": "^4.1.5", 64 | "@typescript-eslint/eslint-plugin": "^4.29.3", 65 | "@typescript-eslint/parser": "^4.29.3", 66 | "babel-eslint": "^10.1.0", 67 | "babel-plugin-typescript-to-proptypes": "^1.4.2", 68 | "canvas": "^2.8.0", 69 | "cross-env": "^7.0.3", 70 | "eslint": "^7.32.0", 71 | "eslint-config-yoctol": "^0.26.2", 72 | "eslint-plugin-import": "^2.24.0", 73 | "eslint-plugin-jsx-a11y": "^6.4.1", 74 | "eslint-plugin-prettier": "^3.4.0", 75 | "eslint-plugin-react": "7.24.0", 76 | "eslint-plugin-react-hooks": "^4.2.0", 77 | "eslint-plugin-sort-imports-es6-autofix": "^0.6.0", 78 | "husky": "^7.0.1", 79 | "jest": "^27.0.6", 80 | "lint-staged": "^11.1.2", 81 | "prettier": "^2.3.2", 82 | "prettier-package-json": "^2.6.0", 83 | "react": "^16.14.0", 84 | "react-dom": "^16.14.0", 85 | "regenerator-runtime": "^0.13.9", 86 | "rimraf": "^3.0.2", 87 | "typescript": "^4.4.2" 88 | }, 89 | "keywords": [ 90 | "d3", 91 | "react", 92 | "word-cloud" 93 | ] 94 | } 95 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'es5', 4 | }; 5 | -------------------------------------------------------------------------------- /src/WordCloud.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import ReactFauxDom from 'react-faux-dom'; 3 | import cloud from 'd3-cloud'; 4 | import isDeepEqual from 'react-fast-compare'; 5 | import { BaseType, ValueFn, select } from 'd3-selection'; 6 | import { scaleOrdinal } from 'd3-scale'; 7 | import { schemeCategory10 } from 'd3-scale-chromatic'; 8 | 9 | interface Datum { 10 | text: string; 11 | value: number; 12 | } 13 | 14 | export interface Word extends cloud.Word { 15 | text: string; 16 | value: number; 17 | } 18 | 19 | type WordCloudProps = { 20 | data: Datum[]; 21 | width?: number; 22 | height?: number; 23 | font?: string | ((word: Word, index: number) => string); 24 | fontStyle?: string | ((word: Word, index: number) => string); 25 | fontWeight?: 26 | | string 27 | | number 28 | | ((word: Word, index: number) => string | number); 29 | fontSize?: number | ((word: Word, index: number) => number); 30 | rotate?: number | ((word: Word, index: number) => number); 31 | spiral?: 32 | | 'archimedean' 33 | | 'rectangular' 34 | | ((size: [number, number]) => (t: number) => [number, number]); 35 | padding?: number | ((word: Word, index: number) => number); 36 | random?: () => number; 37 | fill?: ValueFn; 38 | onWordClick?: (this: BaseType, event: any, d: Word) => void; 39 | onWordMouseOver?: (this: BaseType, event: any, d: Word) => void; 40 | onWordMouseOut?: (this: BaseType, event: any, d: Word) => void; 41 | }; 42 | 43 | const defaultScaleOrdinal = scaleOrdinal(schemeCategory10); 44 | 45 | function WordCloud({ 46 | data, 47 | width = 700, 48 | height = 600, 49 | font = 'serif', 50 | fontStyle = 'normal', 51 | fontWeight = 'normal', 52 | fontSize = (d) => Math.sqrt(d.value), 53 | // eslint-disable-next-line no-bitwise 54 | rotate = () => (~~(Math.random() * 6) - 3) * 30, 55 | spiral = 'archimedean', 56 | padding = 1, 57 | random = Math.random, 58 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 59 | // @ts-ignore The ordinal function should accept number 60 | fill = (_, i) => defaultScaleOrdinal(i), 61 | onWordClick, 62 | onWordMouseOver, 63 | onWordMouseOut, 64 | }: WordCloudProps) { 65 | const elementRef = useRef(); 66 | 67 | if (!elementRef.current) { 68 | elementRef.current = ReactFauxDom.createElement('div'); 69 | } 70 | 71 | const el = elementRef.current; 72 | 73 | // clear old words 74 | select(el).selectAll('*').remove(); 75 | 76 | // render based on new data 77 | const layout = cloud() 78 | .words(data) 79 | .size([width, height]) 80 | .font(font) 81 | .fontStyle(fontStyle) 82 | .fontWeight(fontWeight) 83 | .fontSize(fontSize) 84 | .rotate(rotate) 85 | .spiral(spiral) 86 | .padding(padding) 87 | .random(random) 88 | .on('end', (words) => { 89 | const [w, h] = layout.size(); 90 | 91 | const texts = select(el) 92 | .append('svg') 93 | .attr('viewBox', `0 0 ${w} ${h}`) 94 | .attr('preserveAspectRatio', 'xMinYMin meet') 95 | .append('g') 96 | .attr('transform', `translate(${w / 2},${h / 2})`) 97 | .selectAll('text') 98 | .data(words) 99 | .enter() 100 | .append('text') 101 | .style( 102 | 'font-family', 103 | ((d) => d.font) as ValueFn 104 | ) 105 | .style( 106 | 'font-style', 107 | ((d) => d.style) as ValueFn 108 | ) 109 | .style( 110 | 'font-weight', 111 | ((d) => d.weight) as ValueFn 112 | ) 113 | .style( 114 | 'font-size', 115 | ((d) => `${d.size}px`) as ValueFn 116 | ) 117 | .style('fill', fill) 118 | .attr('text-anchor', 'middle') 119 | .attr('transform', (d) => `translate(${[d.x, d.y]})rotate(${d.rotate})`) 120 | .text((d) => d.text); 121 | 122 | if (onWordClick) { 123 | texts.on('click', onWordClick); 124 | } 125 | if (onWordMouseOver) { 126 | texts.on('mouseover', onWordMouseOver); 127 | } 128 | if (onWordMouseOut) { 129 | texts.on('mouseout', onWordMouseOut); 130 | } 131 | }); 132 | 133 | layout.start(); 134 | 135 | return el.toReact(); 136 | } 137 | 138 | export default React.memo(WordCloud, isDeepEqual); 139 | -------------------------------------------------------------------------------- /src/__tests__/WordCloud.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | RenderResult, 4 | fireEvent, 5 | render, 6 | screen, 7 | } from '@testing-library/react'; 8 | 9 | import WordCloud, { Word } from '../WordCloud'; 10 | 11 | const data = [ 12 | { text: 'Hey', value: 1 }, 13 | { text: 'Ok', value: 5 }, 14 | { text: 'Cool', value: 10 }, 15 | ]; 16 | 17 | function renderInStrictMode(ui: React.ReactElement): RenderResult { 18 | return render(ui, { wrapper: React.StrictMode }); 19 | } 20 | 21 | it('should render words', async () => { 22 | renderInStrictMode(); 23 | 24 | await screen.findByText('Hey'); 25 | 26 | expect(screen.getByText('Hey')).toBeInTheDocument(); 27 | expect(screen.getByText('Ok')).toBeInTheDocument(); 28 | expect(screen.getByText('Cool')).toBeInTheDocument(); 29 | }); 30 | 31 | it('should render correct words after re-render', async () => { 32 | const { rerender } = renderInStrictMode(); 33 | 34 | const newData = [ 35 | { text: 'New', value: 1 }, 36 | { text: 'World', value: 5 }, 37 | ]; 38 | rerender(); 39 | 40 | await screen.findByText('New'); 41 | 42 | expect(screen.queryByText('Hey')).not.toBeInTheDocument(); 43 | expect(screen.queryByText('Ok')).not.toBeInTheDocument(); 44 | expect(screen.queryByText('Cool')).not.toBeInTheDocument(); 45 | 46 | expect(screen.getByText('New')).toBeInTheDocument(); 47 | expect(screen.getByText('World')).toBeInTheDocument(); 48 | }); 49 | 50 | it('should render with custom font', async () => { 51 | const font = 'sans-serif'; 52 | 53 | renderInStrictMode(); 54 | 55 | await screen.findByText('Hey'); 56 | 57 | expect(screen.getByText('Hey')).toHaveStyle('font-family: sans-serif'); 58 | expect(screen.getByText('Ok')).toHaveStyle('font-family: sans-serif'); 59 | expect(screen.getByText('Cool')).toHaveStyle('font-family: sans-serif'); 60 | }); 61 | 62 | it('should render with custom fontStyle', async () => { 63 | const fontStyle = 'bold'; 64 | 65 | renderInStrictMode(); 66 | 67 | await screen.findByText('Hey'); 68 | 69 | expect(screen.getByText('Hey')).toHaveStyle('font-style: bold'); 70 | expect(screen.getByText('Ok')).toHaveStyle('font-style: bold'); 71 | expect(screen.getByText('Cool')).toHaveStyle('font-style: bold'); 72 | }); 73 | 74 | it('should render with custom fontWeight', async () => { 75 | const fontWeight = 900; 76 | 77 | renderInStrictMode(); 78 | 79 | await screen.findByText('Hey'); 80 | 81 | expect(screen.getByText('Hey')).toHaveStyle('font-weight: 900'); 82 | expect(screen.getByText('Ok')).toHaveStyle('font-weight: 900'); 83 | expect(screen.getByText('Cool')).toHaveStyle('font-weight: 900'); 84 | }); 85 | 86 | it('should render with custom fontSize', async () => { 87 | const fontSize = (d: Word) => d.value * 2; 88 | 89 | renderInStrictMode(); 90 | 91 | await screen.findByText('Hey'); 92 | 93 | expect(screen.getByText('Hey')).toHaveStyle('font-size: 2px'); 94 | expect(screen.getByText('Ok')).toHaveStyle('font-size: 10px'); 95 | expect(screen.getByText('Cool')).toHaveStyle('font-size: 20px'); 96 | }); 97 | 98 | it('should render with custom rotate', async () => { 99 | const rotate = (_: Word, i: number) => (i + 1) * 30; 100 | 101 | renderInStrictMode(); 102 | 103 | await screen.findByText('Hey'); 104 | 105 | expect(screen.getByText('Hey')).toHaveAttribute( 106 | 'transform', 107 | expect.stringContaining('rotate(30)') 108 | ); 109 | expect(screen.getByText('Ok')).toHaveAttribute( 110 | 'transform', 111 | expect.stringContaining('rotate(60)') 112 | ); 113 | expect(screen.getByText('Cool')).toHaveAttribute( 114 | 'transform', 115 | expect.stringContaining('rotate(90)') 116 | ); 117 | }); 118 | 119 | it('should render with custom fill', async () => { 120 | const fill = () => '#000000'; 121 | 122 | renderInStrictMode(); 123 | 124 | await screen.findByText('Hey'); 125 | 126 | expect(screen.getByText('Hey')).toHaveStyle('fill: #000000'); 127 | expect(screen.getByText('Ok')).toHaveStyle('fill: #000000'); 128 | expect(screen.getByText('Cool')).toHaveStyle('fill: #000000'); 129 | }); 130 | 131 | it('should support click handler', async () => { 132 | const onWordClick = jest.fn(); 133 | 134 | renderInStrictMode(); 135 | 136 | await screen.findByText('Hey'); 137 | 138 | fireEvent.click(screen.getByText('Hey')); 139 | 140 | expect(onWordClick).toBeCalled(); 141 | }); 142 | 143 | it('should support mouse over handler', async () => { 144 | const onWordMouseOver = jest.fn(); 145 | 146 | renderInStrictMode( 147 | 148 | ); 149 | 150 | await screen.findByText('Hey'); 151 | 152 | fireEvent.mouseOver(screen.getByText('Hey')); 153 | 154 | expect(onWordMouseOver).toBeCalled(); 155 | }); 156 | 157 | it('should support mouse out handler', async () => { 158 | const onWordMouseOut = jest.fn(); 159 | 160 | renderInStrictMode(); 161 | 162 | await screen.findByText('Hey'); 163 | 164 | fireEvent.mouseOut(screen.getByText('Hey')); 165 | 166 | expect(onWordMouseOut).toBeCalled(); 167 | }); 168 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import WordCloud from './WordCloud'; 2 | 3 | export default WordCloud; 4 | -------------------------------------------------------------------------------- /test/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import 'regenerator-runtime/runtime'; 3 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*"], 4 | "exclude": ["src/**/__tests__/**/*"], 5 | "compilerOptions": { 6 | "outDir": "lib", 7 | "rootDir": "src" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./test/jest-setup.ts"], 3 | "compilerOptions": { 4 | "target": "es2018", 5 | "module": "es2015", 6 | "lib": ["es2018", "dom"], 7 | "jsx": "react", 8 | "declaration": true, 9 | "isolatedModules": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "strictFunctionTypes": true, 14 | "strictBindCallApply": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | "noPropertyAccessFromIndexSignature": true, 24 | "moduleResolution": "node", 25 | "esModuleInterop": true, 26 | "skipLibCheck": true, 27 | "forceConsistentCasingInFileNames": true 28 | } 29 | } 30 | --------------------------------------------------------------------------------