├── .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 | [](https://badge.fury.io/js/react-d3-cloud)
4 | [](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 | 
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 |
--------------------------------------------------------------------------------