├── test
├── integration
│ ├── node-commonjs
│ │ ├── node_modules
│ │ │ ├── chartjs-chart-matrix
│ │ │ ├── chart.js
│ │ │ └── @napi-rs
│ │ │ │ └── canvas
│ │ ├── package.json
│ │ └── test.js
│ └── node-module
│ │ ├── node_modules
│ │ ├── chartjs-chart-matrix
│ │ ├── chart.js
│ │ └── @napi-rs
│ │ │ └── canvas
│ │ ├── package.json
│ │ └── test.js
├── fixtures
│ ├── scales
│ │ ├── time.png
│ │ └── time.js
│ ├── border
│ │ ├── object.png
│ │ └── object.js
│ └── anchor
│ │ ├── left-top.png
│ │ ├── right-top.png
│ │ ├── left-bottom.png
│ │ ├── right-bottom.png
│ │ ├── center-center.png
│ │ ├── center-center.js
│ │ ├── left-top.js
│ │ ├── right-top.js
│ │ ├── left-bottom.js
│ │ └── right-bottom.js
├── specs
│ ├── fixtures.spec.js
│ └── controller.spec.js
└── index.js
├── matrix.png
├── jasmine.json
├── docs
├── .vuepress
│ ├── public
│ │ ├── logo.png
│ │ ├── matrix.png
│ │ ├── favicon.ico
│ │ └── logo.svg
│ └── config.ts
├── scripts
│ ├── helpers.js
│ ├── register.js
│ └── utils.js
├── samples
│ ├── utils.md
│ ├── basic.md
│ ├── category.md
│ ├── calendar.md
│ ├── time.md
│ └── yearweek.md
├── integration.md
├── index.md
└── usage.md
├── src
├── index.esm.ts
├── index.ts
├── helpers.ts
├── element.ts
├── controller.ts
└── helpers.test.ts
├── .prettierrc
├── .gitignore
├── .github
├── dependabot.yml
└── workflows
│ ├── pr-ci.yml
│ └── main-ci.yml
├── .editorconfig
├── types
├── tests
│ ├── tsconfig.json
│ └── options.ts
└── index.esm.d.ts
├── .swcrc-spec
├── sonar-project.properties
├── tsconfig.json
├── LICENSE
├── rollup.config.js
├── karma.conf.cjs
├── README.md
├── eslint.config.mjs
└── package.json
/test/integration/node-commonjs/node_modules/chartjs-chart-matrix:
--------------------------------------------------------------------------------
1 | ../../../../
--------------------------------------------------------------------------------
/test/integration/node-module/node_modules/chartjs-chart-matrix:
--------------------------------------------------------------------------------
1 | ../../../../
--------------------------------------------------------------------------------
/matrix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/matrix.png
--------------------------------------------------------------------------------
/test/integration/node-module/node_modules/chart.js:
--------------------------------------------------------------------------------
1 | ../../../../node_modules/chart.js
--------------------------------------------------------------------------------
/jasmine.json:
--------------------------------------------------------------------------------
1 | {
2 | "spec_dir": "build",
3 | "spec_files": ["**/*.test.js"]
4 | }
5 |
--------------------------------------------------------------------------------
/test/integration/node-commonjs/node_modules/chart.js:
--------------------------------------------------------------------------------
1 | ../../../../node_modules/chart.js
--------------------------------------------------------------------------------
/test/integration/node-commonjs/node_modules/@napi-rs/canvas:
--------------------------------------------------------------------------------
1 | ../../../../../node_modules/@napi-rs/canvas
--------------------------------------------------------------------------------
/test/integration/node-module/node_modules/@napi-rs/canvas:
--------------------------------------------------------------------------------
1 | ../../../../../node_modules/@napi-rs/canvas
--------------------------------------------------------------------------------
/docs/.vuepress/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/docs/.vuepress/public/logo.png
--------------------------------------------------------------------------------
/test/fixtures/scales/time.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/test/fixtures/scales/time.png
--------------------------------------------------------------------------------
/docs/.vuepress/public/matrix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/docs/.vuepress/public/matrix.png
--------------------------------------------------------------------------------
/test/fixtures/border/object.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/test/fixtures/border/object.png
--------------------------------------------------------------------------------
/docs/.vuepress/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/docs/.vuepress/public/favicon.ico
--------------------------------------------------------------------------------
/test/fixtures/anchor/left-top.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/test/fixtures/anchor/left-top.png
--------------------------------------------------------------------------------
/test/fixtures/anchor/right-top.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/test/fixtures/anchor/right-top.png
--------------------------------------------------------------------------------
/test/fixtures/anchor/left-bottom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/test/fixtures/anchor/left-bottom.png
--------------------------------------------------------------------------------
/test/fixtures/anchor/right-bottom.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/test/fixtures/anchor/right-bottom.png
--------------------------------------------------------------------------------
/src/index.esm.ts:
--------------------------------------------------------------------------------
1 | export { default as MatrixController } from './controller.js'
2 | export { default as MatrixElement } from './element.js'
3 |
--------------------------------------------------------------------------------
/test/fixtures/anchor/center-center.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kurkle/chartjs-chart-matrix/HEAD/test/fixtures/anchor/center-center.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | bracketSpacing: true
2 | singleQuote: true
3 | printWidth: 120
4 | semi: false
5 | tabWidth: 2
6 | useTabs: false
7 | trailingComma: 'es5'
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .vscode/
3 | bower.json
4 | build/
5 | cc-test-reporter
6 | coverage/
7 | dist/
8 | /node_modules
9 | /docs/node_modules
10 | *.stackdump
11 |
--------------------------------------------------------------------------------
/test/specs/fixtures.spec.js:
--------------------------------------------------------------------------------
1 | describe('auto', jasmine.fixtures('anchor'))
2 | describe('auto', jasmine.fixtures('border'))
3 | describe('auto', jasmine.fixtures('scales'))
4 |
--------------------------------------------------------------------------------
/docs/scripts/helpers.js:
--------------------------------------------------------------------------------
1 | // Add helpers needed in samples here.
2 | // Usable through `helpers[name]`.
3 | export { color, getHoverColor } from 'chart.js/helpers'
4 | export { _adapters } from 'chart.js'
5 |
--------------------------------------------------------------------------------
/test/integration/node-commonjs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "description": "chartjs-chart-matrix should work in Node, using commonjs",
4 | "scripts": {
5 | "test": "node test.js"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/test/integration/node-module/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "description": "chartjs-chart-matrix should work in Node, using commonjs",
4 | "type": "module",
5 | "scripts": {
6 | "test": "node test.js"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Chart } from 'chart.js'
2 |
3 | import MatrixController from './controller.js'
4 | import MatrixElement from './element.js'
5 |
6 | Chart.register(MatrixController, MatrixElement)
7 |
8 | export { MatrixController, MatrixElement }
9 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 | - package-ecosystem: "github-actions"
8 | directory: "/"
9 | schedule:
10 | interval: "weekly"
11 |
--------------------------------------------------------------------------------
/test/specs/controller.spec.js:
--------------------------------------------------------------------------------
1 | import { Chart } from 'chart.js'
2 |
3 | describe('controller', function () {
4 | it('should be registered', function () {
5 | expect(Chart.controllers.matrix).toBeDefined()
6 | expect(Chart.registry.getElement('matrix')).toBeDefined()
7 | })
8 | })
9 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.html]
13 | indent_style = tab
14 | indent_size = 4
15 |
--------------------------------------------------------------------------------
/types/tests/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "moduleResolution": "Node",
5 | "alwaysStrict": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "allowJs": true,
9 | "checkJs": false,
10 | "skipLibCheck": true
11 | },
12 | "include": [
13 | "../index.esm.d.ts",
14 | "./**/*.ts",
15 | ]
16 | }
17 |
--------------------------------------------------------------------------------
/.swcrc-spec:
--------------------------------------------------------------------------------
1 | {
2 | "jsc": {
3 | "parser": {
4 | "syntax": "typescript",
5 | "tsx": false,
6 | "decorators": true,
7 | "dynamicImport": true,
8 | "importMeta": true
9 | },
10 | "target": "es2022",
11 | "baseUrl": "/"
12 | },
13 | "module": {
14 | "type": "es6",
15 | "resolveFully": true
16 | },
17 | "sourceMaps": true
18 | }
19 |
--------------------------------------------------------------------------------
/test/integration/node-commonjs/test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-require-imports */
2 | const { Chart } = require('chart.js')
3 |
4 | // side-effects
5 | require('chartjs-chart-matrix')
6 |
7 | const { createCanvas } = require('@napi-rs/canvas')
8 |
9 | const canvas = createCanvas(300, 320)
10 | const ctx = canvas.getContext('2d')
11 |
12 | // Chart.js assumes ctx contains the canvas
13 | ctx.canvas = canvas
14 |
15 | module.exports = new Chart(ctx, {
16 | type: 'matrix',
17 | })
18 |
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | sonar.projectKey=kurkle_chartjs-chart-matrix
2 | sonar.organization=kurkle
3 |
4 | # This is the name and version displayed in the SonarCloud UI.
5 | sonar.projectName=chartjs-chart-matrix
6 |
7 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
8 | sonar.sources=src/
9 | sonar.exclusions=**/*.test.*,
10 | sonar.tests=test/
11 |
12 | sonar.javascript.lcov.reportPaths=coverage/chrome/lcov.info,coverage/firefox/lcov.info,coverage/unit/lcov.info
13 |
--------------------------------------------------------------------------------
/test/integration/node-module/test.js:
--------------------------------------------------------------------------------
1 | import { createCanvas } from '@napi-rs/canvas'
2 | import { Chart, LinearScale } from 'chart.js'
3 | import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'
4 |
5 | Chart.register(LinearScale, MatrixController, MatrixElement)
6 |
7 | const canvas = createCanvas(300, 320)
8 | const ctx = canvas.getContext('2d')
9 |
10 | // Chart.js assumes ctx contains the canvas
11 | ctx.canvas = canvas
12 |
13 | export const chart = new Chart(ctx, {
14 | type: 'matrix',
15 | })
16 |
--------------------------------------------------------------------------------
/test/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | acquireChart,
3 | addMatchers,
4 | afterEvent,
5 | releaseCharts,
6 | specsFromFixtures,
7 | triggerMouseEvent,
8 | } from 'chartjs-test-utils'
9 |
10 | window.devicePixelRatio = 1
11 | window.acquireChart = acquireChart
12 | window.afterEvent = afterEvent
13 | window.triggerMouseEvent = triggerMouseEvent
14 |
15 | jasmine.fixtures = specsFromFixtures
16 |
17 | beforeEach(function () {
18 | addMatchers()
19 | })
20 |
21 | afterEach(function () {
22 | releaseCharts()
23 | })
24 |
--------------------------------------------------------------------------------
/docs/samples/utils.md:
--------------------------------------------------------------------------------
1 | # Utils
2 |
3 | ## Disclaimer
4 |
5 | The Utils file contains multiple helper functions that the chart.js sample pages use to generate charts.
6 | These functions are subject to change, including but not limited to breaking changes without prior notice.
7 |
8 | Because of this please don't rely on this file in production environments.
9 |
10 | ## Functions
11 |
12 | <<< @/docs/scripts/utils.js
13 |
14 | [File on github](https://github.com/kurkle/chartjs-chart-matrix/blob/main/docs/scripts/utils.js)
15 |
--------------------------------------------------------------------------------
/types/tests/options.ts:
--------------------------------------------------------------------------------
1 | import { Chart } from 'chart.js'
2 |
3 | import { MatrixController, MatrixElement } from '../index.esm'
4 |
5 | Chart.register(MatrixController, MatrixElement)
6 |
7 | const chart = new Chart('test', {
8 | type: 'matrix',
9 | data: {
10 | datasets: [
11 | {
12 | label: 'Matrix',
13 | data: [{ x: 1, y: 1, v: 10 }],
14 | anchorX: 'center',
15 | anchorY: 'top',
16 | width: 10,
17 | height: 10,
18 | borderWidth: 1,
19 | hoverBorderWidth: () => 2,
20 | },
21 | ],
22 | },
23 | options: {
24 | scales: {
25 | x: {
26 | type: 'linear',
27 | },
28 | },
29 | },
30 | })
31 |
--------------------------------------------------------------------------------
/docs/integration.md:
--------------------------------------------------------------------------------
1 | # Integration
2 |
3 | chartjs-chart-matrix can be integrated with plain JavaScript or with different module loaders. The examples below show to load the plugin in different systems.
4 |
5 | ## Script Tag
6 |
7 | ```html
8 |
9 |
10 |
13 | ```
14 |
15 | ## Bundlers (Webpack, Rollup, etc.)
16 |
17 | ```javascript
18 | import { Chart } from 'chart.js';
19 | import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
20 |
21 | Chart.register(MatrixController, MatrixElement);
22 | ```
23 |
--------------------------------------------------------------------------------
/docs/scripts/register.js:
--------------------------------------------------------------------------------
1 | import Chart from 'chart.js/auto'
2 |
3 | import 'chartjs-adapter-date-fns'
4 |
5 | import { MatrixController, MatrixElement } from '../../dist/chartjs-chart-matrix.esm'
6 |
7 | Chart.register(MatrixController, MatrixElement)
8 |
9 | Chart.register({
10 | id: 'version',
11 | afterDraw(chart) {
12 | const ctx = chart.ctx
13 | ctx.save()
14 | ctx.font = '9px monospace'
15 | ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
16 | ctx.textAlign = 'right'
17 | ctx.textBaseline = 'top'
18 | ctx.fillText(
19 | 'Chart.js v' + Chart.version + ' + chartjs-chart-matrix v' + MatrixController.version,
20 | chart.chartArea.right,
21 | 0
22 | )
23 | ctx.restore()
24 | },
25 | })
26 |
--------------------------------------------------------------------------------
/test/fixtures/anchor/center-center.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | config: {
3 | type: 'matrix',
4 | data: {
5 | datasets: [
6 | {
7 | data: [{ x: 1, y: 1, v: 1 }],
8 | backgroundColor: 'red',
9 | borderColor: 'black',
10 | borderWidth: 2,
11 | width: 50,
12 | height: 50,
13 | },
14 | ],
15 | },
16 | options: {
17 | plugins: {
18 | legend: false,
19 | },
20 | scales: {
21 | x: {
22 | type: 'linear',
23 | display: false,
24 | },
25 | y: {
26 | type: 'linear',
27 | display: false,
28 | },
29 | },
30 | },
31 | },
32 | options: {
33 | canvas: {
34 | height: 256,
35 | width: 256,
36 | },
37 | },
38 | }
39 |
--------------------------------------------------------------------------------
/test/fixtures/anchor/left-top.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | config: {
3 | type: 'matrix',
4 | data: {
5 | datasets: [
6 | {
7 | anchorX: 'left',
8 | anchorY: 'top',
9 | data: [{ x: 1, y: 1, v: 1 }],
10 | backgroundColor: 'red',
11 | borderColor: 'black',
12 | borderWidth: 2,
13 | width: 50,
14 | height: 50,
15 | },
16 | ],
17 | },
18 | options: {
19 | plugins: {
20 | legend: false,
21 | },
22 | scales: {
23 | x: {
24 | type: 'linear',
25 | display: false,
26 | },
27 | y: {
28 | type: 'linear',
29 | display: false,
30 | },
31 | },
32 | },
33 | },
34 | options: {
35 | canvas: {
36 | height: 256,
37 | width: 256,
38 | },
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/test/fixtures/anchor/right-top.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | config: {
3 | type: 'matrix',
4 | data: {
5 | datasets: [
6 | {
7 | anchorX: 'right',
8 | anchorY: 'top',
9 | data: [{ x: 1, y: 1, v: 1 }],
10 | backgroundColor: 'red',
11 | borderColor: 'black',
12 | borderWidth: 2,
13 | width: 50,
14 | height: 50,
15 | },
16 | ],
17 | },
18 | options: {
19 | plugins: {
20 | legend: false,
21 | },
22 | scales: {
23 | x: {
24 | type: 'linear',
25 | display: false,
26 | },
27 | y: {
28 | type: 'linear',
29 | display: false,
30 | },
31 | },
32 | },
33 | },
34 | options: {
35 | canvas: {
36 | height: 256,
37 | width: 256,
38 | },
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/test/fixtures/anchor/left-bottom.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | config: {
3 | type: 'matrix',
4 | data: {
5 | datasets: [
6 | {
7 | anchorX: 'left',
8 | anchorY: 'bottom',
9 | data: [{ x: 1, y: 1, v: 1 }],
10 | backgroundColor: 'red',
11 | borderColor: 'black',
12 | borderWidth: 2,
13 | width: 50,
14 | height: 50,
15 | },
16 | ],
17 | },
18 | options: {
19 | plugins: {
20 | legend: false,
21 | },
22 | scales: {
23 | x: {
24 | type: 'linear',
25 | display: false,
26 | },
27 | y: {
28 | type: 'linear',
29 | display: false,
30 | },
31 | },
32 | },
33 | },
34 | options: {
35 | canvas: {
36 | height: 256,
37 | width: 256,
38 | },
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/test/fixtures/anchor/right-bottom.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | config: {
3 | type: 'matrix',
4 | data: {
5 | datasets: [
6 | {
7 | anchorX: 'right',
8 | anchorY: 'bottom',
9 | data: [{ x: 1, y: 1, v: 1 }],
10 | backgroundColor: 'red',
11 | borderColor: 'black',
12 | borderWidth: 2,
13 | width: 50,
14 | height: 50,
15 | },
16 | ],
17 | },
18 | options: {
19 | plugins: {
20 | legend: false,
21 | },
22 | scales: {
23 | x: {
24 | type: 'linear',
25 | display: false,
26 | },
27 | y: {
28 | type: 'linear',
29 | display: false,
30 | },
31 | },
32 | },
33 | },
34 | options: {
35 | canvas: {
36 | height: 256,
37 | width: 256,
38 | },
39 | },
40 | }
41 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Type Checking */
4 | "alwaysStrict": true,
5 | "strictBindCallApply": true,
6 | "strictFunctionTypes": true,
7 | /* todo: uncomment after transition to TS */
8 | "noFallthroughCasesInSwitch": true,
9 | "noImplicitOverride": true,
10 | "noImplicitReturns": true,
11 | "noUnusedLocals": true,
12 | "noUnusedParameters": true,
13 | /* Modules */
14 | "baseUrl": ".",
15 | "module": "ES2022",
16 | "moduleResolution": "Node",
17 | "resolveJsonModule": true,
18 | "rootDir": "src",
19 | "types": ["jasmine"],
20 | /* Emit */
21 | "declaration": true,
22 | "inlineSourceMap": true,
23 | "outDir": "dist",
24 | /* JavaScript Support */
25 | "allowJs": true,
26 | "checkJs": true,
27 | /* Interop Constraints */
28 | "allowSyntheticDefaultImports": true,
29 | /* Language and Environment */
30 | "target": "ES6",
31 | "lib": ["es2022", "DOM"]
32 | },
33 | "include": [
34 | "./src/**/*",
35 | "./types/index.esm.d.ts",
36 | ],
37 | "exclude": [
38 | "./dist/**"
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019-2025 Jukka Kurkela
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 |
--------------------------------------------------------------------------------
/docs/.vuepress/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
15 |
--------------------------------------------------------------------------------
/test/fixtures/border/object.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | tolerance: 0.15,
3 | config: {
4 | type: 'matrix',
5 | data: {
6 | datasets: [
7 | {
8 | data: [
9 | { x: 1, y: 1, v: 1 },
10 | { x: 2, y: 1, v: 1 },
11 | { x: 1, y: 2, v: 1 },
12 | { x: 2, y: 2, v: 1 },
13 | ],
14 | backgroundColor: 'red',
15 | borderColor: 'black',
16 | borderWidth: [{ right: 10 }, { bottom: 10 }, { top: 10 }, { left: 10 }],
17 | width: ({ chart }) => (chart.chartArea || {}).width / 2 - 1,
18 | height: ({ chart }) => (chart.chartArea || {}).height / 2 - 1,
19 | },
20 | ],
21 | },
22 | options: {
23 | events: [],
24 | plugins: {
25 | legend: false,
26 | },
27 | scales: {
28 | x: {
29 | type: 'linear',
30 | display: false,
31 | offset: false,
32 | min: 0.5,
33 | max: 2.5,
34 | },
35 | y: {
36 | type: 'linear',
37 | display: false,
38 | offset: false,
39 | min: 0.5,
40 | max: 2.5,
41 | },
42 | },
43 | },
44 | },
45 | options: {
46 | canvas: {
47 | height: 256,
48 | width: 256,
49 | },
50 | },
51 | }
52 |
--------------------------------------------------------------------------------
/docs/scripts/utils.js:
--------------------------------------------------------------------------------
1 | import { valueOrDefault } from 'chart.js/helpers'
2 |
3 | // Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/
4 | var _seed = Date.now()
5 |
6 | export function srand(seed) {
7 | _seed = seed
8 | }
9 |
10 | export function rand(min, max) {
11 | min = valueOrDefault(min, 0)
12 | max = valueOrDefault(max, 0)
13 | _seed = (_seed * 9301 + 49297) % 233280
14 | return min + (_seed / 233280) * (max - min)
15 | }
16 |
17 | export function numbers(config) {
18 | var cfg = config || {}
19 | var min = valueOrDefault(cfg.min, 0)
20 | var max = valueOrDefault(cfg.max, 100)
21 | var from = valueOrDefault(cfg.from, [])
22 | var count = valueOrDefault(cfg.count, 8)
23 | var decimals = valueOrDefault(cfg.decimals, 8)
24 | var continuity = valueOrDefault(cfg.continuity, 1)
25 | var dfactor = Math.pow(10, decimals) || 0
26 | var data = []
27 | var i, value
28 |
29 | for (i = 0; i < count; ++i) {
30 | value = (from[i] || 0) + this.rand(min, max)
31 | if (this.rand() <= continuity) {
32 | data.push(Math.round(dfactor * value) / dfactor)
33 | } else {
34 | data.push(null)
35 | }
36 | }
37 |
38 | return data
39 | }
40 |
41 | export function isoDayOfWeek(dt) {
42 | let wd = dt.getDay() // 0..6, from sunday
43 | wd = ((wd + 6) % 7) + 1 // 1..7 from monday
44 | return '' + wd // string so it gets parsed
45 | }
46 |
47 | export function startOfToday() {
48 | const d = new Date()
49 | return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0)
50 | }
51 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Getting Started
2 |
3 | [Chart.js](https://www.chartjs.org/) **v3+, v4+** extension for creating matrix charts.
4 |
5 | 
6 |
7 | ## Installation
8 |
9 | ### npm
10 |
11 | [](https://npmjs.com/package/chartjs-chart-matrix) [](https://npmjs.com/package/chartjs-chart-matrix)
12 |
13 | ```bash
14 | > npm install chartjs-chart-matrix
15 | ```
16 |
17 | ### CDN
18 |
19 | [](https://cdn.jsdelivr.net/npm/chartjs-chart-matrix@latest/dist/) [](https://www.jsdelivr.com/package/npm/chartjs-chart-matrix)
20 |
21 | By default, `https://cdn.jsdelivr.net/npm/chartjs-chart-matrix` returns the latest (minified) version, however it's [**highly recommended**](https://www.jsdelivr.com/features) to always specify a version in order to avoid breaking changes. This can be achieved by appending `@{version}` to the url:
22 |
23 | ```html
24 | https://cdn.jsdelivr.net/npm/chartjs-chart-matrix@2.0.1 // exact version
25 | https://cdn.jsdelivr.net/npm/chartjs-chart-matrix@2 // latest 2.x.x
26 | ```
27 |
28 | Read more about jsDeliver versioning on their [website](http://www.jsdelivr.com/).
29 |
30 | :::tip
31 |
32 | **Note:** For Chart.js v2 support, see [2.x branch](https://github.com/kurkle/chartjs-chart-matrix/tree/2.x)
33 |
34 | :::
35 |
--------------------------------------------------------------------------------
/docs/usage.md:
--------------------------------------------------------------------------------
1 | # Usage
2 |
3 | The chartjs-chart-matrix plugin provides matrix charts, which visualize data in a grid format using colored rectangular cells. Each cell represents a data point with three key properties:
4 |
5 | - X-axis value (column position)
6 | - Y-axis value (row position)
7 | - Value (v) (represented by color intensity or another visual cue)
8 |
9 | ## Common Use Cases
10 | 1. Heatmaps – Show intensity variations across a grid, often used for:
11 | - Temperature variations
12 | - Website traffic heatmaps
13 | - Performance monitoring
14 | 2. Confusion Matrices – Used in machine learning to visualize classification performance.
15 | 3. Availability/Occupancy Grids – Indicate occupied vs. available slots in a schedule or seating arrangement.
16 | 4. Correlation Matrices – Display relationships between variables in datasets.
17 |
18 | ## Chart Features
19 | - Supports linear, category, and time scales
20 | - Customizable cell size (width & height)
21 | - Dynamic color mapping for data values
22 | - Interactive tooltips & legend integration
23 |
24 | ```js chart-editor
25 | const config = {
26 | type: 'matrix',
27 | data: {
28 | datasets: [{
29 | label: 'Basic matrix',
30 | data: [{x: 1, y: 1}, {x: 2, y: 1}, {x: 1, y: 2}, {x: 2, y: 2}],
31 | borderWidth: 1,
32 | borderColor: 'rgba(0,0,0,0.5)',
33 | backgroundColor: 'rgba(200,200,0,0.3)',
34 | width: ({chart}) => (chart.chartArea || {}).width / 2 - 1,
35 | height: ({chart}) => (chart.chartArea || {}).height / 2 - 1,
36 | }],
37 | },
38 | options: {
39 | scales: {
40 | x: {
41 | display: false,
42 | min: 0.5,
43 | max: 2.5,
44 | offset: false
45 | },
46 | y: {
47 | display: false,
48 | min: 0.5,
49 | max: 2.5
50 | }
51 | }
52 | }
53 | };
54 |
55 | module.exports = {
56 | config
57 | };
58 | ```
59 |
--------------------------------------------------------------------------------
/docs/.vuepress/config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path'
2 | import { DefaultThemeConfig, defineConfig, PluginTuple } from 'vuepress/config'
3 |
4 | export default defineConfig({
5 | title: 'chartjs-chart-matrix',
6 | description: 'Chart.js module for creating matrix charts',
7 | theme: 'chartjs',
8 | // base: '',
9 | dest: path.resolve(__dirname, '../../dist/docs'),
10 | head: [['link', { rel: 'icon', href: '/favicon.ico' }]],
11 | plugins: [
12 | ['flexsearch'],
13 | [
14 | 'redirect',
15 | {
16 | redirectors: [
17 | // Default sample page when accessing /samples.
18 | { base: '/samples', alternative: ['basic'] },
19 | ],
20 | },
21 | ],
22 | ] as PluginTuple[],
23 | chainWebpack(config) {
24 | config.module
25 | .rule('chart.js')
26 | .include.add(path.resolve('node_modules/chart.js'))
27 | .end()
28 | .use('babel-loader')
29 | .loader('babel-loader')
30 | .options({
31 | presets: ['@babel/preset-env'],
32 | })
33 | .end()
34 | },
35 | themeConfig: {
36 | repo: 'kurkle/chartjs-chart-matrix',
37 | logo: '/favicon.ico',
38 | lastUpdated: 'Last Updated',
39 | searchPlaceholder: 'Search...',
40 | editLinks: false,
41 | docsDir: 'docs',
42 | chart: {
43 | imports: [
44 | ['scripts/register.js', 'Register'],
45 | ['scripts/utils.js', 'Utils'],
46 | ['scripts/helpers.js', 'helpers'],
47 | ],
48 | },
49 | nav: [
50 | { text: 'Home', link: '/' },
51 | { text: 'Samples', link: `/samples/` },
52 | {
53 | text: 'Ecosystem',
54 | ariaLabel: 'Community Menu',
55 | items: [{ text: 'Awesome', link: 'https://github.com/chartjs/awesome' }],
56 | },
57 | ],
58 | sidebar: {
59 | '/samples/': ['basic', 'calendar', 'category', 'time', 'yearweek', 'utils'],
60 | '/': ['', 'integration', 'usage'],
61 | },
62 | } as DefaultThemeConfig,
63 | })
64 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import json from '@rollup/plugin-json'
2 | import resolve from '@rollup/plugin-node-resolve'
3 | import { default as swc } from '@rollup/plugin-swc'
4 | import terser from '@rollup/plugin-terser'
5 | import { readFileSync } from 'fs'
6 | import cleanup from 'rollup-plugin-cleanup'
7 |
8 | const { author, homepage, license, main, module, name, version } = JSON.parse(readFileSync('./package.json'))
9 |
10 | const banner = `/*!
11 | * ${name} v${version}
12 | * ${homepage}
13 | * (c) ${new Date(process.env.SOURCE_DATE_EPOCH ? process.env.SOURCE_DATE_EPOCH * 1000 : new Date().getTime()).getFullYear()} ${author}
14 | * Released under the ${license} license
15 | */`
16 |
17 | const input = 'src/index.ts'
18 | const inputESM = 'src/index.esm.ts'
19 | const external = ['chart.js', 'chart.js/helpers']
20 | const globals = {
21 | 'chart.js': 'Chart',
22 | 'chart.js/helpers': 'Chart.helpers',
23 | }
24 |
25 | const plugins = (minify) => [
26 | json(),
27 | resolve({ extensions: ['.ts', '.mjs', '.js', '.json'] }),
28 | swc({
29 | jsc: {
30 | parser: {
31 | syntax: 'typescript',
32 | },
33 | target: 'es2022',
34 | },
35 | module: {
36 | type: 'es6',
37 | },
38 | sourceMaps: true,
39 | }),
40 | minify
41 | ? terser({
42 | output: { preamble: banner },
43 | })
44 | : cleanup({ comments: ['some', /__PURE__/] }),
45 | ]
46 |
47 | export default [
48 | {
49 | input,
50 | output: {
51 | name,
52 | file: main,
53 | banner,
54 | format: 'umd',
55 | indent: false,
56 | globals,
57 | },
58 | plugins: plugins(),
59 | external,
60 | },
61 | {
62 | input,
63 | output: {
64 | name,
65 | file: main.replace('.cjs', '.min.js'),
66 | format: 'umd',
67 | sourcemap: true,
68 | indent: false,
69 | globals,
70 | },
71 | plugins: plugins(true),
72 | external,
73 | },
74 | {
75 | input: inputESM,
76 | output: {
77 | name,
78 | file: module,
79 | banner,
80 | format: 'esm',
81 | indent: false,
82 | },
83 | plugins: plugins(),
84 | external,
85 | },
86 | ]
87 |
--------------------------------------------------------------------------------
/src/helpers.ts:
--------------------------------------------------------------------------------
1 | import { isObject } from 'chart.js/helpers'
2 | import { MatrixOptions } from 'types/index.esm'
3 |
4 | import MatrixElement from './element'
5 |
6 | type Bounds = { left: number; top: number; right: number; bottom: number }
7 |
8 | function getBounds(element: MatrixElement, useFinalPosition: boolean): Bounds {
9 | const { x, y, width, height } = element.getProps(['x', 'y', 'width', 'height'], useFinalPosition)
10 | return { left: x, top: y, right: x + width, bottom: y + height }
11 | }
12 |
13 | function limit(value: number, min: number, max: number) {
14 | return Math.max(Math.min(value, max), min)
15 | }
16 |
17 | export function parseBorderWidth(options: Pick, maxW: number, maxH: number) {
18 | const value = options.borderWidth
19 | let t: number, r: number, b: number, l: number
20 |
21 | if (isObject(value)) {
22 | t = +value.top || 0
23 | r = +value.right || 0
24 | b = +value.bottom || 0
25 | l = +value.left || 0
26 | } else {
27 | t = r = b = l = +value || 0
28 | }
29 |
30 | return {
31 | t: limit(t, 0, maxH),
32 | r: limit(r, 0, maxW),
33 | b: limit(b, 0, maxH),
34 | l: limit(l, 0, maxW),
35 | }
36 | }
37 |
38 | export function boundingRects(element: MatrixElement) {
39 | const bounds = getBounds(element, false)
40 | const width = bounds.right - bounds.left
41 | const height = bounds.bottom - bounds.top
42 | const border = parseBorderWidth(element.options, width / 2, height / 2)
43 |
44 | return {
45 | outer: {
46 | x: bounds.left,
47 | y: bounds.top,
48 | w: width,
49 | h: height,
50 | },
51 | inner: {
52 | x: bounds.left + border.l,
53 | y: bounds.top + border.t,
54 | w: width - border.l - border.r,
55 | h: height - border.t - border.b,
56 | },
57 | }
58 | }
59 |
60 | export function inRange(element: MatrixElement, x: number, y: number, useFinalPosition: boolean) {
61 | const skipX = x === null
62 | const skipY = y === null
63 | const bounds = !element || (skipX && skipY) ? false : getBounds(element, useFinalPosition)
64 |
65 | return (
66 | bounds && (skipX || (x >= bounds.left && x <= bounds.right)) && (skipY || (y >= bounds.top && y <= bounds.bottom))
67 | )
68 | }
69 |
--------------------------------------------------------------------------------
/types/index.esm.d.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BorderRadius,
3 | CartesianScaleTypeRegistry,
4 | Chart,
5 | ChartComponent,
6 | CommonElementOptions,
7 | CommonHoverOptions,
8 | ControllerDatasetOptions,
9 | CoreChartOptions,
10 | DatasetController,
11 | Element,
12 | ScriptableAndArrayOptions,
13 | ScriptableContext,
14 | VisualElement,
15 | } from 'chart.js'
16 |
17 | type AnyObject = Record
18 |
19 | export type AnchorX = 'left' | 'center' | 'right' | 'start' | 'end'
20 | export type AnchorY = 'top' | 'center' | 'bottom' | 'start' | 'end'
21 | export interface MatrixOptions extends Omit {
22 | borderRadius: number | BorderRadius
23 | borderWidth: number | { top?: number; right?: number; bottom?: number; left?: number }
24 | anchorX: AnchorX
25 | anchorY: AnchorY
26 | width: number
27 | height: number
28 | }
29 | export interface MatrixControllerDatasetOptions
30 | extends ControllerDatasetOptions,
31 | ScriptableAndArrayOptions>,
32 | ScriptableAndArrayOptions> {}
33 |
34 | export interface MatrixDataPoint {
35 | x: number
36 | y: number
37 | }
38 |
39 | declare module 'chart.js' {
40 | export interface ChartTypeRegistry {
41 | matrix: {
42 | chartOptions: CoreChartOptions<'matrix'>
43 | datasetOptions: MatrixControllerDatasetOptions
44 | defaultDataPoint: MatrixDataPoint
45 | parsedDataType: MatrixDataPoint
46 | metaExtensions: AnyObject
47 | scales: keyof CartesianScaleTypeRegistry
48 | }
49 | }
50 | }
51 |
52 | export interface MatrixProps {
53 | x: number
54 | y: number
55 | width: number
56 | height: number
57 | options?: Partial
58 | }
59 |
60 | export type MatrixController = DatasetController
61 | export const MatrixController: ChartComponent & {
62 | prototype: MatrixController
63 | new (chart: Chart, datasetIndex: number): MatrixController
64 | }
65 |
66 | export interface MatrixElement
67 | extends Element,
68 | VisualElement {}
69 |
70 | export const MatrixElement: ChartComponent & {
71 | prototype: MatrixElement
72 | new (cfg: AnyObject): MatrixElement
73 | }
74 |
--------------------------------------------------------------------------------
/docs/samples/basic.md:
--------------------------------------------------------------------------------
1 | # Basic (Linear Scale)
2 |
3 | ```js chart-editor
4 | //
5 | const data = {
6 | datasets: [{
7 | label: 'My Matrix',
8 | data: [
9 | {x: 1, y: 1, v: 11},
10 | {x: 1, y: 2, v: 12},
11 | {x: 1, y: 3, v: 13},
12 | {x: 2, y: 1, v: 21},
13 | {x: 2, y: 2, v: 22},
14 | {x: 2, y: 3, v: 23},
15 | {x: 3, y: 1, v: 31},
16 | {x: 3, y: 2, v: 32},
17 | {x: 3, y: 3, v: 33}
18 | ],
19 | backgroundColor(context) {
20 | const value = context.dataset.data[context.dataIndex].v;
21 | const alpha = (value - 5) / 40;
22 | return helpers.color('green').alpha(alpha).rgbString();
23 | },
24 | borderColor(context) {
25 | const value = context.dataset.data[context.dataIndex].v;
26 | const alpha = (value - 5) / 40;
27 | return helpers.color('darkgreen').alpha(alpha).rgbString();
28 | },
29 | borderWidth: 1,
30 | width: ({chart}) => (chart.chartArea || {}).width / 3 - 1,
31 | height: ({chart}) =>(chart.chartArea || {}).height / 3 - 1
32 | }]
33 | };
34 | //
35 |
36 |
37 | //
38 | const config = {
39 | type: 'matrix',
40 | data: data,
41 | options: {
42 | plugins: {
43 | legend: false,
44 | tooltip: {
45 | callbacks: {
46 | title() {
47 | return '';
48 | },
49 | label(context) {
50 | const v = context.dataset.data[context.dataIndex];
51 | return ['x: ' + v.x, 'y: ' + v.y, 'v: ' + v.v];
52 | }
53 | }
54 | }
55 | },
56 | scales: {
57 | x: {
58 | ticks: {
59 | stepSize: 1
60 | },
61 | grid: {
62 | display: false
63 | }
64 | },
65 | y: {
66 | offset: true,
67 | ticks: {
68 | stepSize: 1
69 | },
70 | grid: {
71 | display: false
72 | }
73 | }
74 | }
75 | }
76 | };
77 |
78 | //
79 |
80 | const actions = [
81 | {
82 | name: 'Randomize',
83 | handler(chart) {
84 | chart.data.datasets.forEach(dataset => {
85 | dataset.data.forEach(point => {
86 | point.v = Math.random() * 20;
87 | });
88 | });
89 | chart.update();
90 | }
91 | },
92 | ];
93 |
94 | module.exports = {
95 | actions,
96 | config,
97 | };
98 | ```
99 |
--------------------------------------------------------------------------------
/.github/workflows/pr-ci.yml:
--------------------------------------------------------------------------------
1 | name: PR CI
2 |
3 | on:
4 | pull_request:
5 | branches: [ main ]
6 |
7 | permissions:
8 | contents: read # for checkout
9 |
10 | jobs:
11 | ci:
12 | runs-on: ubuntu-latest
13 |
14 | permissions:
15 | actions: write # Needed for actions/cache/save
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | - uses: actions/setup-node@v4
20 | with:
21 | cache: npm
22 |
23 | - name: Install dependencies
24 | run: npm clean-install
25 |
26 | - name: Lint
27 | run: npm run lint
28 |
29 | - name: Typecheck
30 | run: npm run typecheck
31 |
32 | - name: Build
33 | run: npm run build
34 |
35 | - name: Test
36 | run: xvfb-run --auto-servernum npm test
37 | shell: bash
38 |
39 | - name: Store coverage reports
40 | uses: actions/cache/save@v4
41 | with:
42 | path: |
43 | coverage/chrome/lcov.info
44 | coverage/firefox/lcov.info
45 | coverage/unit/lcov.info
46 | key: coverage
47 |
48 | - name: Compressed size
49 | uses: preactjs/compressed-size-action@v2
50 | with:
51 | repo-token: ${{ secrets.GITHUB_TOKEN }}
52 | pattern: "./dist/*.{js,cjs}"
53 |
54 | sonar:
55 | runs-on: ubuntu-latest
56 | needs: [ci]
57 |
58 | permissions:
59 | actions: read # Needed for actions/cache/restore
60 |
61 | steps:
62 | - uses: actions/checkout@v4
63 | with:
64 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis (SonarCloud)
65 |
66 | - uses: actions/setup-node@v4
67 | with:
68 | cache: npm
69 |
70 | - name: Restore coverage from cache
71 | uses: actions/cache/restore@v4
72 | with:
73 | path: |
74 | coverage/chrome/lcov.info
75 | coverage/firefox/lcov.info
76 | coverage/unit/lcov.info
77 | key: coverage
78 | restore-keys: coverage
79 |
80 | - name: Set version
81 | env:
82 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
83 | run: |
84 | VERSION=$(gh release view --json tagName -q .tagName)
85 | echo -e "\nsonar.projectVersion=$VERSION" >> sonar-project.properties
86 |
87 | - name: SonarCloud Scan
88 | uses: SonarSource/sonarqube-scan-action@v5
89 | env:
90 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
91 |
--------------------------------------------------------------------------------
/src/element.ts:
--------------------------------------------------------------------------------
1 | import { Element } from 'chart.js'
2 | import { addRoundedRectPath, toTRBLCorners } from 'chart.js/helpers'
3 | import { MatrixOptions, MatrixProps } from 'types/index.esm'
4 |
5 | import { boundingRects, inRange } from './helpers'
6 |
7 | export default class MatrixElement extends Element {
8 | static readonly id = 'matrix'
9 |
10 | static override readonly defaults = {
11 | backgroundColor: undefined,
12 | borderColor: undefined,
13 | borderWidth: undefined,
14 | borderRadius: 0,
15 | anchorX: 'center',
16 | anchorY: 'center',
17 | width: 20,
18 | height: 20,
19 | }
20 |
21 | width: number
22 | height: number
23 |
24 | constructor(cfg: MatrixProps) {
25 | super()
26 |
27 | if (cfg) {
28 | Object.assign(this, cfg)
29 | }
30 | }
31 |
32 | draw(ctx: CanvasRenderingContext2D) {
33 | const options = this.options
34 | const { inner, outer } = boundingRects(this)
35 | const radius = toTRBLCorners(options.borderRadius)
36 |
37 | ctx.save()
38 |
39 | if (outer.w !== inner.w || outer.h !== inner.h) {
40 | ctx.beginPath()
41 | addRoundedRectPath(ctx, { x: outer.x, y: outer.y, w: outer.w, h: outer.h, radius })
42 | addRoundedRectPath(ctx, { x: inner.x, y: inner.y, w: inner.w, h: inner.h, radius })
43 | ctx.fillStyle = options.backgroundColor
44 | ctx.fill()
45 | ctx.fillStyle = options.borderColor
46 | ctx.fill('evenodd')
47 | } else {
48 | ctx.beginPath()
49 | addRoundedRectPath(ctx, { x: inner.x, y: inner.y, w: inner.w, h: inner.h, radius })
50 | ctx.fillStyle = options.backgroundColor
51 | ctx.fill()
52 | }
53 |
54 | ctx.restore()
55 | }
56 |
57 | inRange(mouseX: number, mouseY: number, useFinalPosition?: boolean) {
58 | return inRange(this, mouseX, mouseY, useFinalPosition)
59 | }
60 |
61 | inXRange(mouseX: number, useFinalPosition?: boolean) {
62 | return inRange(this, mouseX, null, useFinalPosition)
63 | }
64 |
65 | inYRange(mouseY: number, useFinalPosition?: boolean) {
66 | return inRange(this, null, mouseY, useFinalPosition)
67 | }
68 |
69 | getCenterPoint(useFinalPosition?: boolean) {
70 | const { x, y, width, height } = this.getProps(['x', 'y', 'width', 'height'], useFinalPosition)
71 | return {
72 | x: x + width / 2,
73 | y: y + height / 2,
74 | }
75 | }
76 |
77 | override tooltipPosition() {
78 | return this.getCenterPoint()
79 | }
80 |
81 | getRange(axis: 'x' | 'y') {
82 | return axis === 'x' ? this.width / 2 : this.height / 2
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/docs/samples/category.md:
--------------------------------------------------------------------------------
1 | # On Category Scale
2 |
3 | ```js chart-editor
4 | //
5 | const data = {
6 | datasets: [{
7 | label: 'My Matrix',
8 | data: [
9 | {x: 'A', y: 'X', v: 11},
10 | {x: 'A', y: 'Y', v: 12},
11 | {x: 'A', y: 'Z', v: 13},
12 | {x: 'B', y: 'X', v: 21},
13 | {x: 'B', y: 'Y', v: 22},
14 | {x: 'B', y: 'Z', v: 23},
15 | {x: 'C', y: 'X', v: 31},
16 | {x: 'C', y: 'Y', v: 32},
17 | {x: 'C', y: 'Z', v: 33}
18 | ],
19 | backgroundColor(context) {
20 | const value = context.dataset.data[context.dataIndex].v;
21 | const alpha = (value - 5) / 40;
22 | return helpers.color('green').alpha(alpha).rgbString();
23 | },
24 | borderColor(context) {
25 | const value = context.dataset.data[context.dataIndex].v;
26 | const alpha = (value - 5) / 40;
27 | return helpers.color('darkgreen').alpha(alpha).rgbString();
28 | },
29 | borderWidth: 1,
30 | width: ({chart}) => (chart.chartArea || {}).width / 3 - 1,
31 | height: ({chart}) =>(chart.chartArea || {}).height / 3 - 1
32 | }]
33 | };
34 | //
35 |
36 | //
37 | const config = {
38 | type: 'matrix',
39 | data: data,
40 | options: {
41 | plugins: {
42 | legend: false,
43 | tooltip: {
44 | callbacks: {
45 | title() {
46 | return '';
47 | },
48 | label(context) {
49 | const v = context.dataset.data[context.dataIndex];
50 | return ['x: ' + v.x, 'y: ' + v.y, 'v: ' + v.v];
51 | }
52 | }
53 | }
54 | },
55 | scales: {
56 | x: {
57 | type: 'category',
58 | labels: ['A', 'B', 'C'],
59 | ticks: {
60 | display: true
61 | },
62 | grid: {
63 | display: false
64 | }
65 | },
66 | y: {
67 | type: 'category',
68 | labels: ['X', 'Y', 'Z'],
69 | offset: true,
70 | ticks: {
71 | display: true
72 | },
73 | grid: {
74 | display: false
75 | }
76 | }
77 | }
78 | }
79 | };
80 | //
81 |
82 | const actions = [
83 | {
84 | name: 'Randomize',
85 | handler(chart) {
86 | chart.data.datasets.forEach(dataset => {
87 | dataset.data.forEach(point => {
88 | point.v = Math.random() * 50;
89 | });
90 | });
91 | chart.update();
92 | }
93 | },
94 | ];
95 |
96 | module.exports = {
97 | actions,
98 | config,
99 | };
100 | ```
101 |
--------------------------------------------------------------------------------
/karma.conf.cjs:
--------------------------------------------------------------------------------
1 | const istanbul = require('rollup-plugin-istanbul')
2 | const env = process.env.NODE_ENV
3 |
4 | module.exports = async function (karma) {
5 | const builds = (await import('./rollup.config.js')).default
6 | const build = builds[0]
7 | const buildPlugins = [...build.plugins]
8 |
9 | if (env === 'test') {
10 | build.plugins.push(istanbul({ exclude: ['node_modules/**/*.js', 'package.json'] }))
11 | }
12 |
13 | karma.set({
14 | browsers: ['chrome', 'firefox'],
15 | frameworks: ['jasmine'],
16 | reporters: ['spec', 'kjhtml'],
17 | logLevel: karma.LOG_WARN,
18 |
19 | files: [
20 | { pattern: './test/fixtures/**/*.js', included: false },
21 | { pattern: './test/fixtures/**/*.png', included: false },
22 | 'node_modules/chart.js/dist/chart.umd.js',
23 | 'node_modules/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.js',
24 | 'test/index.js',
25 | { pattern: 'src/index.ts', type: 'js' },
26 | { pattern: 'test/specs/**/*.js', type: 'js' },
27 | ],
28 |
29 | customLaunchers: {
30 | chrome: {
31 | base: 'Chrome',
32 | flags: [
33 | '--disable-background-timer-throttling',
34 | '--disable-backgrounding-occluded-windows',
35 | '--disable-renderer-backgrounding',
36 | ],
37 | },
38 | firefox: {
39 | base: 'Firefox',
40 | prefs: {
41 | 'layers.acceleration.disabled': true,
42 | },
43 | },
44 | },
45 |
46 | preprocessors: {
47 | 'test/fixtures/**/*.js': ['fixtures'],
48 | 'test/specs/**/*.js': ['rollup'],
49 | 'test/index.js': ['rollup'],
50 | 'src/index.ts': ['sources'],
51 | },
52 |
53 | rollupPreprocessor: {
54 | plugins: buildPlugins,
55 | external: ['chart.js'],
56 | output: {
57 | name: 'test',
58 | format: 'umd',
59 | sourcemap: karma.autoWatch ? 'inline' : false,
60 | globals: {
61 | 'chart.js': 'Chart',
62 | },
63 | },
64 | },
65 |
66 | customPreprocessors: {
67 | fixtures: {
68 | base: 'rollup',
69 | options: {
70 | output: {
71 | format: 'iife',
72 | name: 'fixture',
73 | },
74 | },
75 | },
76 | sources: {
77 | base: 'rollup',
78 | options: build,
79 | },
80 | },
81 | })
82 |
83 | if (env === 'test') {
84 | karma.reporters.push('coverage')
85 | karma.coverageReporter = {
86 | dir: 'coverage/',
87 | reporters: [
88 | { type: 'html', subdir: 'html' },
89 | { type: 'lcovonly', subdir: (browser) => browser.toLowerCase().split(/[ /-]/)[0] },
90 | ],
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/test/fixtures/scales/time.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | config: {
3 | type: 'matrix',
4 | data: {
5 | datasets: [
6 | {
7 | label: 'My Matrix',
8 | data: [
9 | { x: '2019-01-05', y: '08:00', v: 11 },
10 | { x: '2019-01-01', y: '12:00', v: 12 },
11 | { x: '2019-01-01', y: '16:00', v: 13 },
12 | { x: '2019-01-02', y: '08:00', v: 21 },
13 | { x: '2019-01-02', y: '12:00', v: 22 },
14 | { x: '2019-01-02', y: '16:00', v: 23 },
15 | { x: '2019-01-03', y: '08:00', v: 31 },
16 | { x: '2019-01-03', y: '12:00', v: 32 },
17 | { x: '2019-01-04', y: '16:00', v: 33 },
18 | ],
19 | backgroundColor(ctx) {
20 | const value = ctx.dataset.data[ctx.dataIndex].v
21 | const alpha = (value - 5) / 40
22 | // eslint-disable-next-line no-undef
23 | return Chart.helpers.color('green').alpha(alpha).rgbString()
24 | },
25 | borderColor(ctx) {
26 | const value = ctx.dataset.data[ctx.dataIndex].v
27 | const alpha = (value - 5) / 40
28 | // eslint-disable-next-line no-undef
29 | return Chart.helpers.color('green').alpha(alpha).darken(0.4).rgbString()
30 | },
31 | borderWidth: { left: 3, right: 3 },
32 | width(ctx) {
33 | const a = ctx.chart.chartArea || {}
34 | return (a.right - a.left) / 5.5
35 | },
36 | height(ctx) {
37 | const a = ctx.chart.chartArea || {}
38 | return (a.bottom - a.top) / 3.5
39 | },
40 | },
41 | ],
42 | },
43 | options: {
44 | animation: false,
45 | maintainAspectRatio: false,
46 | plugins: {
47 | legend: {
48 | display: false,
49 | },
50 | },
51 | scales: {
52 | x: {
53 | type: 'time',
54 | offset: true,
55 | time: {
56 | unit: 'day',
57 | },
58 | ticks: {
59 | display: false,
60 | },
61 | grid: {
62 | display: false,
63 | },
64 | },
65 | y: {
66 | type: 'time',
67 | min: '06:00',
68 | max: '18:00',
69 | time: {
70 | unit: 'hour',
71 | parser: 'HH:mm',
72 | displayFormats: {
73 | hour: 'HH',
74 | },
75 | },
76 | reverse: false,
77 | ticks: {
78 | display: false,
79 | },
80 | grid: {
81 | display: false,
82 | },
83 | },
84 | },
85 | },
86 | },
87 | options: {
88 | canvas: {
89 | height: 256,
90 | width: 512,
91 | },
92 | },
93 | }
94 |
--------------------------------------------------------------------------------
/.github/workflows/main-ci.yml:
--------------------------------------------------------------------------------
1 | name: Main CI
2 | on:
3 | push:
4 | branches:
5 | - main
6 |
7 | permissions:
8 | contents: read # for checkout
9 |
10 | jobs:
11 | ci:
12 | name: CI
13 | runs-on: ubuntu-latest
14 |
15 | permissions:
16 | actions: write # Needed for actions/cache/save
17 | contents: write # to be able to publish a GitHub release
18 | id-token: write # to enable use of OIDC for npm provenance
19 | issues: write # to be able to comment on released issues
20 | pull-requests: write # to be able to comment on released pull requests
21 |
22 | steps:
23 | - name: Checkout
24 | uses: actions/checkout@v4
25 | with:
26 | fetch-depth: 0
27 |
28 | - name: Setup Node.js
29 | uses: actions/setup-node@v4
30 | with:
31 | cache: npm
32 |
33 | - name: Install dependencies
34 | run: npm clean-install
35 |
36 | - name: Lint
37 | run: npm run lint
38 |
39 | - name: Typecheck
40 | run: npm run typecheck
41 |
42 | - name: Build
43 | run: npm run build
44 |
45 | - name: Test
46 | run: xvfb-run --auto-servernum npm test
47 | shell: bash
48 |
49 | - name: Store coverage reports
50 | uses: actions/cache/save@v4
51 | with:
52 | path: |
53 | coverage/chrome/lcov.info
54 | coverage/firefox/lcov.info
55 | coverage/unit/lcov.info
56 | key: coverage
57 |
58 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
59 | run: npm audit signatures
60 |
61 | - name: Release
62 | env:
63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
64 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
65 | run: npx semantic-release
66 |
67 | sonar:
68 | runs-on: ubuntu-latest
69 | needs: [ci]
70 |
71 | permissions:
72 | actions: read # Needed for actions/cache/restore
73 |
74 | steps:
75 | - uses: actions/checkout@v4
76 | with:
77 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis (SonarCloud)
78 |
79 | - uses: actions/setup-node@v4
80 | with:
81 | cache: npm
82 |
83 | - name: Restore coverage from cache
84 | uses: actions/cache/restore@v4
85 | with:
86 | path: |
87 | coverage/chrome/lcov.info
88 | coverage/firefox/lcov.info
89 | coverage/unit/lcov.info
90 | key: coverage
91 | restore-keys: coverage
92 |
93 | - name: Set version
94 | env:
95 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
96 | run: |
97 | VERSION=$(gh release view --json tagName -q .tagName)
98 | echo -e "\nsonar.projectVersion=$VERSION" >> sonar-project.properties
99 |
100 | - name: SonarCloud Scan
101 | uses: SonarSource/sonarqube-scan-action@v5
102 | env:
103 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
104 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # chartjs-chart-matrix
2 |
3 | [Chart.js](https://www.chartjs.org/) **v3+, v4+** module for creating matrix charts
4 |
5 | [](https://www.npmjs.com/package/chartjs-chart-matrix)
6 | [](https://github.com/kurkle/chartjs-chart-matrix/releases/latest)
7 | 
8 | [](https://sonarcloud.io/summary/new_code?id=kurkle_chartjs-chart-matrix)
9 | [](https://sonarcloud.io/summary/new_code?id=kurkle_chartjs-chart-matrix)
10 | [](https://chartjs-chart-matrix.pages.dev)
11 | 
12 |
13 | ## Example
14 |
15 | 
16 |
17 | ## Documentation
18 |
19 | You can find documentation for chartjs-chart-matrix at [https://chartjs-chart-matrix.pages.dev/](https://chartjs-chart-matrix.pages.dev/).
20 |
21 | ## Quickstart
22 |
23 | ```html
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
33 |
64 |
65 | ```
66 |
67 | This simple example is also available online in the documentation: https://chartjs-chart-matrix.pages.dev/usage.html
68 |
69 | ## Development
70 |
71 | You first need to install node dependencies (requires [Node.js](https://nodejs.org/)):
72 |
73 | ```bash
74 | > npm install
75 | ```
76 |
77 | The following commands will then be available from the repository root:
78 |
79 | ```bash
80 | > npm run build // build dist files
81 | > npm test // run all tests
82 | > npm run lint // perform code linting
83 | > npm package // create an archive with dist files and samples
84 | ```
85 |
86 | ## License
87 |
88 | chartjs-chart-matrix is available under the [MIT license](https://opensource.org/licenses/MIT).
89 |
--------------------------------------------------------------------------------
/src/controller.ts:
--------------------------------------------------------------------------------
1 | import { DatasetController, UpdateMode } from 'chart.js'
2 | import { AnchorX, AnchorY, MatrixControllerDatasetOptions, MatrixDataPoint } from 'types/index.esm'
3 |
4 | import { version } from '../package.json'
5 |
6 | import MatrixElement from './element'
7 |
8 | export default class MatrixController extends DatasetController<
9 | 'matrix',
10 | MatrixElement,
11 | MatrixElement,
12 | MatrixDataPoint
13 | > {
14 | static readonly id = 'matrix'
15 | static readonly version = version
16 |
17 | static readonly defaults = {
18 | dataElementType: 'matrix',
19 |
20 | animations: {
21 | numbers: {
22 | type: 'number',
23 | properties: ['x', 'y', 'width', 'height'],
24 | },
25 | },
26 | }
27 |
28 | static readonly overrides = {
29 | interaction: {
30 | mode: 'nearest',
31 | intersect: true,
32 | },
33 | scales: {
34 | x: {
35 | type: 'linear',
36 | offset: true,
37 | },
38 | y: {
39 | type: 'linear',
40 | reverse: true,
41 | },
42 | },
43 | }
44 |
45 | options: MatrixControllerDatasetOptions
46 |
47 | override initialize() {
48 | this.enableOptionSharing = true
49 | super.initialize()
50 | }
51 |
52 | override update(mode: UpdateMode) {
53 | const meta = this._cachedMeta
54 |
55 | this.updateElements(meta.data, 0, meta.data.length, mode)
56 | }
57 |
58 | override updateElements(rects: MatrixElement[], start: number, count: number, mode: UpdateMode) {
59 | const reset = mode === 'reset'
60 | const { xScale, yScale } = this._cachedMeta
61 | const firstOpts = this.resolveDataElementOptions(start, mode)
62 | const sharedOptions = this.getSharedOptions(firstOpts)
63 |
64 | for (let i = start; i < start + count; i++) {
65 | const parsed = !reset && this.getParsed(i)
66 | const x = reset ? xScale.getBasePixel() : xScale.getPixelForValue(parsed.x)
67 | const y = reset ? yScale.getBasePixel() : yScale.getPixelForValue(parsed.y)
68 | const options = this.resolveDataElementOptions(i, mode)
69 | const { width, height, anchorX, anchorY } = options
70 | const properties = {
71 | x: resolveX(anchorX, x, width),
72 | y: resolveY(anchorY, y, height),
73 | width,
74 | height,
75 | options,
76 | }
77 | this.updateElement(rects[i], i, properties, mode)
78 | }
79 |
80 | this.updateSharedOptions(sharedOptions, mode, firstOpts)
81 | }
82 |
83 | override draw() {
84 | const ctx = this.chart.ctx
85 | const data = this.getMeta().data || []
86 | let i: number, ilen: number
87 |
88 | for (i = 0, ilen = data.length; i < ilen; ++i) {
89 | data[i].draw(ctx)
90 | }
91 | }
92 | }
93 |
94 | function resolveX(anchorX: AnchorX, x: number, width: number) {
95 | if (anchorX === 'left' || anchorX === 'start') {
96 | return x
97 | }
98 | if (anchorX === 'right' || anchorX === 'end') {
99 | return x - width
100 | }
101 | return x - width / 2
102 | }
103 |
104 | function resolveY(anchorY: AnchorY, y: number, height: number) {
105 | if (anchorY === 'top' || anchorY === 'start') {
106 | return y
107 | }
108 | if (anchorY === 'bottom' || anchorY === 'end') {
109 | return y - height
110 | }
111 | return y - height / 2
112 | }
113 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { FlatCompat } from '@eslint/eslintrc'
2 | import js from '@eslint/js'
3 | import markdown from '@eslint/markdown'
4 | import tsParser from '@typescript-eslint/parser'
5 | import prettier from 'eslint-plugin-prettier'
6 | import simpleImportSort from 'eslint-plugin-simple-import-sort'
7 | import unusedImports from 'eslint-plugin-unused-imports'
8 | import globals from 'globals'
9 | import path from 'node:path'
10 | import { fileURLToPath } from 'node:url'
11 |
12 | const __filename = fileURLToPath(import.meta.url)
13 | const __dirname = path.dirname(__filename)
14 | const compat = new FlatCompat({
15 | baseDirectory: __dirname,
16 | recommendedConfig: js.configs.recommended,
17 | allConfig: js.configs.all,
18 | })
19 |
20 | export default [
21 | {
22 | ignores: ['**/*\\{.,-}min.js', 'build/**/*', 'dist/**/*'],
23 | },
24 | ...compat.extends('chartjs', 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'),
25 | {
26 | plugins: {
27 | 'unused-imports': unusedImports,
28 | 'simple-import-sort': simpleImportSort,
29 | prettier,
30 | },
31 |
32 | languageOptions: {
33 | globals: {
34 | ...globals.browser,
35 | ...globals.node,
36 | ...globals.jasmine,
37 | },
38 |
39 | ecmaVersion: 'latest',
40 | sourceType: 'module',
41 |
42 | parser: tsParser,
43 | },
44 |
45 | rules: {
46 | 'prettier/prettier': 'error',
47 | 'class-methods-use-this': 'off',
48 | complexity: ['warn', 10],
49 | 'max-statements': ['warn', 30],
50 | 'no-empty-function': 'off',
51 | semi: ['error', 'never'],
52 | quotes: [
53 | 'error',
54 | 'single',
55 | {
56 | avoidEscape: true,
57 | allowTemplateLiterals: true,
58 | },
59 | ],
60 | 'comma-spacing': [
61 | 'error',
62 | {
63 | before: false,
64 | after: true,
65 | },
66 | ],
67 |
68 | 'no-use-before-define': [
69 | 'error',
70 | {
71 | functions: false,
72 | },
73 | ],
74 | '@typescript-eslint/no-explicit-any': 'off',
75 | '@typescript-eslint/no-unused-vars': 'off',
76 | '@typescript-eslint/indent': 'off',
77 | 'simple-import-sort/imports': [
78 | 'error',
79 | {
80 | groups: [
81 | ['^@?\\w.*\\u0000$', '^[^.].*\\u0000$', '^\\..*\\u0000$'],
82 | ['^react', '^@?\\w'],
83 | ['^(@|components)(/.*|$)'],
84 | ['^\\u0000'],
85 | ['^\\.\\.(?!/?$)', '^\\.\\./?$'],
86 | ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'],
87 | ['^.+\\.?(css)$'],
88 | ],
89 | },
90 | ],
91 |
92 | 'unused-imports/no-unused-imports': 'error',
93 | },
94 | },
95 | {
96 | files: ['src/**/*.ts', '**/*.js', '**/*.mjs'],
97 | },
98 | {
99 | files: ['**/*.md'],
100 | language: 'markdown/commonmark',
101 | plugins: {
102 | markdown,
103 | },
104 | rules: {
105 | 'no-irregular-whitespace': 'off',
106 | },
107 | },
108 | {
109 | files: ['types/**/*.ts'],
110 | rules: {
111 | 'no-use-before-define': 'warn',
112 | },
113 | },
114 | {
115 | files: ['**/*.cjs'],
116 | rules: {
117 | '@typescript-eslint/no-require-imports': 'off',
118 | },
119 | },
120 | ]
121 |
--------------------------------------------------------------------------------
/docs/samples/calendar.md:
--------------------------------------------------------------------------------
1 | # Calendar (Time Scale)
2 |
3 | ```js chart-editor
4 | //
5 | function generateData() {
6 | const adapter = new helpers._adapters._date();
7 | const data = [];
8 | let dt = adapter.startOf(new Date(), 'month');
9 | const end = adapter.endOf(dt, 'month');
10 | while (dt <= end) {
11 | const iso = adapter.format(dt, 'yyyy-MM-dd');
12 | data.push({
13 | x: Utils.isoDayOfWeek(dt),
14 | y: iso,
15 | d: iso,
16 | v: Math.random() * 50
17 | });
18 | dt = new Date(dt.setDate(dt.getDate() + 1));
19 | }
20 | return data;
21 | }
22 | //
23 |
24 | //
25 | const data = {
26 | datasets: [{
27 | data: generateData(),
28 | backgroundColor({raw}) {
29 | const alpha = (10 + raw.v) / 60;
30 | return helpers.color('green').alpha(alpha).rgbString();
31 | },
32 | borderColor({raw}) {
33 | const alpha = (10 + raw.v) / 60;
34 | return helpers.color('green').alpha(alpha).darken(0.3).rgbString();
35 | },
36 | borderWidth: 1,
37 | hoverBackgroundColor: 'yellow',
38 | hoverBorderColor: 'yellowgreen',
39 | width: ({chart}) => (chart.chartArea || {}).width / chart.scales.x.ticks.length - 3,
40 | height: ({chart}) =>(chart.chartArea || {}).height / chart.scales.y.ticks.length - 3
41 | }]
42 | };
43 | //
44 |
45 | //
46 | const scales = {
47 | y: {
48 | type: 'time',
49 | left: 'left',
50 | offset: true,
51 | time: {
52 | unit: 'week',
53 | round: 'week',
54 | isoWeekday: 1,
55 | displayFormats: {
56 | week: 'I'
57 | }
58 | },
59 | ticks: {
60 | maxRotation: 0,
61 | autoSkip: true,
62 | padding: 1
63 | },
64 | grid: {
65 | display: false,
66 | drawBorder: false,
67 | tickLength: 0,
68 | },
69 | title: {
70 | display: true,
71 | font: {size: 15, weigth: 'bold'},
72 | text: ({chart}) => chart.scales.x._adapter.format(Date.now(), 'MMM, yyyy'),
73 | padding: 0
74 | }
75 | },
76 | x: {
77 | type: 'time',
78 | position: 'top',
79 | offset: true,
80 | time: {
81 | unit: 'day',
82 | parser: 'i',
83 | isoWeekday: 1,
84 | displayFormats: {
85 | day: 'iiiiii'
86 | }
87 | },
88 | reverse: false,
89 | ticks: {
90 | source: 'data',
91 | padding: 0,
92 | maxRotation: 0,
93 | },
94 | grid: {
95 | display: false,
96 | drawBorder: false,
97 | }
98 | }
99 | };
100 | //
101 |
102 | //
103 | const options = {
104 | plugins: {
105 | legend: false,
106 | tooltip: {
107 | displayColors: false,
108 | callbacks: {
109 | title() {
110 | return '';
111 | },
112 | label(context) {
113 | const v = context.dataset.data[context.dataIndex];
114 | return ['d: ' + v.d, 'v: ' + v.v.toFixed(2)];
115 | }
116 | }
117 | },
118 | },
119 | scales: scales,
120 | layout: {
121 | padding: {
122 | top: 10,
123 | }
124 | }
125 | };
126 | //
127 | //
128 | const config = {
129 | type: 'matrix',
130 | data: data,
131 | options: options
132 | };
133 | //
134 |
135 | const actions = [
136 | {
137 | name: 'Randomize',
138 | handler(chart) {
139 | chart.data.datasets.forEach(dataset => {
140 | dataset.data.forEach(point => {
141 | point.v = Math.random() * 50;
142 | });
143 | });
144 | chart.update();
145 | }
146 | },
147 | ];
148 |
149 | module.exports = {
150 | actions,
151 | config,
152 | };
153 | ```
154 |
--------------------------------------------------------------------------------
/docs/samples/time.md:
--------------------------------------------------------------------------------
1 | # On Time Scale
2 |
3 | ```js chart-editor
4 | //
5 | function generateData() {
6 | const data = [];
7 | const end = Utils.startOfToday();
8 | let dt = new Date(new Date().setDate(end.getDate() - 365));
9 | while (dt <= end) {
10 | const iso = dt.toISOString().substr(0, 10);
11 | data.push({
12 | x: iso,
13 | y: Utils.isoDayOfWeek(dt),
14 | d: iso,
15 | v: Math.random() * 50
16 | });
17 | dt = new Date(dt.setDate(dt.getDate() + 1));
18 | }
19 | return data;
20 | }
21 | //
22 |
23 | //
24 | const data = {
25 | datasets: [{
26 | label: 'My Matrix',
27 | data: generateData(),
28 | backgroundColor(c) {
29 | const value = c.dataset.data[c.dataIndex].v;
30 | const alpha = (10 + value) / 60;
31 | return helpers.color('green').alpha(alpha).rgbString();
32 | },
33 | borderColor(c) {
34 | const value = c.dataset.data[c.dataIndex].v;
35 | const alpha = (10 + value) / 60;
36 | return helpers.color('green').alpha(alpha).darken(0.3).rgbString();
37 | },
38 | borderWidth: 1,
39 | hoverBackgroundColor: 'yellow',
40 | hoverBorderColor: 'yellowgreen',
41 | width(c) {
42 | const a = c.chart.chartArea || {};
43 | return (a.right - a.left) / 53 - 1;
44 | },
45 | height(c) {
46 | const a = c.chart.chartArea || {};
47 | return (a.bottom - a.top) / 7 - 1;
48 | }
49 | }]
50 | };
51 | //
52 |
53 | //
54 | const scales = {
55 | y: {
56 | type: 'time',
57 | offset: true,
58 | time: {
59 | unit: 'day',
60 | round: 'day',
61 | isoWeekday: 1,
62 | parser: 'i',
63 | displayFormats: {
64 | day: 'iiiiii'
65 | }
66 | },
67 | reverse: true,
68 | position: 'right',
69 | ticks: {
70 | maxRotation: 0,
71 | autoSkip: true,
72 | padding: 1,
73 | font: {
74 | size: 9
75 | }
76 | },
77 | grid: {
78 | display: false,
79 | drawBorder: false,
80 | tickLength: 0
81 | }
82 | },
83 | x: {
84 | type: 'time',
85 | position: 'bottom',
86 | offset: true,
87 | time: {
88 | unit: 'week',
89 | round: 'week',
90 | isoWeekday: 1,
91 | displayFormats: {
92 | week: 'MMM dd'
93 | }
94 | },
95 | ticks: {
96 | maxRotation: 0,
97 | autoSkip: true,
98 | font: {
99 | size: 9
100 | }
101 | },
102 | grid: {
103 | display: false,
104 | drawBorder: false,
105 | tickLength: 0,
106 | }
107 | }
108 | };
109 | //
110 |
111 | //
112 | const options = {
113 | aspectRatio: 5,
114 | plugins: {
115 | legend: false,
116 | tooltip: {
117 | displayColors: false,
118 | callbacks: {
119 | title() {
120 | return '';
121 | },
122 | label(context) {
123 | const v = context.dataset.data[context.dataIndex];
124 | return ['d: ' + v.d, 'v: ' + v.v.toFixed(2)];
125 | }
126 | }
127 | },
128 | },
129 | scales: scales,
130 | layout: {
131 | padding: {
132 | top: 10
133 | }
134 | }
135 | };
136 | //
137 |
138 | //
139 | const config = {
140 | type: 'matrix',
141 | data: data,
142 | options: options
143 | };
144 | //
145 |
146 | const actions = [
147 | {
148 | name: 'Randomize',
149 | handler(chart) {
150 | chart.data.datasets.forEach(dataset => {
151 | dataset.data.forEach(point => {
152 | point.v = Math.random() * 50;
153 | });
154 | });
155 | chart.update();
156 | }
157 | },
158 | ];
159 |
160 | module.exports = {
161 | actions,
162 | config,
163 | };
164 | ```
165 |
--------------------------------------------------------------------------------
/docs/samples/yearweek.md:
--------------------------------------------------------------------------------
1 | # Year-week heatmap
2 |
3 | ```js chart-editor
4 | //
5 | function generateData() {
6 | const adapter = new helpers._adapters._date();
7 | const data = [];
8 | const end = adapter.startOf(new Date(), 'isoWeek', 1);
9 | const startY = adapter.startOf(adapter.add(end, -10, 'year'), 'year');
10 | const fourth = adapter.add(startY, 3, 'day');
11 | const start = adapter.startOf(fourth, 'isoWeek', 1);
12 | for (let dt = start; dt < end; dt = adapter.add(dt, 1, 'week')) {
13 | const isoYear = adapter.format(dt, 'RRRR');
14 | const iso = adapter.format(dt, 'RRRR-MM-dd');
15 | const weekMonths = [];
16 | const monday = adapter.startOf(dt, 'isoWeek', 1);
17 | const sunday = adapter.add(dt, 7, 'day');
18 | const isoWeek = +adapter.format(dt, 'I');
19 | const monthIndex = isoWeek === 1 ? 0 : monday.getMonth();
20 | const startWeekOfMonth = +adapter.format(adapter.startOf(dt, 'month'), 'I');
21 | const weekOfMonth = startWeekOfMonth > isoWeek ? isoWeek : isoWeek - startWeekOfMonth + 1;
22 | const x = monthIndex * 6 + weekOfMonth;
23 | data.push({
24 | x,
25 | y: isoYear,
26 | d: iso,
27 | w: adapter.format(monday, 'yyyy-MM-dd') + ' - ' + adapter.format(sunday, 'yyyy-MM-dd'),
28 | v: Math.random() * 50,
29 | iw: isoWeek,
30 | });
31 | }
32 | window.data = data;
33 | return data;
34 | }
35 | //
36 |
37 | //
38 | const data = {
39 | datasets: [{
40 | data: generateData(),
41 | backgroundColor({raw}) {
42 | const alpha = (10 + raw.v) / 60;
43 | return helpers.color('green').alpha(alpha).rgbString();
44 | },
45 | borderColor({raw}) {
46 | const alpha = (10 + raw.v) / 60;
47 | return helpers.color('green').alpha(alpha).darken(0.3).rgbString();
48 | },
49 | borderWidth: 1,
50 | hoverBackgroundColor: 'yellow',
51 | hoverBorderColor: 'yellowgreen',
52 | width: 10,
53 | height: ({chart}) =>(chart.chartArea || {}).height / chart.scales.y.ticks.length - 3
54 | }]
55 | };
56 | //
57 |
58 | //
59 | const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
60 | const scales = {
61 | y: {
62 | type: 'time',
63 | left: 'left',
64 | offset: true,
65 | time: {
66 | unit: 'year',
67 | round: 'year',
68 | displayFormats: {
69 | parsing: 'yyyy',
70 | year: 'R' // ISO week-numbering year
71 | }
72 | },
73 | ticks: {
74 | maxRotation: 0,
75 | autoSkip: false,
76 | padding: 1
77 | },
78 | grid: {
79 | display: false,
80 | drawBorder: false,
81 | tickLength: 0,
82 | },
83 | title: {
84 | display: true,
85 | font: {size: 15, weigth: 'bold'},
86 | text: 'Year',
87 | padding: 0
88 | }
89 | },
90 | x: {
91 | type: 'linear',
92 | position: 'top',
93 | offset: true,
94 | min: 1,
95 | max: 72,
96 | reverse: false,
97 | ticks: {
98 | autoSkip: false,
99 | callback: (val, index) => val % 6 === 3 ? months[(val - 3) / 6] : '',
100 | maxTicksLimit: 100,
101 | stepSize: 1,
102 | padding: 0,
103 | maxRotation: 0,
104 | },
105 | grid: {
106 | display: false,
107 | drawBorder: false,
108 | }
109 | }
110 | };
111 | //
112 |
113 | //
114 | const options = {
115 | plugins: {
116 | legend: false,
117 | tooltip: {
118 | displayColors: false,
119 | callbacks: {
120 | title() {
121 | return '';
122 | },
123 | label({raw}) {
124 | return ['w: ' + raw.w, 'isoWeek: ' + raw.iw, 'v: ' + raw.v.toFixed(2)];
125 | }
126 | }
127 | },
128 | },
129 | scales: scales,
130 | layout: {
131 | padding: {
132 | top: 10,
133 | }
134 | }
135 | };
136 | //
137 |
138 | //
139 | const config = {
140 | type: 'matrix',
141 | data: data,
142 | options: options
143 | };
144 | //
145 |
146 | const actions = [];
147 |
148 | module.exports = {
149 | actions,
150 | config,
151 | };
152 | ```
153 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "chartjs-chart-matrix",
3 | "version": "0.0.0-development",
4 | "description": "Chart.js module for creating matrix charts",
5 | "type": "module",
6 | "main": "dist/chartjs-chart-matrix.cjs",
7 | "module": "dist/chartjs-chart-matrix.esm.js",
8 | "types": "types/index.esm.d.ts",
9 | "jsdelivr": "dist/chartjs-chart-matrix.min.js",
10 | "unpkg": "dist/chartjs-chart-matrix.min.js",
11 | "exports": {
12 | "types": "./types/index.esm.d.ts",
13 | "import": "./dist/chartjs-chart-matrix.esm.js",
14 | "require": "./dist/chartjs-chart-matrix.cjs",
15 | "script": "./dist/chartjs-chart-matrix.min.js"
16 | },
17 | "sideEffects": [
18 | "dist/chartjs-chart-matrix.cjs",
19 | "dist/chartjs-chart-matrix-min.js"
20 | ],
21 | "scripts": {
22 | "autobuild": "rollup -c -w",
23 | "build": "rollup -c",
24 | "dev": "karma start ./karma.conf.cjs --no-single-run --auto-watch --browsers chrome",
25 | "dev:ff": "karma start ./karma.conf.cjs --auto-watch --no-single-run --browsers firefox",
26 | "docs": "npm run build && vuepress build docs --no-cache",
27 | "docs:dev": "npm run build && vuepress dev docs --no-cache",
28 | "lint": "eslint",
29 | "typecheck": "tsc --noEmit && tsc --noEmit -p types/tests/",
30 | "test": "cross-env NODE_ENV=test concurrently \"npm:test-*\"",
31 | "pretest-unit": "swc --config-file .swcrc-spec src -d build",
32 | "test-unit": "cross-env JASMINE_CONFIG_PATH=jasmine.json c8 --src=src --reporter=text --reporter=lcov -o=coverage/unit jasmine",
33 | "test-karma": "karma start ./karma.conf.cjs --no-auto-watch --single-run",
34 | "test-integration:node-commonjs": "npm run test --prefix test/integration/node-commonjs",
35 | "test-integration:node-module": "npm run test --prefix test/integration/node-module"
36 | },
37 | "repository": {
38 | "type": "git",
39 | "url": "git+https://github.com/kurkle/chartjs-chart-matrix.git"
40 | },
41 | "keywords": [
42 | "chart.js",
43 | "chart",
44 | "matrix"
45 | ],
46 | "files": [
47 | "dist/*",
48 | "!dist/docs/**",
49 | "types/index.esm.d.ts"
50 | ],
51 | "author": "Jukka Kurkela",
52 | "license": "MIT",
53 | "bugs": {
54 | "url": "https://github.com/kurkle/chartjs-chart-matrix/issues"
55 | },
56 | "homepage": "https://chartjs-chart-matrix.pages.dev/",
57 | "devDependencies": {
58 | "@eslint/js": "^9.22.0",
59 | "@eslint/markdown": "^6.3.0",
60 | "@napi-rs/canvas": "^0.1.30",
61 | "@rollup/plugin-commonjs": "^28.0.1",
62 | "@rollup/plugin-json": "^6.0.0",
63 | "@rollup/plugin-node-resolve": "^16.0.1",
64 | "@rollup/plugin-swc": "^0.4.0",
65 | "@rollup/plugin-terser": "^0.4.4",
66 | "@swc/cli": "^0.6.0",
67 | "@swc/core": "^1.11.9",
68 | "@types/jasmine": "^5.1.7",
69 | "@types/node": "^24.0.3",
70 | "@typescript-eslint/eslint-plugin": "^8.26.1",
71 | "@typescript-eslint/parser": "^8.26.1",
72 | "c8": "^10.1.3",
73 | "chart.js": "^4.0.1",
74 | "chartjs-adapter-date-fns": "^3.0.0",
75 | "chartjs-test-utils": "^0.5.0",
76 | "concurrently": "^9.0.1",
77 | "cross-env": "^7.0.3",
78 | "date-fns": "^2.19.0",
79 | "eslint": "^9.22.0",
80 | "eslint-config-chartjs": "^0.3.0",
81 | "eslint-config-prettier": "^10.1.1",
82 | "eslint-plugin-prettier": "^5.2.3",
83 | "eslint-plugin-simple-import-sort": "^12.1.1",
84 | "eslint-plugin-unused-imports": "^4.1.4",
85 | "globals": "^16.0.0",
86 | "jasmine": "^5.6.0",
87 | "jasmine-core": "^5.1.2",
88 | "karma": "^6.2.0",
89 | "karma-chrome-launcher": "^3.1.0",
90 | "karma-coverage": "^2.0.3",
91 | "karma-firefox-launcher": "^2.1.0",
92 | "karma-jasmine": "^5.1.0",
93 | "karma-jasmine-html-reporter": "^2.0.0",
94 | "karma-rollup-preprocessor": "^7.0.7",
95 | "karma-spec-reporter": "^0.0.36",
96 | "rollup": "^4.21.2",
97 | "rollup-plugin-analyzer": "^4.0.0",
98 | "rollup-plugin-cleanup": "^3.2.1",
99 | "rollup-plugin-istanbul": "^5.0.0",
100 | "typescript": "^5.3.3",
101 | "vuepress": "^1.9.7",
102 | "vuepress-plugin-flexsearch": "^0.3.0",
103 | "vuepress-plugin-redirect": "^1.2.5",
104 | "vuepress-theme-chartjs": "^0.2.0"
105 | },
106 | "peerDependencies": {
107 | "chart.js": ">=3.0.0"
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/helpers.test.ts:
--------------------------------------------------------------------------------
1 | import MatrixElement from './element'
2 | import { boundingRects, inRange, parseBorderWidth } from './helpers'
3 |
4 | describe('parseBorderWidth', () => {
5 | it('should return uniform border width when given a number', () => {
6 | const result = parseBorderWidth({ borderWidth: 5 }, 10, 10)
7 | expect(result).toEqual({ t: 5, r: 5, b: 5, l: 5 })
8 | })
9 |
10 | it('should return individual border widths when given an object', () => {
11 | const borderWidth = { top: 2, right: 4, bottom: 6, left: 8 }
12 | const result = parseBorderWidth({ borderWidth }, 10, 10)
13 | expect(result).toEqual({ t: 2, r: 4, b: 6, l: 8 })
14 | })
15 |
16 | it('should apply limits on border width', () => {
17 | const borderWidth = { top: 15, right: 20, bottom: 25, left: 30 }
18 | const result = parseBorderWidth({ borderWidth }, 10, 10)
19 | expect(result).toEqual({ t: 10, r: 10, b: 10, l: 10 })
20 | })
21 |
22 | it('should handle missing object properties as zero', () => {
23 | const borderWidth = { top: 3, right: undefined, bottom: null, left: 7 }
24 | const result = parseBorderWidth({ borderWidth }, 10, 10)
25 | expect(result).toEqual({ t: 3, r: 0, b: 0, l: 7 })
26 | })
27 |
28 | it('should return zero when borderWidth is undefined', () => {
29 | const result = parseBorderWidth({ borderWidth: undefined }, 10, 10)
30 | expect(result).toEqual({ t: 0, r: 0, b: 0, l: 0 })
31 | })
32 |
33 | it('should return zero when borderWidth is null', () => {
34 | const result = parseBorderWidth({ borderWidth: null }, 10, 10)
35 | expect(result).toEqual({ t: 0, r: 0, b: 0, l: 0 })
36 | })
37 |
38 | it('should return zero when borderWidth is NaN', () => {
39 | const result = parseBorderWidth({ borderWidth: NaN }, 10, 10)
40 | expect(result).toEqual({ t: 0, r: 0, b: 0, l: 0 })
41 | })
42 |
43 | it('should coerce string numbers to actual numbers', () => {
44 | const borderWidth = { top: '2', right: '4', bottom: '6', left: '8' }
45 | // @ts-expect-error types don't allow strings in borderwidth
46 | const result = parseBorderWidth({ borderWidth }, 10, 10)
47 | expect(result).toEqual({ t: 2, r: 4, b: 6, l: 8 })
48 | })
49 | })
50 |
51 | describe('inRange', () => {
52 | it('should return false if rect is null', () => {
53 | expect(inRange(null, 5, 5, false)).toBeFalse()
54 | })
55 |
56 | it('should return false if both x and y are null', () => {
57 | const rect = new MatrixElement({ x: 0, width: 10, y: 0, height: 10 })
58 | expect(inRange(rect, null, null, false)).toBeFalse()
59 | })
60 |
61 | it('should return true if x and y are within bounds', () => {
62 | const rect = new MatrixElement({ x: 0, width: 10, y: 0, height: 10 })
63 | expect(inRange(rect, 5, 5, false)).toBeTrue()
64 | })
65 |
66 | it('should return false if x is out of bounds', () => {
67 | const rect = new MatrixElement({ x: 0, width: 10, y: 0, height: 10 })
68 | expect(inRange(rect, 15, 5, false)).toBeFalse()
69 | })
70 |
71 | it('should return false if y is out of bounds', () => {
72 | const rect = new MatrixElement({ x: 0, width: 10, y: 0, height: 10 })
73 | expect(inRange(rect, 5, 15, false)).toBeFalse()
74 | })
75 |
76 | it('should return true if x is null (ignores x check)', () => {
77 | const rect = new MatrixElement({ x: 0, width: 10, y: 0, height: 10 })
78 | expect(inRange(rect, null, 5, false)).toBeTrue()
79 | })
80 |
81 | it('should return true if y is null (ignores y check)', () => {
82 | const rect = new MatrixElement({ x: 0, width: 10, y: 0, height: 10 })
83 | expect(inRange(rect, 5, null, false)).toBeTrue()
84 | })
85 |
86 | it('should return true if x and y are on the boundary', () => {
87 | const rect = new MatrixElement({ x: 0, width: 10, y: 0, height: 10 })
88 | expect(inRange(rect, 0, 0, false)).toBeTrue()
89 | expect(inRange(rect, 10, 10, false)).toBeTrue()
90 | })
91 | })
92 |
93 | describe('boundingRects', () => {
94 | it('should return correct outer and inner bounding rectangles', () => {
95 | const element = new MatrixElement({
96 | x: 10,
97 | width: 20,
98 | y: 20,
99 | height: 30,
100 | options: { borderWidth: { top: 2, right: 3, bottom: 4, left: 5 } },
101 | })
102 |
103 | const result = boundingRects(element)
104 | expect(result.outer).toEqual({ x: 10, y: 20, w: 20, h: 30 })
105 | expect(result.inner).toEqual({ x: 15, y: 22, w: 12, h: 24 })
106 | })
107 |
108 | it('should handle uniform numeric border width', () => {
109 | const element = new MatrixElement({ x: 0, width: 20, y: 0, height: 20, options: { borderWidth: 3 } })
110 |
111 | const result = boundingRects(element)
112 | expect(result.outer).toEqual({ x: 0, y: 0, w: 20, h: 20 })
113 | expect(result.inner).toEqual({ x: 3, y: 3, w: 14, h: 14 })
114 | })
115 |
116 | it('should handle missing border width as zero', () => {
117 | const element = new MatrixElement({ x: 5, width: 20, y: 10, height: 30, options: { borderWidth: undefined } })
118 |
119 | const result = boundingRects(element)
120 | expect(result.outer).toEqual({ x: 5, y: 10, w: 20, h: 30 })
121 | expect(result.inner).toEqual({ x: 5, y: 10, w: 20, h: 30 })
122 | })
123 |
124 | it('should handle partially defined border width', () => {
125 | const element = new MatrixElement({
126 | x: 10,
127 | width: 30,
128 | y: 15,
129 | height: 30,
130 | options: { borderWidth: { top: 2, left: 4 } },
131 | })
132 |
133 | const result = boundingRects(element)
134 | expect(result.outer).toEqual({ x: 10, y: 15, w: 30, h: 30 })
135 | expect(result.inner).toEqual({ x: 14, y: 17, w: 26, h: 28 })
136 | })
137 |
138 | it('should return the same inner and outer bounds if border width exceeds half size', () => {
139 | const element = new MatrixElement({
140 | x: 0,
141 | width: 10,
142 | y: 0,
143 | height: 10,
144 | options: { borderWidth: { top: 10, right: 10, bottom: 10, left: 10 } },
145 | })
146 |
147 | const result = boundingRects(element)
148 | expect(result.outer).toEqual({ x: 0, y: 0, w: 10, h: 10 })
149 | expect(result.inner).toEqual({ x: 5, y: 5, w: 0, h: 0 })
150 | })
151 | })
152 |
--------------------------------------------------------------------------------