├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ ├── feature_request.md │ └── question.md ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── create_release.yml │ ├── deploy_website.yml │ └── release_helper.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-4.5.1.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── docs ├── .vitepress │ ├── config.ts │ └── theme │ │ └── index.ts ├── examples │ ├── dendrogram.md │ ├── dendrogram.ts │ ├── directed.md │ ├── directed.ts │ ├── force.md │ ├── force.ts │ ├── index.md │ ├── label.md │ ├── label.ts │ ├── linked.md │ ├── linked.ts │ ├── miserables.json │ ├── orientation.md │ ├── tree.json │ ├── tree.md │ └── tree.ts ├── getting-started.md ├── index.md └── related.md ├── eslint.config.mjs ├── package.json ├── rollup.config.js ├── samples ├── graphChange.html └── tree.html ├── src ├── __tests__ │ └── createChart.ts ├── controllers │ ├── DendrogramController.spec.ts │ ├── DendrogramController.ts │ ├── ForceDirectedGraphController.ts │ ├── GraphController.ts │ ├── TreeController.ts │ ├── __image_snapshots__ │ │ ├── dendrogram-controller-spec-ts-dendogram-vertical-tree-1-snap.png │ │ ├── dendrogram-controller-spec-ts-dendrogram-default-1-snap.png │ │ ├── dendrogram-controller-spec-ts-dendrogram-default-tree-1-snap.png │ │ ├── dendrogram-controller-spec-ts-dendrogram-directed-1-snap.png │ │ ├── dendrogram-controller-spec-ts-dendrogram-radial-1-snap.png │ │ ├── dendrogram-controller-spec-ts-dendrogram-radial-tree-1-snap.png │ │ ├── dendrogram-controller-spec-ts-dendrogram-vertical-1-snap.png │ │ ├── dendrogram-controller-spec-ts-dendrogram-vertical-tree-1-snap.png │ │ └── force-directed-spec-ts-dendrogram-default-1-snap.png │ ├── __tests__ │ │ ├── miserables.ts │ │ └── tree.ts │ ├── forceDirected.spec.ts │ ├── index.ts │ ├── interpolatePoints.ts │ └── patchController.ts ├── elements │ ├── EdgeLine.ts │ └── index.ts ├── index.ts └── index.umd.ts ├── tsconfig.c.json ├── tsconfig.json ├── typedoc.json ├── vitest.config.ts └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # These settings are for any web project 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto eol=lf 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text 23 | *.jsx text 24 | *.ts text 25 | *.tsx text 26 | *.coffee text 27 | *.json text 28 | *.htm text 29 | *.html text 30 | *.xml text 31 | *.txt text 32 | *.ini text 33 | *.inc text 34 | *.pl text 35 | *.rb text 36 | *.py text 37 | *.scm text 38 | *.sql text 39 | *.sh text eof=LF 40 | *.bat text 41 | 42 | # templates 43 | *.hbt text 44 | *.jade text 45 | *.haml text 46 | *.hbs text 47 | *.dot text 48 | *.tmpl text 49 | *.phtml text 50 | 51 | # server config 52 | .htaccess text 53 | 54 | # git config 55 | .gitattributes text 56 | .gitignore text 57 | 58 | # code analysis config 59 | .jshintrc text 60 | .jscsrc text 61 | .jshintignore text 62 | .csslintrc text 63 | 64 | # misc config 65 | *.yaml text 66 | *.yml text 67 | .editorconfig text 68 | 69 | # build config 70 | *.npmignore text 71 | *.bowerrc text 72 | Dockerfile text eof=LF 73 | 74 | # Heroku 75 | Procfile text 76 | .slugignore text 77 | 78 | # Documentation 79 | *.md text 80 | LICENSE text 81 | AUTHORS text 82 | 83 | 84 | # 85 | ## These files are binary and should be left untouched 86 | # 87 | 88 | # (binary is a macro for -text -diff) 89 | *.png binary 90 | *.jpg binary 91 | *.jpeg binary 92 | *.gif binary 93 | *.ico binary 94 | *.mov binary 95 | *.mp4 binary 96 | *.mp3 binary 97 | *.flv binary 98 | *.fla binary 99 | *.swf binary 100 | *.gz binary 101 | *.zip binary 102 | *.7z binary 103 | *.ttf binary 104 | *.pyc binary 105 | *.pdf binary 106 | 107 | # Source files 108 | # ============ 109 | *.pxd text 110 | *.py text 111 | *.py3 text 112 | *.pyw text 113 | *.pyx text 114 | *.sh text eol=lf 115 | *.json text 116 | 117 | # Binary files 118 | # ============ 119 | *.db binary 120 | *.p binary 121 | *.pkl binary 122 | *.pyc binary 123 | *.pyd binary 124 | *.pyo binary 125 | 126 | # Note: .db, .p, and .pkl files are associated 127 | # with the python modules ``pickle``, ``dbm.*``, 128 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 129 | # (among others). 130 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [sgratzl] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: If something isn't working as expected 🤔. 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | When I... 12 | 13 | **To Reproduce** 14 | 15 | 17 | 18 | 1. 19 | 20 | **Expected behavior** 21 | 22 | 23 | 24 | **Screenshots** 25 | 26 | 27 | 28 | **Context** 29 | 30 | - Version: 31 | - Browser: 32 | 33 | **Additional context** 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | # contact_links: 3 | # - name: Samuel Gratzl 4 | # url: https://www.sgratzl.com 5 | # about: Please ask and answer questions here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | It would be great if ... 12 | 13 | **User story** 14 | 15 | 16 | 17 | **Additional context** 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🤗 Question 3 | about: ask question about the library (usage, features,...) 4 | title: '' 5 | labels: 'question' 6 | assignees: '' 7 | --- 8 | 9 | 13 | 14 | I'm having the following question... 15 | 16 | **Screenshots / Sketches** 17 | 18 | 19 | 20 | **Context** 21 | 22 | - Version: 23 | - Browser: 24 | 25 | **Additional context** 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: 'github-actions' 5 | directory: '/' 6 | schedule: 7 | interval: 'monthly' 8 | target-branch: 'dev' 9 | labels: 10 | - 'dependencies' 11 | - 'chore' 12 | - package-ecosystem: 'npm' 13 | directory: '/' 14 | schedule: 15 | interval: 'monthly' 16 | target-branch: 'dev' 17 | labels: 18 | - 'dependencies' 19 | - 'chore' 20 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | categories: 4 | - title: '🚀 Features' 5 | labels: 6 | - 'enhancement' 7 | - 'feature' 8 | - title: '🐛 Bugs Fixes' 9 | labels: 10 | - 'bug' 11 | - title: 'Documentation' 12 | labels: 13 | - 'documentation' 14 | - title: '🧰 Development' 15 | labels: 16 | - 'chore' 17 | change-template: '- #$NUMBER $TITLE' 18 | change-title-escapes: '\<*_&`#@' 19 | template: | 20 | $CHANGES 21 | 22 | Thanks to $CONTRIBUTORS 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | - run: npm i -g yarn 16 | - run: yarn config set checksumBehavior ignore 17 | - name: Cache Node.js modules 18 | uses: actions/cache@v4 19 | with: 20 | path: | 21 | ./.yarn/cache 22 | ./.yarn/unplugged 23 | key: ${{ runner.os }}-yarn2-v5-${{ hashFiles('**/yarn.lock') }} 24 | restore-keys: | 25 | ${{ runner.os }}-yarn2-v5 26 | - run: yarn install 27 | - run: yarn build 28 | - run: yarn lint 29 | - run: yarn test 30 | - uses: actions/upload-artifact@v4 31 | if: failure() 32 | with: 33 | name: diff outputs 34 | path: src/**/__diff_output__/*.png 35 | - run: yarn docs:build 36 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | versionName: 6 | description: 'Semantic Version Number (i.e., 5.5.0 or patch, minor, major, prepatch, preminor, premajor, prerelease)' 7 | required: true 8 | default: patch 9 | preid: 10 | description: 'Pre Release Identifier (i.e., alpha, beta)' 11 | required: true 12 | default: alpha 13 | jobs: 14 | create_release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out code 18 | uses: actions/checkout@v4 19 | with: 20 | ref: main 21 | ssh-key: ${{ secrets.PRIVATE_SSH_KEY }} 22 | - name: Reset main branch 23 | run: | 24 | git fetch origin dev:dev 25 | git reset --hard origin/dev 26 | - name: Change version number 27 | id: version 28 | run: | 29 | echo "next_tag=$(npm version --no-git-tag-version ${{ github.event.inputs.versionName }} --preid ${{ github.event.inputs.preid }})" >> $GITHUB_OUTPUT 30 | - name: Create pull request into main 31 | uses: peter-evans/create-pull-request@v7 32 | with: 33 | branch: release/${{ steps.version.outputs.next_tag }} 34 | commit-message: 'chore: release ${{ steps.version.outputs.next_tag }}' 35 | base: main 36 | title: Release ${{ steps.version.outputs.next_tag }} 37 | labels: chore 38 | assignees: sgratzl 39 | body: | 40 | Releasing ${{ steps.version.outputs.next_tag }}. 41 | -------------------------------------------------------------------------------- /.github/workflows/deploy_website.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Website 2 | on: 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | pages: write 12 | id-token: write 13 | environment: 14 | name: github-pages 15 | url: ${{ steps.deployment.outputs.page_url }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: npm 24 | - run: npm i -g yarn 25 | - run: yarn config set checksumBehavior ignore 26 | - name: Cache Node.js modules 27 | uses: actions/cache@v4 28 | with: 29 | path: | 30 | ./.yarn/cache 31 | ./.yarn/unplugged 32 | key: ${{ runner.os }}-yarn2-v5-${{ hashFiles('**/yarn.lock') }} 33 | restore-keys: | 34 | ${{ runner.os }}-yarn2-v5 35 | - run: yarn install 36 | - run: yarn docs:build 37 | - uses: actions/configure-pages@v5 38 | - uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: docs/.vitepress/dist 41 | - name: Deploy 42 | id: deployment 43 | uses: actions/deploy-pages@v4 44 | -------------------------------------------------------------------------------- /.github/workflows/release_helper.yml: -------------------------------------------------------------------------------- 1 | name: Release Helper 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | correct_repository: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: fail on fork 12 | if: github.repository_owner != 'sgratzl' 13 | run: exit 1 14 | 15 | create_release: 16 | needs: correct_repository 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Check out code 20 | uses: actions/checkout@v4 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | - name: Extract version 25 | id: extract_version 26 | run: | 27 | node -pe "'version=' + require('./package.json').version" >> $GITHUB_OUTPUT 28 | node -pe "'npm_tag=' + (require('./package.json').version.includes('-') ? 'next' : 'latest')" >> $GITHUB_OUTPUT 29 | - name: Print version 30 | run: | 31 | echo "releasing ${{ steps.extract_version.outputs.version }} with tag ${{ steps.extract_version.outputs.npm_tag }}" 32 | - name: Create Release 33 | id: create_release 34 | uses: release-drafter/release-drafter@v6 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | with: 38 | name: v${{ steps.extract_version.outputs.version }} 39 | tag: v${{ steps.extract_version.outputs.version }} 40 | version: ${{ steps.extract_version.outputs.version }} 41 | prerelease: ${{ needs.create_release.outputs.tag_name == 'next' }} 42 | publish: true 43 | outputs: 44 | version: ${{ steps.extract_version.outputs.version }} 45 | npm_tag: ${{ steps.extract_version.outputs.npm_tag }} 46 | upload_url: ${{ steps.create_release.outputs.upload_url }} 47 | tag_name: ${{ steps.create_release.outputs.tag_name }} 48 | 49 | build_assets: 50 | needs: create_release 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Check out code 54 | uses: actions/checkout@v4 55 | - uses: actions/setup-node@v4 56 | with: 57 | node-version: 20 58 | - run: npm i -g yarn 59 | - run: yarn config set checksumBehavior ignore 60 | - name: Cache Node.js modules 61 | uses: actions/cache@v4 62 | with: 63 | path: | 64 | ./.yarn/cache 65 | ./.yarn/unplugged 66 | key: ${{ runner.os }}-yarn2-v5-${{ hashFiles('**/yarn.lock') }} 67 | restore-keys: | 68 | ${{ runner.os }}-yarn2-v5 69 | - run: yarn install 70 | - run: yarn build 71 | - run: yarn pack 72 | - name: Upload Release Asset 73 | uses: AButler/upload-release-assets@v3.0 74 | with: 75 | files: 'package.tgz' 76 | repo-token: ${{ secrets.GITHUB_TOKEN }} 77 | release-tag: ${{ needs.create_release.outputs.tag_name }} 78 | - name: Pack Publish 79 | run: | 80 | yarn config set npmAuthToken "${{ secrets.NPM_TOKEN }}" 81 | yarn pack 82 | yarn npm publish --tag "${{ needs.create_release.outputs.npm_tag }}" 83 | 84 | sync_dev: 85 | needs: correct_repository 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: Check out code 89 | uses: actions/checkout@v4 90 | with: 91 | ref: dev 92 | ssh-key: ${{ secrets.PRIVATE_SSH_KEY }} 93 | - name: Reset dev branch 94 | run: | 95 | git fetch origin main:main 96 | git merge main 97 | git push 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | /coverage 7 | /node_modules 8 | .npm 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/releases 12 | !.yarn/plugins 13 | !.yarn/versions 14 | .pnp.* 15 | 16 | # Build files 17 | /.tmp 18 | /build 19 | 20 | *.tgz 21 | /.vscode/extensions.json 22 | *.tsbuildinfo 23 | .eslintcache 24 | __diff_output__ 25 | 26 | docs/.vitepress/dist 27 | docs/.vitepress/cache 28 | docs/.vitepress/config.ts.timestamp* 29 | docs/api/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.pnp* 2 | /.yarnrc.yml 3 | /.yarn 4 | /build 5 | /docs/.vitepress/cache 6 | /docs/.vitepress/dist 7 | /docs/.vitepress/config.ts.timestamp* 8 | /docs/api 9 | /coverage 10 | /.gitattributes 11 | /.gitignore 12 | /.prettierignore 13 | /LICENSE 14 | /yarn.lock 15 | /.vscode 16 | *.png 17 | *.tgz 18 | *.tsbuildinfo 19 | .eslintcache 20 | .nojekyll 21 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | semi: true, 4 | singleQuote: true, 5 | trailingComma: 'es5', 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnType": true, 4 | "[javascript]": { 5 | "editor.defaultFormatter": "esbenp.prettier-vscode" 6 | }, 7 | "[typescript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[json]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "[yaml]": { 14 | "editor.defaultFormatter": "esbenp.prettier-vscode" 15 | }, 16 | "npm.packageManager": "yarn", 17 | "eslint.nodePath": ".yarn/sdks", 18 | "prettier.prettierPath": ".yarn/sdks/prettier/index.cjs", 19 | "files.eol": "\n", 20 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 21 | "typescript.enablePromptUseWorkspaceTsdk": true, 22 | "editor.detectIndentation": false, 23 | "editor.tabSize": 2, 24 | "search.exclude": { 25 | "**/.yarn": true, 26 | "**/.pnp.*": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | yarnPath: .yarn/releases/yarn-4.5.1.cjs 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 Samuel Gratzl 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chart.js Graphs 2 | 3 | [![NPM Package][npm-image]][npm-url] [![Github Actions][github-actions-image]][github-actions-url] 4 | 5 | Chart.js module for charting graphs. Adding new chart types: `graph`, `forceDirectedGraph`, `dendrogram`, and `tree`. 6 | 7 | ![force](https://user-images.githubusercontent.com/4129778/65398353-9bc03f80-dd84-11e9-8f14-339635c1ba4e.png) 8 | 9 | ![dend_h](https://user-images.githubusercontent.com/4129778/65398352-9bc03f80-dd84-11e9-9197-ecb66a872736.png) 10 | 11 | ![tree_v](https://user-images.githubusercontent.com/4129778/65398350-9bc03f80-dd84-11e9-8c94-e93c07040ee7.png) 12 | 13 | ![radial](https://user-images.githubusercontent.com/4129778/65398354-9bc03f80-dd84-11e9-9633-c4c80bd9c384.png) 14 | 15 | Works great with https://github.com/chartjs/chartjs-plugin-datalabels or https://github.com/chrispahm/chartjs-plugin-dragdata 16 | 17 | ![data label](https://user-images.githubusercontent.com/4129778/65398517-a0392800-dd85-11e9-800a-144a13ad2ba1.png) 18 | 19 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/vYNVbgd) 20 | 21 | ## Related Plugins 22 | 23 | Check out also my other chart.js plugins: 24 | 25 | - [chartjs-chart-boxplot](https://github.com/sgratzl/chartjs-chart-boxplot) for rendering boxplots and violin plots 26 | - [chartjs-chart-error-bars](https://github.com/sgratzl/chartjs-chart-error-bars) for rendering errors bars to bars and line charts 27 | - [chartjs-chart-geo](https://github.com/sgratzl/chartjs-chart-geo) for rendering map, bubble maps, and choropleth charts 28 | - [chartjs-chart-pcp](https://github.com/sgratzl/chartjs-chart-pcp) for rendering parallel coordinate plots 29 | - [chartjs-chart-venn](https://github.com/sgratzl/chartjs-chart-venn) for rendering venn and euler diagrams 30 | - [chartjs-chart-wordcloud](https://github.com/sgratzl/chartjs-chart-wordcloud) for rendering word clouds 31 | - [chartjs-plugin-hierarchical](https://github.com/sgratzl/chartjs-plugin-hierarchical) for rendering hierarchical categorical axes which can be expanded and collapsed 32 | 33 | ## Install 34 | 35 | ```bash 36 | npm install --save chart.js chartjs-chart-graph 37 | ``` 38 | 39 | ## Usage 40 | 41 | see [Examples](https://www.sgratzl.com/chartjs-chart-graph/examples/) 42 | 43 | CodePens 44 | 45 | - [Force Directed Layout](https://codepen.io/sgratzl/pen/mdezvmL) 46 | - [Tree Layouts](https://codepen.io/sgratzl/pen/jObedwg) 47 | - [Tree With Data Labels](https://codepen.io/sgratzl/pen/vYNVbgd) 48 | 49 | ## Graphviz Dot File Parsing 50 | 51 | A Graphviz Dot File parsing package is located at https://github.com/sgratzl/chartjs-chart-graph-dot-parser. 52 | It creates compatible data structures to be consumed by this plugin. 53 | 54 | ## Styling 55 | 56 | The new chart types are based on the existing `line` controller. Tho, instead of showing a line per dataset it shows edges as lines. Therefore, the styling options for points and lines are the same. See also https://www.chartjs.org/docs/latest/charts/line.html. However, to avoid confusion, the line options have a default `line` prefix, e..g `lineBorderColor` to specify the edge border color and `pointBorderColor` to specify the node border color. 57 | 58 | ## Data Structure 59 | 60 | ```js 61 | data: { 62 | labels: ['A', 'B', 'C'], // node labels 63 | datasets: [{ 64 | data: [ // nodes as objects 65 | { x: 1, y: 2 }, // x, y will be set by the force directed graph and can be omitted 66 | { x: 3, y: 1 }, 67 | { x: 5, y: 3 } 68 | ], 69 | edges: [ // edge list where source/target refers to the node index 70 | { source: 0, target: 1}, 71 | { source: 0, target: 2} 72 | ] 73 | }] 74 | }, 75 | ``` 76 | 77 | Alternative structure for trees 78 | 79 | ```js 80 | data: { 81 | labels: ['A', 'B', 'C'], // node labels 82 | datasets: [{ 83 | data: [ // nodes as objects 84 | { x: 1, y: 2 }, // x, y will be set by the force directed graph and can be omitted 85 | { x: 3, y: 1, parent: 0 }, 86 | { x: 5, y: 3, parent: 0 } 87 | ] 88 | }] 89 | }, 90 | ``` 91 | 92 | ## Force Directed Graph 93 | 94 | chart type: `forceDirectedGraph` 95 | 96 | Computes the x,y position of nodes based on a force simulation. It is based on https://github.com/d3/d3-force/. 97 | 98 | ![force](https://user-images.githubusercontent.com/4129778/65398353-9bc03f80-dd84-11e9-8f14-339635c1ba4e.png) 99 | 100 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/mdezvmL) 101 | 102 | ### Options 103 | 104 | ## Dendrogram, Tree 105 | 106 | chart types: `dendrogram`, `tree` 107 | 108 | The tree and dendrograms layouts are based on https://github.com/d3/d3-hierarchy. 109 | 110 | **Dendrogram Horizontal** 111 | 112 | ![dend_h](https://user-images.githubusercontent.com/4129778/65398352-9bc03f80-dd84-11e9-9197-ecb66a872736.png) 113 | 114 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg) 115 | 116 | **Dendrogram Vertical** 117 | 118 | ![dend_v](https://user-images.githubusercontent.com/4129778/65398355-9bc03f80-dd84-11e9-9ea3-9501a79491fb.png) 119 | 120 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg) 121 | 122 | **Dendrogram Radial** 123 | 124 | ![radial](https://user-images.githubusercontent.com/4129778/65398460-581a0580-dd85-11e9-93b6-b70946f1155f.png) 125 | 126 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg) 127 | 128 | **Tidy Tree Horizontal** 129 | 130 | ![tree_h](https://user-images.githubusercontent.com/4129778/65398351-9bc03f80-dd84-11e9-83f9-50b454fa6929.png) 131 | 132 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg) 133 | 134 | **Tidy Tree Vertical** 135 | 136 | ![tree_v](https://user-images.githubusercontent.com/4129778/65398350-9bc03f80-dd84-11e9-8c94-e93c07040ee7.png) 137 | 138 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg) 139 | 140 | **Tidy Tree Radial** 141 | 142 | ![radial](https://user-images.githubusercontent.com/4129778/65398354-9bc03f80-dd84-11e9-9633-c4c80bd9c384.png) 143 | 144 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg) 145 | 146 | ### Options 147 | 148 | ### ESM and Tree Shaking 149 | 150 | The ESM build of the library supports tree shaking thus having no side effects. As a consequence the chart.js library won't be automatically manipulated nor new controllers automatically registered. One has to manually import and register them. 151 | 152 | Variant A: 153 | 154 | ```js 155 | import { Chart, LinearScale, PointElement } from 'chart.js'; 156 | import { ForceDirectedGraphController, EdgeLine } from 'chartjs-chart-graph'; 157 | 158 | // register controller in chart.js and ensure the defaults are set 159 | Chart.register(ForceDirectedGraphController, EdgeLine, LinearScale, PointElement); 160 | ... 161 | 162 | new Chart(ctx, { 163 | type: ForceDirectedGraphController.id, 164 | data: [...], 165 | }); 166 | ``` 167 | 168 | Variant B: 169 | 170 | ```js 171 | import { ForceDirectedGraphChart } from 'chartjs-chart-graph'; 172 | 173 | new ForceDirectedGraphChart(ctx, { 174 | data: [...], 175 | }); 176 | ``` 177 | 178 | ## Development Environment 179 | 180 | ```sh 181 | npm i -g yarn 182 | yarn install 183 | yarn sdks vscode 184 | ``` 185 | 186 | ### Building 187 | 188 | ```sh 189 | yarn install 190 | yarn build 191 | ``` 192 | 193 | [npm-image]: https://badge.fury.io/js/chartjs-chart-graph.svg 194 | [npm-url]: https://npmjs.org/package/chartjs-chart-graph 195 | [github-actions-image]: https://github.com/sgratzl/chartjs-chart-graph/workflows/ci/badge.svg 196 | [github-actions-url]: https://github.com/sgratzl/chartjs-chart-graph/actions 197 | [codepen]: https://img.shields.io/badge/CodePen-open-blue?logo=codepen 198 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | import { name, description, repository, license, author } from '../../package.json'; 3 | import typedocSidebar from '../api/typedoc-sidebar.json'; 4 | 5 | const cleanName = name.replace('@sgratzl/', ''); 6 | 7 | // https://vitepress.dev/reference/site-config 8 | export default defineConfig({ 9 | title: cleanName, 10 | description, 11 | base: `/${cleanName}/`, 12 | useWebFonts: false, 13 | themeConfig: { 14 | // https://vitepress.dev/reference/default-theme-config 15 | nav: [ 16 | { text: 'Home', link: '/' }, 17 | { text: 'Getting Started', link: '/getting-started' }, 18 | { text: 'Examples', link: '/examples/' }, 19 | { text: 'API', link: '/api/' }, 20 | { text: 'Related Plugins', link: '/related' }, 21 | ], 22 | 23 | sidebar: [ 24 | { 25 | text: 'Examples', 26 | items: [ 27 | { text: 'Basic', link: '/examples/' }, 28 | { text: 'Dendrogram', link: '/examples/dendrogram' }, 29 | { text: 'Tree', link: '/examples/tree' }, 30 | { text: 'Force Directed Graph', link: '/examples/force' }, 31 | { text: 'Tree with Labels', link: '/examples/label' }, 32 | { text: 'Directed Tree', link: '/examples/directed' }, 33 | { text: 'Tree Orientations', link: '/examples/orientation' }, 34 | { text: 'Fully Linked', link: '/examples/linked' }, 35 | ], 36 | }, 37 | { 38 | text: 'API', 39 | collapsed: true, 40 | items: typedocSidebar, 41 | }, 42 | ], 43 | 44 | socialLinks: [{ icon: 'github', link: repository.url.replace('.git', '') }], 45 | 46 | footer: { 47 | message: `Released under the ${license} license.`, 51 | copyright: `Copyright © 2019-present ${author.name}`, 52 | }, 53 | 54 | editLink: { 55 | pattern: `${repository.url.replace('.git', '')}/edit/main/docs/:path`, 56 | }, 57 | 58 | search: { 59 | provider: 'local', 60 | }, 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import Theme from 'vitepress/theme'; 2 | import { createTypedChart } from 'vue-chartjs'; 3 | import { Tooltip, LineElement, PointElement, LinearScale } from 'chart.js'; 4 | import { DendrogramController, ForceDirectedGraphController, EdgeLine, TreeController } from '../../../src'; 5 | import ChartPluginDataLabel from 'chartjs-plugin-datalabels'; 6 | 7 | export default { 8 | ...Theme, 9 | enhanceApp({ app }) { 10 | const deps = [ 11 | Tooltip, 12 | LineElement, 13 | PointElement, 14 | DendrogramController, 15 | ForceDirectedGraphController, 16 | EdgeLine, 17 | TreeController, 18 | LinearScale, 19 | ChartPluginDataLabel, 20 | ]; 21 | app.component('DendrogramChart', createTypedChart('dendrogram', deps)); 22 | app.component('TreeChart', createTypedChart('tree', deps)); 23 | app.component('ForceDirectedGraphChart', createTypedChart('forceDirectedGraph', deps)); 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /docs/examples/dendrogram.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Dendrogram 3 | --- 4 | 5 | # Dendrogram 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./dendrogram.ts#config [config] 21 | 22 | <<< ./dendrogram.ts#data [data] 23 | 24 | ::: 25 | -------------------------------------------------------------------------------- /docs/examples/dendrogram.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import 'chartjs-plugin-datalabels'; 4 | 5 | // #region data 6 | import nodes from './tree.json'; 7 | 8 | export const data: ChartConfiguration<'dendrogram'>['data'] = { 9 | labels: nodes.map((d) => d.name), 10 | datasets: [ 11 | { 12 | pointBackgroundColor: 'steelblue', 13 | pointRadius: 5, 14 | data: nodes.map((d) => Object.assign({}, d)), 15 | }, 16 | ], 17 | }; 18 | // #endregion data 19 | // #region config 20 | export const config: ChartConfiguration<'dendrogram'> = { 21 | type: 'dendrogram', 22 | data, 23 | options: { 24 | plugins: { 25 | datalabels: { 26 | display: false, 27 | }, 28 | }, 29 | }, 30 | }; 31 | // #endregion config 32 | -------------------------------------------------------------------------------- /docs/examples/directed.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Directed Tree 3 | --- 4 | 5 | # Directed Tree 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./directed.ts#config [config] 21 | 22 | <<< ./directed.ts#data [data] 23 | 24 | ::: 25 | -------------------------------------------------------------------------------- /docs/examples/directed.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import 'chartjs-plugin-datalabels'; 4 | 5 | // #region data 6 | import nodes from './tree.json'; 7 | 8 | export const data: ChartConfiguration<'tree'>['data'] = { 9 | labels: nodes.map((d) => d.name), 10 | datasets: [ 11 | { 12 | pointBackgroundColor: 'steelblue', 13 | pointRadius: 5, 14 | directed: true, 15 | data: nodes.map((d) => Object.assign({}, d)), 16 | }, 17 | ], 18 | }; 19 | // #endregion data 20 | // #region config 21 | export const config: ChartConfiguration<'tree'> = { 22 | type: 'tree', 23 | data, 24 | options: { 25 | plugins: { 26 | datalabels: { 27 | display: false, 28 | }, 29 | }, 30 | }, 31 | }; 32 | // #endregion config 33 | -------------------------------------------------------------------------------- /docs/examples/force.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Force Directed Graph 3 | --- 4 | 5 | # Force Directed Graph 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./force.ts#config [config] 21 | 22 | <<< ./force.ts#data [data] 23 | 24 | ::: 25 | -------------------------------------------------------------------------------- /docs/examples/force.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import 'chartjs-plugin-datalabels'; 4 | 5 | // #region data 6 | import miserables from './miserables.json'; 7 | export const data: ChartConfiguration<'forceDirectedGraph'>['data'] = { 8 | labels: miserables.nodes.map((d) => d.id), 9 | datasets: [ 10 | { 11 | pointBackgroundColor: 'steelblue', 12 | pointRadius: 5, 13 | data: miserables.nodes, 14 | edges: miserables.links, 15 | }, 16 | ], 17 | }; 18 | // #endregion data 19 | // #region config 20 | export const config: ChartConfiguration<'forceDirectedGraph'> = { 21 | type: 'forceDirectedGraph', 22 | data, 23 | options: { 24 | plugins: { 25 | datalabels: { 26 | display: false, 27 | }, 28 | }, 29 | }, 30 | }; 31 | // #endregion config 32 | -------------------------------------------------------------------------------- /docs/examples/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Examples 3 | --- 4 | 5 | # Examples 6 | 7 | 12 | 13 | ## Force Directed Graph 14 | 15 | 19 | 20 | ### Code 21 | 22 | :::code-group 23 | 24 | <<< ./force.ts#config [config] 25 | 26 | <<< ./force.ts#data [data] 27 | 28 | ::: 29 | 30 | ## Dendrogram 31 | 32 | 36 | 37 | ### Code 38 | 39 | :::code-group 40 | 41 | <<< ./dendrogram.ts#config [config] 42 | 43 | <<< ./dendrogram.ts#data [data] 44 | 45 | ::: 46 | 47 | ## Tree 48 | 49 | 53 | 54 | ### Code 55 | 56 | :::code-group 57 | 58 | <<< ./tree.ts#tree [config] 59 | 60 | <<< ./tree.ts#data [data] 61 | 62 | ::: 63 | -------------------------------------------------------------------------------- /docs/examples/label.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tree with Labels 3 | --- 4 | 5 | # Tree with Labels 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./label.ts#config [config] 21 | 22 | <<< ./label.ts#data [data] 23 | 24 | ::: 25 | 26 | ## Radial Tree with Labels 27 | 28 | 32 | 33 | ### Code 34 | 35 | :::code-group 36 | 37 | <<< ./label.ts#radial [config] 38 | 39 | <<< ./label.ts#data [data] 40 | 41 | ::: 42 | -------------------------------------------------------------------------------- /docs/examples/label.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import ChartDataLabels from 'chartjs-plugin-datalabels'; 4 | // #region data 5 | import nodes from './tree.json'; 6 | 7 | export const data: ChartConfiguration<'tree'>['data'] = { 8 | labels: nodes.map((d) => d.name), 9 | datasets: [ 10 | { 11 | pointBackgroundColor: ['#002838', '#ed7d00', '#395c6b', '#d94d15', '#889da6'], 12 | pointRadius: 10, 13 | data: nodes.map((d) => Object.assign({}, d)), 14 | }, 15 | ], 16 | }; 17 | // #endregion data 18 | // #region config 19 | export const config: ChartConfiguration<'tree'> = { 20 | type: 'tree', 21 | data, 22 | options: { 23 | tree: { 24 | orientation: 'radial', 25 | }, 26 | layout: { 27 | padding: { 28 | left: 20, 29 | top: 20, 30 | bottom: 20, 31 | right: 20, 32 | }, 33 | }, 34 | plugins: { 35 | legend: { 36 | display: false, 37 | }, 38 | datalabels: { 39 | // display: true, 40 | align: 'right', 41 | offset: 6, 42 | formatter: function (value, context) { 43 | return '' + value.name + ''; 44 | }, 45 | color: 'black', 46 | backgroundColor: 'steelblue', 47 | }, 48 | }, 49 | }, 50 | plugins: [ChartDataLabels], 51 | }; 52 | // #endregion config 53 | 54 | // #region radial 55 | export const radial: ChartConfiguration<'tree'> = { 56 | type: 'tree', 57 | data, 58 | options: { 59 | tree: { 60 | orientation: 'radial', 61 | }, 62 | layout: { 63 | padding: 40, 64 | }, 65 | plugins: { 66 | datalabels: { 67 | display: (context) => { 68 | // const index = context.dataIndex; 69 | // const value = context.dataset.data[index]; 70 | return true; //value.children.length === 0; 71 | }, 72 | align: (context) => { 73 | const index = context.dataIndex; 74 | const value = context.dataset.data[index] as { angle: number }; 75 | return (-value.angle / Math.PI) * 180; 76 | }, 77 | rotation: (context) => { 78 | const index = context.dataIndex; 79 | const value = context.dataset.data[index] as { angle: number }; 80 | return (-value.angle / Math.PI) * 180; 81 | }, 82 | backgroundColor: 'white', 83 | formatter: (v) => { 84 | return v.name; 85 | }, 86 | }, 87 | }, 88 | }, 89 | plugins: [ChartDataLabels], 90 | }; 91 | // #endregion radial 92 | -------------------------------------------------------------------------------- /docs/examples/linked.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Linked 3 | --- 4 | 5 | # Linked 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./linked.ts#config [config] 21 | 22 | <<< ./linked.ts#data [data] 23 | 24 | ::: 25 | -------------------------------------------------------------------------------- /docs/examples/linked.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import 'chartjs-plugin-datalabels'; 4 | 5 | // #region data 6 | 7 | const edges = [ 8 | { source: 1, target: 0 }, 9 | { source: 2, target: 0 }, 10 | { source: 2, target: 1 }, 11 | { source: 3, target: 1 }, 12 | { source: 3, target: 0 }, 13 | { source: 3, target: 2 }, 14 | ]; 15 | 16 | const widths = [2, 5, 10, 15, 20, 25]; 17 | const dashes = [ 18 | [2, 2], 19 | [5, 5], 20 | [10, 10], 21 | [15, 15], 22 | [20, 20], 23 | [25, 25], 24 | ]; 25 | const colors = ['blue', 'red', 'green', 'purple', 'pink', 'yellow']; 26 | const nodeColors = ['yellow', 'pink', 'teal', 'violet']; 27 | 28 | export const data: ChartConfiguration<'tree'>['data'] = { 29 | labels: ['A', 'B', 'C', 'D'], 30 | datasets: [ 31 | { 32 | data: [{}, {}, {}, {}], 33 | edges: edges, 34 | pointRadius: 20, 35 | pointBackgroundColor: (ctx) => nodeColors[ctx.index], 36 | edgeLineBorderWidth: (ctx) => widths[ctx.index], 37 | edgeLineBorderDash: (ctx) => dashes[ctx.index], 38 | edgeLineBorderColor: (ctx) => colors[ctx.index], 39 | }, 40 | ], 41 | }; 42 | // #endregion data 43 | // #region config 44 | export const config: ChartConfiguration<'tree'> = { 45 | type: 'forceDirectedGraph', 46 | data, 47 | options: { 48 | scales: { 49 | x: { min: -1.5, max: 1.5 }, 50 | y: { min: -1.5, max: 1.5 }, 51 | }, 52 | plugins: { legend: { display: false } }, 53 | }, 54 | }; 55 | // #endregion config 56 | -------------------------------------------------------------------------------- /docs/examples/miserables.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { "id": "Myriel", "group": 1 }, 4 | { "id": "Napoleon", "group": 1 }, 5 | { "id": "Mlle.Baptistine", "group": 1 }, 6 | { "id": "Mme.Magloire", "group": 1 }, 7 | { "id": "CountessdeLo", "group": 1 }, 8 | { "id": "Geborand", "group": 1 }, 9 | { "id": "Champtercier", "group": 1 }, 10 | { "id": "Cravatte", "group": 1 }, 11 | { "id": "Count", "group": 1 }, 12 | { "id": "OldMan", "group": 1 }, 13 | { "id": "Labarre", "group": 2 }, 14 | { "id": "Valjean", "group": 2 }, 15 | { "id": "Marguerite", "group": 3 }, 16 | { "id": "Mme.deR", "group": 2 }, 17 | { "id": "Isabeau", "group": 2 }, 18 | { "id": "Gervais", "group": 2 }, 19 | { "id": "Tholomyes", "group": 3 }, 20 | { "id": "Listolier", "group": 3 }, 21 | { "id": "Fameuil", "group": 3 }, 22 | { "id": "Blacheville", "group": 3 }, 23 | { "id": "Favourite", "group": 3 }, 24 | { "id": "Dahlia", "group": 3 }, 25 | { "id": "Zephine", "group": 3 }, 26 | { "id": "Fantine", "group": 3 }, 27 | { "id": "Mme.Thenardier", "group": 4 }, 28 | { "id": "Thenardier", "group": 4 }, 29 | { "id": "Cosette", "group": 5 }, 30 | { "id": "Javert", "group": 4 }, 31 | { "id": "Fauchelevent", "group": 0 }, 32 | { "id": "Bamatabois", "group": 2 }, 33 | { "id": "Perpetue", "group": 3 }, 34 | { "id": "Simplice", "group": 2 }, 35 | { "id": "Scaufflaire", "group": 2 }, 36 | { "id": "Woman1", "group": 2 }, 37 | { "id": "Judge", "group": 2 }, 38 | { "id": "Champmathieu", "group": 2 }, 39 | { "id": "Brevet", "group": 2 }, 40 | { "id": "Chenildieu", "group": 2 }, 41 | { "id": "Cochepaille", "group": 2 }, 42 | { "id": "Pontmercy", "group": 4 }, 43 | { "id": "Boulatruelle", "group": 6 }, 44 | { "id": "Eponine", "group": 4 }, 45 | { "id": "Anzelma", "group": 4 }, 46 | { "id": "Woman2", "group": 5 }, 47 | { "id": "MotherInnocent", "group": 0 }, 48 | { "id": "Gribier", "group": 0 }, 49 | { "id": "Jondrette", "group": 7 }, 50 | { "id": "Mme.Burgon", "group": 7 }, 51 | { "id": "Gavroche", "group": 8 }, 52 | { "id": "Gillenormand", "group": 5 }, 53 | { "id": "Magnon", "group": 5 }, 54 | { "id": "Mlle.Gillenormand", "group": 5 }, 55 | { "id": "Mme.Pontmercy", "group": 5 }, 56 | { "id": "Mlle.Vaubois", "group": 5 }, 57 | { "id": "Lt.Gillenormand", "group": 5 }, 58 | { "id": "Marius", "group": 8 }, 59 | { "id": "BaronessT", "group": 5 }, 60 | { "id": "Mabeuf", "group": 8 }, 61 | { "id": "Enjolras", "group": 8 }, 62 | { "id": "Combeferre", "group": 8 }, 63 | { "id": "Prouvaire", "group": 8 }, 64 | { "id": "Feuilly", "group": 8 }, 65 | { "id": "Courfeyrac", "group": 8 }, 66 | { "id": "Bahorel", "group": 8 }, 67 | { "id": "Bossuet", "group": 8 }, 68 | { "id": "Joly", "group": 8 }, 69 | { "id": "Grantaire", "group": 8 }, 70 | { "id": "MotherPlutarch", "group": 9 }, 71 | { "id": "Gueulemer", "group": 4 }, 72 | { "id": "Babet", "group": 4 }, 73 | { "id": "Claquesous", "group": 4 }, 74 | { "id": "Montparnasse", "group": 4 }, 75 | { "id": "Toussaint", "group": 5 }, 76 | { "id": "Child1", "group": 10 }, 77 | { "id": "Child2", "group": 10 }, 78 | { "id": "Brujon", "group": 4 }, 79 | { "id": "Mme.Hucheloup", "group": 8 } 80 | ], 81 | "links": [ 82 | { "source": "Napoleon", "target": "Myriel", "value": 1 }, 83 | { "source": "Mlle.Baptistine", "target": "Myriel", "value": 8 }, 84 | { "source": "Mme.Magloire", "target": "Myriel", "value": 10 }, 85 | { "source": "Mme.Magloire", "target": "Mlle.Baptistine", "value": 6 }, 86 | { "source": "CountessdeLo", "target": "Myriel", "value": 1 }, 87 | { "source": "Geborand", "target": "Myriel", "value": 1 }, 88 | { "source": "Champtercier", "target": "Myriel", "value": 1 }, 89 | { "source": "Cravatte", "target": "Myriel", "value": 1 }, 90 | { "source": "Count", "target": "Myriel", "value": 2 }, 91 | { "source": "OldMan", "target": "Myriel", "value": 1 }, 92 | { "source": "Valjean", "target": "Labarre", "value": 1 }, 93 | { "source": "Valjean", "target": "Mme.Magloire", "value": 3 }, 94 | { "source": "Valjean", "target": "Mlle.Baptistine", "value": 3 }, 95 | { "source": "Valjean", "target": "Myriel", "value": 5 }, 96 | { "source": "Marguerite", "target": "Valjean", "value": 1 }, 97 | { "source": "Mme.deR", "target": "Valjean", "value": 1 }, 98 | { "source": "Isabeau", "target": "Valjean", "value": 1 }, 99 | { "source": "Gervais", "target": "Valjean", "value": 1 }, 100 | { "source": "Listolier", "target": "Tholomyes", "value": 4 }, 101 | { "source": "Fameuil", "target": "Tholomyes", "value": 4 }, 102 | { "source": "Fameuil", "target": "Listolier", "value": 4 }, 103 | { "source": "Blacheville", "target": "Tholomyes", "value": 4 }, 104 | { "source": "Blacheville", "target": "Listolier", "value": 4 }, 105 | { "source": "Blacheville", "target": "Fameuil", "value": 4 }, 106 | { "source": "Favourite", "target": "Tholomyes", "value": 3 }, 107 | { "source": "Favourite", "target": "Listolier", "value": 3 }, 108 | { "source": "Favourite", "target": "Fameuil", "value": 3 }, 109 | { "source": "Favourite", "target": "Blacheville", "value": 4 }, 110 | { "source": "Dahlia", "target": "Tholomyes", "value": 3 }, 111 | { "source": "Dahlia", "target": "Listolier", "value": 3 }, 112 | { "source": "Dahlia", "target": "Fameuil", "value": 3 }, 113 | { "source": "Dahlia", "target": "Blacheville", "value": 3 }, 114 | { "source": "Dahlia", "target": "Favourite", "value": 5 }, 115 | { "source": "Zephine", "target": "Tholomyes", "value": 3 }, 116 | { "source": "Zephine", "target": "Listolier", "value": 3 }, 117 | { "source": "Zephine", "target": "Fameuil", "value": 3 }, 118 | { "source": "Zephine", "target": "Blacheville", "value": 3 }, 119 | { "source": "Zephine", "target": "Favourite", "value": 4 }, 120 | { "source": "Zephine", "target": "Dahlia", "value": 4 }, 121 | { "source": "Fantine", "target": "Tholomyes", "value": 3 }, 122 | { "source": "Fantine", "target": "Listolier", "value": 3 }, 123 | { "source": "Fantine", "target": "Fameuil", "value": 3 }, 124 | { "source": "Fantine", "target": "Blacheville", "value": 3 }, 125 | { "source": "Fantine", "target": "Favourite", "value": 4 }, 126 | { "source": "Fantine", "target": "Dahlia", "value": 4 }, 127 | { "source": "Fantine", "target": "Zephine", "value": 4 }, 128 | { "source": "Fantine", "target": "Marguerite", "value": 2 }, 129 | { "source": "Fantine", "target": "Valjean", "value": 9 }, 130 | { "source": "Mme.Thenardier", "target": "Fantine", "value": 2 }, 131 | { "source": "Mme.Thenardier", "target": "Valjean", "value": 7 }, 132 | { "source": "Thenardier", "target": "Mme.Thenardier", "value": 13 }, 133 | { "source": "Thenardier", "target": "Fantine", "value": 1 }, 134 | { "source": "Thenardier", "target": "Valjean", "value": 12 }, 135 | { "source": "Cosette", "target": "Mme.Thenardier", "value": 4 }, 136 | { "source": "Cosette", "target": "Valjean", "value": 31 }, 137 | { "source": "Cosette", "target": "Tholomyes", "value": 1 }, 138 | { "source": "Cosette", "target": "Thenardier", "value": 1 }, 139 | { "source": "Javert", "target": "Valjean", "value": 17 }, 140 | { "source": "Javert", "target": "Fantine", "value": 5 }, 141 | { "source": "Javert", "target": "Thenardier", "value": 5 }, 142 | { "source": "Javert", "target": "Mme.Thenardier", "value": 1 }, 143 | { "source": "Javert", "target": "Cosette", "value": 1 }, 144 | { "source": "Fauchelevent", "target": "Valjean", "value": 8 }, 145 | { "source": "Fauchelevent", "target": "Javert", "value": 1 }, 146 | { "source": "Bamatabois", "target": "Fantine", "value": 1 }, 147 | { "source": "Bamatabois", "target": "Javert", "value": 1 }, 148 | { "source": "Bamatabois", "target": "Valjean", "value": 2 }, 149 | { "source": "Perpetue", "target": "Fantine", "value": 1 }, 150 | { "source": "Simplice", "target": "Perpetue", "value": 2 }, 151 | { "source": "Simplice", "target": "Valjean", "value": 3 }, 152 | { "source": "Simplice", "target": "Fantine", "value": 2 }, 153 | { "source": "Simplice", "target": "Javert", "value": 1 }, 154 | { "source": "Scaufflaire", "target": "Valjean", "value": 1 }, 155 | { "source": "Woman1", "target": "Valjean", "value": 2 }, 156 | { "source": "Woman1", "target": "Javert", "value": 1 }, 157 | { "source": "Judge", "target": "Valjean", "value": 3 }, 158 | { "source": "Judge", "target": "Bamatabois", "value": 2 }, 159 | { "source": "Champmathieu", "target": "Valjean", "value": 3 }, 160 | { "source": "Champmathieu", "target": "Judge", "value": 3 }, 161 | { "source": "Champmathieu", "target": "Bamatabois", "value": 2 }, 162 | { "source": "Brevet", "target": "Judge", "value": 2 }, 163 | { "source": "Brevet", "target": "Champmathieu", "value": 2 }, 164 | { "source": "Brevet", "target": "Valjean", "value": 2 }, 165 | { "source": "Brevet", "target": "Bamatabois", "value": 1 }, 166 | { "source": "Chenildieu", "target": "Judge", "value": 2 }, 167 | { "source": "Chenildieu", "target": "Champmathieu", "value": 2 }, 168 | { "source": "Chenildieu", "target": "Brevet", "value": 2 }, 169 | { "source": "Chenildieu", "target": "Valjean", "value": 2 }, 170 | { "source": "Chenildieu", "target": "Bamatabois", "value": 1 }, 171 | { "source": "Cochepaille", "target": "Judge", "value": 2 }, 172 | { "source": "Cochepaille", "target": "Champmathieu", "value": 2 }, 173 | { "source": "Cochepaille", "target": "Brevet", "value": 2 }, 174 | { "source": "Cochepaille", "target": "Chenildieu", "value": 2 }, 175 | { "source": "Cochepaille", "target": "Valjean", "value": 2 }, 176 | { "source": "Cochepaille", "target": "Bamatabois", "value": 1 }, 177 | { "source": "Pontmercy", "target": "Thenardier", "value": 1 }, 178 | { "source": "Boulatruelle", "target": "Thenardier", "value": 1 }, 179 | { "source": "Eponine", "target": "Mme.Thenardier", "value": 2 }, 180 | { "source": "Eponine", "target": "Thenardier", "value": 3 }, 181 | { "source": "Anzelma", "target": "Eponine", "value": 2 }, 182 | { "source": "Anzelma", "target": "Thenardier", "value": 2 }, 183 | { "source": "Anzelma", "target": "Mme.Thenardier", "value": 1 }, 184 | { "source": "Woman2", "target": "Valjean", "value": 3 }, 185 | { "source": "Woman2", "target": "Cosette", "value": 1 }, 186 | { "source": "Woman2", "target": "Javert", "value": 1 }, 187 | { "source": "MotherInnocent", "target": "Fauchelevent", "value": 3 }, 188 | { "source": "MotherInnocent", "target": "Valjean", "value": 1 }, 189 | { "source": "Gribier", "target": "Fauchelevent", "value": 2 }, 190 | { "source": "Mme.Burgon", "target": "Jondrette", "value": 1 }, 191 | { "source": "Gavroche", "target": "Mme.Burgon", "value": 2 }, 192 | { "source": "Gavroche", "target": "Thenardier", "value": 1 }, 193 | { "source": "Gavroche", "target": "Javert", "value": 1 }, 194 | { "source": "Gavroche", "target": "Valjean", "value": 1 }, 195 | { "source": "Gillenormand", "target": "Cosette", "value": 3 }, 196 | { "source": "Gillenormand", "target": "Valjean", "value": 2 }, 197 | { "source": "Magnon", "target": "Gillenormand", "value": 1 }, 198 | { "source": "Magnon", "target": "Mme.Thenardier", "value": 1 }, 199 | { "source": "Mlle.Gillenormand", "target": "Gillenormand", "value": 9 }, 200 | { "source": "Mlle.Gillenormand", "target": "Cosette", "value": 2 }, 201 | { "source": "Mlle.Gillenormand", "target": "Valjean", "value": 2 }, 202 | { "source": "Mme.Pontmercy", "target": "Mlle.Gillenormand", "value": 1 }, 203 | { "source": "Mme.Pontmercy", "target": "Pontmercy", "value": 1 }, 204 | { "source": "Mlle.Vaubois", "target": "Mlle.Gillenormand", "value": 1 }, 205 | { "source": "Lt.Gillenormand", "target": "Mlle.Gillenormand", "value": 2 }, 206 | { "source": "Lt.Gillenormand", "target": "Gillenormand", "value": 1 }, 207 | { "source": "Lt.Gillenormand", "target": "Cosette", "value": 1 }, 208 | { "source": "Marius", "target": "Mlle.Gillenormand", "value": 6 }, 209 | { "source": "Marius", "target": "Gillenormand", "value": 12 }, 210 | { "source": "Marius", "target": "Pontmercy", "value": 1 }, 211 | { "source": "Marius", "target": "Lt.Gillenormand", "value": 1 }, 212 | { "source": "Marius", "target": "Cosette", "value": 21 }, 213 | { "source": "Marius", "target": "Valjean", "value": 19 }, 214 | { "source": "Marius", "target": "Tholomyes", "value": 1 }, 215 | { "source": "Marius", "target": "Thenardier", "value": 2 }, 216 | { "source": "Marius", "target": "Eponine", "value": 5 }, 217 | { "source": "Marius", "target": "Gavroche", "value": 4 }, 218 | { "source": "BaronessT", "target": "Gillenormand", "value": 1 }, 219 | { "source": "BaronessT", "target": "Marius", "value": 1 }, 220 | { "source": "Mabeuf", "target": "Marius", "value": 1 }, 221 | { "source": "Mabeuf", "target": "Eponine", "value": 1 }, 222 | { "source": "Mabeuf", "target": "Gavroche", "value": 1 }, 223 | { "source": "Enjolras", "target": "Marius", "value": 7 }, 224 | { "source": "Enjolras", "target": "Gavroche", "value": 7 }, 225 | { "source": "Enjolras", "target": "Javert", "value": 6 }, 226 | { "source": "Enjolras", "target": "Mabeuf", "value": 1 }, 227 | { "source": "Enjolras", "target": "Valjean", "value": 4 }, 228 | { "source": "Combeferre", "target": "Enjolras", "value": 15 }, 229 | { "source": "Combeferre", "target": "Marius", "value": 5 }, 230 | { "source": "Combeferre", "target": "Gavroche", "value": 6 }, 231 | { "source": "Combeferre", "target": "Mabeuf", "value": 2 }, 232 | { "source": "Prouvaire", "target": "Gavroche", "value": 1 }, 233 | { "source": "Prouvaire", "target": "Enjolras", "value": 4 }, 234 | { "source": "Prouvaire", "target": "Combeferre", "value": 2 }, 235 | { "source": "Feuilly", "target": "Gavroche", "value": 2 }, 236 | { "source": "Feuilly", "target": "Enjolras", "value": 6 }, 237 | { "source": "Feuilly", "target": "Prouvaire", "value": 2 }, 238 | { "source": "Feuilly", "target": "Combeferre", "value": 5 }, 239 | { "source": "Feuilly", "target": "Mabeuf", "value": 1 }, 240 | { "source": "Feuilly", "target": "Marius", "value": 1 }, 241 | { "source": "Courfeyrac", "target": "Marius", "value": 9 }, 242 | { "source": "Courfeyrac", "target": "Enjolras", "value": 17 }, 243 | { "source": "Courfeyrac", "target": "Combeferre", "value": 13 }, 244 | { "source": "Courfeyrac", "target": "Gavroche", "value": 7 }, 245 | { "source": "Courfeyrac", "target": "Mabeuf", "value": 2 }, 246 | { "source": "Courfeyrac", "target": "Eponine", "value": 1 }, 247 | { "source": "Courfeyrac", "target": "Feuilly", "value": 6 }, 248 | { "source": "Courfeyrac", "target": "Prouvaire", "value": 3 }, 249 | { "source": "Bahorel", "target": "Combeferre", "value": 5 }, 250 | { "source": "Bahorel", "target": "Gavroche", "value": 5 }, 251 | { "source": "Bahorel", "target": "Courfeyrac", "value": 6 }, 252 | { "source": "Bahorel", "target": "Mabeuf", "value": 2 }, 253 | { "source": "Bahorel", "target": "Enjolras", "value": 4 }, 254 | { "source": "Bahorel", "target": "Feuilly", "value": 3 }, 255 | { "source": "Bahorel", "target": "Prouvaire", "value": 2 }, 256 | { "source": "Bahorel", "target": "Marius", "value": 1 }, 257 | { "source": "Bossuet", "target": "Marius", "value": 5 }, 258 | { "source": "Bossuet", "target": "Courfeyrac", "value": 12 }, 259 | { "source": "Bossuet", "target": "Gavroche", "value": 5 }, 260 | { "source": "Bossuet", "target": "Bahorel", "value": 4 }, 261 | { "source": "Bossuet", "target": "Enjolras", "value": 10 }, 262 | { "source": "Bossuet", "target": "Feuilly", "value": 6 }, 263 | { "source": "Bossuet", "target": "Prouvaire", "value": 2 }, 264 | { "source": "Bossuet", "target": "Combeferre", "value": 9 }, 265 | { "source": "Bossuet", "target": "Mabeuf", "value": 1 }, 266 | { "source": "Bossuet", "target": "Valjean", "value": 1 }, 267 | { "source": "Joly", "target": "Bahorel", "value": 5 }, 268 | { "source": "Joly", "target": "Bossuet", "value": 7 }, 269 | { "source": "Joly", "target": "Gavroche", "value": 3 }, 270 | { "source": "Joly", "target": "Courfeyrac", "value": 5 }, 271 | { "source": "Joly", "target": "Enjolras", "value": 5 }, 272 | { "source": "Joly", "target": "Feuilly", "value": 5 }, 273 | { "source": "Joly", "target": "Prouvaire", "value": 2 }, 274 | { "source": "Joly", "target": "Combeferre", "value": 5 }, 275 | { "source": "Joly", "target": "Mabeuf", "value": 1 }, 276 | { "source": "Joly", "target": "Marius", "value": 2 }, 277 | { "source": "Grantaire", "target": "Bossuet", "value": 3 }, 278 | { "source": "Grantaire", "target": "Enjolras", "value": 3 }, 279 | { "source": "Grantaire", "target": "Combeferre", "value": 1 }, 280 | { "source": "Grantaire", "target": "Courfeyrac", "value": 2 }, 281 | { "source": "Grantaire", "target": "Joly", "value": 2 }, 282 | { "source": "Grantaire", "target": "Gavroche", "value": 1 }, 283 | { "source": "Grantaire", "target": "Bahorel", "value": 1 }, 284 | { "source": "Grantaire", "target": "Feuilly", "value": 1 }, 285 | { "source": "Grantaire", "target": "Prouvaire", "value": 1 }, 286 | { "source": "MotherPlutarch", "target": "Mabeuf", "value": 3 }, 287 | { "source": "Gueulemer", "target": "Thenardier", "value": 5 }, 288 | { "source": "Gueulemer", "target": "Valjean", "value": 1 }, 289 | { "source": "Gueulemer", "target": "Mme.Thenardier", "value": 1 }, 290 | { "source": "Gueulemer", "target": "Javert", "value": 1 }, 291 | { "source": "Gueulemer", "target": "Gavroche", "value": 1 }, 292 | { "source": "Gueulemer", "target": "Eponine", "value": 1 }, 293 | { "source": "Babet", "target": "Thenardier", "value": 6 }, 294 | { "source": "Babet", "target": "Gueulemer", "value": 6 }, 295 | { "source": "Babet", "target": "Valjean", "value": 1 }, 296 | { "source": "Babet", "target": "Mme.Thenardier", "value": 1 }, 297 | { "source": "Babet", "target": "Javert", "value": 2 }, 298 | { "source": "Babet", "target": "Gavroche", "value": 1 }, 299 | { "source": "Babet", "target": "Eponine", "value": 1 }, 300 | { "source": "Claquesous", "target": "Thenardier", "value": 4 }, 301 | { "source": "Claquesous", "target": "Babet", "value": 4 }, 302 | { "source": "Claquesous", "target": "Gueulemer", "value": 4 }, 303 | { "source": "Claquesous", "target": "Valjean", "value": 1 }, 304 | { "source": "Claquesous", "target": "Mme.Thenardier", "value": 1 }, 305 | { "source": "Claquesous", "target": "Javert", "value": 1 }, 306 | { "source": "Claquesous", "target": "Eponine", "value": 1 }, 307 | { "source": "Claquesous", "target": "Enjolras", "value": 1 }, 308 | { "source": "Montparnasse", "target": "Javert", "value": 1 }, 309 | { "source": "Montparnasse", "target": "Babet", "value": 2 }, 310 | { "source": "Montparnasse", "target": "Gueulemer", "value": 2 }, 311 | { "source": "Montparnasse", "target": "Claquesous", "value": 2 }, 312 | { "source": "Montparnasse", "target": "Valjean", "value": 1 }, 313 | { "source": "Montparnasse", "target": "Gavroche", "value": 1 }, 314 | { "source": "Montparnasse", "target": "Eponine", "value": 1 }, 315 | { "source": "Montparnasse", "target": "Thenardier", "value": 1 }, 316 | { "source": "Toussaint", "target": "Cosette", "value": 2 }, 317 | { "source": "Toussaint", "target": "Javert", "value": 1 }, 318 | { "source": "Toussaint", "target": "Valjean", "value": 1 }, 319 | { "source": "Child1", "target": "Gavroche", "value": 2 }, 320 | { "source": "Child2", "target": "Gavroche", "value": 2 }, 321 | { "source": "Child2", "target": "Child1", "value": 3 }, 322 | { "source": "Brujon", "target": "Babet", "value": 3 }, 323 | { "source": "Brujon", "target": "Gueulemer", "value": 3 }, 324 | { "source": "Brujon", "target": "Thenardier", "value": 3 }, 325 | { "source": "Brujon", "target": "Gavroche", "value": 1 }, 326 | { "source": "Brujon", "target": "Eponine", "value": 1 }, 327 | { "source": "Brujon", "target": "Claquesous", "value": 1 }, 328 | { "source": "Brujon", "target": "Montparnasse", "value": 1 }, 329 | { "source": "Mme.Hucheloup", "target": "Bossuet", "value": 1 }, 330 | { "source": "Mme.Hucheloup", "target": "Joly", "value": 1 }, 331 | { "source": "Mme.Hucheloup", "target": "Grantaire", "value": 1 }, 332 | { "source": "Mme.Hucheloup", "target": "Bahorel", "value": 1 }, 333 | { "source": "Mme.Hucheloup", "target": "Courfeyrac", "value": 1 }, 334 | { "source": "Mme.Hucheloup", "target": "Gavroche", "value": 1 }, 335 | { "source": "Mme.Hucheloup", "target": "Enjolras", "value": 1 } 336 | ] 337 | } 338 | -------------------------------------------------------------------------------- /docs/examples/orientation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tree Orientations 3 | --- 4 | 5 | # Tree Orientations 6 | 7 | 10 | 11 | ## Horizontal 12 | 13 | 17 | 18 | ### Code 19 | 20 | :::code-group 21 | 22 | <<< ./tree.ts#horizontal [config] 23 | 24 | <<< ./tree.ts#data [data] 25 | 26 | ::: 27 | 28 | ## Vertical 29 | 30 | 34 | 35 | ### Code 36 | 37 | :::code-group 38 | 39 | <<< ./tree.ts#vertical [config] 40 | 41 | <<< ./tree.ts#data [data] 42 | 43 | ::: 44 | 45 | ## Radial 46 | 47 | 51 | 52 | ### Code 53 | 54 | :::code-group 55 | 56 | <<< ./tree.ts#radial [config] 57 | 58 | <<< ./tree.ts#data [data] 59 | 60 | ::: 61 | -------------------------------------------------------------------------------- /docs/examples/tree.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "name": "1" }, 3 | { "name": "11", "parent": 0 }, 4 | { "name": "111", "parent": 1 }, 5 | { "name": "1111", "parent": 2 }, 6 | { "name": "1112", "parent": 2 }, 7 | { "name": "112", "parent": 1 }, 8 | { "name": "1121", "parent": 5 }, 9 | { "name": "1122", "parent": 5 }, 10 | { "name": "113", "parent": 1 }, 11 | { "name": "1131", "parent": 8 }, 12 | { "name": "1132", "parent": 8 }, 13 | { "name": "12", "parent": 0 }, 14 | { "name": "121", "parent": 11 }, 15 | { "name": "1211", "parent": 12 }, 16 | { "name": "1212", "parent": 12 }, 17 | { "name": "122", "parent": 11 }, 18 | { "name": "1221", "parent": 15 }, 19 | { "name": "1222", "parent": 15 }, 20 | { "name": "123", "parent": 11 }, 21 | { "name": "1231", "parent": 18 }, 22 | { "name": "1232", "parent": 18 }, 23 | { "name": "13", "parent": 0 }, 24 | { "name": "131", "parent": 21 } 25 | ] 26 | -------------------------------------------------------------------------------- /docs/examples/tree.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tree 3 | --- 4 | 5 | # Tree 6 | 7 | 10 | 11 | 15 | 16 | ### Code 17 | 18 | :::code-group 19 | 20 | <<< ./tree.ts#config [config] 21 | 22 | <<< ./tree.ts#data [data] 23 | 24 | ::: 25 | -------------------------------------------------------------------------------- /docs/examples/tree.ts: -------------------------------------------------------------------------------- 1 | import type { ChartConfiguration } from 'chart.js'; 2 | import type {} from '../../src'; 3 | import 'chartjs-plugin-datalabels'; 4 | 5 | // #region data 6 | import nodes from './tree.json'; 7 | 8 | export const data: ChartConfiguration<'tree'>['data'] = { 9 | labels: nodes.map((d) => d.name), 10 | datasets: [ 11 | { 12 | pointBackgroundColor: 'steelblue', 13 | pointRadius: 5, 14 | data: nodes.map((d) => Object.assign({}, d)), 15 | edgeLineBorderWidth: (ctx) => { 16 | return ctx.dataIndex; 17 | }, 18 | }, 19 | ], 20 | }; 21 | // #endregion data 22 | // #region tree 23 | export const config: ChartConfiguration<'tree'> = { 24 | type: 'tree', 25 | data, 26 | options: { 27 | plugins: { 28 | datalabels: { 29 | display: false, 30 | }, 31 | }, 32 | tree: { 33 | mode: 'tree', 34 | }, 35 | }, 36 | }; 37 | // #endregion tree 38 | // #region horizontal 39 | export const horizontal: ChartConfiguration<'tree'> = { 40 | type: 'tree', 41 | data, 42 | options: { 43 | plugins: { 44 | datalabels: { 45 | display: false, 46 | }, 47 | }, 48 | tree: { 49 | orientation: 'horizontal', 50 | }, 51 | }, 52 | }; 53 | // #endregion horizontal 54 | // #region vertical 55 | export const vertical: ChartConfiguration<'tree'> = { 56 | type: 'tree', 57 | data, 58 | options: { 59 | plugins: { 60 | datalabels: { 61 | display: false, 62 | }, 63 | }, 64 | tree: { 65 | orientation: 'vertical', 66 | }, 67 | }, 68 | }; 69 | // #endregion vertical 70 | // #region radial 71 | export const radial: ChartConfiguration<'tree'> = { 72 | type: 'tree', 73 | data, 74 | options: { 75 | plugins: { 76 | datalabels: { 77 | display: false, 78 | }, 79 | }, 80 | tree: { 81 | orientation: 'radial', 82 | }, 83 | }, 84 | }; 85 | // #endregion radial 86 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | --- 4 | 5 | Chart.js module for charting graphs. Adding new chart types: `graph`, `forceDirectedGraph`, `dendrogram`, and `tree`. 6 | 7 | ![force](https://user-images.githubusercontent.com/4129778/65398353-9bc03f80-dd84-11e9-8f14-339635c1ba4e.png) 8 | 9 | ![dend_h](https://user-images.githubusercontent.com/4129778/65398352-9bc03f80-dd84-11e9-9197-ecb66a872736.png) 10 | 11 | ![tree_v](https://user-images.githubusercontent.com/4129778/65398350-9bc03f80-dd84-11e9-8c94-e93c07040ee7.png) 12 | 13 | ![radial](https://user-images.githubusercontent.com/4129778/65398354-9bc03f80-dd84-11e9-9633-c4c80bd9c384.png) 14 | 15 | Works great with https://github.com/chartjs/chartjs-plugin-datalabels or https://github.com/chrispahm/chartjs-plugin-dragdata 16 | 17 | ## Install 18 | 19 | ```sh 20 | npm install chart.js chartjs-chart-graph 21 | ``` 22 | 23 | ## Usage 24 | 25 | see [Examples](./examples/) 26 | 27 | CodePens 28 | 29 | - [Force Directed Layout](https://codepen.io/sgratzl/pen/mdezvmL) 30 | - [Tree Layouts](https://codepen.io/sgratzl/pen/jObedwg) 31 | - [Tree With Data Labels](https://codepen.io/sgratzl/pen/vYNVbgd) 32 | 33 | ## Configuration 34 | 35 | ### Data Structure 36 | 37 | TODO 38 | 39 | ### Styling 40 | 41 | The new chart types are based on the existing `line` controller. Tho, instead of showing a line per dataset it shows edges as lines. Therefore, the styling options for points and lines are the same. See also https://www.chartjs.org/docs/latest/charts/line.html. However, to avoid confusion, the line options have a default `line` prefix, e..g `lineBorderColor` to specify the edge border color and `pointBorderColor` to specify the node border color. 42 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: 'chartjs-chart-graph' 7 | text: 'chart.js plugin' 8 | tagline: Chart.js module for charting graphs 9 | actions: 10 | - theme: brand 11 | text: Getting Started 12 | link: /getting-started 13 | - theme: alt 14 | text: Examples 15 | link: /examples/ 16 | - theme: alt 17 | text: API 18 | link: /api/ 19 | # features: 20 | # - title: Feature A 21 | # details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 22 | --- 23 | -------------------------------------------------------------------------------- /docs/related.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Related Plugins 3 | --- 4 | 5 | There are several related chart.js plugins providing additional functionality and chart types: 6 | 7 | - [chartjs-chart-boxplot](https://github.com/sgratzl/chartjs-chart-boxplot) for rendering boxplots and violin charts 8 | - [chartjs-chart-error-bars](https://github.com/sgratzl/chartjs-chart-error-bars) for rendering errors bars to bars and line charts 9 | - [chartjs-chart-funnel](https://github.com/sgratzl/chartjs-chart-funnel) for rendering funnel charts 10 | - [chartjs-chart-geo](https://github.com/sgratzl/chartjs-chart-geo) for rendering map, bubble maps, and choropleth charts 11 | - [chartjs-chart-graph](https://github.com/sgratzl/chartjs-chart-graph) for rendering graphs, trees, and networks 12 | - [chartjs-chart-pcp](https://github.com/sgratzl/chartjs-chart-pcp) for rendering parallel coordinate plots 13 | - [chartjs-chart-venn](https://github.com/sgratzl/chartjs-chart-venn) for rendering venn and euler diagrams 14 | - [chartjs-chart-wordcloud](https://github.com/sgratzl/chartjs-chart-wordcloud) for rendering word clouds 15 | - [chartjs-plugin-hierarchical](https://github.com/sgratzl/chartjs-plugin-hierarchical) for rendering hierarchical categorical axes which can be expanded and collapsed 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | import prettier from 'eslint-plugin-prettier'; 6 | 7 | export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, { 8 | plugins: { prettier }, 9 | rules: { 10 | '@typescript-eslint/no-explicit-any': 'off', 11 | 'max-classes-per-file': 'off', 12 | 'no-underscore-dangle': 'off', 13 | 'import/extensions': 'off', 14 | }, 15 | }); 16 | 17 | // import path from "node:path"; 18 | // import { fileURLToPath } from "node:url"; 19 | // import js from "@eslint/js"; 20 | // import { FlatCompat } from "@eslint/eslintrc"; 21 | 22 | // const __filename = fileURLToPath(import.meta.url); 23 | // const __dirname = path.dirname(__filename); 24 | // const compat = new FlatCompat({ 25 | // baseDirectory: __dirname, 26 | // recommendedConfig: js.configs.recommended, 27 | // allConfig: js.configs.all 28 | // }); 29 | 30 | // export default [...fixupConfigRules(compat.extends( 31 | // "airbnb-typescript", 32 | // "react-app", 33 | // "plugin:prettier/recommended", 34 | // "prettier", 35 | // )), { 36 | // plugins: { 37 | // prettier: fixupPluginRules(prettier), 38 | // }, 39 | 40 | // languageOptions: { 41 | // ecmaVersion: 5, 42 | // sourceType: "script", 43 | 44 | // parserOptions: { 45 | // project: "./tsconfig.eslint.json", 46 | // }, 47 | // }, 48 | 49 | // settings: { 50 | // react: { 51 | // version: "99.99.99", 52 | // }, 53 | // }, 54 | 55 | // rules: { 56 | // "@typescript-eslint/no-explicit-any": "off", 57 | // "max-classes-per-file": "off", 58 | // "no-underscore-dangle": "off", 59 | // "import/extensions": "off", 60 | // }, 61 | // }]; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chartjs-chart-graph", 3 | "description": "Chart.js module for charting graphs", 4 | "version": "4.3.4", 5 | "author": { 6 | "name": "Samuel Gratzl", 7 | "email": "sam@sgratzl.com", 8 | "url": "https://www.sgratzl.com" 9 | }, 10 | "license": "MIT", 11 | "homepage": "https://github.com/sgratzl/chartjs-chart-graph", 12 | "bugs": { 13 | "url": "https://github.com/sgratzl/chartjs-chart-graph/issues" 14 | }, 15 | "keywords": [ 16 | "chart.js", 17 | "graph", 18 | "force-directed", 19 | "tree" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/sgratzl/chartjs-chart-graph.git" 24 | }, 25 | "global": "ChartGraphs", 26 | "type": "module", 27 | "main": "build/index.js", 28 | "module": "build/index.js", 29 | "require": "build/index.cjs", 30 | "umd": "build/index.umd.js", 31 | "unpkg": "build/index.umd.min.js", 32 | "jsdelivr": "build/index.umd.min.js", 33 | "types": "build/index.d.ts", 34 | "exports": { 35 | ".": { 36 | "import": "./build/index.js", 37 | "require": "./build/index.cjs", 38 | "scripts": "./build/index.umd.min.js", 39 | "types": "./build/index.d.ts" 40 | } 41 | }, 42 | "sideEffects": false, 43 | "files": [ 44 | "build", 45 | "src/**/*.ts" 46 | ], 47 | "peerDependencies": { 48 | "chart.js": "^4.1.0" 49 | }, 50 | "browserslist": [ 51 | "Firefox ESR", 52 | "last 2 Chrome versions", 53 | "last 2 Firefox versions" 54 | ], 55 | "dependencies": { 56 | "@types/d3-force": "^3.0.10", 57 | "@types/d3-hierarchy": "^3.1.7", 58 | "d3-dispatch": "^3.0.1", 59 | "d3-force": "^3.0.0", 60 | "d3-hierarchy": "^3.1.2", 61 | "d3-quadtree": "^3.0.1", 62 | "d3-timer": "^3.0.1" 63 | }, 64 | "devDependencies": { 65 | "@chiogen/rollup-plugin-terser": "^7.1.3", 66 | "@eslint/js": "~9.15.0", 67 | "@rollup/plugin-commonjs": "^28.0.1", 68 | "@rollup/plugin-node-resolve": "^15.3.0", 69 | "@rollup/plugin-replace": "^6.0.1", 70 | "@rollup/plugin-typescript": "^12.1.1", 71 | "@types/jest-image-snapshot": "^6.4.0", 72 | "@types/node": "^22.9.0", 73 | "@yarnpkg/sdks": "^3.2.0", 74 | "canvas": "^2.11.2", 75 | "canvas-5-polyfill": "^0.1.5", 76 | "chart.js": "^4.4.6", 77 | "chartjs-plugin-datalabels": "^2.2.0", 78 | "eslint": "~9.14.0", 79 | "eslint-plugin-prettier": "^5.2.1", 80 | "jest-image-snapshot": "^6.4.0", 81 | "jsdom": "^25.0.1", 82 | "prettier": "^3.3.3", 83 | "rimraf": "^6.0.1", 84 | "rollup": "^4.27.2", 85 | "rollup-plugin-cleanup": "^3.2.1", 86 | "rollup-plugin-dts": "^6.1.1", 87 | "ts-jest": "^29.2.5", 88 | "tslib": "^2.8.1", 89 | "typedoc": "^0.26.11", 90 | "typedoc-plugin-markdown": "^4.2.10", 91 | "typedoc-vitepress-theme": "^1.0.2", 92 | "typescript": "^5.6.3", 93 | "typescript-eslint": "^8.14.0", 94 | "vite": "^5.4.11", 95 | "vitepress": "^1.5.0", 96 | "vitest": "^2.1.5", 97 | "vue": "^3.5.13", 98 | "vue-chartjs": "^5.3.2" 99 | }, 100 | "scripts": { 101 | "clean": "rimraf --glob build node_modules \"*.tgz\" \"*.tsbuildinfo\"", 102 | "compile": "tsc -b tsconfig.c.json", 103 | "start": "yarn run watch", 104 | "watch": "rollup -c -w", 105 | "build": "rollup -c", 106 | "test": "vitest --passWithNoTests", 107 | "test:watch": "yarn run test --watch", 108 | "test:coverage": "yarn run test --coverage", 109 | "lint": "yarn run eslint && yarn run prettier", 110 | "fix": "yarn run eslint:fix && yarn run prettier:write", 111 | "prettier:write": "prettier \"*\" \"*/**\" --write", 112 | "prettier": "prettier \"*\" \"*/**\" --check", 113 | "eslint": "eslint src --cache", 114 | "eslint:fix": "yarn run eslint --fix", 115 | "prepare": "yarn run build", 116 | "docs:api": "typedoc --options typedoc.json", 117 | "docs:dev": "vitepress dev docs", 118 | "docs:build": "yarn run docs:api && vitepress build docs", 119 | "docs:preview": "vitepress preview docs" 120 | }, 121 | "packageManager": "yarn@4.5.1" 122 | } 123 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import cleanup from 'rollup-plugin-cleanup'; 4 | import dts from 'rollup-plugin-dts'; 5 | import typescript from '@rollup/plugin-typescript'; 6 | import { terser } from '@chiogen/rollup-plugin-terser'; 7 | import replace from '@rollup/plugin-replace'; 8 | 9 | import fs from 'fs'; 10 | 11 | const pkg = JSON.parse(fs.readFileSync('./package.json')); 12 | 13 | function resolveYear() { 14 | // Extract copyrights from the LICENSE. 15 | const license = fs.readFileSync('./LICENSE', 'utf-8').toString(); 16 | const matches = Array.from(license.matchAll(/\(c\) (\d+-\d+)/gm)); 17 | if (!matches || matches.length === 0) { 18 | return 2021; 19 | } 20 | return matches[matches.length - 1][1]; 21 | } 22 | const year = resolveYear(); 23 | 24 | const banner = `/** 25 | * ${pkg.name} 26 | * ${pkg.homepage} 27 | * 28 | * Copyright (c) ${year} ${pkg.author.name} <${pkg.author.email}> 29 | */ 30 | `; 31 | 32 | /** 33 | * defines which formats (umd, esm, cjs, types) should be built when watching 34 | */ 35 | const watchOnly = ['umd']; 36 | 37 | const isDependency = (v) => Object.keys(pkg.dependencies || {}).some((e) => e === v || v.startsWith(`${e}/`)); 38 | const isPeerDependency = (v) => Object.keys(pkg.peerDependencies || {}).some((e) => e === v || v.startsWith(`${e}/`)); 39 | 40 | export default function Config(options) { 41 | const buildFormat = (format) => { 42 | return !options.watch || watchOnly.includes(format); 43 | }; 44 | const commonOutput = { 45 | sourcemap: true, 46 | banner, 47 | globals: { 48 | 'chart.js': 'Chart', 49 | 'chart.js/helpers': 'Chart.helpers', 50 | }, 51 | }; 52 | 53 | const base = { 54 | input: './src/index.ts', 55 | external: (v) => isDependency(v) || isPeerDependency(v), 56 | plugins: [ 57 | typescript(), 58 | resolve({ 59 | mainFields: ['module', 'main'], 60 | extensions: ['.mjs', '.cjs', '.js', '.jsx', '.json', '.node'], 61 | modulesOnly: true, 62 | }), 63 | commonjs(), 64 | replace({ 65 | preventAssignment: true, 66 | values: { 67 | // eslint-disable-next-line no-undef 68 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) || 'production', 69 | __VERSION__: JSON.stringify(pkg.version), 70 | }, 71 | }), 72 | cleanup({ 73 | comments: ['some', 'ts', 'ts3s'], 74 | extensions: ['ts', 'tsx', 'js', 'jsx'], 75 | include: './src/**/*', 76 | }), 77 | ], 78 | }; 79 | return [ 80 | buildFormat('esm') && { 81 | ...base, 82 | output: { 83 | ...commonOutput, 84 | file: pkg.module, 85 | format: 'esm', 86 | }, 87 | }, 88 | buildFormat('cjs') && { 89 | ...base, 90 | output: { 91 | ...commonOutput, 92 | file: pkg.require, 93 | format: 'cjs', 94 | }, 95 | external: (v) => (isDependency(v) || isPeerDependency(v)) && ['d3-'].every((di) => !v.includes(di)), 96 | }, 97 | (buildFormat('umd') || buildFormat('umd-min')) && { 98 | ...base, 99 | input: './src/index.umd.ts', 100 | output: [ 101 | buildFormat('umd') && { 102 | ...commonOutput, 103 | file: pkg.umd, 104 | format: 'umd', 105 | name: pkg.global, 106 | }, 107 | buildFormat('umd-min') && { 108 | ...commonOutput, 109 | file: pkg.unpkg, 110 | format: 'umd', 111 | name: pkg.global, 112 | plugins: [terser()], 113 | }, 114 | ].filter(Boolean), 115 | external: (v) => isPeerDependency(v), 116 | }, 117 | buildFormat('types') && { 118 | ...base, 119 | output: { 120 | ...commonOutput, 121 | file: pkg.types, 122 | format: 'es', 123 | }, 124 | plugins: [ 125 | dts({ 126 | respectExternal: true, 127 | compilerOptions: { 128 | skipLibCheck: true, 129 | skipDefaultLibCheck: true, 130 | }, 131 | }), 132 | ], 133 | }, 134 | ].filter(Boolean); 135 | } 136 | -------------------------------------------------------------------------------- /samples/graphChange.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 |
12 | 13 | 14 | 15 | 16 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /samples/tree.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 |
12 | 13 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/__tests__/createChart.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { expect } from 'vitest'; 4 | import { Chart, ChartConfiguration, defaults, ChartType, DefaultDataPoint } from 'chart.js'; 5 | import { toMatchImageSnapshot, MatchImageSnapshotOptions } from 'jest-image-snapshot'; 6 | import 'canvas-5-polyfill'; 7 | 8 | expect.extend({ toMatchImageSnapshot }); 9 | 10 | function toBuffer(canvas: HTMLCanvasElement) { 11 | return new Promise((resolve) => { 12 | canvas.toBlob((b) => { 13 | const file = new FileReader(); 14 | file.onload = () => resolve(Buffer.from(file.result as ArrayBuffer)); 15 | 16 | file.readAsArrayBuffer(b!); 17 | }); 18 | }); 19 | } 20 | 21 | export async function expectMatchSnapshot(canvas: HTMLCanvasElement): Promise { 22 | const image = await toBuffer(canvas); 23 | expect(image).toMatchImageSnapshot(); 24 | } 25 | 26 | export interface ChartHelper, LABEL = string> { 27 | chart: Chart; 28 | canvas: HTMLCanvasElement; 29 | ctx: CanvasRenderingContext2D; 30 | toMatchImageSnapshot(options?: MatchImageSnapshotOptions): Promise; 31 | } 32 | 33 | export default function createChart< 34 | TYPE extends ChartType, 35 | DATA extends unknown[] = DefaultDataPoint, 36 | LABEL = string, 37 | >(config: ChartConfiguration, width = 800, height = 600): ChartHelper { 38 | const canvas = document.createElement('canvas'); 39 | canvas.width = width; 40 | canvas.height = height; 41 | Object.assign(defaults.font, { family: 'Courier New' }); 42 | // defaults.color = 'transparent'; 43 | 44 | config.options = { 45 | responsive: false, 46 | animation: { 47 | duration: 1, 48 | }, 49 | plugins: { 50 | legend: { 51 | display: false, 52 | }, 53 | title: { 54 | display: false, 55 | }, 56 | }, 57 | ...(config.options || {}), 58 | } as any; 59 | 60 | const ctx = canvas.getContext('2d')!; 61 | 62 | const t = new Chart(ctx, config); 63 | 64 | return { 65 | chart: t, 66 | canvas, 67 | ctx, 68 | async toMatchImageSnapshot(options?: MatchImageSnapshotOptions) { 69 | await new Promise((resolve) => setTimeout(resolve, 100)); 70 | 71 | const image = await toBuffer(canvas); 72 | expect(image).toMatchImageSnapshot(options); 73 | }, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/controllers/DendrogramController.spec.ts: -------------------------------------------------------------------------------- 1 | import { registry, PointElement, LinearScale } from 'chart.js'; 2 | import createChart from '../__tests__/createChart'; 3 | import { DendrogramController } from './DendrogramController'; 4 | import nodes from './__tests__/tree'; 5 | import { EdgeLine } from '../elements'; 6 | import { describe, beforeAll, test } from 'vitest'; 7 | describe('dendrogram', () => { 8 | beforeAll(() => { 9 | registry.addControllers(DendrogramController); 10 | registry.addElements(EdgeLine, PointElement); 11 | registry.addScales(LinearScale); 12 | }); 13 | test('default', () => { 14 | return createChart({ 15 | type: DendrogramController.id as 'dendrogram', 16 | data: { 17 | labels: nodes.map((d) => d.name), 18 | datasets: [ 19 | { 20 | pointBackgroundColor: 'steelblue', 21 | pointRadius: 5, 22 | // stepped: 'middle', 23 | data: nodes, 24 | }, 25 | ], 26 | }, 27 | }).toMatchImageSnapshot(); 28 | }); 29 | test('vertical', () => { 30 | return createChart({ 31 | type: DendrogramController.id as 'dendrogram', 32 | data: { 33 | labels: nodes.map((d) => d.name), 34 | datasets: [ 35 | { 36 | tree: { 37 | orientation: 'vertical', 38 | }, 39 | pointBackgroundColor: 'steelblue', 40 | pointRadius: 5, 41 | // stepped: 'middle', 42 | data: nodes, 43 | }, 44 | ], 45 | }, 46 | }).toMatchImageSnapshot(); 47 | }); 48 | test('radial', () => { 49 | return createChart({ 50 | type: DendrogramController.id as 'dendrogram', 51 | data: { 52 | labels: nodes.map((d) => d.name), 53 | datasets: [ 54 | { 55 | tree: { 56 | orientation: 'radial', 57 | }, 58 | pointBackgroundColor: 'steelblue', 59 | pointRadius: 5, 60 | // stepped: 'middle', 61 | data: nodes, 62 | }, 63 | ], 64 | }, 65 | }).toMatchImageSnapshot(); 66 | }); 67 | 68 | test('default tree', () => { 69 | return createChart({ 70 | type: DendrogramController.id as 'dendrogram', 71 | data: { 72 | labels: nodes.map((d) => d.name), 73 | datasets: [ 74 | { 75 | tree: { 76 | mode: 'tree', 77 | }, 78 | pointBackgroundColor: 'steelblue', 79 | pointRadius: 5, 80 | // stepped: 'middle', 81 | data: nodes, 82 | }, 83 | ], 84 | }, 85 | }).toMatchImageSnapshot(); 86 | }); 87 | test('vertical tree', () => { 88 | return createChart({ 89 | type: DendrogramController.id as 'dendrogram', 90 | data: { 91 | labels: nodes.map((d) => d.name), 92 | datasets: [ 93 | { 94 | tree: { 95 | mode: 'tree', 96 | orientation: 'vertical', 97 | }, 98 | pointBackgroundColor: 'steelblue', 99 | pointRadius: 5, 100 | // stepped: 'middle', 101 | data: nodes, 102 | }, 103 | ], 104 | }, 105 | }).toMatchImageSnapshot(); 106 | }); 107 | test('radial tree', () => { 108 | return createChart({ 109 | type: DendrogramController.id as 'dendrogram', 110 | data: { 111 | labels: nodes.map((d) => d.name), 112 | datasets: [ 113 | { 114 | tree: { 115 | mode: 'tree', 116 | orientation: 'radial', 117 | }, 118 | pointBackgroundColor: 'steelblue', 119 | pointRadius: 5, 120 | // stepped: 'middle', 121 | data: nodes, 122 | }, 123 | ], 124 | }, 125 | }).toMatchImageSnapshot(); 126 | }); 127 | test('directed', () => { 128 | return createChart({ 129 | type: DendrogramController.id as 'dendrogram', 130 | data: { 131 | labels: nodes.map((d) => d.name), 132 | datasets: [ 133 | { 134 | pointBackgroundColor: 'steelblue', 135 | pointRadius: 5, 136 | directed: true, 137 | // stepped: 'middle', 138 | data: nodes, 139 | }, 140 | ], 141 | }, 142 | }).toMatchImageSnapshot(); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/controllers/DendrogramController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Chart, 3 | ChartItem, 4 | ChartConfiguration, 5 | LinearScale, 6 | PointElement, 7 | UpdateMode, 8 | Element, 9 | CartesianScaleTypeRegistry, 10 | CoreChartOptions, 11 | } from 'chart.js'; 12 | import { merge } from 'chart.js/helpers'; 13 | import { cluster, hierarchy, HierarchyNode, tree } from 'd3-hierarchy'; 14 | import { EdgeLine } from '../elements'; 15 | import { 16 | GraphController, 17 | IGraphChartControllerDatasetOptions, 18 | IGraphDataPoint, 19 | ITreeNode, 20 | AnyObject, 21 | } from './GraphController'; 22 | import patchController from './patchController'; 23 | 24 | export interface ITreeOptions { 25 | /** 26 | * tree (cluster) or dendrogram layout default depends on the chart type 27 | */ 28 | mode: 'dendrogram' | 'tree' | 'dendrogram'; 29 | /** 30 | * orientation of the tree layout 31 | * @default horizontal 32 | */ 33 | orientation: 'horizontal' | 'vertical' | 'radial'; 34 | } 35 | 36 | export class DendrogramController extends GraphController { 37 | /** 38 | * @hidden 39 | */ 40 | declare options: { tree: ITreeOptions }; 41 | 42 | private _animTimer: number = -1; 43 | 44 | /** 45 | * @hidden 46 | */ 47 | 48 | updateEdgeElement(line: EdgeLine, index: number, properties: any, mode: UpdateMode): void { 49 | properties._orientation = this.options.tree.orientation; 50 | super.updateEdgeElement(line, index, properties, mode); 51 | } 52 | 53 | _destroy() { 54 | if (this._animTimer >= 0) { 55 | cancelAnimationFrame(this._animTimer); 56 | } 57 | this._animTimer = -2; 58 | return super._destroy(); 59 | } 60 | 61 | /** 62 | * @hidden 63 | */ 64 | 65 | updateElement(point: Element, index: number, properties: any, mode: UpdateMode): void { 66 | if (index != null) { 67 | properties.angle = (this.getParsed(index) as { angle: number }).angle; 68 | } 69 | super.updateElement(point, index, properties, mode); 70 | } 71 | 72 | resyncLayout(): void { 73 | const meta = this._cachedMeta as any; 74 | 75 | meta.root = hierarchy(this.getTreeRoot(), (d) => this.getTreeChildren(d)) 76 | .count() 77 | .sort((a, b) => b.height - a.height || (b.data.index ?? 0) - (a.data.index ?? 0)); 78 | 79 | this.doLayout(meta.root); 80 | 81 | super.resyncLayout(); 82 | } 83 | 84 | reLayout(newOptions: Partial = {}): void { 85 | if (newOptions) { 86 | Object.assign(this.options.tree, newOptions); 87 | const ds = this.getDataset() as any; 88 | if (ds.tree) { 89 | Object.assign(ds.tree, newOptions); 90 | } else { 91 | ds.tree = newOptions; 92 | } 93 | } 94 | this.doLayout((this._cachedMeta as any).root); 95 | } 96 | 97 | doLayout(root: HierarchyNode<{ x: number; y: number; angle?: number }>): void { 98 | const options = this.options.tree; 99 | 100 | const layout = 101 | options.mode === 'tree' 102 | ? tree<{ x: number; y: number; angle?: number }>() 103 | : cluster<{ x: number; y: number; angle?: number }>(); 104 | 105 | if (options.orientation === 'radial') { 106 | layout.size([Math.PI * 2, 1]); 107 | } else { 108 | layout.size([2, 2]); 109 | } 110 | 111 | const orientation = { 112 | horizontal: (d: { x: number; y: number; data: { x: number; y: number } }) => { 113 | d.data.x = d.y - 1; 114 | 115 | d.data.y = -d.x + 1; 116 | }, 117 | vertical: (d: { x: number; y: number; data: { x: number; y: number } }) => { 118 | d.data.x = d.x - 1; 119 | 120 | d.data.y = -d.y + 1; 121 | }, 122 | radial: (d: { x: number; y: number; data: { x: number; y: number; angle?: number } }) => { 123 | d.data.x = Math.cos(d.x) * d.y; 124 | 125 | d.data.y = Math.sin(d.x) * d.y; 126 | 127 | d.data.angle = d.y === 0 ? Number.NaN : d.x; 128 | }, 129 | }; 130 | 131 | layout(root).each((orientation[options.orientation] || orientation.horizontal) as any); 132 | 133 | const chart = this.chart; 134 | if (this._animTimer !== -2) { 135 | this._animTimer = requestAnimationFrame(() => { 136 | if (chart.canvas) { 137 | chart.update(); 138 | } 139 | }); 140 | } 141 | } 142 | 143 | static readonly id: string = 'dendrogram'; 144 | 145 | /** 146 | * @hidden 147 | */ 148 | static readonly defaults: any = /* #__PURE__ */ merge({}, [ 149 | GraphController.defaults, 150 | { 151 | tree: { 152 | mode: 'dendrogram', // dendrogram, tree 153 | orientation: 'horizontal', // vertical, horizontal, radial 154 | }, 155 | animations: { 156 | numbers: { 157 | type: 'number', 158 | properties: ['x', 'y', 'angle', 'radius', 'rotation', 'borderWidth'], 159 | }, 160 | }, 161 | tension: 0.4, 162 | }, 163 | ]); 164 | 165 | /** 166 | * @hidden 167 | */ 168 | static readonly overrides: any = /* #__PURE__ */ merge({}, [ 169 | GraphController.overrides, 170 | { 171 | scales: { 172 | x: { 173 | min: -1, 174 | max: 1, 175 | }, 176 | y: { 177 | min: -1, 178 | max: 1, 179 | }, 180 | }, 181 | }, 182 | ]); 183 | } 184 | 185 | export interface IDendrogramChartControllerDatasetOptions extends IGraphChartControllerDatasetOptions { 186 | tree: ITreeOptions; 187 | } 188 | 189 | declare module 'chart.js' { 190 | export interface ChartTypeRegistry { 191 | dendogram: { 192 | chartOptions: CoreChartOptions<'dendrogram'> & { tree: ITreeOptions }; 193 | datasetOptions: IDendrogramChartControllerDatasetOptions; 194 | defaultDataPoint: IGraphDataPoint & Record; 195 | metaExtensions: Record; 196 | parsedDataType: ITreeNode & { angle?: number }; 197 | scales: keyof CartesianScaleTypeRegistry; 198 | }; 199 | dendrogram: { 200 | chartOptions: CoreChartOptions<'dendrogram'> & { tree: ITreeOptions }; 201 | datasetOptions: IDendrogramChartControllerDatasetOptions; 202 | defaultDataPoint: IGraphDataPoint & Record; 203 | metaExtensions: Record; 204 | parsedDataType: ITreeNode & { angle?: number }; 205 | scales: keyof CartesianScaleTypeRegistry; 206 | }; 207 | } 208 | } 209 | 210 | export class DendrogramChart extends Chart< 211 | 'dendrogram', 212 | DATA, 213 | LABEL 214 | > { 215 | static id = DendrogramController.id; 216 | 217 | constructor(item: ChartItem, config: Omit, 'type'>) { 218 | super(item, patchController('dendrogram', config, DendrogramController, [EdgeLine, PointElement], LinearScale)); 219 | } 220 | } 221 | 222 | export class DendogramController extends DendrogramController { 223 | static readonly id: string = 'dendogram'; 224 | 225 | /** 226 | * @hidden 227 | */ 228 | static readonly defaults: any = /* #__PURE__ */ merge({}, [ 229 | DendrogramController.defaults, 230 | { 231 | tree: { 232 | mode: 'dendrogram', // dendrogram, tree 233 | }, 234 | }, 235 | ]); 236 | } 237 | 238 | export const DendogramChart = DendrogramChart; 239 | -------------------------------------------------------------------------------- /src/controllers/ForceDirectedGraphController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Chart, 3 | ChartItem, 4 | ChartConfiguration, 5 | LinearScale, 6 | PointElement, 7 | CoreChartOptions, 8 | CartesianScaleTypeRegistry, 9 | } from 'chart.js'; 10 | import { merge } from 'chart.js/helpers'; 11 | import { 12 | forceCenter, 13 | forceCollide, 14 | forceLink, 15 | ForceLink, 16 | forceManyBody, 17 | forceRadial, 18 | forceSimulation, 19 | forceX, 20 | forceY, 21 | Simulation, 22 | SimulationLinkDatum, 23 | SimulationNodeDatum, 24 | } from 'd3-force'; 25 | import { EdgeLine } from '../elements'; 26 | import { 27 | GraphController, 28 | IGraphChartControllerDatasetOptions, 29 | IGraphDataPoint, 30 | ITreeNode, 31 | IExtendedChartMeta, 32 | } from './GraphController'; 33 | import patchController from './patchController'; 34 | 35 | export interface ITreeSimNode extends ITreeNode { 36 | _sim: { x?: number; y?: number; vx?: number; vy?: number; index?: number }; 37 | reset?: boolean; 38 | } 39 | 40 | export interface IForceDirectedControllerOptions { 41 | simulation: { 42 | /** 43 | * auto restarts the simulation upon dataset change, one can manually restart by calling: `chart.getDatasetMeta(0).controller.reLayout();` 44 | * 45 | * @default true 46 | */ 47 | autoRestart: boolean; 48 | 49 | initialIterations: number; 50 | 51 | forces: { 52 | /** 53 | * center force 54 | * https://github.com/d3/d3-force/#centering 55 | * 56 | * @default true 57 | */ 58 | center: boolean | ICenterForce; 59 | 60 | /** 61 | * collision between nodes 62 | * https://github.com/d3/d3-force/#collision 63 | * 64 | * @default false 65 | */ 66 | collide: boolean | ICollideForce; 67 | 68 | /** 69 | * link force 70 | * https://github.com/d3/d3-force/#links 71 | * 72 | * @default true 73 | */ 74 | link: boolean | ILinkForce; 75 | 76 | /** 77 | * link force 78 | * https://github.com/d3/d3-force/#many-body 79 | * 80 | * @default true 81 | */ 82 | manyBody: boolean | IManyBodyForce; 83 | 84 | /** 85 | * x positioning force 86 | * https://github.com/d3/d3-force/#forceX 87 | * 88 | * @default false 89 | */ 90 | x: boolean | IForceXForce; 91 | 92 | /** 93 | * y positioning force 94 | * https://github.com/d3/d3-force/#forceY 95 | * 96 | * @default false 97 | */ 98 | y: boolean | IForceYForce; 99 | 100 | /** 101 | * radial positioning force 102 | * https://github.com/d3/d3-force/#forceRadial 103 | * 104 | * @default false 105 | */ 106 | radial: boolean | IRadialForce; 107 | }; 108 | }; 109 | } 110 | 111 | export declare type ID3NodeCallback = (d: any, i: number) => number; 112 | export declare type ID3EdgeCallback = (d: any, i: number) => number; 113 | 114 | export interface ICenterForce { 115 | x?: number; 116 | y?: number; 117 | } 118 | 119 | export interface ICollideForce { 120 | radius?: number | ID3NodeCallback; 121 | strength?: number | ID3NodeCallback; 122 | } 123 | 124 | export interface ILinkForce { 125 | id?: (d: { source: any; target: any }) => string | number; 126 | distance?: number | ID3EdgeCallback; 127 | strength?: number | ID3EdgeCallback; 128 | } 129 | 130 | export interface IManyBodyForce { 131 | strength?: number | ID3NodeCallback; 132 | theta?: number; 133 | distanceMin?: number; 134 | distanceMax?: number; 135 | } 136 | 137 | export interface IForceXForce { 138 | x?: number; 139 | strength?: number; 140 | } 141 | 142 | export interface IForceYForce { 143 | y?: number; 144 | strength?: number; 145 | } 146 | 147 | export interface IRadialForce { 148 | x?: number; 149 | y?: number; 150 | radius?: number; 151 | strength?: number; 152 | } 153 | 154 | export class ForceDirectedGraphController extends GraphController { 155 | /** 156 | * @hidden 157 | */ 158 | declare options: IForceDirectedControllerOptions; 159 | 160 | /** 161 | * @hidden 162 | */ 163 | private readonly _simulation: Simulation; 164 | 165 | private _animTimer: number = -1; 166 | 167 | constructor(chart: Chart, datasetIndex: number) { 168 | super(chart, datasetIndex); 169 | this._simulation = forceSimulation() 170 | .on('tick', () => { 171 | if (this.chart.canvas && this._animTimer !== -2) { 172 | this._copyPosition(); 173 | this.chart.render(); 174 | } else { 175 | this._simulation.stop(); 176 | } 177 | }) 178 | .on('end', () => { 179 | if (this.chart.canvas && this._animTimer !== -2) { 180 | this._copyPosition(); 181 | this.chart.render(); 182 | // trigger a full update 183 | this.chart.update('default'); 184 | } 185 | }); 186 | const sim = this.options.simulation; 187 | 188 | const fs = { 189 | center: forceCenter, 190 | collide: forceCollide, 191 | link: forceLink, 192 | manyBody: forceManyBody, 193 | x: forceX, 194 | y: forceY, 195 | radial: forceRadial, 196 | }; 197 | 198 | (Object.keys(fs) as (keyof typeof fs)[]).forEach((key) => { 199 | const options = sim.forces[key] as any; 200 | if (!options) { 201 | return; 202 | } 203 | const f = (fs[key] as any)(); 204 | if (typeof options !== 'boolean') { 205 | Object.keys(options).forEach((attr) => { 206 | f[attr](options[attr]); 207 | }); 208 | } 209 | this._simulation.force(key, f); 210 | }); 211 | this._simulation.stop(); 212 | } 213 | 214 | _destroy() { 215 | if (this._animTimer >= 0) { 216 | cancelAnimationFrame(this._animTimer); 217 | } 218 | this._animTimer = -2; 219 | return super._destroy(); 220 | } 221 | 222 | /** 223 | * @hidden 224 | */ 225 | _copyPosition(): void { 226 | const nodes = this._cachedMeta._parsed as ITreeSimNode[]; 227 | 228 | const minmax = nodes.reduce( 229 | (acc, v) => { 230 | const s = v._sim; 231 | if (!s || s.x == null || s.y == null) { 232 | return acc; 233 | } 234 | if (s.x < acc.minX) { 235 | acc.minX = s.x; 236 | } 237 | if (s.x > acc.maxX) { 238 | acc.maxX = s.x; 239 | } 240 | if (s.y < acc.minY) { 241 | acc.minY = s.y; 242 | } 243 | if (s.y > acc.maxY) { 244 | acc.maxY = s.y; 245 | } 246 | return acc; 247 | }, 248 | { 249 | minX: Number.POSITIVE_INFINITY, 250 | maxX: Number.NEGATIVE_INFINITY, 251 | minY: Number.POSITIVE_INFINITY, 252 | maxY: Number.NEGATIVE_INFINITY, 253 | } 254 | ); 255 | 256 | const rescaleX = (v: number) => ((v - minmax.minX) / (minmax.maxX - minmax.minX)) * 2 - 1; 257 | const rescaleY = (v: number) => ((v - minmax.minY) / (minmax.maxY - minmax.minY)) * 2 - 1; 258 | 259 | nodes.forEach((node) => { 260 | if (node._sim) { 261 | node.x = rescaleX(node._sim.x ?? 0); 262 | 263 | node.y = rescaleY(node._sim.y ?? 0); 264 | } 265 | }); 266 | 267 | const { xScale, yScale } = this._cachedMeta; 268 | const elems = this._cachedMeta.data; 269 | elems.forEach((elem, i) => { 270 | const parsed = nodes[i]; 271 | Object.assign(elem, { 272 | x: xScale?.getPixelForValue(parsed.x, i) ?? 0, 273 | y: yScale?.getPixelForValue(parsed.y, i) ?? 0, 274 | skip: false, 275 | }); 276 | }); 277 | } 278 | 279 | resetLayout(): void { 280 | super.resetLayout(); 281 | this._simulation.stop(); 282 | 283 | const nodes = (this._cachedMeta._parsed as ITreeSimNode[]).map((node, i) => { 284 | const simNode: ITreeSimNode['_sim'] = { ...node }; 285 | simNode.index = i; 286 | 287 | node._sim = simNode; 288 | if (!node.reset) { 289 | return simNode; 290 | } 291 | delete simNode.x; 292 | delete simNode.y; 293 | delete simNode.vx; 294 | delete simNode.vy; 295 | return simNode; 296 | }); 297 | this._simulation.nodes(nodes); 298 | this._simulation.alpha(1).restart(); 299 | } 300 | 301 | resyncLayout(): void { 302 | super.resyncLayout(); 303 | this._simulation.stop(); 304 | 305 | const meta = this._cachedMeta; 306 | 307 | const nodes = (meta._parsed as ITreeSimNode[]).map((node, i) => { 308 | const simNode: ITreeSimNode['_sim'] = { ...node }; 309 | simNode.index = i; 310 | 311 | node._sim = simNode; 312 | if (simNode.x === null) { 313 | delete simNode.x; 314 | } 315 | if (simNode.y === null) { 316 | delete simNode.y; 317 | } 318 | if (simNode.x == null && simNode.y == null) { 319 | node.reset = true; 320 | } 321 | return simNode; 322 | }); 323 | const link = 324 | this._simulation.force>>('link'); 325 | if (link) { 326 | link.links([]); 327 | } 328 | this._simulation.nodes(nodes); 329 | if (link) { 330 | // console.assert(ds.edges.length === meta.edges.length); 331 | // work on copy to avoid change 332 | link.links(((meta as unknown as IExtendedChartMeta)._parsedEdges || []).map((l) => ({ ...l }))); 333 | } 334 | 335 | if (this.options.simulation.initialIterations > 0) { 336 | this._simulation.alpha(1); 337 | this._simulation.tick(this.options.simulation.initialIterations); 338 | this._copyPosition(); 339 | if (this.options.simulation.autoRestart) { 340 | this._simulation.restart(); 341 | } else if (this.chart.canvas != null && this._animTimer !== -2) { 342 | const chart = this.chart; 343 | this._animTimer = requestAnimationFrame(() => { 344 | if (chart.canvas) { 345 | chart.update(); 346 | } 347 | }); 348 | } 349 | } else if (this.options.simulation.autoRestart && this.chart.canvas != null && this._animTimer !== -2) { 350 | this._simulation.alpha(1).restart(); 351 | } 352 | } 353 | 354 | reLayout(): void { 355 | this._simulation.alpha(1).restart(); 356 | } 357 | 358 | stopLayout(): void { 359 | super.stopLayout(); 360 | this._simulation.stop(); 361 | } 362 | 363 | static readonly id = 'forceDirectedGraph'; 364 | 365 | /** 366 | * @hidden 367 | */ 368 | static readonly defaults: any = /* #__PURE__ */ merge({}, [ 369 | GraphController.defaults, 370 | { 371 | animation: false, 372 | simulation: { 373 | initialIterations: 0, 374 | autoRestart: true, 375 | forces: { 376 | center: true, 377 | collide: false, 378 | link: true, 379 | manyBody: true, 380 | x: false, 381 | y: false, 382 | radial: false, 383 | }, 384 | }, 385 | }, 386 | ]); 387 | 388 | /** 389 | * @hidden 390 | */ 391 | static readonly overrides: any = /* #__PURE__ */ merge({}, [ 392 | GraphController.overrides, 393 | { 394 | scales: { 395 | x: { 396 | min: -1, 397 | max: 1, 398 | }, 399 | y: { 400 | min: -1, 401 | max: 1, 402 | }, 403 | }, 404 | }, 405 | ]); 406 | } 407 | 408 | export interface IForceDirectedGraphChartControllerDatasetOptions 409 | extends IGraphChartControllerDatasetOptions, 410 | IForceDirectedControllerOptions {} 411 | 412 | declare module 'chart.js' { 413 | export interface ChartTypeRegistry { 414 | forceDirectedGraph: { 415 | chartOptions: CoreChartOptions<'forceDirectedGraph'> & IForceDirectedControllerOptions; 416 | datasetOptions: IForceDirectedGraphChartControllerDatasetOptions; 417 | defaultDataPoint: IGraphDataPoint & Record; 418 | metaExtensions: Record; 419 | parsedDataType: ITreeSimNode; 420 | scales: keyof CartesianScaleTypeRegistry; 421 | }; 422 | } 423 | } 424 | 425 | export class ForceDirectedGraphChart extends Chart< 426 | 'forceDirectedGraph', 427 | DATA, 428 | LABEL 429 | > { 430 | static id = ForceDirectedGraphController.id; 431 | 432 | constructor(item: ChartItem, config: Omit, 'type'>) { 433 | super( 434 | item, 435 | patchController('forceDirectedGraph', config, ForceDirectedGraphController, [EdgeLine, PointElement], LinearScale) 436 | ); 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /src/controllers/GraphController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaults, 3 | Chart, 4 | ScatterController, 5 | registry, 6 | LinearScale, 7 | PointElement, 8 | UpdateMode, 9 | TooltipItem, 10 | ChartItem, 11 | ChartConfiguration, 12 | ControllerDatasetOptions, 13 | ScriptableAndArrayOptions, 14 | LineHoverOptions, 15 | PointPrefixedOptions, 16 | PointPrefixedHoverOptions, 17 | ScriptableContext, 18 | Element, 19 | CartesianScaleTypeRegistry, 20 | CoreChartOptions, 21 | } from 'chart.js'; 22 | import { merge, clipArea, unclipArea, listenArrayEvents, unlistenArrayEvents } from 'chart.js/helpers'; 23 | import { EdgeLine, IEdgeLineOptions } from '../elements'; 24 | import interpolatePoints from './interpolatePoints'; 25 | import patchController from './patchController'; 26 | 27 | export type AnyObject = Record; 28 | 29 | export interface IExtendedChartMeta { 30 | edges: EdgeLine[]; 31 | _parsedEdges: ITreeEdge[]; 32 | } 33 | 34 | export interface ITreeNode extends IGraphDataPoint { 35 | x: number; 36 | y: number; 37 | index?: number; 38 | } 39 | 40 | export interface ITreeEdge { 41 | source: number; 42 | target: number; 43 | points?: { x: number; y: number }[]; 44 | } 45 | 46 | export class GraphController extends ScatterController { 47 | /** 48 | * @hidden 49 | */ 50 | declare _ctx: CanvasRenderingContext2D; 51 | 52 | /** 53 | * @hidden 54 | */ 55 | declare _cachedDataOpts: any; 56 | 57 | /** 58 | * @hidden 59 | */ 60 | declare _type: string; 61 | 62 | /** 63 | * @hidden 64 | */ 65 | declare _data: any[]; 66 | 67 | /** 68 | * @hidden 69 | */ 70 | declare _edges: any[]; 71 | 72 | /** 73 | * @hidden 74 | */ 75 | declare _sharedOptions: any; 76 | 77 | /** 78 | * @hidden 79 | */ 80 | declare _edgeSharedOptions: any; 81 | 82 | /** 83 | * @hidden 84 | */ 85 | declare dataElementType: any; 86 | 87 | /** 88 | * @hidden 89 | */ 90 | private _scheduleResyncLayoutId = -1; 91 | 92 | /** 93 | * @hidden 94 | */ 95 | edgeElementType: any; 96 | 97 | /** 98 | * @hidden 99 | */ 100 | private readonly _edgeListener = { 101 | _onDataPush: (...args: any[]) => { 102 | const count = args.length; 103 | const start = (this.getDataset() as any).edges.length - count; 104 | const parsed = (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges; 105 | args.forEach((edge) => { 106 | parsed.push(this._parseDefinedEdge(edge)); 107 | }); 108 | this._insertEdgeElements(start, count); 109 | }, 110 | _onDataPop: () => { 111 | (this._cachedMeta as unknown as IExtendedChartMeta).edges.pop(); 112 | (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges.pop(); 113 | this._scheduleResyncLayout(); 114 | }, 115 | _onDataShift: () => { 116 | (this._cachedMeta as unknown as IExtendedChartMeta).edges.shift(); 117 | (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges.shift(); 118 | this._scheduleResyncLayout(); 119 | }, 120 | _onDataSplice: (start: number, count: number, ...args: any[]) => { 121 | (this._cachedMeta as unknown as IExtendedChartMeta).edges.splice(start, count); 122 | (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges.splice(start, count); 123 | if (args.length > 0) { 124 | const parsed = (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges; 125 | parsed.splice(start, 0, ...args.map((edge) => this._parseDefinedEdge(edge))); 126 | this._insertEdgeElements(start, args.length); 127 | } else { 128 | this._scheduleResyncLayout(); 129 | } 130 | }, 131 | _onDataUnshift: (...args: any[]) => { 132 | const parsed = (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges; 133 | parsed.unshift(...args.map((edge) => this._parseDefinedEdge(edge))); 134 | this._insertEdgeElements(0, args.length); 135 | }, 136 | }; 137 | 138 | /** 139 | * @hidden 140 | */ 141 | initialize(): void { 142 | const type = this._type; 143 | const defaultConfig = defaults.datasets[type as 'graph'] as any; 144 | this.edgeElementType = registry.getElement(defaultConfig.edgeElementType as string); 145 | super.initialize(); 146 | this.enableOptionSharing = true; 147 | this._scheduleResyncLayout(); 148 | } 149 | 150 | /** 151 | * @hidden 152 | */ 153 | parse(start: number, count: number): void { 154 | const meta = this._cachedMeta; 155 | const data = this._data; 156 | const { iScale, vScale } = meta; 157 | for (let i = 0; i < count; i += 1) { 158 | const index = i + start; 159 | const d = data[index]; 160 | const v = (meta._parsed[index] || {}) as { x: number; y: number }; 161 | if (d && typeof d.x === 'number') { 162 | v.x = d.x; 163 | } 164 | if (d && typeof d.y === 'number') { 165 | v.y = d.y; 166 | } 167 | meta._parsed[index] = v; 168 | } 169 | if (meta._parsed.length > data.length) { 170 | meta._parsed.splice(data.length, meta._parsed.length - data.length); 171 | } 172 | this._cachedMeta._sorted = false; 173 | (iScale as any)._dataLimitsCached = false; 174 | (vScale as any)._dataLimitsCached = false; 175 | 176 | this._parseEdges(); 177 | } 178 | 179 | /** 180 | * @hidden 181 | */ 182 | reset(): void { 183 | this.resetLayout(); 184 | super.reset(); 185 | } 186 | 187 | /** 188 | * @hidden 189 | */ 190 | update(mode: UpdateMode): void { 191 | super.update(mode); 192 | 193 | const meta = this._cachedMeta as unknown as IExtendedChartMeta; 194 | const edges = meta.edges || []; 195 | 196 | this.updateEdgeElements(edges, 0, mode); 197 | } 198 | 199 | /** 200 | * @hidden 201 | */ 202 | _destroy(): void { 203 | (ScatterController.prototype as any)._destroy.call(this); 204 | if (this._edges) { 205 | unlistenArrayEvents(this._edges, this._edgeListener); 206 | } 207 | this.stopLayout(); 208 | } 209 | 210 | declare getContext: (index: number, active: boolean, mode: UpdateMode) => unknown; 211 | 212 | /** 213 | * @hidden 214 | */ 215 | updateEdgeElements(edges: EdgeLine[], start: number, mode: UpdateMode): void { 216 | const bak = { 217 | _cachedDataOpts: this._cachedDataOpts, 218 | dataElementType: this.dataElementType, 219 | _sharedOptions: this._sharedOptions, 220 | // getDataset: this.getDataset, 221 | // getParsed: this.getParsed, 222 | }; 223 | this._cachedDataOpts = {}; 224 | this.dataElementType = this.edgeElementType; 225 | this._sharedOptions = this._edgeSharedOptions; 226 | 227 | const dataset = this.getDataset(); 228 | const meta = this._cachedMeta; 229 | const nodeElements = meta.data; 230 | const data = (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges; 231 | 232 | // get generic context to prefill cache 233 | this.getContext(-1, false, mode); 234 | this.getDataset = () => { 235 | return new Proxy(dataset, { 236 | get(obj: any, prop: string) { 237 | return prop === 'data' ? (obj.edges ?? []) : obj[prop]; 238 | }, 239 | }); 240 | }; 241 | this.getParsed = (index: number) => { 242 | return data[index] as any; 243 | }; 244 | // patch meta to store edges 245 | meta.data = (meta as any).edges; 246 | 247 | const reset = mode === 'reset'; 248 | 249 | const firstOpts = this.resolveDataElementOptions(start, mode); 250 | const dummyShared = {}; 251 | const sharedOptions = this.getSharedOptions(firstOpts) ?? dummyShared; 252 | const includeOptions = this.includeOptions(mode, sharedOptions); 253 | 254 | const { xScale, yScale } = meta; 255 | 256 | const base = { 257 | x: xScale?.getBasePixel() ?? 0, 258 | y: yScale?.getBasePixel() ?? 0, 259 | }; 260 | 261 | function copyPoint(point: { x: number; y: number; angle?: number }) { 262 | const x = reset ? base.x : (xScale?.getPixelForValue(point.x, 0) ?? 0); 263 | const y = reset ? base.y : (yScale?.getPixelForValue(point.y, 0) ?? 0); 264 | return { 265 | x, 266 | y, 267 | angle: point.angle, 268 | }; 269 | } 270 | 271 | for (let i = 0; i < edges.length; i += 1) { 272 | const edge = edges[i]; 273 | const index = start + i; 274 | const parsed = data[index]; 275 | 276 | const properties: any = { 277 | source: nodeElements[parsed.source], 278 | target: nodeElements[parsed.target], 279 | points: Array.isArray(parsed.points) ? parsed.points.map((p) => copyPoint(p)) : [], 280 | }; 281 | properties.points._source = nodeElements[parsed.source]; 282 | if (includeOptions) { 283 | if (sharedOptions !== dummyShared) { 284 | properties.options = sharedOptions; 285 | } else { 286 | properties.options = this.resolveDataElementOptions(index, mode); 287 | } 288 | } 289 | this.updateEdgeElement(edge, index, properties, mode); 290 | } 291 | this.updateSharedOptions(sharedOptions, mode, firstOpts); 292 | 293 | this._edgeSharedOptions = this._sharedOptions; 294 | Object.assign(this, bak); 295 | delete (this as any).getDataset; 296 | delete (this as any).getParsed; 297 | // patch meta to store edges 298 | meta.data = nodeElements; 299 | } 300 | 301 | /** 302 | * @hidden 303 | */ 304 | 305 | updateEdgeElement(edge: EdgeLine, index: number, properties: any, mode: UpdateMode): void { 306 | super.updateElement(edge as unknown as Element, index, properties, mode); 307 | } 308 | 309 | /** 310 | * @hidden 311 | */ 312 | 313 | updateElement(point: Element, index: number, properties: any, mode: UpdateMode): void { 314 | if (mode === 'reset') { 315 | // start in center also in x 316 | const { xScale } = this._cachedMeta; 317 | 318 | properties.x = xScale?.getBasePixel() ?? 0; 319 | } 320 | super.updateElement(point, index, properties, mode); 321 | } 322 | 323 | /** 324 | * @hidden 325 | */ 326 | resolveNodeIndex(nodes: any[], ref: string | number | any): number { 327 | if (typeof ref === 'number') { 328 | // index 329 | return ref; 330 | } 331 | if (typeof ref === 'string') { 332 | // label 333 | const labels = this.chart.data.labels as string[]; 334 | return labels.indexOf(ref); 335 | } 336 | const nIndex = nodes.indexOf(ref); 337 | if (nIndex >= 0) { 338 | // hit 339 | return nIndex; 340 | } 341 | 342 | const data = this.getDataset().data as any[]; 343 | const index = data.indexOf(ref); 344 | if (index >= 0) { 345 | return index; 346 | } 347 | 348 | console.warn('cannot resolve edge ref', ref); 349 | return -1; 350 | } 351 | 352 | /** 353 | * @hidden 354 | */ 355 | buildOrUpdateElements(): void { 356 | const dataset = this.getDataset() as any; 357 | const edges = dataset.edges || []; 358 | 359 | // In order to correctly handle data addition/deletion animation (an thus simulate 360 | // real-time charts), we need to monitor these data modifications and synchronize 361 | // the internal meta data accordingly. 362 | if (this._edges !== edges) { 363 | if (this._edges) { 364 | // This case happens when the user replaced the data array instance. 365 | unlistenArrayEvents(this._edges, this._edgeListener); 366 | } 367 | 368 | if (edges && Object.isExtensible(edges)) { 369 | listenArrayEvents(edges, this._edgeListener); 370 | } 371 | this._edges = edges; 372 | } 373 | super.buildOrUpdateElements(); 374 | } 375 | 376 | /** 377 | * @hidden 378 | */ 379 | draw(): void { 380 | const meta = this._cachedMeta; 381 | const edges = (this._cachedMeta as unknown as IExtendedChartMeta).edges || []; 382 | const elements = (meta.data || []) as unknown[] as PointElement[]; 383 | 384 | const area = this.chart.chartArea; 385 | const ctx = this._ctx; 386 | 387 | if (edges.length > 0) { 388 | clipArea(ctx, area); 389 | edges.forEach((edge) => (edge.draw.call as any)(edge, ctx, area)); 390 | unclipArea(ctx); 391 | } 392 | 393 | elements.forEach((elem) => (elem.draw.call as any)(elem, ctx, area)); 394 | } 395 | 396 | protected _resyncElements(): void { 397 | (ScatterController.prototype as any)._resyncElements.call(this); 398 | 399 | const meta = this._cachedMeta as unknown as IExtendedChartMeta; 400 | const edges = meta._parsedEdges; 401 | const metaEdges = meta.edges || (meta.edges = []); 402 | const numMeta = metaEdges.length; 403 | const numData = edges.length; 404 | 405 | if (numData < numMeta) { 406 | metaEdges.splice(numData, numMeta - numData); 407 | this._scheduleResyncLayout(); 408 | } else if (numData > numMeta) { 409 | this._insertEdgeElements(numMeta, numData - numMeta); 410 | } 411 | } 412 | 413 | getTreeRootIndex(): number { 414 | const ds = this.getDataset() as any; 415 | const nodes = ds.data as any[]; 416 | if (ds.derivedEdges) { 417 | // find the one with no parent 418 | return nodes.findIndex((d) => d.parent == null); 419 | } 420 | // find the one with no edge 421 | const edges = (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges || []; 422 | const nodeIndices = new Set(nodes.map((_, i) => i)); 423 | edges.forEach((edge) => { 424 | nodeIndices.delete(edge.target); 425 | }); 426 | return Array.from(nodeIndices)[0]; 427 | } 428 | 429 | getTreeRoot(): ITreeNode { 430 | const index = this.getTreeRootIndex(); 431 | const p = this.getParsed(index) as ITreeNode; 432 | p.index = index; 433 | return p; 434 | } 435 | 436 | getTreeChildren(node: { index?: number }): ITreeNode[] { 437 | const edges = (this._cachedMeta as unknown as IExtendedChartMeta)._parsedEdges; 438 | const index = node.index ?? 0; 439 | return edges 440 | .filter((d) => d.source === index) 441 | .map((d) => { 442 | const p = this.getParsed(d.target) as ITreeNode; 443 | p.index = d.target; 444 | return p; 445 | }); 446 | } 447 | 448 | /** 449 | * @hidden 450 | */ 451 | _parseDefinedEdge(edge: { source: number; target: number }): ITreeEdge { 452 | const ds = this.getDataset(); 453 | const { data } = ds; 454 | return { 455 | source: this.resolveNodeIndex(data, edge.source), 456 | target: this.resolveNodeIndex(data, edge.target), 457 | points: [], 458 | }; 459 | } 460 | 461 | /** 462 | * @hidden 463 | */ 464 | _parseEdges(): ITreeEdge[] { 465 | const ds = this.getDataset() as any; 466 | const data = ds.data as { parent?: number }[]; 467 | const meta = this._cachedMeta as unknown as IExtendedChartMeta; 468 | if (ds.edges) { 469 | const edges = ds.edges.map((edge: any) => this._parseDefinedEdge(edge)); 470 | meta._parsedEdges = edges; 471 | return edges; 472 | } 473 | 474 | const edges: ITreeEdge[] = []; 475 | meta._parsedEdges = edges as any; 476 | // try to derive edges via parent links 477 | data.forEach((node, i) => { 478 | if (node.parent != null) { 479 | // tree edge 480 | const parent = this.resolveNodeIndex(data, node.parent); 481 | edges.push({ 482 | source: parent, 483 | target: i, 484 | points: [], 485 | }); 486 | } 487 | }); 488 | return edges; 489 | } 490 | 491 | /** 492 | * @hidden 493 | */ 494 | addElements(): void { 495 | super.addElements(); 496 | 497 | const meta = this._cachedMeta as unknown as IExtendedChartMeta; 498 | const edges = this._parseEdges(); 499 | const metaData = new Array(edges.length); 500 | meta.edges = metaData; 501 | 502 | for (let i = 0; i < edges.length; i += 1) { 503 | metaData[i] = new this.edgeElementType(); 504 | } 505 | } 506 | 507 | /** 508 | * @hidden 509 | */ 510 | _resyncEdgeElements(): void { 511 | const meta = this._cachedMeta as unknown as IExtendedChartMeta; 512 | const edges = this._parseEdges(); 513 | const metaData = meta.edges || (meta.edges = []); 514 | 515 | for (let i = 0; i < edges.length; i += 1) { 516 | metaData[i] = metaData[i] || new this.edgeElementType(); 517 | } 518 | if (edges.length < metaData.length) { 519 | metaData.splice(edges.length, metaData.length); 520 | } 521 | } 522 | 523 | /** 524 | * @hidden 525 | */ 526 | _insertElements(start: number, count: number): void { 527 | (ScatterController.prototype as any)._insertElements.call(this, start, count); 528 | if (count > 0) { 529 | this._resyncEdgeElements(); 530 | } 531 | } 532 | 533 | /** 534 | * @hidden 535 | */ 536 | _removeElements(start: number, count: number): void { 537 | (ScatterController.prototype as any)._removeElements.call(this, start, count); 538 | if (count > 0) { 539 | this._resyncEdgeElements(); 540 | } 541 | } 542 | 543 | /** 544 | * @hidden 545 | */ 546 | _insertEdgeElements(start: number, count: number): void { 547 | const elements = []; 548 | for (let i = 0; i < count; i += 1) { 549 | elements.push(new this.edgeElementType()); 550 | } 551 | (this._cachedMeta as unknown as IExtendedChartMeta).edges.splice(start, 0, ...elements); 552 | this.updateEdgeElements(elements, start, 'reset'); 553 | this._scheduleResyncLayout(); 554 | } 555 | 556 | reLayout(): void { 557 | // hook 558 | } 559 | 560 | resetLayout(): void { 561 | // hook 562 | } 563 | 564 | stopLayout(): void { 565 | // hook 566 | } 567 | 568 | /** 569 | * @hidden 570 | */ 571 | _scheduleResyncLayout(): void { 572 | if (this._scheduleResyncLayoutId != null && this._scheduleResyncLayoutId >= 0) { 573 | return; 574 | } 575 | this._scheduleResyncLayoutId = requestAnimationFrame(() => { 576 | this._scheduleResyncLayoutId = -1; 577 | this.resyncLayout(); 578 | }); 579 | } 580 | 581 | resyncLayout(): void { 582 | // hook 583 | } 584 | 585 | static readonly id: string = 'graph'; 586 | 587 | /** 588 | * @hidden 589 | */ 590 | static readonly defaults: any = /* #__PURE__ */ merge({}, [ 591 | ScatterController.defaults, 592 | { 593 | clip: 10, // some space in combination with padding 594 | animations: { 595 | points: { 596 | fn: interpolatePoints, 597 | properties: ['points'], 598 | }, 599 | }, 600 | edgeElementType: EdgeLine.id, 601 | }, 602 | ]); 603 | 604 | /** 605 | * @hidden 606 | */ 607 | static readonly overrides: any = /* #__PURE__ */ merge({}, [ 608 | (ScatterController as any).overrides, 609 | { 610 | layout: { 611 | padding: 10, 612 | }, 613 | scales: { 614 | x: { 615 | display: false, 616 | ticks: { 617 | maxTicksLimit: 2, 618 | precision: 100, 619 | minRotation: 0, 620 | maxRotation: 0, 621 | }, 622 | }, 623 | y: { 624 | display: false, 625 | ticks: { 626 | maxTicksLimit: 2, 627 | precision: 100, 628 | minRotation: 0, 629 | maxRotation: 0, 630 | }, 631 | }, 632 | }, 633 | plugins: { 634 | tooltip: { 635 | callbacks: { 636 | label(item: TooltipItem<'graph'>) { 637 | return item.chart.data?.labels?.[item.dataIndex]; 638 | }, 639 | }, 640 | }, 641 | }, 642 | }, 643 | ]); 644 | } 645 | 646 | export interface IGraphDataPoint { 647 | parent?: number; 648 | } 649 | 650 | export interface IGraphEdgeDataPoint { 651 | source: number | string; 652 | target: number | string; 653 | } 654 | 655 | export interface IGraphChartControllerDatasetOptions 656 | extends ControllerDatasetOptions, 657 | ScriptableAndArrayOptions>, 658 | ScriptableAndArrayOptions>, 659 | ScriptableAndArrayOptions>, 660 | ScriptableAndArrayOptions> { 661 | edges: IGraphEdgeDataPoint[]; 662 | } 663 | 664 | declare module 'chart.js' { 665 | export interface ChartTypeRegistry { 666 | graph: { 667 | chartOptions: CoreChartOptions<'graph'>; 668 | datasetOptions: IGraphChartControllerDatasetOptions; 669 | defaultDataPoint: IGraphDataPoint; 670 | metaExtensions: Record; 671 | parsedDataType: ITreeNode; 672 | scales: keyof CartesianScaleTypeRegistry; 673 | }; 674 | } 675 | } 676 | 677 | export class GraphChart extends Chart< 678 | 'graph', 679 | DATA, 680 | LABEL 681 | > { 682 | static id = GraphController.id; 683 | 684 | constructor(item: ChartItem, config: Omit, 'type'>) { 685 | super(item, patchController('graph', config, GraphController, [EdgeLine, PointElement], LinearScale)); 686 | } 687 | } 688 | -------------------------------------------------------------------------------- /src/controllers/TreeController.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CartesianScaleTypeRegistry, 3 | Chart, 4 | ChartConfiguration, 5 | ChartItem, 6 | CoreChartOptions, 7 | LinearScale, 8 | PointElement, 9 | } from 'chart.js'; 10 | import { merge } from 'chart.js/helpers'; 11 | import { EdgeLine } from '../elements'; 12 | import { DendrogramController, IDendrogramChartControllerDatasetOptions, ITreeOptions } from './DendrogramController'; 13 | import type { IGraphDataPoint, ITreeNode } from './GraphController'; 14 | import patchController from './patchController'; 15 | 16 | export class TreeController extends DendrogramController { 17 | static readonly id = 'tree'; 18 | 19 | /** 20 | * @hidden 21 | */ 22 | static readonly defaults: any = /* #__PURE__ */ merge({}, [ 23 | DendrogramController.defaults, 24 | { 25 | tree: { 26 | mode: 'tree', 27 | }, 28 | }, 29 | ]); 30 | 31 | /** 32 | * @hidden 33 | */ 34 | static readonly overrides: any = /* #__PURE__ */ DendrogramController.overrides; 35 | } 36 | 37 | declare module 'chart.js' { 38 | export interface ChartTypeRegistry { 39 | tree: { 40 | chartOptions: CoreChartOptions<'tree'> & { tree: ITreeOptions }; 41 | datasetOptions: IDendrogramChartControllerDatasetOptions; 42 | defaultDataPoint: IGraphDataPoint & Record; 43 | metaExtensions: Record; 44 | parsedDataType: ITreeNode; 45 | scales: keyof CartesianScaleTypeRegistry; 46 | }; 47 | } 48 | } 49 | 50 | export class TreeChart extends Chart<'tree', DATA, LABEL> { 51 | static id = TreeController.id; 52 | 53 | constructor(item: ChartItem, config: Omit, 'type'>) { 54 | super(item, patchController('tree', config, TreeController, [EdgeLine, PointElement], LinearScale)); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendogram-vertical-tree-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendogram-vertical-tree-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-default-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-default-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-default-tree-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-default-tree-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-directed-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-directed-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-radial-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-radial-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-radial-tree-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-radial-tree-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-vertical-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-vertical-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-vertical-tree-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/dendrogram-controller-spec-ts-dendrogram-vertical-tree-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__image_snapshots__/force-directed-spec-ts-dendrogram-default-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sgratzl/chartjs-chart-graph/61eb7f70f4e5eabd9b30f96dff918ba08006229c/src/controllers/__image_snapshots__/force-directed-spec-ts-dendrogram-default-1-snap.png -------------------------------------------------------------------------------- /src/controllers/__tests__/miserables.ts: -------------------------------------------------------------------------------- 1 | const data = { 2 | nodes: [ 3 | { id: 'Myriel', group: 1 }, 4 | { id: 'Napoleon', group: 1 }, 5 | { id: 'Mlle.Baptistine', group: 1 }, 6 | { id: 'Mme.Magloire', group: 1 }, 7 | { id: 'CountessdeLo', group: 1 }, 8 | { id: 'Geborand', group: 1 }, 9 | { id: 'Champtercier', group: 1 }, 10 | { id: 'Cravatte', group: 1 }, 11 | { id: 'Count', group: 1 }, 12 | { id: 'OldMan', group: 1 }, 13 | { id: 'Labarre', group: 2 }, 14 | { id: 'Valjean', group: 2 }, 15 | { id: 'Marguerite', group: 3 }, 16 | { id: 'Mme.deR', group: 2 }, 17 | { id: 'Isabeau', group: 2 }, 18 | { id: 'Gervais', group: 2 }, 19 | { id: 'Tholomyes', group: 3 }, 20 | { id: 'Listolier', group: 3 }, 21 | { id: 'Fameuil', group: 3 }, 22 | { id: 'Blacheville', group: 3 }, 23 | { id: 'Favourite', group: 3 }, 24 | { id: 'Dahlia', group: 3 }, 25 | { id: 'Zephine', group: 3 }, 26 | { id: 'Fantine', group: 3 }, 27 | { id: 'Mme.Thenardier', group: 4 }, 28 | { id: 'Thenardier', group: 4 }, 29 | { id: 'Cosette', group: 5 }, 30 | { id: 'Javert', group: 4 }, 31 | { id: 'Fauchelevent', group: 0 }, 32 | { id: 'Bamatabois', group: 2 }, 33 | { id: 'Perpetue', group: 3 }, 34 | { id: 'Simplice', group: 2 }, 35 | { id: 'Scaufflaire', group: 2 }, 36 | { id: 'Woman1', group: 2 }, 37 | { id: 'Judge', group: 2 }, 38 | { id: 'Champmathieu', group: 2 }, 39 | { id: 'Brevet', group: 2 }, 40 | { id: 'Chenildieu', group: 2 }, 41 | { id: 'Cochepaille', group: 2 }, 42 | { id: 'Pontmercy', group: 4 }, 43 | { id: 'Boulatruelle', group: 6 }, 44 | { id: 'Eponine', group: 4 }, 45 | { id: 'Anzelma', group: 4 }, 46 | { id: 'Woman2', group: 5 }, 47 | { id: 'MotherInnocent', group: 0 }, 48 | { id: 'Gribier', group: 0 }, 49 | { id: 'Jondrette', group: 7 }, 50 | { id: 'Mme.Burgon', group: 7 }, 51 | { id: 'Gavroche', group: 8 }, 52 | { id: 'Gillenormand', group: 5 }, 53 | { id: 'Magnon', group: 5 }, 54 | { id: 'Mlle.Gillenormand', group: 5 }, 55 | { id: 'Mme.Pontmercy', group: 5 }, 56 | { id: 'Mlle.Vaubois', group: 5 }, 57 | { id: 'Lt.Gillenormand', group: 5 }, 58 | { id: 'Marius', group: 8 }, 59 | { id: 'BaronessT', group: 5 }, 60 | { id: 'Mabeuf', group: 8 }, 61 | { id: 'Enjolras', group: 8 }, 62 | { id: 'Combeferre', group: 8 }, 63 | { id: 'Prouvaire', group: 8 }, 64 | { id: 'Feuilly', group: 8 }, 65 | { id: 'Courfeyrac', group: 8 }, 66 | { id: 'Bahorel', group: 8 }, 67 | { id: 'Bossuet', group: 8 }, 68 | { id: 'Joly', group: 8 }, 69 | { id: 'Grantaire', group: 8 }, 70 | { id: 'MotherPlutarch', group: 9 }, 71 | { id: 'Gueulemer', group: 4 }, 72 | { id: 'Babet', group: 4 }, 73 | { id: 'Claquesous', group: 4 }, 74 | { id: 'Montparnasse', group: 4 }, 75 | { id: 'Toussaint', group: 5 }, 76 | { id: 'Child1', group: 10 }, 77 | { id: 'Child2', group: 10 }, 78 | { id: 'Brujon', group: 4 }, 79 | { id: 'Mme.Hucheloup', group: 8 }, 80 | ], 81 | links: [ 82 | { source: 'Napoleon', target: 'Myriel', value: 1 }, 83 | { source: 'Mlle.Baptistine', target: 'Myriel', value: 8 }, 84 | { source: 'Mme.Magloire', target: 'Myriel', value: 10 }, 85 | { source: 'Mme.Magloire', target: 'Mlle.Baptistine', value: 6 }, 86 | { source: 'CountessdeLo', target: 'Myriel', value: 1 }, 87 | { source: 'Geborand', target: 'Myriel', value: 1 }, 88 | { source: 'Champtercier', target: 'Myriel', value: 1 }, 89 | { source: 'Cravatte', target: 'Myriel', value: 1 }, 90 | { source: 'Count', target: 'Myriel', value: 2 }, 91 | { source: 'OldMan', target: 'Myriel', value: 1 }, 92 | { source: 'Valjean', target: 'Labarre', value: 1 }, 93 | { source: 'Valjean', target: 'Mme.Magloire', value: 3 }, 94 | { source: 'Valjean', target: 'Mlle.Baptistine', value: 3 }, 95 | { source: 'Valjean', target: 'Myriel', value: 5 }, 96 | { source: 'Marguerite', target: 'Valjean', value: 1 }, 97 | { source: 'Mme.deR', target: 'Valjean', value: 1 }, 98 | { source: 'Isabeau', target: 'Valjean', value: 1 }, 99 | { source: 'Gervais', target: 'Valjean', value: 1 }, 100 | { source: 'Listolier', target: 'Tholomyes', value: 4 }, 101 | { source: 'Fameuil', target: 'Tholomyes', value: 4 }, 102 | { source: 'Fameuil', target: 'Listolier', value: 4 }, 103 | { source: 'Blacheville', target: 'Tholomyes', value: 4 }, 104 | { source: 'Blacheville', target: 'Listolier', value: 4 }, 105 | { source: 'Blacheville', target: 'Fameuil', value: 4 }, 106 | { source: 'Favourite', target: 'Tholomyes', value: 3 }, 107 | { source: 'Favourite', target: 'Listolier', value: 3 }, 108 | { source: 'Favourite', target: 'Fameuil', value: 3 }, 109 | { source: 'Favourite', target: 'Blacheville', value: 4 }, 110 | { source: 'Dahlia', target: 'Tholomyes', value: 3 }, 111 | { source: 'Dahlia', target: 'Listolier', value: 3 }, 112 | { source: 'Dahlia', target: 'Fameuil', value: 3 }, 113 | { source: 'Dahlia', target: 'Blacheville', value: 3 }, 114 | { source: 'Dahlia', target: 'Favourite', value: 5 }, 115 | { source: 'Zephine', target: 'Tholomyes', value: 3 }, 116 | { source: 'Zephine', target: 'Listolier', value: 3 }, 117 | { source: 'Zephine', target: 'Fameuil', value: 3 }, 118 | { source: 'Zephine', target: 'Blacheville', value: 3 }, 119 | { source: 'Zephine', target: 'Favourite', value: 4 }, 120 | { source: 'Zephine', target: 'Dahlia', value: 4 }, 121 | { source: 'Fantine', target: 'Tholomyes', value: 3 }, 122 | { source: 'Fantine', target: 'Listolier', value: 3 }, 123 | { source: 'Fantine', target: 'Fameuil', value: 3 }, 124 | { source: 'Fantine', target: 'Blacheville', value: 3 }, 125 | { source: 'Fantine', target: 'Favourite', value: 4 }, 126 | { source: 'Fantine', target: 'Dahlia', value: 4 }, 127 | { source: 'Fantine', target: 'Zephine', value: 4 }, 128 | { source: 'Fantine', target: 'Marguerite', value: 2 }, 129 | { source: 'Fantine', target: 'Valjean', value: 9 }, 130 | { source: 'Mme.Thenardier', target: 'Fantine', value: 2 }, 131 | { source: 'Mme.Thenardier', target: 'Valjean', value: 7 }, 132 | { source: 'Thenardier', target: 'Mme.Thenardier', value: 13 }, 133 | { source: 'Thenardier', target: 'Fantine', value: 1 }, 134 | { source: 'Thenardier', target: 'Valjean', value: 12 }, 135 | { source: 'Cosette', target: 'Mme.Thenardier', value: 4 }, 136 | { source: 'Cosette', target: 'Valjean', value: 31 }, 137 | { source: 'Cosette', target: 'Tholomyes', value: 1 }, 138 | { source: 'Cosette', target: 'Thenardier', value: 1 }, 139 | { source: 'Javert', target: 'Valjean', value: 17 }, 140 | { source: 'Javert', target: 'Fantine', value: 5 }, 141 | { source: 'Javert', target: 'Thenardier', value: 5 }, 142 | { source: 'Javert', target: 'Mme.Thenardier', value: 1 }, 143 | { source: 'Javert', target: 'Cosette', value: 1 }, 144 | { source: 'Fauchelevent', target: 'Valjean', value: 8 }, 145 | { source: 'Fauchelevent', target: 'Javert', value: 1 }, 146 | { source: 'Bamatabois', target: 'Fantine', value: 1 }, 147 | { source: 'Bamatabois', target: 'Javert', value: 1 }, 148 | { source: 'Bamatabois', target: 'Valjean', value: 2 }, 149 | { source: 'Perpetue', target: 'Fantine', value: 1 }, 150 | { source: 'Simplice', target: 'Perpetue', value: 2 }, 151 | { source: 'Simplice', target: 'Valjean', value: 3 }, 152 | { source: 'Simplice', target: 'Fantine', value: 2 }, 153 | { source: 'Simplice', target: 'Javert', value: 1 }, 154 | { source: 'Scaufflaire', target: 'Valjean', value: 1 }, 155 | { source: 'Woman1', target: 'Valjean', value: 2 }, 156 | { source: 'Woman1', target: 'Javert', value: 1 }, 157 | { source: 'Judge', target: 'Valjean', value: 3 }, 158 | { source: 'Judge', target: 'Bamatabois', value: 2 }, 159 | { source: 'Champmathieu', target: 'Valjean', value: 3 }, 160 | { source: 'Champmathieu', target: 'Judge', value: 3 }, 161 | { source: 'Champmathieu', target: 'Bamatabois', value: 2 }, 162 | { source: 'Brevet', target: 'Judge', value: 2 }, 163 | { source: 'Brevet', target: 'Champmathieu', value: 2 }, 164 | { source: 'Brevet', target: 'Valjean', value: 2 }, 165 | { source: 'Brevet', target: 'Bamatabois', value: 1 }, 166 | { source: 'Chenildieu', target: 'Judge', value: 2 }, 167 | { source: 'Chenildieu', target: 'Champmathieu', value: 2 }, 168 | { source: 'Chenildieu', target: 'Brevet', value: 2 }, 169 | { source: 'Chenildieu', target: 'Valjean', value: 2 }, 170 | { source: 'Chenildieu', target: 'Bamatabois', value: 1 }, 171 | { source: 'Cochepaille', target: 'Judge', value: 2 }, 172 | { source: 'Cochepaille', target: 'Champmathieu', value: 2 }, 173 | { source: 'Cochepaille', target: 'Brevet', value: 2 }, 174 | { source: 'Cochepaille', target: 'Chenildieu', value: 2 }, 175 | { source: 'Cochepaille', target: 'Valjean', value: 2 }, 176 | { source: 'Cochepaille', target: 'Bamatabois', value: 1 }, 177 | { source: 'Pontmercy', target: 'Thenardier', value: 1 }, 178 | { source: 'Boulatruelle', target: 'Thenardier', value: 1 }, 179 | { source: 'Eponine', target: 'Mme.Thenardier', value: 2 }, 180 | { source: 'Eponine', target: 'Thenardier', value: 3 }, 181 | { source: 'Anzelma', target: 'Eponine', value: 2 }, 182 | { source: 'Anzelma', target: 'Thenardier', value: 2 }, 183 | { source: 'Anzelma', target: 'Mme.Thenardier', value: 1 }, 184 | { source: 'Woman2', target: 'Valjean', value: 3 }, 185 | { source: 'Woman2', target: 'Cosette', value: 1 }, 186 | { source: 'Woman2', target: 'Javert', value: 1 }, 187 | { source: 'MotherInnocent', target: 'Fauchelevent', value: 3 }, 188 | { source: 'MotherInnocent', target: 'Valjean', value: 1 }, 189 | { source: 'Gribier', target: 'Fauchelevent', value: 2 }, 190 | { source: 'Mme.Burgon', target: 'Jondrette', value: 1 }, 191 | { source: 'Gavroche', target: 'Mme.Burgon', value: 2 }, 192 | { source: 'Gavroche', target: 'Thenardier', value: 1 }, 193 | { source: 'Gavroche', target: 'Javert', value: 1 }, 194 | { source: 'Gavroche', target: 'Valjean', value: 1 }, 195 | { source: 'Gillenormand', target: 'Cosette', value: 3 }, 196 | { source: 'Gillenormand', target: 'Valjean', value: 2 }, 197 | { source: 'Magnon', target: 'Gillenormand', value: 1 }, 198 | { source: 'Magnon', target: 'Mme.Thenardier', value: 1 }, 199 | { source: 'Mlle.Gillenormand', target: 'Gillenormand', value: 9 }, 200 | { source: 'Mlle.Gillenormand', target: 'Cosette', value: 2 }, 201 | { source: 'Mlle.Gillenormand', target: 'Valjean', value: 2 }, 202 | { source: 'Mme.Pontmercy', target: 'Mlle.Gillenormand', value: 1 }, 203 | { source: 'Mme.Pontmercy', target: 'Pontmercy', value: 1 }, 204 | { source: 'Mlle.Vaubois', target: 'Mlle.Gillenormand', value: 1 }, 205 | { source: 'Lt.Gillenormand', target: 'Mlle.Gillenormand', value: 2 }, 206 | { source: 'Lt.Gillenormand', target: 'Gillenormand', value: 1 }, 207 | { source: 'Lt.Gillenormand', target: 'Cosette', value: 1 }, 208 | { source: 'Marius', target: 'Mlle.Gillenormand', value: 6 }, 209 | { source: 'Marius', target: 'Gillenormand', value: 12 }, 210 | { source: 'Marius', target: 'Pontmercy', value: 1 }, 211 | { source: 'Marius', target: 'Lt.Gillenormand', value: 1 }, 212 | { source: 'Marius', target: 'Cosette', value: 21 }, 213 | { source: 'Marius', target: 'Valjean', value: 19 }, 214 | { source: 'Marius', target: 'Tholomyes', value: 1 }, 215 | { source: 'Marius', target: 'Thenardier', value: 2 }, 216 | { source: 'Marius', target: 'Eponine', value: 5 }, 217 | { source: 'Marius', target: 'Gavroche', value: 4 }, 218 | { source: 'BaronessT', target: 'Gillenormand', value: 1 }, 219 | { source: 'BaronessT', target: 'Marius', value: 1 }, 220 | { source: 'Mabeuf', target: 'Marius', value: 1 }, 221 | { source: 'Mabeuf', target: 'Eponine', value: 1 }, 222 | { source: 'Mabeuf', target: 'Gavroche', value: 1 }, 223 | { source: 'Enjolras', target: 'Marius', value: 7 }, 224 | { source: 'Enjolras', target: 'Gavroche', value: 7 }, 225 | { source: 'Enjolras', target: 'Javert', value: 6 }, 226 | { source: 'Enjolras', target: 'Mabeuf', value: 1 }, 227 | { source: 'Enjolras', target: 'Valjean', value: 4 }, 228 | { source: 'Combeferre', target: 'Enjolras', value: 15 }, 229 | { source: 'Combeferre', target: 'Marius', value: 5 }, 230 | { source: 'Combeferre', target: 'Gavroche', value: 6 }, 231 | { source: 'Combeferre', target: 'Mabeuf', value: 2 }, 232 | { source: 'Prouvaire', target: 'Gavroche', value: 1 }, 233 | { source: 'Prouvaire', target: 'Enjolras', value: 4 }, 234 | { source: 'Prouvaire', target: 'Combeferre', value: 2 }, 235 | { source: 'Feuilly', target: 'Gavroche', value: 2 }, 236 | { source: 'Feuilly', target: 'Enjolras', value: 6 }, 237 | { source: 'Feuilly', target: 'Prouvaire', value: 2 }, 238 | { source: 'Feuilly', target: 'Combeferre', value: 5 }, 239 | { source: 'Feuilly', target: 'Mabeuf', value: 1 }, 240 | { source: 'Feuilly', target: 'Marius', value: 1 }, 241 | { source: 'Courfeyrac', target: 'Marius', value: 9 }, 242 | { source: 'Courfeyrac', target: 'Enjolras', value: 17 }, 243 | { source: 'Courfeyrac', target: 'Combeferre', value: 13 }, 244 | { source: 'Courfeyrac', target: 'Gavroche', value: 7 }, 245 | { source: 'Courfeyrac', target: 'Mabeuf', value: 2 }, 246 | { source: 'Courfeyrac', target: 'Eponine', value: 1 }, 247 | { source: 'Courfeyrac', target: 'Feuilly', value: 6 }, 248 | { source: 'Courfeyrac', target: 'Prouvaire', value: 3 }, 249 | { source: 'Bahorel', target: 'Combeferre', value: 5 }, 250 | { source: 'Bahorel', target: 'Gavroche', value: 5 }, 251 | { source: 'Bahorel', target: 'Courfeyrac', value: 6 }, 252 | { source: 'Bahorel', target: 'Mabeuf', value: 2 }, 253 | { source: 'Bahorel', target: 'Enjolras', value: 4 }, 254 | { source: 'Bahorel', target: 'Feuilly', value: 3 }, 255 | { source: 'Bahorel', target: 'Prouvaire', value: 2 }, 256 | { source: 'Bahorel', target: 'Marius', value: 1 }, 257 | { source: 'Bossuet', target: 'Marius', value: 5 }, 258 | { source: 'Bossuet', target: 'Courfeyrac', value: 12 }, 259 | { source: 'Bossuet', target: 'Gavroche', value: 5 }, 260 | { source: 'Bossuet', target: 'Bahorel', value: 4 }, 261 | { source: 'Bossuet', target: 'Enjolras', value: 10 }, 262 | { source: 'Bossuet', target: 'Feuilly', value: 6 }, 263 | { source: 'Bossuet', target: 'Prouvaire', value: 2 }, 264 | { source: 'Bossuet', target: 'Combeferre', value: 9 }, 265 | { source: 'Bossuet', target: 'Mabeuf', value: 1 }, 266 | { source: 'Bossuet', target: 'Valjean', value: 1 }, 267 | { source: 'Joly', target: 'Bahorel', value: 5 }, 268 | { source: 'Joly', target: 'Bossuet', value: 7 }, 269 | { source: 'Joly', target: 'Gavroche', value: 3 }, 270 | { source: 'Joly', target: 'Courfeyrac', value: 5 }, 271 | { source: 'Joly', target: 'Enjolras', value: 5 }, 272 | { source: 'Joly', target: 'Feuilly', value: 5 }, 273 | { source: 'Joly', target: 'Prouvaire', value: 2 }, 274 | { source: 'Joly', target: 'Combeferre', value: 5 }, 275 | { source: 'Joly', target: 'Mabeuf', value: 1 }, 276 | { source: 'Joly', target: 'Marius', value: 2 }, 277 | { source: 'Grantaire', target: 'Bossuet', value: 3 }, 278 | { source: 'Grantaire', target: 'Enjolras', value: 3 }, 279 | { source: 'Grantaire', target: 'Combeferre', value: 1 }, 280 | { source: 'Grantaire', target: 'Courfeyrac', value: 2 }, 281 | { source: 'Grantaire', target: 'Joly', value: 2 }, 282 | { source: 'Grantaire', target: 'Gavroche', value: 1 }, 283 | { source: 'Grantaire', target: 'Bahorel', value: 1 }, 284 | { source: 'Grantaire', target: 'Feuilly', value: 1 }, 285 | { source: 'Grantaire', target: 'Prouvaire', value: 1 }, 286 | { source: 'MotherPlutarch', target: 'Mabeuf', value: 3 }, 287 | { source: 'Gueulemer', target: 'Thenardier', value: 5 }, 288 | { source: 'Gueulemer', target: 'Valjean', value: 1 }, 289 | { source: 'Gueulemer', target: 'Mme.Thenardier', value: 1 }, 290 | { source: 'Gueulemer', target: 'Javert', value: 1 }, 291 | { source: 'Gueulemer', target: 'Gavroche', value: 1 }, 292 | { source: 'Gueulemer', target: 'Eponine', value: 1 }, 293 | { source: 'Babet', target: 'Thenardier', value: 6 }, 294 | { source: 'Babet', target: 'Gueulemer', value: 6 }, 295 | { source: 'Babet', target: 'Valjean', value: 1 }, 296 | { source: 'Babet', target: 'Mme.Thenardier', value: 1 }, 297 | { source: 'Babet', target: 'Javert', value: 2 }, 298 | { source: 'Babet', target: 'Gavroche', value: 1 }, 299 | { source: 'Babet', target: 'Eponine', value: 1 }, 300 | { source: 'Claquesous', target: 'Thenardier', value: 4 }, 301 | { source: 'Claquesous', target: 'Babet', value: 4 }, 302 | { source: 'Claquesous', target: 'Gueulemer', value: 4 }, 303 | { source: 'Claquesous', target: 'Valjean', value: 1 }, 304 | { source: 'Claquesous', target: 'Mme.Thenardier', value: 1 }, 305 | { source: 'Claquesous', target: 'Javert', value: 1 }, 306 | { source: 'Claquesous', target: 'Eponine', value: 1 }, 307 | { source: 'Claquesous', target: 'Enjolras', value: 1 }, 308 | { source: 'Montparnasse', target: 'Javert', value: 1 }, 309 | { source: 'Montparnasse', target: 'Babet', value: 2 }, 310 | { source: 'Montparnasse', target: 'Gueulemer', value: 2 }, 311 | { source: 'Montparnasse', target: 'Claquesous', value: 2 }, 312 | { source: 'Montparnasse', target: 'Valjean', value: 1 }, 313 | { source: 'Montparnasse', target: 'Gavroche', value: 1 }, 314 | { source: 'Montparnasse', target: 'Eponine', value: 1 }, 315 | { source: 'Montparnasse', target: 'Thenardier', value: 1 }, 316 | { source: 'Toussaint', target: 'Cosette', value: 2 }, 317 | { source: 'Toussaint', target: 'Javert', value: 1 }, 318 | { source: 'Toussaint', target: 'Valjean', value: 1 }, 319 | { source: 'Child1', target: 'Gavroche', value: 2 }, 320 | { source: 'Child2', target: 'Gavroche', value: 2 }, 321 | { source: 'Child2', target: 'Child1', value: 3 }, 322 | { source: 'Brujon', target: 'Babet', value: 3 }, 323 | { source: 'Brujon', target: 'Gueulemer', value: 3 }, 324 | { source: 'Brujon', target: 'Thenardier', value: 3 }, 325 | { source: 'Brujon', target: 'Gavroche', value: 1 }, 326 | { source: 'Brujon', target: 'Eponine', value: 1 }, 327 | { source: 'Brujon', target: 'Claquesous', value: 1 }, 328 | { source: 'Brujon', target: 'Montparnasse', value: 1 }, 329 | { source: 'Mme.Hucheloup', target: 'Bossuet', value: 1 }, 330 | { source: 'Mme.Hucheloup', target: 'Joly', value: 1 }, 331 | { source: 'Mme.Hucheloup', target: 'Grantaire', value: 1 }, 332 | { source: 'Mme.Hucheloup', target: 'Bahorel', value: 1 }, 333 | { source: 'Mme.Hucheloup', target: 'Courfeyrac', value: 1 }, 334 | { source: 'Mme.Hucheloup', target: 'Gavroche', value: 1 }, 335 | { source: 'Mme.Hucheloup', target: 'Enjolras', value: 1 }, 336 | ], 337 | }; 338 | 339 | export default data; 340 | -------------------------------------------------------------------------------- /src/controllers/__tests__/tree.ts: -------------------------------------------------------------------------------- 1 | const data = [ 2 | { name: '1' }, 3 | { name: '11', parent: 0 }, 4 | { name: '111', parent: 1 }, 5 | { name: '1111', parent: 2 }, 6 | { name: '1112', parent: 2 }, 7 | { name: '112', parent: 1 }, 8 | { name: '1121', parent: 5 }, 9 | { name: '1122', parent: 5 }, 10 | { name: '113', parent: 1 }, 11 | { name: '1131', parent: 8 }, 12 | { name: '1132', parent: 8 }, 13 | { name: '12', parent: 0 }, 14 | { name: '121', parent: 11 }, 15 | { name: '1211', parent: 12 }, 16 | { name: '1212', parent: 12 }, 17 | { name: '122', parent: 11 }, 18 | { name: '1221', parent: 15 }, 19 | { name: '1222', parent: 15 }, 20 | { name: '123', parent: 11 }, 21 | { name: '1231', parent: 18 }, 22 | { name: '1232', parent: 18 }, 23 | { name: '13', parent: 0 }, 24 | { name: '131', parent: 21 }, 25 | ]; 26 | 27 | export default data; 28 | -------------------------------------------------------------------------------- /src/controllers/forceDirected.spec.ts: -------------------------------------------------------------------------------- 1 | import { registry, PointElement, LinearScale } from 'chart.js'; 2 | import createChart from '../__tests__/createChart'; 3 | import { ForceDirectedGraphController } from './ForceDirectedGraphController'; 4 | import data from './__tests__/miserables'; 5 | import { EdgeLine } from '../elements'; 6 | import { describe, beforeAll, test } from 'vitest'; 7 | describe('dendrogram', () => { 8 | beforeAll(() => { 9 | registry.addControllers(ForceDirectedGraphController); 10 | registry.addElements(EdgeLine, PointElement); 11 | registry.addScales(LinearScale); 12 | }); 13 | test('default', () => { 14 | return createChart({ 15 | type: ForceDirectedGraphController.id, 16 | data: { 17 | labels: data.nodes.map((d) => d.id), 18 | datasets: [ 19 | { 20 | simulation: { 21 | initialIterations: 100, 22 | autoRestart: false, 23 | }, 24 | pointBackgroundColor: 'steelblue', 25 | pointRadius: 5, 26 | data: data.nodes, 27 | edges: data.links as any, 28 | }, 29 | ], 30 | }, 31 | }).toMatchImageSnapshot(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './GraphController'; 2 | export * from './ForceDirectedGraphController'; 3 | export * from './DendrogramController'; 4 | export * from './TreeController'; 5 | -------------------------------------------------------------------------------- /src/controllers/interpolatePoints.ts: -------------------------------------------------------------------------------- 1 | function interpolateNumber(from: number, to: number, factor: number) { 2 | if (from === to) { 3 | return to; 4 | } 5 | return from + (to - from) * factor; 6 | } 7 | 8 | function interpolatorPoint(fromArray: any, i: number, to: { x: number; y: number; angle: number }, factor: number) { 9 | const from = fromArray[i] || fromArray[i - 1] || fromArray._source; 10 | if (!from) { 11 | return to; 12 | } 13 | const x = interpolateNumber(from.x, to.x, factor); 14 | const y = interpolateNumber(from.y, to.y, factor); 15 | const angle = Number.isNaN(from.angle) ? interpolateNumber(from.angle, to.angle, factor) : undefined; 16 | return { x, y, angle }; 17 | } 18 | 19 | export default function interpolatePoints( 20 | from: { x: number; y: number; angle: number }[], 21 | to: { x: number; y: number; angle: number }[], 22 | factor: number 23 | ): { x: number; y: number; angle?: number }[] { 24 | if (Array.isArray(from) && Array.isArray(to) && to.length > 0) { 25 | return to.map((t, i) => interpolatorPoint(from, i, t, factor)); 26 | } 27 | return to; 28 | } 29 | -------------------------------------------------------------------------------- /src/controllers/patchController.ts: -------------------------------------------------------------------------------- 1 | import { registry, DatasetControllerChartComponent, ChartComponent } from 'chart.js'; 2 | 3 | export default function patchController( 4 | type: TYPE, 5 | config: T, 6 | controller: DatasetControllerChartComponent, 7 | elements: ChartComponent | ChartComponent[] = [], 8 | scales: ChartComponent | ChartComponent[] = [] 9 | ): T & { type: TYPE } { 10 | registry.addControllers(controller); 11 | if (Array.isArray(elements)) { 12 | registry.addElements(...elements); 13 | } else { 14 | registry.addElements(elements); 15 | } 16 | if (Array.isArray(scales)) { 17 | registry.addScales(...scales); 18 | } else { 19 | registry.addScales(scales); 20 | } 21 | const c = config as any; 22 | c.type = type; 23 | return c; 24 | } 25 | -------------------------------------------------------------------------------- /src/elements/EdgeLine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChartType, 3 | LineElement, 4 | LineOptions, 5 | PointElement, 6 | ScriptableAndArrayOptions, 7 | ScriptableContext, 8 | } from 'chart.js'; 9 | 10 | function horizontal(from: { x: number }, to: { x: number }, options: { tension: number }) { 11 | return { 12 | fx: (to.x - from.x) * options.tension, 13 | fy: 0, 14 | tx: (from.x - to.x) * options.tension, 15 | ty: 0, 16 | }; 17 | } 18 | 19 | function vertical(from: { y: number }, to: { y: number }, options: { tension: number }) { 20 | return { 21 | fx: 0, 22 | fy: (to.y - from.y) * options.tension, 23 | tx: 0, 24 | ty: (from.y - to.y) * options.tension, 25 | }; 26 | } 27 | 28 | function radial( 29 | from: { x: number; angle?: number; y: number }, 30 | to: { x: number; angle?: number; y: number }, 31 | options: { tension: number } 32 | ) { 33 | const angleHelper = Math.hypot(to.x - from.x, to.y - from.y) * options.tension; 34 | return { 35 | fx: Number.isNaN(from.angle) ? 0 : Math.cos(from.angle || 0) * angleHelper, 36 | fy: Number.isNaN(from.angle) ? 0 : Math.sin(from.angle || 0) * -angleHelper, 37 | tx: Number.isNaN(to.angle) ? 0 : Math.cos(to.angle || 0) * -angleHelper, 38 | ty: Number.isNaN(to.angle) ? 0 : Math.sin(to.angle || 0) * angleHelper, 39 | }; 40 | } 41 | 42 | export interface IEdgeLineOptions extends LineOptions { 43 | directed: boolean; 44 | arrowHeadSize: number; 45 | arrowHeadOffset: number; 46 | } 47 | 48 | export interface IEdgeLineProps extends LineOptions { 49 | points: { x: number; y: number }[]; 50 | } 51 | 52 | export class EdgeLine extends LineElement { 53 | /** 54 | * @hidden 55 | */ 56 | declare _orientation: 'vertical' | 'radial' | 'horizontal'; 57 | 58 | /** 59 | * @hidden 60 | */ 61 | declare source: PointElement; 62 | 63 | /** 64 | * @hidden 65 | */ 66 | declare target: PointElement; 67 | 68 | /** 69 | * @hidden 70 | */ 71 | declare options: IEdgeLineOptions; 72 | 73 | /** 74 | * @hidden 75 | */ 76 | draw(ctx: CanvasRenderingContext2D): void { 77 | const { options } = this; 78 | 79 | ctx.save(); 80 | 81 | // Stroke Line Options 82 | ctx.lineCap = options.borderCapStyle; 83 | ctx.setLineDash(options.borderDash || []); 84 | ctx.lineDashOffset = options.borderDashOffset; 85 | ctx.lineJoin = options.borderJoinStyle; 86 | ctx.lineWidth = options.borderWidth; 87 | ctx.strokeStyle = options.borderColor; 88 | 89 | const orientations = { 90 | horizontal, 91 | vertical, 92 | radial, 93 | }; 94 | const layout = orientations[this._orientation] || orientations.horizontal; 95 | 96 | const renderLine = ( 97 | from: { x: number; y: number; angle?: number }, 98 | to: { x: number; y: number; angle?: number } 99 | ) => { 100 | const shift = layout(from, to, options); 101 | 102 | const fromX = { 103 | cpx: from.x + shift.fx, 104 | cpy: from.y + shift.fy, 105 | }; 106 | const toX = { 107 | cpx: to.x + shift.tx, 108 | cpy: to.y + shift.ty, 109 | }; 110 | 111 | // Line to next point 112 | if (options.stepped === 'middle') { 113 | const midpoint = (from.x + to.x) / 2.0; 114 | ctx.lineTo(midpoint, from.y); 115 | ctx.lineTo(midpoint, to.y); 116 | ctx.lineTo(to.x, to.y); 117 | } else if (options.stepped === 'after') { 118 | ctx.lineTo(from.x, to.y); 119 | ctx.lineTo(to.x, to.y); 120 | } else if (options.stepped) { 121 | ctx.lineTo(to.x, from.y); 122 | ctx.lineTo(to.x, to.y); 123 | } else if (options.tension) { 124 | ctx.bezierCurveTo(fromX.cpx, fromX.cpy, toX.cpx, toX.cpy, to.x, to.y); 125 | } else { 126 | ctx.lineTo(to.x, to.y); 127 | } 128 | return to; 129 | }; 130 | 131 | const source = this.source.getProps(['x', 'y', 'angle']) as { x: number; y: number; angle?: number }; 132 | const target = this.target.getProps(['x', 'y', 'angle']) as { x: number; y: number; angle?: number }; 133 | const points = (this.getProps(['points'] as any) as any).points as { 134 | x: number; 135 | y: number; 136 | angle: number; 137 | }[]; 138 | 139 | // Stroke Line 140 | ctx.beginPath(); 141 | 142 | let from = source; 143 | ctx.moveTo(from.x, from.y); 144 | if (points && points.length > 0) { 145 | from = points.reduce(renderLine, from); 146 | } 147 | renderLine(from, target); 148 | 149 | ctx.stroke(); 150 | 151 | if (options.directed) { 152 | const to = target; 153 | // compute the rotation based on from and to 154 | const shift = layout(from, to, options); 155 | const s = options.arrowHeadSize; 156 | const offset = options.arrowHeadOffset; 157 | ctx.save(); 158 | ctx.translate(to.x, target.y); 159 | if (options.stepped === 'middle') { 160 | const midpoint = (from.x + to.x) / 2.0; 161 | ctx.rotate(Math.atan2(to.y - to.y, to.x - midpoint)); 162 | } else if (options.stepped === 'after') { 163 | ctx.rotate(Math.atan2(to.y - to.y, to.x - from.x)); 164 | } else if (options.stepped) { 165 | ctx.rotate(Math.atan2(to.y - from.y, to.x - to.x)); 166 | } else if (options.tension) { 167 | const toX = { 168 | x: to.x + shift.tx, 169 | y: to.y + shift.ty, 170 | }; 171 | const f = 0.1; 172 | ctx.rotate(Math.atan2(to.y - toX.y * (1 - f) - from.y * f, to.x - toX.x * (1 - f) - from.x * f)); 173 | } else { 174 | ctx.rotate(Math.atan2(to.y - from.y, to.x - from.x)); 175 | } 176 | ctx.translate(-offset, 0); 177 | ctx.beginPath(); 178 | 179 | ctx.moveTo(0, 0); 180 | ctx.lineTo(-s, -s / 2); 181 | ctx.lineTo(-s * 0.9, 0); 182 | ctx.lineTo(-s, s / 2); 183 | ctx.closePath(); 184 | ctx.fillStyle = ctx.strokeStyle; 185 | ctx.fill(); 186 | 187 | ctx.restore(); 188 | } 189 | 190 | ctx.restore(); 191 | 192 | // point helper 193 | // ctx.save(); 194 | // ctx.strokeStyle = 'blue'; 195 | // ctx.beginPath(); 196 | // ctx.moveTo(from.x, from.y); 197 | // ctx.lineTo(from.x + shift.fx, from.y + shift.fy, 3, 3); 198 | // ctx.stroke(); 199 | // ctx.strokeStyle = 'red'; 200 | // ctx.beginPath(); 201 | // ctx.moveTo(to.x, to.y); 202 | // ctx.lineTo(to.x + shift.tx, to.y + shift.ty, 3, 3); 203 | // ctx.stroke(); 204 | // ctx.restore(); 205 | } 206 | 207 | static readonly id = 'edgeLine'; 208 | 209 | /** 210 | * @hidden 211 | */ 212 | static readonly defaults: any = /* #__PURE__ */ { 213 | ...LineElement.defaults, 214 | tension: 0, 215 | directed: false, 216 | arrowHeadSize: 15, 217 | arrowHeadOffset: 5, 218 | }; 219 | 220 | /** 221 | * @hidden 222 | */ 223 | static readonly defaultRoutes = LineElement.defaultRoutes; 224 | 225 | /** 226 | * @hidden 227 | */ 228 | static readonly descriptors = /* #__PURE__ */ { 229 | _scriptable: true, 230 | _indexable: (name: keyof IEdgeLineOptions): boolean => name !== 'borderDash', 231 | }; 232 | } 233 | 234 | declare module 'chart.js' { 235 | export interface ElementOptionsByType { 236 | edgeLine: ScriptableAndArrayOptions>; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/elements/index.ts: -------------------------------------------------------------------------------- 1 | export * from './EdgeLine'; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './elements'; 2 | export * from './controllers'; 3 | -------------------------------------------------------------------------------- /src/index.umd.ts: -------------------------------------------------------------------------------- 1 | import { registry } from 'chart.js'; 2 | import { 3 | DendrogramController, 4 | DendogramController, 5 | ForceDirectedGraphController, 6 | GraphController, 7 | TreeController, 8 | } from './controllers'; 9 | import { EdgeLine } from './elements'; 10 | 11 | export * from '.'; 12 | 13 | registry.addControllers( 14 | DendrogramController, 15 | DendogramController, 16 | ForceDirectedGraphController, 17 | GraphController, 18 | TreeController 19 | ); 20 | registry.addElements(EdgeLine); 21 | -------------------------------------------------------------------------------- /tsconfig.c.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "noEmit": true, 8 | "composite": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "lib": ["DOM", "ES2020"], 6 | "importHelpers": false, 7 | "declaration": false, 8 | "sourceMap": true, 9 | "strict": true, 10 | "removeComments": true, 11 | "verbatimModuleSyntax": false, 12 | "experimentalDecorators": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strictBindCallApply": true, 15 | "stripInternal": true, 16 | "resolveJsonModule": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "moduleResolution": "Bundler", 22 | "jsx": "react", 23 | "esModuleInterop": true, 24 | "rootDir": "./src", 25 | "baseUrl": "./", 26 | "noEmit": true, 27 | "paths": { 28 | "@": ["./src"], 29 | "*": ["*", "node_modules/*"], 30 | // workaround for: https://github.com/vitest-dev/vitest/issues/4567 31 | "rollup/parseAst": ["./node_modules/rollup/dist/parseAst"] 32 | } 33 | }, 34 | "include": ["src/**/*.ts", "src/**/*.tsx", "docs/**/*.tsx"] 35 | } 36 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": ["./src"], 4 | "plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"], 5 | "name": "chartjs-chart-graph", 6 | "out": "./docs/api", 7 | "docsRoot": "./docs/", 8 | "readme": "none", 9 | "sidebar": { 10 | "pretty": true 11 | }, 12 | "theme": "default", 13 | "excludeExternals": true, 14 | "excludeInternal": true, 15 | "excludePrivate": true, 16 | "includeVersion": true, 17 | "categorizeByGroup": true, 18 | "cleanOutputDir": true, 19 | "hideGenerator": true 20 | } 21 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | environment: 'jsdom', 7 | root: './src', 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------