├── .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
│ ├── attributes.md
│ ├── attributes.ts
│ ├── basic.ts
│ ├── deep.md
│ ├── deep.ts
│ ├── endHierarchy.md
│ ├── endHierarchy.ts
│ ├── horizontal.md
│ ├── horizontal.ts
│ ├── horizontalReverse.md
│ ├── index.md
│ ├── line.md
│ ├── line.ts
│ ├── noLabel.md
│ ├── scriptable.md
│ ├── scriptable.ts
│ ├── single.md
│ ├── single.ts
│ ├── singleChild.md
│ ├── singleChild.ts
│ ├── static.md
│ └── static.ts
├── getting-started.md
├── index.md
└── related.md
├── eslint.config.mjs
├── package.json
├── rollup.config.js
├── samples
├── lazy.html
└── scatterplot.html
├── src
├── index.ts
├── index.umd.ts
├── model.ts
├── plugin
│ ├── hierarchical.ts
│ └── index.ts
├── scale
│ ├── hierarchical.ts
│ └── index.ts
├── utils.spec.ts
├── utils.ts
└── utils_bug_7.spec.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 Hierarchical Scale Plugin
2 |
3 | [![datavisyn][datavisyn-image]][datavisyn-url] [![NPM Package][npm-image]][npm-url] [![Github Actions][github-actions-image]][github-actions-url]
4 |
5 | Chart.js module for adding a new categorical scale which mimics a hierarchical tree.
6 |
7 | 
8 |
9 | ## Related Plugins
10 |
11 | Check out also my other chart.js plugins:
12 |
13 | - [chartjs-chart-boxplot](https://github.com/sgratzl/chartjs-chart-boxplot) for rendering boxplots and violin plots
14 | - [chartjs-chart-error-bars](https://github.com/sgratzl/chartjs-chart-error-bars) for rendering errors bars to bars and line charts
15 | - [chartjs-chart-geo](https://github.com/sgratzl/chartjs-chart-geo) for rendering map, bubble maps, and choropleth charts
16 | - [chartjs-chart-graph](https://github.com/sgratzl/chartjs-chart-graph) for rendering graphs, trees, and networks
17 | - [chartjs-chart-pcp](https://github.com/sgratzl/chartjs-chart-pcp) for rendering parallel coordinate plots
18 | - [chartjs-chart-venn](https://github.com/sgratzl/chartjs-chart-venn) for rendering venn and euler diagrams
19 | - [chartjs-chart-wordcloud](https://github.com/sgratzl/chartjs-chart-wordcloud) for rendering word clouds
20 |
21 | ## Install
22 |
23 | ```bash
24 | npm install --save chart.js chartjs-plugin-hierarchical
25 | ```
26 |
27 | ## Usage
28 |
29 | see [Examples](https://www.sgratzl.com/chartjs-plugin-hierarchical/examples/)
30 |
31 | or at this [![Open in CodePen][codepen]](https://codepen.io/sgratzl/pen/KKdryvg)
32 |
33 | ## Scale
34 |
35 | a new scale type `hierarchical`.
36 |
37 | ## Styling
38 |
39 | The `hierarchical` axis scale has the following styling options
40 |
41 | see [IHierarchicalScaleOptions](https://www.sgratzl/com/chartjs-plugin-hierarchical/api/interfaces/IHierarchicalScaleOptions.html)
42 |
43 | ## Data structure
44 |
45 | see [ILabelNode](https://www.sgratzl/com/chartjs-plugin-hierarchical/api/interfaces/ILabelNode.html) and [IValueNode](https://www.sgratzl/com/chartjs-plugin-hierarchical/api/interfaces/IValueNode.html)
46 |
47 | ### ESM and Tree Shaking
48 |
49 | 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.
50 |
51 | ```js
52 | import { Chart } from 'chart.js';
53 | import { HierarchicalScale } from 'chartjs-plugin-hierarchical';
54 |
55 | // register scale in chart.js and ensure the defaults are set
56 | Chart.register(HierarchicalScale);
57 | ...
58 | ```
59 |
60 | ## Development Environment
61 |
62 | ```sh
63 | npm i -g yarn
64 | yarn install
65 | yarn sdks vscode
66 | ```
67 |
68 | ### Common commands
69 |
70 | ```sh
71 | yarn compile
72 | yarn test
73 | yarn lint
74 | yarn fix
75 | yarn build
76 | yarn docs
77 | ```
78 |
79 | ---
80 |
81 |
82 | developed by **[datavisyn][datavisyn-url]**.
83 |
84 | [datavisyn-image]: https://img.shields.io/badge/datavisyn-io-black.svg
85 | [datavisyn-url]: https://www.datavisyn.io
86 | [npm-image]: https://badge.fury.io/js/chartjs-plugin-hierarchical.svg
87 | [npm-url]: https://npmjs.org/package/chartjs-plugin-hierarchical
88 | [github-actions-image]: https://github.com/sgratzl/chartjs-plugin-hierarchical/workflows/ci/badge.svg
89 | [github-actions-url]: https://github.com/sgratzl/chartjs-plugin-hierarchical/actions
90 | [codepen]: https://img.shields.io/badge/CodePen-open-blue?logo=codepen
91 |
--------------------------------------------------------------------------------
/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: 'Single Node', link: '/examples/single' },
29 | { text: 'Attributes', link: '/examples/attributes' },
30 | { text: 'Scriptable', link: '/examples/scriptable' },
31 | { text: 'Static Expansion', link: '/examples/static' },
32 | { text: 'Single Child', link: '/examples/singleChild' },
33 | { text: 'Horizontal Scale', link: '/examples/horizontal' },
34 | { text: 'Horizontal Scale (Reversed)', link: '/examples/horizontalReverse' },
35 | { text: 'Deep Hierarchy', link: '/examples/deep' },
36 | { text: 'Line Chart', link: '/examples/line' },
37 | { text: 'End Hierarchy', link: '/examples/endHierarchy' },
38 | { text: 'No Label', link: '/examples/noLabel' },
39 | //
40 | ],
41 | },
42 | {
43 | text: 'API',
44 | collapsed: true,
45 | items: typedocSidebar,
46 | },
47 | ],
48 |
49 | socialLinks: [{ icon: 'github', link: repository.url.replace('.git', '') }],
50 |
51 | footer: {
52 | message: `Released under the ${license} license.`,
56 | copyright: `Copyright © 2019-present ${author.name}`,
57 | },
58 |
59 | editLink: {
60 | pattern: `${repository.url.replace('.git', '')}/edit/main/docs/:path`,
61 | },
62 |
63 | search: {
64 | provider: 'local',
65 | },
66 | },
67 | });
68 |
--------------------------------------------------------------------------------
/docs/.vitepress/theme/index.ts:
--------------------------------------------------------------------------------
1 | import Theme from 'vitepress/theme';
2 | import { Chart } from 'vue-chartjs';
3 | import {
4 | Chart as ChartJS,
5 | CategoryScale,
6 | Tooltip,
7 | LinearScale,
8 | BarController,
9 | BarElement,
10 | LineController,
11 | Colors,
12 | LineElement,
13 | PointElement,
14 | } from 'chart.js';
15 | import { HierarchicalScale } from '../../../src';
16 |
17 | export default {
18 | ...Theme,
19 | enhanceApp({ app }) {
20 | ChartJS.register(
21 | HierarchicalScale,
22 | CategoryScale,
23 | Tooltip,
24 | LinearScale,
25 | BarController,
26 | BarElement,
27 | LineController,
28 | LineElement,
29 | PointElement,
30 | Colors
31 | );
32 | app.component('Chart', Chart);
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/docs/examples/attributes.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Attributes
3 | ---
4 |
5 | # Attributes
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./attributes.ts#config [config]
22 |
23 | <<< ./attributes.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/attributes.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 |
4 | // #region data
5 |
6 | export const data: ChartConfiguration<'bar'>['data'] = {
7 | labels: [
8 | {
9 | label: 'A',
10 | backgroundColor: 'red',
11 | },
12 | {
13 | label: 'B',
14 | backgroundColor: 'blue',
15 | expand: true, // expand level
16 | children: [
17 | {
18 | label: 'B1',
19 | },
20 | {
21 | label: 'B2',
22 | backgroundColor: 'lightblue',
23 | },
24 | ],
25 | },
26 | {
27 | label: 'C',
28 | backgroundColor: 'green',
29 | },
30 | ],
31 | datasets: [
32 | {
33 | label: 'Test',
34 | // store as the tree attribute for reference, the data attribute will be automatically managed
35 | tree: [
36 | 1,
37 | {
38 | value: 2,
39 | children: [11, 12],
40 | },
41 | 3,
42 | ],
43 | data: [],
44 | },
45 | ],
46 | };
47 |
48 | // #endregion
49 |
50 | // #region config
51 | export const config: ChartConfiguration<'bar'> = {
52 | type: 'bar',
53 | data,
54 | options: {
55 | layout: {
56 | padding: {
57 | // add more space at the bottom for the hierarchy
58 | bottom: 60,
59 | },
60 | },
61 | scales: {
62 | x: {
63 | type: 'hierarchical',
64 | attributes: {
65 | backgroundColor: 'gray',
66 | },
67 | },
68 | },
69 | },
70 | };
71 | // #endregion config
72 |
--------------------------------------------------------------------------------
/docs/examples/basic.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 |
4 | // #region data
5 |
6 | export const data: ChartConfiguration<'bar'>['data'] = {
7 | labels: [
8 | 'A',
9 | {
10 | label: 'B1',
11 | expand: false, // 'focus', // expand level
12 | children: [
13 | 'B1.1',
14 | {
15 | label: 'B1.2',
16 | children: ['B1.2.1', 'B1.2.2'],
17 | },
18 | 'B1.3',
19 | ],
20 | },
21 | {
22 | label: 'C1',
23 | children: ['C1.1', 'C1.2', 'C1.3', 'C1.4'],
24 | },
25 | 'D',
26 | ],
27 | datasets: [
28 | {
29 | label: 'Test',
30 | // store as the tree attribute for reference, the data attribute will be automatically managed
31 | tree: [
32 | 1,
33 | {
34 | value: 2,
35 | children: [
36 | 3,
37 | {
38 | value: 4,
39 | children: [4.1, 4.2],
40 | },
41 | 5,
42 | ],
43 | },
44 | {
45 | value: 6,
46 | children: [7, 8, 9, 10],
47 | },
48 | 11,
49 | ],
50 | data: [],
51 | },
52 | ],
53 | };
54 |
55 | // #endregion
56 |
57 | // #region config
58 | export const config: ChartConfiguration<'bar'> = {
59 | type: 'bar',
60 | data,
61 | options: {
62 | layout: {
63 | padding: {
64 | // add more space at the bottom for the hierarchy
65 | bottom: 60,
66 | },
67 | },
68 | scales: {
69 | x: {
70 | type: 'hierarchical',
71 | },
72 | },
73 | },
74 | };
75 | // #endregion config
76 |
77 | // #region nolabel
78 | export const nolabel: ChartConfiguration<'bar'> = {
79 | type: 'bar',
80 | data,
81 | options: {
82 | layout: {
83 | padding: {
84 | // add more space at the bottom for the hierarchy
85 | bottom: 60,
86 | },
87 | },
88 | scales: {
89 | x: {
90 | type: 'hierarchical',
91 | hierarchyLabelPosition: 'none',
92 | ticks: {
93 | display: false,
94 | },
95 | },
96 | },
97 | },
98 | };
99 | // #endregion nolabel
100 |
--------------------------------------------------------------------------------
/docs/examples/deep.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Deep Hierarchy
3 | ---
4 |
5 | # Deep Hierarchy
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./deep.ts#config [config]
22 |
23 | <<< ./deep.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/deep.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 |
4 | // #region data
5 |
6 | export const data: ChartConfiguration<'bar'>['data'] = {
7 | // define label tree
8 | labels: [
9 | 'A',
10 | {
11 | label: 'B1',
12 | children: [
13 | 'B1.1',
14 | {
15 | label: 'B1.2',
16 | children: [
17 | {
18 | label: 'B1.2.1',
19 | children: [
20 | {
21 | label: 'B1.2.1.1',
22 | children: [
23 | {
24 | label: 'B1.2.1.1.1',
25 | children: [
26 | {
27 | label: 'B1.2.1.1.1.2',
28 | children: [{ label: 'ZZ', children: ['X'] }, 'Y', 'Z'],
29 | },
30 | 'XX',
31 | 'X',
32 | ],
33 | },
34 | 'Y',
35 | 'Z',
36 | ],
37 | },
38 | 'B1.2.1.2',
39 | 'B1.2.1.3',
40 | ],
41 | },
42 | 'B1.2.2',
43 | ],
44 | },
45 | 'B1.3',
46 | ],
47 | },
48 | 'D',
49 | ],
50 | datasets: [
51 | {
52 | label: 'Test',
53 | // store as the tree attribute for reference, the data attribute will be automatically managed
54 | tree: [
55 | 1,
56 | {
57 | value: 2,
58 | children: [
59 | 3,
60 | {
61 | value: 4,
62 | children: [
63 | {
64 | value: 4.1,
65 | children: [
66 | {
67 | value: 4.12,
68 | children: [
69 | {
70 | value: 4.121,
71 | children: [4.1211, 4.1212],
72 | },
73 | 4.122,
74 | ],
75 | },
76 | 4.12,
77 | ],
78 | },
79 | 4.2,
80 | ],
81 | },
82 | 5,
83 | ],
84 | },
85 | {
86 | value: 6,
87 | children: [7, 8, 9, 10],
88 | },
89 | 11,
90 | ],
91 | data: [],
92 | },
93 | ],
94 | };
95 |
96 | // #endregion
97 |
98 | // #region config
99 | export const config: ChartConfiguration<'bar'> = {
100 | type: 'bar',
101 | data,
102 | options: {
103 | layout: {
104 | padding: {
105 | // add more space at the bottom for the hierarchy
106 | bottom: 60,
107 | },
108 | },
109 | scales: {
110 | x: {
111 | type: 'hierarchical',
112 | },
113 | },
114 | },
115 | };
116 | // #endregion config
117 |
--------------------------------------------------------------------------------
/docs/examples/endHierarchy.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: End Hierarchy
3 | ---
4 |
5 | # End Hierarchy
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./endHierarchy.ts#config [config]
22 |
23 | <<< ./endHierarchy.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/endHierarchy.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 |
4 | // #region data
5 |
6 | export const data: ChartConfiguration<'bar'>['data'] = {
7 | // define label tree
8 | labels: [
9 | {
10 | label: 'A1',
11 | children: [
12 | {
13 | label: 'A1.1',
14 | children: ['A1.1.1', 'A1.1.2', 'A1.1.3'],
15 | },
16 | {
17 | label: 'A1.2',
18 | children: ['A1.2.1', 'A1.2.2', 'A1.2.3'],
19 | },
20 | 'A1.3',
21 | {
22 | label: 'A1.4',
23 | children: ['A1.4.1', 'A1.4.2', 'A1.4.3'],
24 | },
25 | ],
26 | },
27 | {
28 | label: 'C1',
29 | expand: true,
30 | children: [
31 | 'C1.1',
32 | 'C1.2',
33 | 'C1.3',
34 | {
35 | label: 'C1.4',
36 | expand: true,
37 | children: ['C1.4.1', 'C1.4.2', 'C1.4.3'],
38 | },
39 | ],
40 | },
41 | ],
42 | datasets: [
43 | {
44 | label: 'Test',
45 | // store as the tree attribute for reference, the data attribute will be automatically managed
46 | tree: [
47 | {
48 | value: 1,
49 | children: [
50 | {
51 | value: 2,
52 | children: [3, 4, 5],
53 | },
54 | {
55 | value: 6,
56 | children: [7, 8, 9],
57 | },
58 | 10,
59 | {
60 | value: 11,
61 | children: [12, 13, 14],
62 | },
63 | ],
64 | },
65 | {
66 | value: 6,
67 | children: [
68 | 7,
69 | 8,
70 | 9,
71 | {
72 | value: 10,
73 | children: [11, 12, 13],
74 | },
75 | ],
76 | },
77 | ],
78 | data: [],
79 | },
80 | ],
81 | };
82 | // #endregion
83 |
84 | // #region config
85 | export const config: ChartConfiguration<'bar'> = {
86 | type: 'bar',
87 | data,
88 | options: {
89 | layout: {
90 | padding: {
91 | // add more space at the bottom for the hierarchy
92 | bottom: 60,
93 | },
94 | },
95 | scales: {
96 | x: {
97 | type: 'hierarchical',
98 | },
99 | },
100 | },
101 | };
102 | // #endregion config
103 |
--------------------------------------------------------------------------------
/docs/examples/horizontal.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Horizontal Scale
3 | ---
4 |
5 | # Horizontal Scale
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./horizontal.ts#config [config]
22 |
23 | <<< ./horizontal.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/horizontal.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 | // #region data
4 |
5 | export const data: ChartConfiguration<'bar'>['data'] = {
6 | // define label tree
7 | labels: [
8 | 'A',
9 | {
10 | label: 'B1',
11 | expand: true,
12 | children: [
13 | 'B1.1',
14 | {
15 | label: 'B1.2',
16 | children: ['B1.2.1', 'B1.2.2'],
17 | },
18 | 'B1.3',
19 | ],
20 | },
21 | {
22 | label: 'C1',
23 | children: ['C1.1', 'C1.2', 'C1.3', 'C1.4'],
24 | },
25 | 'D',
26 | ],
27 | datasets: [
28 | {
29 | label: 'Test',
30 | tree: [
31 | 1,
32 | {
33 | value: 2,
34 | children: [
35 | 3,
36 | {
37 | value: 4,
38 | children: [4.1, 4.2],
39 | },
40 | 5,
41 | ],
42 | },
43 | {
44 | value: 6,
45 | children: [7, 8, 9, 10],
46 | },
47 | 11,
48 | ],
49 | data: [],
50 | },
51 | ],
52 | };
53 |
54 | // #endregion
55 |
56 | // #region config
57 | export const config: ChartConfiguration<'bar'> = {
58 | type: 'bar',
59 | data,
60 | options: {
61 | indexAxis: 'y',
62 | layout: {
63 | padding: {
64 | // add more space at the left side for the hierarchy
65 | left: 50,
66 | },
67 | },
68 | scales: {
69 | y: {
70 | type: 'hierarchical',
71 | // tune padding setting
72 | padding: 0,
73 | },
74 | },
75 | },
76 | };
77 | // #endregion config
78 |
79 | // #region reverse
80 | export const reverse: ChartConfiguration<'bar'> = {
81 | type: 'bar',
82 | data,
83 | options: {
84 | indexAxis: 'y',
85 | layout: {
86 | padding: {
87 | // add more space at the left side for the hierarchy
88 | left: 50,
89 | },
90 | },
91 | scales: {
92 | y: {
93 | type: 'hierarchical',
94 | // tune padding setting
95 | padding: 0,
96 | reverseOrder: true,
97 | },
98 | },
99 | },
100 | };
101 | // #endregion reverse
102 |
--------------------------------------------------------------------------------
/docs/examples/horizontalReverse.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Horizontal Scale (Reversed)
3 | ---
4 |
5 | # Horizontal Scale (Reversed)
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./horizontal.ts#reverse [config]
22 |
23 | <<< ./horizontal.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Examples
3 | ---
4 |
5 | # Examples
6 |
7 |
10 |
11 | ## Hierarchical Bar Chart
12 |
13 |
18 |
19 | ### Code
20 |
21 | :::code-group
22 |
23 | <<< ./basic.ts#config [config]
24 |
25 | <<< ./basic.ts#data [data]
26 |
27 | :::
28 |
--------------------------------------------------------------------------------
/docs/examples/line.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Line Chart
3 | ---
4 |
5 | # Line Chart
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./line.ts#config [config]
22 |
23 | <<< ./line.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/line.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 |
4 | // #region data
5 |
6 | export const data: ChartConfiguration<'line'>['data'] = {
7 | // define label tree
8 | labels: [
9 | 'A',
10 | {
11 | label: 'B1',
12 | expand: false, // 'focus', // expand level
13 | children: [
14 | 'B1.1',
15 | {
16 | label: 'B1.2',
17 | children: ['B1.2.1', 'B1.2.2'],
18 | },
19 | 'B1.3',
20 | ],
21 | },
22 | {
23 | label: 'C1',
24 | children: ['C1.1', 'C1.2', 'C1.3', 'C1.4'],
25 | },
26 | 'D',
27 | ],
28 | datasets: [
29 | {
30 | label: 'Test',
31 | // store as the tree attribute for reference, the data attribute will be automatically managed
32 | tree: [
33 | 1,
34 | {
35 | value: 2,
36 | children: [
37 | 3,
38 | {
39 | value: 4,
40 | children: [4.1, 4.2],
41 | },
42 | 5,
43 | ],
44 | },
45 | {
46 | value: 6,
47 | children: [7, 8, 9, 10],
48 | },
49 | 11,
50 | ],
51 | data: [],
52 | },
53 | ],
54 | };
55 |
56 | // #endregion
57 |
58 | // #region config
59 | export const config: ChartConfiguration<'line'> = {
60 | type: 'line',
61 | data,
62 | options: {
63 | layout: {
64 | padding: {
65 | // add more space at the bottom for the hierarchy
66 | bottom: 60,
67 | },
68 | },
69 | scales: {
70 | x: {
71 | type: 'hierarchical',
72 | },
73 | },
74 | },
75 | };
76 | // #endregion config
77 |
--------------------------------------------------------------------------------
/docs/examples/noLabel.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: No Label
3 | ---
4 |
5 | # No Label
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./basic.ts#nolabel [config]
22 |
23 | <<< ./basic.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/scriptable.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Scriptable
3 | ---
4 |
5 | # Scriptable
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./scriptable.ts#config [config]
22 |
23 | <<< ./scriptable.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/scriptable.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 | import { interpolateBlues } from 'd3-scale-chromatic';
4 | import { scaleSequential } from 'd3-scale';
5 | // #region data
6 |
7 | export const data: ChartConfiguration<'bar'>['data'] = {
8 | // define label tree
9 | labels: [
10 | 'A',
11 | {
12 | label: 'B1',
13 | expand: false, // 'focus', // expand level
14 | children: [
15 | 'B1.1',
16 | {
17 | label: 'B1.2',
18 | children: ['B1.2.1', 'B1.2.2'],
19 | },
20 | 'B1.3',
21 | ],
22 | },
23 | {
24 | label: 'C1',
25 | children: ['C1.1', 'C1.2', 'C1.3', 'C1.4'],
26 | },
27 | 'D',
28 | ],
29 | datasets: [
30 | {
31 | label: 'Test',
32 | // store as the tree attribute for reference, the data attribute will be automatically managed
33 | tree: [
34 | 1,
35 | {
36 | value: 2,
37 | children: [
38 | 3,
39 | {
40 | value: 4,
41 | children: [4.1, 4.2],
42 | },
43 | 5,
44 | ],
45 | },
46 | {
47 | value: 6,
48 | children: [7, 8, 9, 10],
49 | },
50 | 11,
51 | ],
52 | data: [],
53 | },
54 | ],
55 | };
56 |
57 | // #endregion
58 |
59 | // #region config
60 | const scale = scaleSequential(interpolateBlues).domain([0, 11]);
61 | export const config: ChartConfiguration<'bar'> = {
62 | type: 'bar',
63 | data,
64 | options: {
65 | layout: {
66 | padding: {
67 | // add more space at the bottom for the hierarchy
68 | bottom: 60,
69 | },
70 | },
71 | scales: {
72 | x: {
73 | type: 'hierarchical',
74 | attributes: {
75 | backgroundColor: () => (ctx) => {
76 | const v = ctx.dataset.data[ctx.dataIndex];
77 | return scale(v);
78 | },
79 | },
80 | },
81 | },
82 | },
83 | };
84 | // #endregion config
85 |
--------------------------------------------------------------------------------
/docs/examples/single.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Single Element Levels
3 | ---
4 |
5 | # Single Element Levels
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./single.ts#config [config]
22 |
23 | <<< ./single.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/single.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 |
4 | // #region data
5 |
6 | export const data: ChartConfiguration<'bar'>['data'] = {
7 | // define label tree
8 | labels: [
9 | {
10 | label: 'a consectetur adipiscing elit',
11 | expand: true, // 'focus', // expand level
12 | children: [
13 | {
14 | label: 'aa consectetur adipiscing elit',
15 | expand: true, // 'focus', // expand level
16 | children: ['aaa ex ea commodo consequat', 'aab ex ea commodo consequat'],
17 | },
18 | {
19 | label: 'ab consectetur adipiscing elit',
20 | children: ['aba ex ea commodo consequat', 'abb ex ea commodo consequat'],
21 | },
22 | 'ac ex ea commodo consequat',
23 | ],
24 | },
25 | ],
26 | datasets: [
27 | {
28 | label: 'Test',
29 | // store as the tree attribute for reference, the data attribute will be automatically managed
30 | tree: [
31 | {
32 | value: 2,
33 | children: [
34 | {
35 | value: 4,
36 | children: [4.1, 4.2],
37 | },
38 | {
39 | value: 4,
40 | children: [4.1, 4.2],
41 | },
42 | 5,
43 | ],
44 | },
45 | ],
46 | data: [],
47 | },
48 | ],
49 | };
50 |
51 | // #endregion
52 |
53 | // #region config
54 | export const config: ChartConfiguration<'bar'> = {
55 | type: 'bar',
56 | data,
57 | options: {
58 | layout: {
59 | padding: {
60 | // add more space at the bottom for the hierarchy
61 | bottom: 60,
62 | },
63 | },
64 | scales: {
65 | x: {
66 | type: 'hierarchical',
67 | padding: 10,
68 | ticks: {
69 | padding: 50,
70 | minRotation: 90,
71 | maxRotation: 90,
72 | },
73 | },
74 | },
75 | },
76 | };
77 | // #endregion config
78 |
--------------------------------------------------------------------------------
/docs/examples/singleChild.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Single Child
3 | ---
4 |
5 | # Single Child
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./singleChild.ts#config [config]
22 |
23 | <<< ./singleChild.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/singleChild.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 | import { interpolateBlues } from 'd3-scale-chromatic';
4 | import { scaleSequential } from 'd3-scale';
5 | // #region data
6 |
7 | export const data: ChartConfiguration<'bar'>['data'] = {
8 | // define label tree
9 | labels: [
10 | 'A',
11 | {
12 | label: 'B1',
13 | expand: false, // 'focus', // expand level
14 | children: ['B1.1'],
15 | },
16 | 'C',
17 | ],
18 | datasets: [
19 | {
20 | label: 'Test',
21 | // store as the tree attribute for reference, the data attribute will be automatically managed
22 | tree: [
23 | 1,
24 | {
25 | value: 2,
26 | children: [2],
27 | },
28 | 11,
29 | ],
30 | data: [],
31 | },
32 | ],
33 | };
34 |
35 | // #endregion
36 |
37 | // #region config
38 | export const config: ChartConfiguration<'bar'> = {
39 | type: 'bar',
40 | data,
41 | options: {
42 | layout: {
43 | padding: {
44 | // add more space at the bottom for the hierarchy
45 | bottom: 60,
46 | },
47 | },
48 | scales: {
49 | x: {
50 | type: 'hierarchical',
51 | },
52 | },
53 | },
54 | };
55 | // #endregion config
56 |
--------------------------------------------------------------------------------
/docs/examples/static.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Static Expanding
3 | ---
4 |
5 | # Static Expanding
6 |
7 |
10 |
11 |
16 |
17 | ### Code
18 |
19 | :::code-group
20 |
21 | <<< ./static.ts#config [config]
22 |
23 | <<< ./static.ts#data [data]
24 |
25 | :::
26 |
--------------------------------------------------------------------------------
/docs/examples/static.ts:
--------------------------------------------------------------------------------
1 | import type { ChartConfiguration } from 'chart.js';
2 | import {} from '../../src';
3 | // #region data
4 |
5 | export const data: ChartConfiguration<'bar'>['data'] = {
6 | // define label tree
7 | labels: [
8 | 'A',
9 | {
10 | label: 'B1',
11 | expand: true, // 'focus', // expand level
12 | children: [
13 | 'B1.1',
14 | {
15 | label: 'B1.2',
16 | children: ['B1.2.1', 'B1.2.2'],
17 | },
18 | 'B1.3',
19 | ],
20 | },
21 | {
22 | label: 'C1',
23 | children: ['C1.1', 'C1.2', 'C1.3', 'C1.4'],
24 | },
25 | 'D',
26 | ],
27 | datasets: [
28 | {
29 | label: 'Test',
30 | // store as the tree attribute for reference, the data attribute will be automatically managed
31 | tree: [
32 | 1,
33 | {
34 | value: 2,
35 | children: [
36 | 3,
37 | {
38 | value: 4,
39 | children: [4.1, 4.2],
40 | },
41 | 5,
42 | ],
43 | },
44 | {
45 | value: 6,
46 | children: [7, 8, 9, 10],
47 | },
48 | 11,
49 | ],
50 | data: [],
51 | },
52 | ],
53 | };
54 |
55 | // #endregion
56 |
57 | // #region config
58 | export const config: ChartConfiguration<'bar'> = {
59 | type: 'bar',
60 | data,
61 | options: {
62 | layout: {
63 | padding: {
64 | // add more space at the bottom for the hierarchy
65 | bottom: 60,
66 | },
67 | },
68 | scales: {
69 | x: {
70 | type: 'hierarchical',
71 | static: true,
72 | hierarchyGroupLabelPosition: 'center',
73 | },
74 | },
75 | },
76 | };
77 | // #endregion config
78 |
--------------------------------------------------------------------------------
/docs/getting-started.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Getting Started
3 | ---
4 |
5 | Chart.js module for adding a new categorical scale which mimics a hierarchical tree.
6 |
7 | 
8 |
9 | ## Install
10 |
11 | ```sh
12 | npm install chart.js chartjs-plugin-hierarchical
13 | ```
14 |
15 | ## Usage
16 |
17 | see [Examples](./examples/)
18 |
19 | and [CodePen](https://codepen.io/sgratzl/pen/KKdryvg)
20 |
21 | ## Configuration
22 |
23 | ### Data Structure
24 |
25 | see [ILabelNode](/api/interfaces/ILabelNode.html) and [IValueNode](/api/interfaces/IValueNode.html)
26 |
27 | ### Styling
28 |
29 | see [IHierarchicalScaleOptions](/api/interfaces/IHierarchicalScaleOptions.html)
30 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # https://vitepress.dev/reference/default-theme-home-page
3 | layout: home
4 |
5 | hero:
6 | name: 'chartjs-plugin-hierarchical'
7 | text: 'chart.js plugin'
8 | tagline: Chart.js module for hierarchical categories
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-plugin-hierarchical",
3 | "description": "Chart.js module for hierarchical categories",
4 | "version": "4.4.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-plugin-hierarchical",
12 | "bugs": {
13 | "url": "https://github.com/sgratzl/chartjs-plugin-hierarchical/issues"
14 | },
15 | "keywords": [
16 | "chart.js",
17 | "hierarchical",
18 | "grouping"
19 | ],
20 | "repository": {
21 | "type": "git",
22 | "url": "https://github.com/sgratzl/chartjs-plugin-hierarchical.git"
23 | },
24 | "global": "ChartHierarchical",
25 | "type": "module",
26 | "main": "build/index.js",
27 | "module": "build/index.js",
28 | "require": "build/index.cjs",
29 | "umd": "build/index.umd.js",
30 | "unpkg": "build/index.umd.min.js",
31 | "jsdelivr": "build/index.umd.min.js",
32 | "types": "build/index.d.ts",
33 | "exports": {
34 | ".": {
35 | "import": "./build/index.js",
36 | "require": "./build/index.cjs",
37 | "scripts": "./build/index.umd.min.js",
38 | "types": "./build/index.d.ts"
39 | }
40 | },
41 | "sideEffects": false,
42 | "files": [
43 | "build",
44 | "src/**/*.ts"
45 | ],
46 | "peerDependencies": {
47 | "chart.js": "^4.1.0"
48 | },
49 | "browserslist": [
50 | "Firefox ESR",
51 | "last 2 Chrome versions",
52 | "last 2 Firefox versions"
53 | ],
54 | "devDependencies": {
55 | "@chiogen/rollup-plugin-terser": "^7.1.3",
56 | "@eslint/js": "~9.14.0",
57 | "@rollup/plugin-commonjs": "^28.0.1",
58 | "@rollup/plugin-node-resolve": "^15.3.0",
59 | "@rollup/plugin-replace": "^6.0.1",
60 | "@rollup/plugin-typescript": "^12.1.1",
61 | "@types/d3-scale": "^4.0.8",
62 | "@types/d3-scale-chromatic": "^3.0.3",
63 | "@types/jest-image-snapshot": "^6.4.0",
64 | "@types/node": "^22.9.0",
65 | "@yarnpkg/sdks": "^3.2.0",
66 | "canvas": "^2.11.2",
67 | "canvas-5-polyfill": "^0.1.5",
68 | "chart.js": "^4.4.6",
69 | "d3-scale": "^4.0.2",
70 | "d3-scale-chromatic": "^3.1.0",
71 | "eslint": "~9.14.0",
72 | "eslint-plugin-prettier": "^5.2.1",
73 | "jest-image-snapshot": "^6.4.0",
74 | "jsdom": "^25.0.1",
75 | "prettier": "^3.3.3",
76 | "rimraf": "^6.0.1",
77 | "rollup": "^4.27.2",
78 | "rollup-plugin-cleanup": "^3.2.1",
79 | "rollup-plugin-dts": "^6.1.1",
80 | "ts-jest": "^29.2.5",
81 | "tslib": "^2.8.1",
82 | "typedoc": "^0.26.11",
83 | "typedoc-plugin-markdown": "^4.2.10",
84 | "typedoc-vitepress-theme": "^1.0.2",
85 | "typescript": "^5.6.3",
86 | "typescript-eslint": "^8.14.0",
87 | "vite": "^5.4.11",
88 | "vitepress": "^1.5.0",
89 | "vitest": "^2.1.5",
90 | "vue": "^3.5.13",
91 | "vue-chartjs": "^5.3.2"
92 | },
93 | "scripts": {
94 | "clean": "rimraf --glob build node_modules \"*.tgz\" \"*.tsbuildinfo\"",
95 | "compile": "tsc -b tsconfig.c.json",
96 | "start": "yarn run watch",
97 | "watch": "rollup -c -w",
98 | "build": "rollup -c",
99 | "test": "vitest --passWithNoTests",
100 | "test:watch": "yarn run test --watch",
101 | "test:coverage": "yarn run test --coverage",
102 | "lint": "yarn run eslint && yarn run prettier",
103 | "fix": "yarn run eslint:fix && yarn run prettier:write",
104 | "prettier:write": "prettier \"*\" \"*/**\" --write",
105 | "prettier": "prettier \"*\" \"*/**\" --check",
106 | "eslint": "eslint src --cache",
107 | "eslint:fix": "yarn run eslint --fix",
108 | "prepare": "yarn run build",
109 | "docs:api": "typedoc --options typedoc.json",
110 | "docs:dev": "vitepress dev docs",
111 | "docs:build": "yarn run docs:api && vitepress build docs",
112 | "docs:preview": "vitepress preview docs"
113 | },
114 | "packageManager": "yarn@4.5.1"
115 | }
116 |
--------------------------------------------------------------------------------
/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/lazy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hierarchical Bar Chart
5 |
6 |
7 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
106 |
107 |
108 |
--------------------------------------------------------------------------------
/samples/scatterplot.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hierarchical Bar Chart
5 |
6 |
7 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
65 |
66 |
67 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './scale/hierarchical';
2 | // export * from './plugin/hierarchical';
3 | export type { IValueNode, IRawLabelNode as ILabelNode } from './model';
4 |
--------------------------------------------------------------------------------
/src/index.umd.ts:
--------------------------------------------------------------------------------
1 | import { registry } from 'chart.js';
2 | import { HierarchicalScale } from './scale';
3 |
4 | export * from '.';
5 |
6 | registry.addScales(HierarchicalScale);
7 |
--------------------------------------------------------------------------------
/src/model.ts:
--------------------------------------------------------------------------------
1 | import type { Chart, ChartData, ChartDataset } from 'chart.js';
2 |
3 | export interface ILabelNode {
4 | label: string;
5 | expand: boolean | 'focus';
6 | level: number;
7 |
8 | center: number;
9 | width: number;
10 | hidden: boolean;
11 | major: boolean;
12 | toString(): string;
13 |
14 | parent: number;
15 | children: ILabelNode[];
16 |
17 | index: number;
18 | relIndex: number;
19 |
20 | value?: string;
21 | }
22 |
23 | export interface IRawLabelNode {
24 | /**
25 | * label
26 | */
27 | label: string;
28 | /**
29 | * defines whether this node is collapsed (false) or expanded (true) or focussed ('focus')
30 | * @default false
31 | */
32 | expand?: boolean | 'focus';
33 | /**
34 | * hide this node
35 | */
36 | hidden?: boolean;
37 | /**
38 | * list of children
39 | */
40 | children?: (IRawLabelNode | string)[];
41 | }
42 |
43 | export declare type ILabelNodes = readonly ILabelNode[];
44 |
45 | export interface IValueNode {
46 | /**
47 | * the actual value of this node
48 | */
49 | value: number;
50 | /**
51 | * list of children
52 | */
53 | children: readonly (IValueNode | number)[];
54 | }
55 |
56 | export function isValueNode(node: IValueNode | any): node is IValueNode {
57 | return node != null && Array.isArray(node.children);
58 | }
59 |
60 | export interface IEnhancedChartDataSet extends ChartDataset<'bar'> {
61 | tree: (IValueNode | number)[];
62 | }
63 |
64 | export interface IEnhancedChart extends Chart {
65 | data: ChartData & {
66 | flatLabels?: ILabelNodes;
67 | labels: ILabelNode[];
68 | _verify?: string;
69 | rootNodes?: ILabelNodes;
70 |
71 | datasets: IEnhancedChartDataSet[];
72 | };
73 | }
74 |
--------------------------------------------------------------------------------
/src/plugin/hierarchical.ts:
--------------------------------------------------------------------------------
1 | import { defaults, Plugin, Chart, Color, FontSpec } from 'chart.js';
2 | import { valueOrDefault, toFont } from 'chart.js/helpers';
3 |
4 | import {
5 | toNodes,
6 | countExpanded,
7 | resolve,
8 | parentsOf,
9 | preOrderTraversal,
10 | lastOfLevel,
11 | spanLogic,
12 | determineVisible,
13 | flatChildren,
14 | getMaxDepth,
15 | } from '../utils';
16 | import type { ILabelNodes, ILabelNode, IEnhancedChart, IEnhancedChartDataSet } from '../model';
17 | import type { HierarchicalScale } from '../scale';
18 |
19 | function generateCode(labels: readonly (ILabelNode | string)[]) {
20 | // label, expand, children
21 | let code = '';
22 | const encode = (label: string | ILabelNode) => {
23 | if (typeof label === 'string') {
24 | code += label;
25 | return;
26 | }
27 | code += `(l=${label.label},e=${label.expand},c=[`;
28 | (label.children || []).forEach(encode);
29 | code += '])';
30 | };
31 |
32 | labels.forEach(encode);
33 | return code;
34 | }
35 |
36 | function isValidScaleType(chart: Chart, scale: string) {
37 | const scales = chart.config.options?.scales as any;
38 | if (!scales || !Object.prototype.hasOwnProperty.call(scales, scale)) {
39 | return false;
40 | }
41 | return Object.prototype.hasOwnProperty.call(scales[scale], 'type');
42 | }
43 |
44 | /**
45 | * checks whether this plugin needs to be enabled based on whether one is a hierarchical axis
46 | */
47 | function enabled(chart: Chart) {
48 | const { options } = chart.config;
49 | if (!options || !Object.prototype.hasOwnProperty.call(options, 'scales')) {
50 | return null;
51 | }
52 | const scales = chart.config.options?.scales as any;
53 | if (scales && isValidScaleType(chart, 'x') && scales.x.type === 'hierarchical') {
54 | return 'x';
55 | }
56 | if (scales && isValidScaleType(chart, 'y') && scales.y.type === 'hierarchical') {
57 | return 'y';
58 | }
59 | return null;
60 | }
61 |
62 | /**
63 | * checks whether the data has been changed by the user and all caches are invalid
64 | */
65 | function check(chart: IEnhancedChart) {
66 | if (chart.data.labels && chart.data._verify === generateCode(chart.data.labels)) {
67 | return;
68 | }
69 |
70 | // convert labels to nodes
71 | const flat = toNodes(chart.data.labels);
72 | chart.data.flatLabels = flat;
73 | chart.data.rootNodes = flat.filter((d) => d.parent === -1);
74 |
75 | const labels = determineVisible(flat);
76 |
77 | (chart.data.labels as any) = labels;
78 | updateVerifyCode(chart);
79 |
80 | // convert the data tree to the flat visible counterpart
81 | chart.data.datasets.forEach((dataset: IEnhancedChartDataSet) => {
82 | if (dataset.tree == null) {
83 | dataset.tree = (dataset.data as any[]).slice();
84 | }
85 | dataset.data = labels.map((l) => resolve(l, flat, dataset.tree));
86 | });
87 |
88 | updateAttributes(chart);
89 | }
90 |
91 | /**
92 | * a verify code is used to recognize when the user changes the data
93 | * @param {*} chart
94 | */
95 | function updateVerifyCode(chart: IEnhancedChart) {
96 | chart.data._verify = generateCode(chart.data.labels);
97 | }
98 |
99 | /**
100 | * updates the attributes according to config, similar to data sync
101 | */
102 | function updateAttributes(chart: IEnhancedChart) {
103 | const scale = findScale(chart);
104 | if (!scale) {
105 | return;
106 | }
107 | const { attributes } = scale.options;
108 |
109 | const nodes = chart.data.labels as ILabelNodes;
110 | const flat = chart.data.flatLabels ?? [];
111 |
112 | Object.keys(attributes).forEach((attr) => {
113 | chart.data.datasets.forEach((d) => {
114 | const v = nodes.map((n: ILabelNode | null) => {
115 | while (n) {
116 | if ((n as any)[attr] !== undefined) {
117 | return (n as any)[attr];
118 | }
119 | // walk up the hierarchy
120 | n = n.parent >= 0 ? flat[n.parent] : null;
121 | }
122 | return attributes[attr]; // default value
123 | });
124 |
125 | // check if all values are the same, if so replace with a single value
126 | (d as any)[attr] = v.length >= 1 && v.every((vi) => vi === v[0]) ? v[0] : v;
127 | });
128 | });
129 | }
130 |
131 | function findScale(chart: Chart) {
132 | const scales = Object.keys(chart.scales).map((d) => chart.scales[d]);
133 | return scales.find((d) => d.type === 'hierarchical') as HierarchicalScale | undefined;
134 | }
135 |
136 | function postDataUpdate(chart: IEnhancedChart) {
137 | updateVerifyCode(chart);
138 | updateAttributes(chart);
139 |
140 | chart.update();
141 | }
142 |
143 | function expandCollapse(chart: IEnhancedChart, index: number, count: number, toAdd: ILabelNodes) {
144 | const labels = chart.data.labels as ILabelNode[];
145 | const flatLabels = chart.data.flatLabels ?? [];
146 | const data = chart.data.datasets as IEnhancedChartDataSet[];
147 |
148 | // use splice since Chart.js is tracking the array using this method to have a proper animation
149 | const removed = labels.splice(index, count, ...toAdd);
150 | removed.forEach((d) => {
151 | d.hidden = true;
152 | });
153 | toAdd.forEach((d) => {
154 | d.hidden = false;
155 | });
156 | // update since line doesn't call it by itself
157 | findScale(chart)?.determineDataLimits();
158 |
159 | data.forEach((dataset) => {
160 | const toAddData = toAdd.map((d) => resolve(d, flatLabels, dataset.tree));
161 | dataset.data?.splice(index, count, ...toAddData);
162 | });
163 | }
164 |
165 | function collapse(chart: IEnhancedChart, index: number, parent: ILabelNode) {
166 | const count = countExpanded(parent);
167 | // collapse sub structures, too
168 | parent.children.forEach((c) =>
169 | preOrderTraversal(c, (d) => {
170 | d.expand = false;
171 | })
172 | );
173 | expandCollapse(chart, index, count, [parent]);
174 | parent.expand = false;
175 |
176 | postDataUpdate(chart);
177 | }
178 |
179 | function expand(chart: IEnhancedChart, index: number, node: ILabelNode) {
180 | expandCollapse(chart, index, 1, node.children);
181 | node.expand = true;
182 |
183 | postDataUpdate(chart);
184 | }
185 |
186 | function zoomIn(chart: IEnhancedChart, lastIndex: number, parent: ILabelNode, flat: ILabelNodes) {
187 | const count = countExpanded(parent);
188 | // reset others
189 | flat.forEach((d) => {
190 | if (d.expand === 'focus') {
191 | d.expand = true;
192 | }
193 | });
194 | parent.expand = 'focus';
195 |
196 | const index = lastIndex - count + 1;
197 |
198 | const { labels } = chart.data;
199 | labels.splice(lastIndex + 1, labels.length);
200 | labels.splice(0, index);
201 | // update since line doesn't call it by itself
202 | findScale(chart)?.determineDataLimits();
203 |
204 | const data = chart.data.datasets;
205 | data.forEach((dataset) => {
206 | if (dataset.data) {
207 | dataset.data.splice(lastIndex + 1, dataset.data.length);
208 | dataset.data.splice(0, index);
209 | }
210 | });
211 |
212 | postDataUpdate(chart);
213 | }
214 |
215 | function zoomOut(chart: IEnhancedChart, parent: ILabelNode) {
216 | const labels = chart.data.labels as ILabelNode[];
217 | const flatLabels = chart.data.flatLabels ?? [];
218 |
219 | parent.expand = true;
220 | const nextLabels = flatLabels.filter((d) => !d.hidden);
221 | const index = nextLabels.indexOf(labels[0]);
222 | const count = labels.length;
223 |
224 | labels.splice(labels.length, 0, ...nextLabels.slice(index + count));
225 | labels.splice(0, 0, ...nextLabels.slice(0, index));
226 | // update since line doesn't call it by itself
227 | findScale(chart)?.determineDataLimits();
228 |
229 | const data = chart.data.datasets as IEnhancedChartDataSet[];
230 | data.forEach((dataset) => {
231 | const toAddBefore = nextLabels.slice(0, index).map((d) => resolve(d, flatLabels, dataset.tree));
232 | const toAddAfter = nextLabels.slice(index + count).map((d) => resolve(d, flatLabels, dataset.tree));
233 |
234 | if (dataset.data) {
235 | dataset.data.splice(dataset.data.length, 0, ...toAddAfter);
236 | dataset.data.splice(0, 0, ...toAddBefore);
237 | }
238 | });
239 |
240 | postDataUpdate(chart);
241 | }
242 |
243 | function resolveElement(event: { x: number; y: number }, scale: HierarchicalScale) {
244 | const hor = scale.isHorizontal();
245 | const offset = hor ? scale.bottom + scale.options.padding : scale.left - scale.options.padding;
246 | if ((hor && event.y <= offset) || (!hor && event.x > offset)) {
247 | return null;
248 | }
249 | const index = scale.getValueForPixel(hor ? event.x - scale.left : event.y - scale.top);
250 | return {
251 | offset,
252 | index,
253 | };
254 | }
255 |
256 | function handleClickEvents(
257 | chart: Chart,
258 | _event: unknown,
259 | elem: { offset: number; index: number },
260 | offsetDelta: number,
261 | inRange: (v: number) => boolean,
262 | reverse: boolean = false
263 | ) {
264 | const cc = chart as unknown as IEnhancedChart;
265 | let { offset } = elem;
266 |
267 | const { index } = elem;
268 | const flat = cc.data.flatLabels ?? [];
269 | const label = cc.data.labels?.[index] as unknown as ILabelNode;
270 | if (!label) {
271 | return;
272 | }
273 | const parents = parentsOf(label, flat);
274 | const maxDepth = getMaxDepth((cc.data.rootNodes || []) as Array);
275 | if (reverse) offset += maxDepth * offsetDelta;
276 |
277 | for (let i = 1; i < parents.length; i += 1, reverse ? (offset -= offsetDelta) : (offset += offsetDelta)) {
278 | if (!inRange(offset)) {
279 | continue;
280 | }
281 | const node = parents[i];
282 | const isParentOfFirstChild = node.children[0] === parents[i + 1] || i === parents.length - 1;
283 |
284 | const parent = flat[node.parent];
285 |
286 | // first child of expanded parent
287 | if (isParentOfFirstChild && node.relIndex === 0 && parent.expand === true) {
288 | collapse(cc, index, parent);
289 | return;
290 | }
291 | const isLastChildOfParent = lastOfLevel(node, flat) === label; // leaf = current node
292 |
293 | // last index of focussed parent
294 | if (isLastChildOfParent && parent.expand === 'focus') {
295 | zoomOut(cc, parent);
296 | return;
297 | }
298 | // last index of expanded parent
299 | if (
300 | isLastChildOfParent &&
301 | parent.expand === true &&
302 | flatChildren(parent, flat).every((d) => d.expand !== 'focus')
303 | ) {
304 | zoomIn(cc, index, parent, flat);
305 | return;
306 | }
307 | }
308 |
309 | if (label.children.length > 0 && inRange(offset)) {
310 | // expand
311 | expand(cc, index, label);
312 | }
313 | }
314 |
315 | const hierarchicalPlugin: Plugin = {
316 | id: 'hierarchical',
317 |
318 | beforeUpdate(chart: Chart): void {
319 | if (!enabled(chart)) {
320 | return;
321 | }
322 | check(chart as unknown as IEnhancedChart);
323 | },
324 |
325 | /**
326 | * draw the hierarchy indicators
327 | */
328 | beforeDatasetsDraw(chart: Chart): void {
329 | if (!enabled(chart)) {
330 | return;
331 | }
332 | const cc = chart as unknown as IEnhancedChart;
333 | const scale = findScale(chart);
334 | const { ctx } = chart;
335 | if (!scale || !ctx) {
336 | return;
337 | }
338 | const flat = cc.data.flatLabels ?? [];
339 | const visible = chart.data.labels as unknown as ILabelNodes;
340 | const roots = cc.data.rootNodes ?? [];
341 | const visibleNodes = new Set(visible);
342 | const hor = scale.isHorizontal();
343 |
344 | const boxSize = scale.options.hierarchyBoxSize;
345 | const boxSize05 = boxSize * 0.5;
346 | const boxSize01 = boxSize * 0.1;
347 | const boxRow = scale.options.hierarchyBoxLineHeight;
348 | const boxColor = scale.options.hierarchyBoxColor;
349 | const boxWidth = scale.options.hierarchyBoxWidth;
350 | const boxSpanColor = scale.options.hierarchySpanColor;
351 | const boxSpanWidth = scale.options.hierarchySpanWidth;
352 | const renderLabel = scale.options.hierarchyLabelPosition;
353 | const groupLabelPosition = scale.options.hierarchyGroupLabelPosition;
354 | const isStatic = scale.options.static;
355 |
356 | const scaleLabel = scale.options.title;
357 | const scaleReverse = scale.options.reverseOrder;
358 | const scaleLabelFontColor = valueOrDefault(scaleLabel.color, defaults.color as Color);
359 | const scaleLabelFont = toFont(scaleLabel.font as Partial);
360 |
361 | function renderButton(type: 'expand' | 'collapse' | 'focus', vert: boolean, x: number, y: number) {
362 | if (isStatic) {
363 | if (type === 'expand') {
364 | return;
365 | }
366 | ctx.save();
367 | ctx.strokeStyle = boxSpanColor;
368 | ctx.lineWidth = boxSpanWidth;
369 | ctx.beginPath();
370 | if (vert) {
371 | ctx.moveTo(x - boxSize01, y);
372 | ctx.lineTo(x - boxSize05, y);
373 | } else {
374 | ctx.moveTo(x, y + boxSize01);
375 | ctx.lineTo(x, y + boxSize05);
376 | ctx.lineTo(x + (type === 'collapse' ? boxSize05 : -boxSize05), y + boxSize05);
377 | }
378 | ctx.stroke();
379 | ctx.restore();
380 | return;
381 | }
382 | const x0 = x - (vert ? boxSize : boxSize05);
383 | const y0 = y - (vert ? boxSize05 : 0);
384 |
385 | ctx.strokeRect(x0, y0, boxSize, boxSize);
386 |
387 | switch (type) {
388 | case 'expand':
389 | // +
390 | ctx.fillRect(x0 + 2, y0 + boxSize05 - 1, boxSize - 4, 2);
391 | ctx.fillRect(x0 + boxSize05 - 1, y0 + 2, 2, boxSize - 4);
392 | break;
393 | case 'collapse':
394 | // -
395 | ctx.fillRect(x0 + 2, y0 + boxSize05 - 1, boxSize - 4, 2);
396 | break;
397 | case 'focus':
398 | // .
399 | ctx.fillRect(x0 + boxSize05 - 2, y0 + boxSize05 - 2, 4, 4);
400 | }
401 | }
402 |
403 | ctx.save();
404 | ctx.strokeStyle = boxColor;
405 | ctx.lineWidth = boxWidth;
406 | ctx.fillStyle = scaleLabelFontColor!; // render in correct color
407 | ctx.font = scaleLabelFont.string;
408 |
409 | const renderHorLevel = (node: ILabelNode, maxDepth: number = 0) => {
410 | if (node.children.length === 0) {
411 | return false;
412 | }
413 | let offset = node.level * boxRow;
414 | if (scaleReverse) {
415 | offset = maxDepth * boxRow - node.level * boxRow;
416 | }
417 |
418 | if (!node.expand) {
419 | if (visibleNodes.has(node)) {
420 | renderButton('expand', false, node.center, offset);
421 | }
422 | return false;
423 | }
424 | const r = spanLogic(node, flat, visibleNodes, groupLabelPosition);
425 | if (!r) {
426 | return false;
427 | }
428 | const {
429 | hasFocusBox,
430 | hasCollapseBox,
431 | leftVisible,
432 | rightVisible,
433 | leftFirstVisible,
434 | rightLastVisible,
435 | groupLabelCenter,
436 | } = r;
437 |
438 | // render group label
439 | if (renderLabel === 'below') {
440 | ctx.fillText(node.label, groupLabelCenter, offset + boxSize);
441 | } else if (renderLabel === 'above') {
442 | ctx.fillText(node.label, groupLabelCenter, offset - boxSize);
443 | }
444 | if (hasCollapseBox) {
445 | renderButton('collapse', false, leftVisible.center, offset);
446 | }
447 | if (hasFocusBox) {
448 | renderButton('focus', false, rightVisible.center, offset);
449 | }
450 |
451 | if (leftVisible !== rightVisible) {
452 | // helper span line
453 | ctx.strokeStyle = boxSpanColor;
454 | ctx.lineWidth = boxSpanWidth;
455 | ctx.beginPath();
456 | if (hasCollapseBox) {
457 | // stitch to box
458 | ctx.moveTo(leftVisible.center + boxSize05, offset + boxSize05);
459 | } else if (leftFirstVisible) {
460 | // add starting group hint
461 | ctx.moveTo(leftVisible.center, offset + boxSize01);
462 | ctx.lineTo(leftVisible.center, offset + boxSize05);
463 | } else {
464 | // just a line
465 | ctx.moveTo(leftVisible.center, offset + boxSize05);
466 | }
467 |
468 | if (hasFocusBox) {
469 | ctx.lineTo(rightVisible.center - boxSize05, offset + boxSize05);
470 | } else if (rightLastVisible) {
471 | ctx.lineTo(rightVisible.center, offset + boxSize05);
472 | ctx.lineTo(rightVisible.center, offset + boxSize01);
473 | } else {
474 | ctx.lineTo(rightVisible.center, offset + boxSize05);
475 | }
476 | ctx.stroke();
477 | ctx.strokeStyle = boxColor;
478 | ctx.lineWidth = boxWidth;
479 | }
480 |
481 | return true;
482 | };
483 |
484 | const renderVertLevel = (node: ILabelNode, maxDepth: number = 0) => {
485 | if (node.children.length === 0) {
486 | return false;
487 | }
488 | let offset = node.level * boxRow * -1;
489 | if (scaleReverse) {
490 | offset = (maxDepth * boxRow - node.level * boxRow) * -1;
491 | }
492 |
493 | if (!node.expand) {
494 | if (visibleNodes.has(node)) {
495 | renderButton('expand', true, offset, node.center);
496 | }
497 | return false;
498 | }
499 | const r = spanLogic(node, flat, visibleNodes, groupLabelPosition);
500 | if (!r) {
501 | return false;
502 | }
503 | const {
504 | hasFocusBox,
505 | hasCollapseBox,
506 | leftVisible,
507 | rightVisible,
508 | leftFirstVisible,
509 | rightLastVisible,
510 | groupLabelCenter,
511 | } = r;
512 |
513 | // render group label
514 | ctx.fillText(node.label, offset - boxSize, groupLabelCenter);
515 |
516 | if (hasCollapseBox) {
517 | renderButton('collapse', true, offset, leftVisible.center);
518 | }
519 | if (hasFocusBox) {
520 | renderButton('focus', true, offset, rightVisible.center);
521 | }
522 |
523 | if (leftVisible !== rightVisible) {
524 | // helper span line
525 | ctx.strokeStyle = boxSpanColor;
526 | ctx.lineWidth = boxSpanWidth;
527 | ctx.beginPath();
528 | if (hasCollapseBox) {
529 | // stitch to box
530 | ctx.moveTo(offset - boxSize05, leftVisible.center + boxSize05);
531 | } else if (leftFirstVisible) {
532 | // add starting group hint
533 | ctx.moveTo(offset - boxSize01, leftVisible.center);
534 | ctx.lineTo(offset - boxSize05, leftVisible.center);
535 | } else {
536 | // just a line
537 | ctx.lineTo(offset - boxSize05, leftVisible.center);
538 | }
539 |
540 | if (hasFocusBox) {
541 | ctx.lineTo(offset - boxSize05, rightVisible.center - boxSize05);
542 | } else if (rightLastVisible) {
543 | ctx.lineTo(offset - boxSize05, rightVisible.center - boxSize05);
544 | ctx.lineTo(offset - boxSize01, rightVisible.center - boxSize05);
545 | } else {
546 | ctx.lineTo(offset - boxSize05, rightVisible.center);
547 | }
548 | ctx.stroke();
549 | ctx.strokeStyle = boxColor;
550 | ctx.lineWidth = boxWidth;
551 | }
552 |
553 | return true;
554 | };
555 |
556 | const maxLevel = getMaxDepth(roots as Array);
557 | if (hor) {
558 | ctx.textAlign = 'center';
559 | ctx.textBaseline = renderLabel === 'above' ? 'bottom' : 'top';
560 | ctx.translate(scale.left, scale.bottom + scale.options.padding);
561 | roots.forEach((n) => preOrderTraversal(n, (m) => renderHorLevel(m, maxLevel)));
562 | } else {
563 | ctx.textAlign = 'right';
564 | ctx.textBaseline = 'middle';
565 | ctx.translate(scale.left - scale.options.padding, scale.top);
566 |
567 | roots.forEach((k) => preOrderTraversal(k, (l) => renderVertLevel(l, maxLevel)));
568 | }
569 |
570 | ctx.restore();
571 | },
572 |
573 | beforeEvent(chart: Chart, { event }) {
574 | if (event.type !== 'click' || !enabled(chart)) {
575 | return;
576 | }
577 | const clickEvent = event as { x: number; y: number };
578 |
579 | const scale = findScale(chart);
580 | if (!scale || scale.options.static) {
581 | return;
582 | }
583 | const hor = scale.isHorizontal();
584 |
585 | const elem = resolveElement(clickEvent, scale);
586 | if (!elem) {
587 | return;
588 | }
589 |
590 | const boxRow = scale.options.hierarchyBoxLineHeight;
591 | const reverse = scale.options.reverseOrder;
592 | const inRange = hor
593 | ? (o: number) => clickEvent.y >= o && clickEvent.y <= o + boxRow
594 | : (o: number) => clickEvent.x <= o && clickEvent.x >= o - boxRow;
595 | const offsetDelta = hor ? boxRow : -boxRow;
596 | handleClickEvents(chart, event, elem, offsetDelta, inRange, reverse);
597 | },
598 | };
599 |
600 | export default hierarchicalPlugin;
601 |
--------------------------------------------------------------------------------
/src/plugin/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './hierarchical';
2 |
--------------------------------------------------------------------------------
/src/scale/hierarchical.ts:
--------------------------------------------------------------------------------
1 | import { CategoryScale, CategoryScaleOptions, registry } from 'chart.js';
2 | import { merge } from 'chart.js/helpers';
3 | import hierarchicalPlugin from '../plugin';
4 | import { parentsOf } from '../utils';
5 | import type { ILabelNodes, IEnhancedChart, IValueNode } from '../model';
6 |
7 | export interface IHierarchicalScaleOptions extends CategoryScaleOptions {
8 | /**
9 | * ratio by which the distance between two elements shrinks the higher the level of the tree is. i.e. two two level bars have a distance of 1. two nested one just 0.75
10 | * @default 0.75
11 | */
12 | levelPercentage: number;
13 | /**
14 | * padding of the first collapse to the start of the x-axis
15 | * @default 25
16 | */
17 | padding: number;
18 | /**
19 | * position of the hierarchy label in expanded levels, 'none' to disable
20 | * @default 'below'
21 | */
22 | hierarchyLabelPosition: 'below' | 'above' | 'none' | null;
23 |
24 | /**
25 | * position of the hierarchy group label relative to the its children
26 | * @default between-first-and-second
27 | */
28 | hierarchyGroupLabelPosition: 'center' | 'first' | 'last' | 'between-first-and-second';
29 |
30 | /**
31 | * whether interactive buttons should be shown or whether it should be static
32 | * @default false
33 | */
34 | static: boolean;
35 | /**
36 | * size of the box to draw
37 | */
38 | hierarchyBoxSize: number;
39 | /**
40 | * distance between two hierarchy indicators
41 | */
42 | hierarchyBoxLineHeight: number;
43 | /**
44 | * color of the line indicator hierarchy children
45 | */
46 | hierarchySpanColor: string;
47 | /**
48 | * stroke width of the line
49 | */
50 | hierarchySpanWidth: number;
51 | /**
52 | * color of the box to toggle collapse/expand
53 | */
54 | hierarchyBoxColor: string;
55 | /**
56 | * stroke width of the toggle box
57 | */
58 | hierarchyBoxWidth: number;
59 |
60 | /**
61 | * object of attributes that should be managed and extracted from the tree
62 | * data structures such as `backgroundColor` for coloring individual bars
63 | * the object contains the key and default value
64 | * @default {}
65 | */
66 | attributes: { [attribute: string]: any };
67 |
68 | offset: true;
69 | /**
70 | * if reverseOrder is true the lowest hierarchy level is on axis level and the highest level is the one furthest from axis
71 | * @default false
72 | */
73 | reverseOrder: boolean;
74 | }
75 |
76 | const defaultConfig: Partial> & {
77 | grid: Partial;
78 | } = {
79 | // offset settings, for centering the categorical axis in the bar chart case
80 | offset: true,
81 |
82 | // grid line settings
83 | grid: {
84 | offset: true,
85 | },
86 |
87 | static: false,
88 |
89 | /**
90 | * reduce the space between items at level X by this factor
91 | */
92 | levelPercentage: 0.75,
93 |
94 | /**
95 | * top/left padding for showing the hierarchy marker
96 | */
97 | padding: 5,
98 | /**
99 | * position of the hierarchy label
100 | * possible values: 'below', 'above', 'none' to disable
101 | */
102 | hierarchyLabelPosition: 'below' as 'below' | 'above' | null | 'none',
103 |
104 | hierarchyGroupLabelPosition: 'between-first-and-second',
105 | /**
106 | * size of the box to draw
107 | */
108 | hierarchyBoxSize: 14,
109 | /**
110 | * distance between two hierarchy indicators
111 | */
112 | hierarchyBoxLineHeight: 30,
113 | /**
114 | * color of the line indicator hierarchy children
115 | */
116 | hierarchySpanColor: 'gray',
117 | /**
118 | * stroke width of the line
119 | */
120 | hierarchySpanWidth: 2,
121 | /**
122 | * color of the box to toggle collapse/expand
123 | */
124 | hierarchyBoxColor: 'gray',
125 | /**
126 | * stroke width of the toggle box
127 | */
128 | hierarchyBoxWidth: 1,
129 |
130 | attributes: {},
131 | /**
132 | * if reverseOrder is true the lowest hierarchy level is on axis level and the highest level is the one furthest from axis
133 | * @default false
134 | */
135 | reverseOrder: false,
136 | };
137 |
138 | export interface IInternalScale {
139 | _valueRange: number;
140 | _startValue: number;
141 | _startPixel: number;
142 | _length: number;
143 | }
144 |
145 | export class HierarchicalScale extends CategoryScale {
146 | /**
147 | * @hidden
148 | */
149 | private _nodes: ILabelNodes = [];
150 |
151 | /**
152 | * @hidden
153 | */
154 | determineDataLimits(): void {
155 | const labels = this.getLabels() as unknown as ILabelNodes;
156 |
157 | // labels are already prepared by the plugin just use them as ticks
158 | this._nodes = labels.slice();
159 |
160 | super.determineDataLimits();
161 | }
162 |
163 | /**
164 | * @hidden
165 | */
166 | buildTicks(): {
167 | label: string;
168 | value: number;
169 | }[] {
170 | const nodes = this._nodes.slice(this.min, this.max + 1);
171 |
172 | const me = this as unknown as IInternalScale;
173 | me._valueRange = Math.max(nodes.length, 1);
174 | me._startValue = this.min - 0.5;
175 | if (nodes.length === 0) {
176 | return [];
177 | }
178 |
179 | return nodes.map((d, i) => ({ label: d.label, value: i })); // copy since mutated during auto skip
180 | }
181 |
182 | /**
183 | * @hidden
184 | */
185 | configure(): void {
186 | super.configure();
187 | const nodes = this._nodes.slice(this.min, this.max + 1);
188 | const flat = (this.chart as unknown as IEnhancedChart).data.flatLabels ?? [];
189 | const total = (this as unknown as IInternalScale)._length;
190 |
191 | if (nodes.length === 0) {
192 | return;
193 | }
194 |
195 | // optimize such that the distance between two points on the same level is same
196 | // creating a grouping effect of nodes
197 | const ratio = this.options.levelPercentage;
198 |
199 | const distances: number[] = [];
200 |
201 | let prev = nodes[0];
202 | let prevParents = parentsOf(prev, flat);
203 | distances.push(0.5); // half top level distance before and after
204 |
205 | for (let i = 1; i < nodes.length; i += 1) {
206 | const n = nodes[i];
207 | const parents = parentsOf(n, flat);
208 | if (prev.parent === n.parent) {
209 | // same parent -> can use the level distance
210 | distances.push(Math.pow(ratio, n.level));
211 | } else {
212 | // different level -> use the distance of the common parent
213 | // find level of common parent
214 | let common = 0;
215 | while (parents[common] === prevParents[common]) {
216 | common += 1;
217 | }
218 | distances.push(Math.pow(ratio, common));
219 | }
220 | prev = n;
221 | prevParents = parents;
222 | }
223 | distances.push(0.5);
224 |
225 | const distance = distances.reduce((acc, s) => acc + s, 0);
226 | const factor = total / distance;
227 |
228 | let offset = distances[0] * factor;
229 | nodes.forEach((node, i) => {
230 | const previous = distances[i] * factor;
231 | const next = distances[i + 1] * factor;
232 |
233 | node.center = offset;
234 | offset += next;
235 |
236 | node.width = Math.min(next, previous) / 2;
237 | });
238 | }
239 |
240 | /**
241 | * @hidden
242 | */
243 | getPixelForDecimal(value: number): number {
244 | const index = Math.min(Math.floor(value * this._nodes.length), this._nodes.length - 1);
245 |
246 | if (index === 1 && this._nodes.length === 1) {
247 | // corner case in chartjs to determine tick width, hard coded 1
248 | return this._nodes[0].width;
249 | }
250 | return this._centerBase(index);
251 | }
252 |
253 | /**
254 | * @hidden
255 | */
256 | _centerBase(index: number): number {
257 | const centerTick = this.options.offset;
258 | const base = (this as unknown as IInternalScale)._startPixel;
259 | const node = this._nodes[index];
260 |
261 | if (node == null) {
262 | return base;
263 | }
264 |
265 | const nodeCenter = node.center != null ? node.center : 0;
266 | const nodeWidth = node.width != null ? node.width : 0;
267 | return base + nodeCenter - (centerTick ? 0 : nodeWidth / 2);
268 | }
269 |
270 | /**
271 | * @hidden
272 | */
273 | getValueForPixel(pixel: number): number {
274 | return this._nodes.findIndex((d) => pixel >= d.center - d.width / 2 && pixel <= d.center + d.width / 2);
275 | }
276 |
277 | /**
278 | * @hidden
279 | */
280 | static id = 'hierarchical';
281 |
282 | /**
283 | * @hidden
284 | */
285 | static defaults: any = /*! __PURE__ */ merge({}, [CategoryScale.defaults, defaultConfig]);
286 |
287 | /**
288 | * @hidden
289 | */
290 | static afterRegister(): void {
291 | registry.addPlugins(hierarchicalPlugin);
292 | }
293 | }
294 |
295 | export interface HierarchicalScaleType extends Partial {
296 | type: 'hierarchical';
297 | }
298 |
299 | declare module 'chart.js' {
300 | export interface ControllerDatasetOptions {
301 | tree?: (IValueNode | number)[];
302 | }
303 | export interface CartesianScaleTypeRegistry {
304 | hierarchical: {
305 | options: IHierarchicalScaleOptions;
306 | };
307 | }
308 | }
309 |
--------------------------------------------------------------------------------
/src/scale/index.ts:
--------------------------------------------------------------------------------
1 | export * from './hierarchical';
2 |
--------------------------------------------------------------------------------
/src/utils.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | asNode,
3 | toNodes,
4 | parentsOf,
5 | lastOfLevel,
6 | countExpanded,
7 | spanLogic,
8 | flatChildren,
9 | determineVisible,
10 | ISpanLogicResult,
11 | } from './utils';
12 | import type { ILabelNode, IRawLabelNode } from './model';
13 | import { describe, test, expect } from 'vitest';
14 | function nodeTest(
15 | n: ILabelNode,
16 | label: string,
17 | options: { childCount?: number; level?: number; hidden?: boolean } = {}
18 | ) {
19 | const { childCount, level, hidden } = {
20 | childCount: 0,
21 | level: 0,
22 | hidden: false,
23 | ...options,
24 | };
25 | expect(n).toBeDefined();
26 | expect(n.label).toBe(label);
27 | expect(n.children.length).toBe(childCount);
28 | expect(n.expand).toBe(false);
29 | expect(n.level).toBe(level);
30 | expect(n.center).toBeNaN();
31 | expect(n.width).toBe(0);
32 | expect(n.hidden).toBe(hidden);
33 | expect(n.major).toBe(level === 0);
34 | }
35 |
36 | function treeNodeTest(n: ILabelNode, parent: number, relIndex: number, index: number) {
37 | expect(n.relIndex).toBe(relIndex);
38 | expect(n.parent).toBe(parent);
39 | expect(n.index).toBe(index);
40 | expect(n.index).toBe(index);
41 | }
42 |
43 | function setupNodes(def: readonly (string | IRawLabelNode)[]) {
44 | const flat = toNodes(def);
45 | const root = flat.filter((d) => d.parent === -1);
46 | const visible = new Set(determineVisible(flat));
47 | return { flat, root, visible };
48 | }
49 |
50 | describe('asNode', () => {
51 | test('from label', () => {
52 | const n = asNode('test');
53 | nodeTest(n, 'test');
54 | });
55 |
56 | test('object', () => {
57 | const n = asNode({
58 | label: 'test2',
59 | });
60 | nodeTest(n, 'test2');
61 | });
62 |
63 | test('object with children', () => {
64 | const n = asNode({
65 | label: 'test3',
66 | children: ['abc', 'def'],
67 | });
68 | nodeTest(n, 'test3', { childCount: 2 });
69 | nodeTest(n.children[0], 'abc', { level: 1 });
70 | nodeTest(n.children[1], 'def', { level: 1 });
71 | });
72 |
73 | test('object with children complex', () => {
74 | const n = asNode({
75 | label: 'test3',
76 | children: [
77 | {
78 | label: 'test4',
79 | children: ['abc'],
80 | },
81 | 'def',
82 | ],
83 | });
84 | nodeTest(n, 'test3', { childCount: 2 });
85 | nodeTest(n.children[0], 'test4', { childCount: 1, level: 1 });
86 | nodeTest(n.children[0].children[0], 'abc', { level: 2 });
87 | nodeTest(n.children[1], 'def', { level: 1 });
88 | });
89 | });
90 |
91 | describe('toNodes', () => {
92 | test('simple', () => {
93 | const nodes = toNodes(['a', 'b', 'c']);
94 | expect(nodes.length).toBe(3);
95 | nodeTest(nodes[0], 'a');
96 | treeNodeTest(nodes[0], -1, 0, 0);
97 | nodeTest(nodes[1], 'b');
98 | treeNodeTest(nodes[1], -1, 1, 1);
99 | nodeTest(nodes[2], 'c');
100 | treeNodeTest(nodes[2], -1, 2, 2);
101 | });
102 |
103 | test('hierarchy', () => {
104 | const nodes = toNodes([{ label: 'a', children: ['aa', 'ab'] }, 'b']);
105 | expect(nodes.length).toBe(4);
106 | nodeTest(nodes[0], 'a', { childCount: 2 });
107 | treeNodeTest(nodes[0], -1, 0, 0);
108 | nodeTest(nodes[1], 'aa', { level: 1, hidden: true });
109 | treeNodeTest(nodes[1], 0, 0, 1);
110 | nodeTest(nodes[2], 'ab', { level: 1, hidden: true });
111 | treeNodeTest(nodes[2], 0, 1, 2);
112 | nodeTest(nodes[3], 'b');
113 | treeNodeTest(nodes[3], -1, 1, 3);
114 | });
115 | });
116 |
117 | describe('parentsOf', () => {
118 | test('simple', () => {
119 | const nodes = toNodes([{ label: 'a', children: ['aa'] }]);
120 | const child = nodes[1];
121 | expect(child.parent).toBe(0);
122 |
123 | const parents = parentsOf(child, nodes);
124 | expect(parents.length).toBe(2);
125 | expect(parents[0]).toBe(nodes[0]);
126 | expect(parents[1]).toBe(child);
127 | });
128 | });
129 |
130 | describe('lastOfLevel', () => {
131 | test('simple', () => {
132 | const nodes = toNodes([{ label: 'a', children: ['aa'] }, 'b', 'c']);
133 |
134 | const last = lastOfLevel(nodes[0], nodes);
135 | expect(last).toBe(nodes[3]);
136 | });
137 | });
138 |
139 | describe('flatChildren', () => {
140 | test('a(aa)', () => {
141 | const { flat, root } = setupNodes([{ label: 'a', children: ['aa'] }, 'b', 'c']);
142 |
143 | const a = root[0];
144 | const children = flatChildren(a, flat);
145 | expect(children).toEqual(a.children);
146 | });
147 |
148 | test('b', () => {
149 | const { flat, root } = setupNodes([{ label: 'a', children: ['aa'] }, 'b', 'c']);
150 |
151 | const b = root[1];
152 | const children = flatChildren(b, flat);
153 | expect(children).toEqual([]);
154 | });
155 |
156 | test('c', () => {
157 | const { flat, root } = setupNodes([{ label: 'a', children: ['aa'] }, 'b', 'c']);
158 |
159 | const c = root[2];
160 | const children = flatChildren(c, flat);
161 | expect(children).toEqual([]);
162 | });
163 |
164 | test('a nested', () => {
165 | const { flat, root } = setupNodes([
166 | { label: 'a', children: ['aa', { label: 'ab', children: ['aba', 'abb'] }] },
167 | 'b',
168 | 'c',
169 | ]);
170 |
171 | const a = root[0];
172 | const children = flatChildren(a, flat);
173 | expect(children).toEqual(flat.slice(1, 1 + 4));
174 | });
175 |
176 | test('ab nested', () => {
177 | const { flat, root } = setupNodes([
178 | { label: 'a', children: ['aa', { label: 'ab', children: ['aba', 'abb'] }] },
179 | 'b',
180 | 'c',
181 | ]);
182 |
183 | const ab = root[0].children[1];
184 | const children = flatChildren(ab, flat);
185 | expect(children).toEqual(ab.children);
186 | });
187 | });
188 |
189 | describe('countExpanded', () => {
190 | test('simple', () => {
191 | const nodes = toNodes([{ label: 'a', children: ['aa'] }, 'b', 'c']);
192 |
193 | const count = countExpanded(nodes[0]);
194 | expect(count).toBe(1);
195 | });
196 |
197 | test('simple2', () => {
198 | const nodes = toNodes([{ label: 'a', children: ['aa', 'bb'], expand: true }, 'b', 'c']);
199 |
200 | const count = countExpanded(nodes[0]);
201 | expect(count).toBe(2);
202 | });
203 |
204 | test('simple3', () => {
205 | const nodes = toNodes([
206 | { label: 'a', children: ['aa', { expand: true, label: 'bb', children: ['bba', 'bbb'] }], expand: true },
207 | 'b',
208 | 'c',
209 | ]);
210 |
211 | const count = countExpanded(nodes[0]);
212 | expect(count).toBe(3);
213 | });
214 | });
215 |
216 | describe('spanLogic', () => {
217 | test('root level visible a', () => {
218 | // a b c
219 | const { flat, root, visible } = setupNodes([{ label: 'a', children: ['aa'] }, 'b', 'c']);
220 |
221 | const a = root[0];
222 |
223 | const r = spanLogic(a, flat, visible);
224 | expect(r).toBe(false);
225 | });
226 |
227 | test('root level visible b', () => {
228 | // a b c
229 | const { flat, root, visible } = setupNodes([{ label: 'a', children: ['aa'] }, 'b', 'c']);
230 |
231 | const b = root[1];
232 |
233 | const r = spanLogic(b, flat, visible);
234 | expect(r).toBe(false);
235 | });
236 |
237 | test('root level visible a (aa) expanded', () => {
238 | // aa b c
239 | const { flat, root, visible } = setupNodes([{ label: 'a', children: ['aa'], expand: true }, 'b', 'c']);
240 |
241 | const a = root[0];
242 | const aa = a.children[0];
243 |
244 | const r = spanLogic(a, flat, visible) as ISpanLogicResult;
245 | expect(r).not.toBe(false);
246 |
247 | const {
248 | hasCollapseBox,
249 | hasFocusBox,
250 | leftVisible,
251 | rightVisible,
252 | groupLabelCenter,
253 | leftFirstVisible,
254 | rightLastVisible,
255 | } = r;
256 | expect(leftVisible).toBe(aa);
257 | expect(rightVisible).toBe(aa);
258 |
259 | expect(hasCollapseBox).toBe(true);
260 | expect(hasFocusBox).toBe(false); // single child no space for both
261 |
262 | expect(groupLabelCenter).toBe(aa.center);
263 | expect(leftFirstVisible).toBe(true);
264 | expect(rightLastVisible).toBe(true);
265 | });
266 |
267 | test('root level visible a (aa,ab) expanded', () => {
268 | // aa ab b c
269 | const { flat, root, visible } = setupNodes([{ label: 'a', children: ['aa', 'ab'], expand: true }, 'b', 'c']);
270 |
271 | const a = root[0];
272 | const aa = a.children[0];
273 | const ab = a.children[1];
274 |
275 | const r = spanLogic(a, flat, visible) as ISpanLogicResult;
276 | expect(r).not.toBe(false);
277 |
278 | const {
279 | hasCollapseBox,
280 | hasFocusBox,
281 | leftVisible,
282 | rightVisible,
283 | groupLabelCenter,
284 | leftFirstVisible,
285 | rightLastVisible,
286 | } = r;
287 | expect(leftVisible).toBe(aa);
288 | expect(rightVisible).toBe(ab);
289 |
290 | expect(hasCollapseBox).toBe(true);
291 | expect(hasFocusBox).toBe(true);
292 | expect(groupLabelCenter).toBe((aa.center + ab.center) / 2);
293 | expect(leftFirstVisible).toBe(true);
294 | expect(rightLastVisible).toBe(true);
295 | });
296 |
297 | test('root level visible a (aa,ab,ac) expanded', () => {
298 | // aa ab ac b c
299 | const { flat, root, visible } = setupNodes([{ label: 'a', children: ['aa', 'ab', 'ac'], expand: true }, 'b', 'c']);
300 |
301 | const a = root[0];
302 | const aa = a.children[0];
303 | const ab = a.children[1];
304 | const ac = a.children[2];
305 |
306 | const r = spanLogic(a, flat, visible) as ISpanLogicResult;
307 | expect(r).not.toBe(false);
308 |
309 | const {
310 | hasCollapseBox,
311 | hasFocusBox,
312 | leftVisible,
313 | rightVisible,
314 | groupLabelCenter,
315 | leftFirstVisible,
316 | rightLastVisible,
317 | } = r;
318 | expect(leftVisible).toBe(aa);
319 | expect(rightVisible).toBe(ac);
320 |
321 | expect(hasCollapseBox).toBe(true);
322 | expect(hasFocusBox).toBe(true);
323 | expect(groupLabelCenter).toBe((aa.center + ab.center) / 2);
324 | expect(leftFirstVisible).toBe(true);
325 | expect(rightLastVisible).toBe(true);
326 | });
327 |
328 | test('root level visible a (aa,ab,ac) focus', () => {
329 | // aa ab ac
330 | const { flat, root, visible } = setupNodes([
331 | { label: 'a', children: ['aa', 'ab', 'ac'], expand: 'focus' },
332 | 'b',
333 | 'c',
334 | ]);
335 |
336 | const a = root[0];
337 | const aa = a.children[0];
338 | const ab = a.children[1];
339 | const ac = a.children[2];
340 |
341 | const r = spanLogic(a, flat, visible) as ISpanLogicResult;
342 | expect(r).not.toBe(false);
343 |
344 | const {
345 | hasCollapseBox,
346 | hasFocusBox,
347 | leftVisible,
348 | rightVisible,
349 | groupLabelCenter,
350 | leftFirstVisible,
351 | rightLastVisible,
352 | } = r;
353 | expect(leftVisible).toBe(aa);
354 | expect(rightVisible).toBe(ac);
355 |
356 | expect(hasCollapseBox).toBe(false); // since focussed
357 | expect(hasFocusBox).toBe(true);
358 | expect(groupLabelCenter).toBe((aa.center + ab.center) / 2);
359 | expect(leftFirstVisible).toBe(true);
360 | expect(rightLastVisible).toBe(true);
361 | });
362 |
363 | test('root level visible ac (aa,ab,(aca, acb)) focus', () => {
364 | // aca acb
365 | const { flat, root, visible } = setupNodes([
366 | { label: 'a', children: ['aa', 'ab', { label: 'ac', children: ['aca', 'acb'], expand: 'focus' }], expand: true },
367 | 'b',
368 | 'c',
369 | ]);
370 |
371 | const a = root[0];
372 | const aa = a.children[0];
373 | const ab = a.children[1];
374 | const ac = a.children[2];
375 | const aca = ac.children[0];
376 | const acb = ac.children[1];
377 |
378 | const r = spanLogic(a, flat, visible) as ISpanLogicResult;
379 | expect(r).not.toBe(false);
380 |
381 | const {
382 | hasCollapseBox,
383 | hasFocusBox,
384 | leftVisible,
385 | rightVisible,
386 | groupLabelCenter,
387 | leftFirstVisible,
388 | rightLastVisible,
389 | } = r;
390 | expect(leftVisible).toBe(aca);
391 | expect(rightVisible).toBe(acb);
392 |
393 | expect(hasCollapseBox).toBe(false); // since focussed
394 | expect(hasFocusBox).toBe(false);
395 | expect(groupLabelCenter).toBe((aa.center + ab.center) / 2);
396 | expect(leftFirstVisible).toBe(false);
397 | expect(rightLastVisible).toBe(true);
398 | });
399 |
400 | test('root level visible c (ca,cb) expand', () => {
401 | // aa ab ac b c
402 | const { flat, root, visible } = setupNodes([
403 | { label: 'a', children: ['aa', 'ab', 'ac'] },
404 | 'b',
405 | { label: 'c', children: ['ca', 'cb'], expand: true },
406 | ]);
407 |
408 | const c = root[2];
409 | const ca = c.children[0];
410 | const cb = c.children[1];
411 |
412 | const r = spanLogic(c, flat, visible) as ISpanLogicResult;
413 | expect(r).not.toBe(false);
414 |
415 | const {
416 | hasCollapseBox,
417 | hasFocusBox,
418 | leftVisible,
419 | rightVisible,
420 | groupLabelCenter,
421 | leftFirstVisible,
422 | rightLastVisible,
423 | } = r;
424 | expect(leftVisible).toBe(ca);
425 | expect(rightVisible).toBe(cb);
426 |
427 | expect(hasCollapseBox).toBe(true);
428 | expect(hasFocusBox).toBe(true);
429 | expect(groupLabelCenter).toBe((ca.center + cb.center) / 2);
430 | expect(leftFirstVisible).toBe(true);
431 | expect(rightLastVisible).toBe(true);
432 | });
433 | });
434 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { ILabelNode, ILabelNodes, IValueNode, isValueNode, IRawLabelNode } from './model';
2 |
3 | /**
4 | * builds up recursively the label tree
5 | * @returns the node itself
6 | */
7 | export function asNode(label: string | IRawLabelNode, parent?: ILabelNode): ILabelNode {
8 | const node: ILabelNode = {
9 | index: 0,
10 | relIndex: 0,
11 | label: '',
12 | children: [],
13 | expand: false,
14 | parent: parent ? parent.index : -1,
15 | level: parent ? parent.level + 1 : 0,
16 | center: Number.NaN,
17 | width: 0,
18 | hidden: false,
19 | major: !parent, // for ticks,
20 | toString() {
21 | return this.label;
22 | },
23 | };
24 | if (typeof label === 'string') {
25 | node.label = label;
26 | } else {
27 | Object.assign(node, {
28 | ...label,
29 | children: (label.children ?? []).map((d) => asNode(d, node)),
30 | });
31 | }
32 | return node;
33 | }
34 |
35 | /**
36 | * pushes a node into the given flat array and updates the index information to avoid parent links
37 | */
38 | function push(node: ILabelNode, i: number, flat: ILabelNode[], parent?: ILabelNode) {
39 | node.relIndex = i;
40 | node.index = flat.length; // absolute index
41 | node.parent = parent ? parent.index : -1;
42 | // node is hidden if parent is visible or not expanded
43 | node.hidden = Boolean(parent ? parent.expand === false || node.expand : node.expand);
44 |
45 | flat.push(node);
46 |
47 | node.children.forEach((d, j) => push(d, j, flat, node));
48 | }
49 |
50 | /**
51 | * converts the given labels to a flat array of linked nodes
52 | */
53 | export function toNodes(labels: readonly (IRawLabelNode | string)[]): ILabelNodes {
54 | const nodes = labels.map((d) => asNode(d));
55 |
56 | const flat: ILabelNode[] = [];
57 | nodes.forEach((d, i) => push(d, i, flat));
58 |
59 | return flat;
60 | }
61 |
62 | /**
63 | * computes the parents (including itself) of the given node
64 | * @param {ILabelNode} node
65 | * @param {ILabelNode[]} flat flatArray for lookup
66 | */
67 | export function parentsOf(node: ILabelNode, flat: ILabelNodes): ILabelNodes {
68 | const parents = [node];
69 | while (parents[0].parent >= 0) {
70 | parents.unshift(flat[parents[0].parent]);
71 | }
72 | return parents;
73 | }
74 |
75 | /**
76 | * computes the right most grand child of expanded nodes
77 | */
78 | function rightMost(node: ILabelNode): ILabelNode {
79 | if (!node.expand || node.children.length === 0) {
80 | return node;
81 | }
82 | return rightMost(node.children[node.children.length - 1]);
83 | }
84 |
85 | /**
86 | * based on the visibility of the nodes computes the last node in the same level that is visible also considering expanded children
87 | */
88 | export function lastOfLevel(node: ILabelNode, flat: ILabelNodes): ILabelNode {
89 | if (node.parent > -1) {
90 | const parent = flat[node.parent];
91 | return rightMost(parent.children[parent.children.length - 1]);
92 | }
93 | // top level search last top level sibling
94 | const sibling =
95 | flat
96 | .slice()
97 | .reverse()
98 | .find((d) => d.parent === -1) ?? flat[0];
99 | return rightMost(sibling);
100 | }
101 |
102 | /**
103 | * traverses the tree in pre order logic
104 | * @param {ILabelNode} node
105 | * @param {} visit return false to skip the traversal of children
106 | */
107 | export function preOrderTraversal(node: ILabelNode, visit: (node: ILabelNode) => void | boolean): void {
108 | const goDeep = visit(node);
109 | if (goDeep !== false) {
110 | node.children.forEach((child) => preOrderTraversal(child, visit));
111 | }
112 | }
113 |
114 | /**
115 | * resolves for the given label node its value node
116 | */
117 | export function resolve(label: ILabelNode, flat: ILabelNodes, dataTree: (IValueNode | number)[]): number {
118 | const parents = parentsOf(label, flat);
119 |
120 | let dataItem: IValueNode | number = {
121 | children: dataTree,
122 | value: Number.NaN,
123 | };
124 | const dataParents = parents.map((p) => {
125 | dataItem = dataItem && isValueNode(dataItem) ? dataItem.children[p.relIndex] : Number.NaN;
126 | return dataItem;
127 | });
128 |
129 | const value = dataParents[dataParents.length - 1];
130 | // convert to value
131 | if (isValueNode(value)) {
132 | return value.value;
133 | }
134 | return value;
135 | }
136 |
137 | /**
138 | * counts the number of nodes that are visible when the given node is expanded
139 | */
140 | export function countExpanded(node: ILabelNode): number {
141 | if (!node.expand) {
142 | return 1;
143 | }
144 | return node.children.reduce((acc, d) => acc + countExpanded(d), 0);
145 | }
146 |
147 | export function flatChildren(node: ILabelNode, flat: ILabelNodes): ILabelNodes {
148 | if (node.children.length === 0) {
149 | return [];
150 | }
151 | const firstChild = node.children[0];
152 | if (node.parent >= 0 && node.relIndex < flat[node.parent].children.length - 1) {
153 | // not the last child and have parent, fast track using sibling
154 | const nextSibling = flat[node.parent].children[node.relIndex + 1];
155 | return flat.slice(firstChild.index, nextSibling.index);
156 | }
157 | // find sibling or next in the hierarchy up
158 | const nextSibling = flat
159 | .slice(firstChild.index + 1)
160 | .find((d) => d.level < node.level || (d.parent === node.parent && d.relIndex === node.relIndex + 1));
161 | if (nextSibling) {
162 | return flat.slice(firstChild.index, nextSibling.index);
163 | }
164 | // no sibling found = till end
165 | return flat.slice(firstChild.index);
166 | }
167 |
168 | export function determineVisible(flat: ILabelNodes): ILabelNodes {
169 | const focus = flat.find((d) => d.expand === 'focus');
170 |
171 | if (focus) {
172 | return flat.slice(focus.index + 1).filter((d) => !d.hidden && parentsOf(d, flat).includes(focus));
173 | }
174 | // the real labels are the one not hidden in the tree
175 | return flat.filter((d) => !d.hidden);
176 | }
177 |
178 | export interface ISpanLogicResult {
179 | hasCollapseBox: boolean;
180 | hasFocusBox: boolean;
181 | leftVisible: ILabelNode;
182 | rightVisible: ILabelNode;
183 | groupLabelCenter: number;
184 | leftFirstVisible: boolean;
185 | rightLastVisible: boolean;
186 | }
187 |
188 | /**
189 | */
190 | export function spanLogic(
191 | node: ILabelNode,
192 | flat: ILabelNodes,
193 | visibleNodes: Set,
194 | groupLabelPosition: 'first' | 'center' | 'last' | 'between-first-and-second' = 'between-first-and-second'
195 | ): false | ISpanLogicResult {
196 | if (node.children.length === 0 || !node.expand) {
197 | return false;
198 | }
199 | const firstChild = node.children[0];
200 | const lastChild = node.children[node.children.length - 1];
201 | const flatSubTree = flatChildren(node, flat);
202 |
203 | const leftVisible = flatSubTree.find((d) => visibleNodes.has(d));
204 | const rightVisible = flatSubTree
205 | .slice()
206 | .reverse()
207 | .find((d) => visibleNodes.has(d));
208 |
209 | if (!leftVisible || !rightVisible) {
210 | return false;
211 | }
212 |
213 | const leftParents = parentsOf(leftVisible, flat);
214 | const rightParents = parentsOf(rightVisible, flat);
215 | // is the left visible one also a child of my first child = whole starting range is visible?
216 | const leftFirstVisible = leftParents[node.level + 1] === firstChild;
217 | // is the right visible one also my last child = whole end range is visible?
218 | const rightLastVisible = rightParents[node.level + 1] === lastChild;
219 |
220 | const hasCollapseBox = leftFirstVisible && node.expand !== 'focus';
221 | const hasFocusBox = leftFirstVisible && rightLastVisible && node.children.length > 1;
222 | // the next visible after the left one
223 | // based on
224 | let groupLabelCenter = 0;
225 | switch (groupLabelPosition) {
226 | case 'between-first-and-second':
227 | {
228 | const nextVisible = flat.slice(leftVisible.index + 1, rightVisible.index + 1).find((d) => visibleNodes.has(d));
229 | groupLabelCenter = !nextVisible ? leftVisible.center : (leftVisible.center + nextVisible.center) / 2;
230 | }
231 | break;
232 | case 'center':
233 | groupLabelCenter = (leftVisible.center + rightVisible.center) / 2;
234 | break;
235 | case 'last':
236 | groupLabelCenter = rightVisible.center;
237 | break;
238 | case 'first':
239 | default:
240 | groupLabelCenter = leftVisible.center;
241 | break;
242 | }
243 |
244 | return {
245 | hasCollapseBox,
246 | hasFocusBox,
247 | leftVisible,
248 | rightVisible,
249 | groupLabelCenter,
250 | leftFirstVisible,
251 | rightLastVisible,
252 | };
253 | }
254 |
255 | export function getMaxDepth(rootNodes: Array) {
256 | const levels: number[] = [];
257 | const addToArray = (node: ILabelNode) => {
258 | levels.push(node.level);
259 | return node.expand && node.children.length > 0;
260 | };
261 | rootNodes.forEach((n) => preOrderTraversal(n, addToArray));
262 | return Math.max(...levels);
263 | }
264 |
--------------------------------------------------------------------------------
/src/utils_bug_7.spec.ts:
--------------------------------------------------------------------------------
1 | import { toNodes, parentsOf, lastOfLevel, countExpanded } from './utils';
2 | import type { ILabelNodes, ILabelNode } from './model';
3 | import { describe, test, expect } from 'vitest';
4 | describe('bug_#7', () => {
5 | let nodes: ILabelNodes;
6 | test('setup', () => {
7 | nodes = toNodes([
8 | {
9 | label: '0',
10 | expand: true, // 'focus', // expand level
11 | children: [
12 | {
13 | label: '00',
14 | expand: true, // 'focus', // expand level
15 | children: ['000', '001'],
16 | },
17 | {
18 | label: '01',
19 | children: ['010', '011'],
20 | },
21 | ],
22 | },
23 | '1',
24 | ]);
25 |
26 | expect(nodes.length).toBe(8);
27 | expect(nodes.map((d) => d.label)).toEqual(['0', '00', '000', '001', '01', '010', '011', '1']);
28 | });
29 |
30 | test('parents', () => {
31 | const parents = (node: ILabelNode, ...arr: string[]) =>
32 | expect(parentsOf(node, nodes).map((d) => d.label)).toEqual(arr);
33 | parents(nodes[0], '0');
34 | parents(nodes[1], '0', '00');
35 | parents(nodes[2], '0', '00', '000');
36 | parents(nodes[3], '0', '00', '001');
37 | });
38 |
39 | test('lastOfLevel', () => {
40 | const last = (node: ILabelNode, label: string) => expect(lastOfLevel(node, nodes).label).toEqual(label);
41 |
42 | last(nodes[0], '1');
43 | last(nodes[1], '01');
44 | last(nodes[2], '001');
45 | });
46 |
47 | test('countExpanded', () => {
48 | const count = (node: ILabelNode, c: number) => expect(countExpanded(node)).toBe(c);
49 |
50 | count(nodes[0], 3);
51 | count(nodes[1], 2);
52 | count(nodes[2], 1);
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/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-plugin-hierarchical",
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 |
--------------------------------------------------------------------------------