├── .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 | 
8 |
9 | 
10 |
11 | 
12 |
13 | 
14 |
15 | Works great with https://github.com/chartjs/chartjs-plugin-datalabels or https://github.com/chrispahm/chartjs-plugin-dragdata
16 |
17 | 
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 | 
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 | 
113 |
114 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg)
115 |
116 | **Dendrogram Vertical**
117 |
118 | 
119 |
120 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg)
121 |
122 | **Dendrogram Radial**
123 |
124 | 
125 |
126 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg)
127 |
128 | **Tidy Tree Horizontal**
129 |
130 | 
131 |
132 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg)
133 |
134 | **Tidy Tree Vertical**
135 |
136 | 
137 |
138 | [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/jObedwg)
139 |
140 | **Tidy Tree Radial**
141 |
142 | 
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 | 
8 |
9 | 
10 |
11 | 
12 |
13 | 
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 |
--------------------------------------------------------------------------------