├── 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 | 5 | 7 | 9 | 12 | 14 | 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 | ![matrix Example Image](matrix.png) 6 | 7 | ## Installation 8 | 9 | ### npm 10 | 11 | [![npm](https://img.shields.io/npm/v/chartjs-chart-matrix.svg?style=flat-square&maxAge=600)](https://npmjs.com/package/chartjs-chart-matrix) [![npm downloads](https://img.shields.io/npm/dm/chartjs-chart-matrix.svg?style=flat-square&maxAge=600)](https://npmjs.com/package/chartjs-chart-matrix) 12 | 13 | ```bash 14 | > npm install chartjs-chart-matrix 15 | ``` 16 | 17 | ### CDN 18 | 19 | [![jsdelivr](https://img.shields.io/npm/v/chartjs-chart-matrix.svg?label=jsdelivr&style=flat-square&maxAge=600)](https://cdn.jsdelivr.net/npm/chartjs-chart-matrix@latest/dist/) [![jsdelivr hits](https://data.jsdelivr.com/v1/package/npm/chartjs-chart-matrix/badge)](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 | [![npm](https://img.shields.io/npm/v/chartjs-chart-matrix.svg)](https://www.npmjs.com/package/chartjs-chart-matrix) 6 | [![release](https://img.shields.io/github/release/kurkle/chartjs-chart-matrix.svg?style=flat-square)](https://github.com/kurkle/chartjs-chart-matrix/releases/latest) 7 | ![npm bundle size](https://img.shields.io/bundlephobia/min/chartjs-chart-matrix.svg) 8 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=kurkle_chartjs-chart-matrix&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=kurkle_chartjs-chart-matrix) 9 | [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=kurkle_chartjs-chart-matrix&metric=coverage)](https://sonarcloud.io/summary/new_code?id=kurkle_chartjs-chart-matrix) 10 | [![documentation](https://img.shields.io/static/v1?message=Documentation&color=informational)](https://chartjs-chart-matrix.pages.dev) 11 | ![GitHub](https://img.shields.io/github/license/kurkle/chartjs-chart-matrix.svg) 12 | 13 | ## Example 14 | 15 | ![Matrix Example Image](matrix.png) 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 | 32 |
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 | --------------------------------------------------------------------------------