diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2432e914909..c1264017cd3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -31,7 +31,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4.0.0
+ - uses: pnpm/action-setup@v4.1.0
- name: Use Node.js
uses: actions/setup-node@v4
with:
diff --git a/.github/workflows/compressed-size.yml b/.github/workflows/compressed-size.yml
index 24cc40e3310..a79a28f8d25 100644
--- a/.github/workflows/compressed-size.yml
+++ b/.github/workflows/compressed-size.yml
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4.0.0
+ - uses: pnpm/action-setup@v4.1.0
- uses: preactjs/compressed-size-action@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml
index 0a6486d5697..949a4ec2118 100644
--- a/.github/workflows/deploy-docs.yml
+++ b/.github/workflows/deploy-docs.yml
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4.0.0
+ - uses: pnpm/action-setup@v4.1.0
- name: Use Node.js
uses: actions/setup-node@v4
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 802c749c6b0..93699285c06 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4.0.0
+ - uses: pnpm/action-setup@v4.1.0
- uses: actions/setup-node@v4
with:
registry-url: https://registry.npmjs.org/
@@ -72,7 +72,7 @@ jobs:
if: "!github.event.release.prerelease"
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4.0.0
+ - uses: pnpm/action-setup@v4.1.0
- uses: actions/setup-node@v4
with:
registry-url: https://registry.npmjs.org/
diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts
index 645754254c1..ccb310094cf 100644
--- a/docs/.vuepress/config.ts
+++ b/docs/.vuepress/config.ts
@@ -294,6 +294,7 @@ export default defineConfig({
'getting-started/installation',
'getting-started/integration',
'getting-started/usage',
+ 'getting-started/using-from-node-js',
]
},
{
diff --git a/docs/axes/cartesian/linear.md b/docs/axes/cartesian/linear.md
index f534a4d9b82..fae3b98d11b 100644
--- a/docs/axes/cartesian/linear.md
+++ b/docs/axes/cartesian/linear.md
@@ -97,4 +97,4 @@ module.exports = {
## Internal data format
-Internally, the linear scale uses numeric data
+Internally, the linear scale uses numeric data.
diff --git a/docs/developers/api.md b/docs/developers/api.md
index 11ece0128a6..2662f68f8a5 100644
--- a/docs/developers/api.md
+++ b/docs/developers/api.md
@@ -25,13 +25,19 @@ Triggers an update of the chart. This can be safely called after updating the da
myLineChart.data.datasets[0].data[2] = 50; // Would update the first dataset's value of 'March' to be 50
myLineChart.update(); // Calling update now animates the position of March from 90 to 50.
```
+A `mode` can be provided to indicate transition configuration should be used. This can be either:
-A `mode` string can be provided to indicate transition configuration should be used. Core calls this method using any of `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'` or `undefined`. `'none'` is also a supported mode for skipping animations for single update. Please see [animations](../configuration/animations.md) docs for more details.
+- **string value**: Core calls this method using any of `'active'`, `'hide'`, `'reset'`, `'resize'`, `'show'` or `undefined`. `'none'` is also supported for skipping animations for single update. Please see [animations](../configuration/animations.md) docs for more details.
-Example:
+- **function**: that receives a context object `{ datasetIndex: number }` and returns a mode string, allowing different modes per dataset.
+Examples:
```javascript
+// Using string mode
myChart.update('active');
+
+// Using function mode for dataset-specific animations
+myChart.update(ctx => ctx.datasetIndex === 0 ? 'active' : 'none');
```
See [Updating Charts](updates.md) for more details.
@@ -141,6 +147,15 @@ Returns the number of datasets that are currently not hidden.
```javascript
const numberOfVisibleDatasets = chart.getVisibleDatasetCount();
```
+## isDatasetVisible(datasetIndex)
+
+Returns a boolean if a dataset at the given index is currently visible.
+
+The visibility is determined by first checking the hidden property in the dataset metadata (set via [`setDatasetVisibility()`](#setdatasetvisibility-datasetindex-visibility) and accessible through [`getDatasetMeta()`](#getdatasetmeta-index)). If this is not set, the hidden property of the dataset object itself (`chart.data.datasets[n].hidden`) is returned.
+
+```javascript
+chart.isDatasetVisible(1);
+```
## setDatasetVisibility(datasetIndex, visibility)
@@ -162,7 +177,7 @@ chart.update(); // chart now renders with item hidden
## getDataVisibility(index)
-Returns the stored visibility state of a data index for all datasets. Set by [toggleDataVisibility](#toggleDataVisibility). A dataset controller should use this method to determine if an item should not be visible.
+Returns the stored visibility state of a data index for all datasets. Set by [toggleDataVisibility](#toggledatavisibility-index). A dataset controller should use this method to determine if an item should not be visible.
```javascript
const visible = chart.getDataVisibility(2);
@@ -229,3 +244,9 @@ Chart.register(Tooltip, LinearScale, PointElement, BubbleController);
## Static: unregister(chartComponentLike)
Used to unregister plugins, axis types or chart types globally from all your charts.
+
+```javascript
+import { Chart, Tooltip, LinearScale, PointElement, BubbleController } from 'chart.js';
+
+Chart.unregister(Tooltip, LinearScale, PointElement, BubbleController);
+```
diff --git a/docs/general/data-structures.md b/docs/general/data-structures.md
index 5c95e560ca6..39074070ab5 100644
--- a/docs/general/data-structures.md
+++ b/docs/general/data-structures.md
@@ -2,8 +2,8 @@
The `data` property of a dataset can be passed in various formats. By default, that `data` is parsed using the associated chart type and scales.
-If the `labels` property of the main `data` property is used, it has to contain the same amount of elements as the dataset with the most values. These labels are used to label the index axis (default x axes). The values for the labels have to be provided in an array.
-The provided labels can be of the type string or number to be rendered correctly. In case you want multiline labels you can provide an array with each line as one entry in the array.
+If the `labels` property of the main `data` property is used, it has to contain the same amount of elements as the dataset with the most values. These labels are used to label the index axis (default `x` axis). The values for the labels have to be provided in an array.
+The provided labels can be of the type string or number to be rendered correctly. If you want multiline labels, you can provide an array with each line as one entry in the array.
## Primitive[]
@@ -19,7 +19,22 @@ const cfg = {
}
```
-When the `data` is an array of numbers, values from `labels` array at the same index are used for the index axis (`x` for vertical, `y` for horizontal charts).
+When `data` is an array of numbers, values from the `labels` array at the same index are used for the index axis (`x` for vertical, `y` for horizontal charts).
+
+## Array[]
+
+```javascript
+const cfg = {
+ type: 'line',
+ data: {
+ datasets: [{
+ data: [[10, 20], [15, null], [20, 10]]
+ }]
+ }
+}
+```
+
+When `data` is an array of arrays (or what TypeScript would call tuples), the first element of each tuple is the index (`x` for vertical, `y` for horizontal charts) and the second element is the value (`y` by default).
## Object[]
@@ -58,7 +73,9 @@ const cfg = {
This is also the internal format used for parsed data. In this mode, parsing can be disabled by specifying `parsing: false` at chart options or dataset. If parsing is disabled, data must be sorted and in the formats the associated chart type and scales use internally.
-The values provided must be parsable by the associated scales or in the internal format of the associated scales. A common mistake would be to provide integers for the `category` scale, which uses integers as an internal format, where each integer represents an index in the labels array. `null` can be used for skipped values.
+The values provided must be parsable by the associated scales or in the internal format of the associated scales. For example, the `category` scale uses integers as an internal format, where each integer represents an index in the labels array; but, if parsing is enabled, it can also parse string labels.
+
+`null` can be used for skipped values.
## Object[] using custom properties
@@ -117,7 +134,7 @@ const cfg = {
```
:::warning
-When using object notation in a radar chart, you still need a labels array with labels for the chart to show correctly.
+When using object notation in a radar chart, you still need a `labels` array with labels for the chart to show correctly.
:::
## Object
@@ -136,7 +153,7 @@ const cfg = {
}
```
-In this mode, property name is used for `index` scale and value for `value` scale. For vertical charts, index scale is `x` and value scale is `y`.
+In this mode, the property name is used for the `index` scale and value for the `value` scale. For vertical charts, the index scale is `x` and value scale is `y`.
## Dataset Configuration
@@ -180,9 +197,9 @@ const cfg = {
};
```
-## Typescript
+## TypeScript
-When using typescript, if you want to use a data structure that is not the default data structure, you will need to pass it to the type interface when instantiating the data variable.
+When using TypeScript, if you want to use a data structure that is not the default data structure, you will need to pass it to the type interface when instantiating the data variable.
```ts
import {ChartData} from 'chart.js';
diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md
index 232f3d40d5b..6df4d1f57bf 100644
--- a/docs/getting-started/index.md
+++ b/docs/getting-started/index.md
@@ -3,8 +3,9 @@
Let's get started with Chart.js!
* **[Follow a step-by-step guide](./usage) to get up to speed with Chart.js**
-* [Install Chart.js](./installation) from npm or a CDN
+* [Install Chart.js](./installation) from npm or a CDN
* [Integrate Chart.js](./integration) with bundlers, loaders, and front-end frameworks
+* [Use Chart.js from Node.js](./using-from-node-js)
Alternatively, see the example below or check [samples](../samples).
@@ -63,7 +64,7 @@ Now that we have a canvas, we can include Chart.js from a CDN.
```
-Finally, we can create a chart. We add a script that acquires the `myChart` canvas element and instantiates `new Chart` with desired configuration: `bar` chart type, labels, data points, and options.
+Finally, we can create a chart. We add a script that acquires the `myChart` canvas element and instantiates `new Chart` with desired configuration: `bar` chart type, labels, data points, and options.
```html
```
-You can see all the ways to use Chart.js in the [step-by-step guide](./usage).
\ No newline at end of file
+You can see all the ways to use Chart.js in the [step-by-step guide](./usage).
diff --git a/docs/getting-started/using-from-node-js.md b/docs/getting-started/using-from-node-js.md
new file mode 100644
index 00000000000..90d8959a7be
--- /dev/null
+++ b/docs/getting-started/using-from-node-js.md
@@ -0,0 +1,38 @@
+# Using from Node.js
+
+You can use Chart.js in Node.js for server-side generation of plots with help from an NPM package such as [node-canvas](https://github.com/Automattic/node-canvas) or [skia-canvas](https://skia-canvas.org/).
+
+Sample usage:
+
+```js
+import {CategoryScale, Chart, LinearScale, LineController, LineElement, PointElement} from 'chart.js';
+import {Canvas} from 'skia-canvas';
+import fsp from 'node:fs/promises';
+
+Chart.register([
+ CategoryScale,
+ LineController,
+ LineElement,
+ LinearScale,
+ PointElement
+]);
+
+const canvas = new Canvas(400, 300);
+const chart = new Chart(
+ canvas, // TypeScript needs "as any" here
+ {
+ type: 'line',
+ data: {
+ labels: ['Red', 'Blue', 'Yellow', 'Green', 'Purple', 'Orange'],
+ datasets: [{
+ label: '# of Votes',
+ data: [12, 19, 3, 5, 2, 3],
+ borderColor: 'red'
+ }]
+ }
+ }
+);
+const pngBuffer = await canvas.toBuffer('png', {matte: 'white'});
+await fsp.writeFile('output.png', pngBuffer);
+chart.destroy();
+```
diff --git a/package.json b/package.json
index 81e031b56d0..c76c9c5c6d4 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "chart.js",
"homepage": "https://www.chartjs.org",
"description": "Simple HTML5 charts using the canvas element.",
- "version": "4.4.8",
+ "version": "4.4.9",
"license": "MIT",
"type": "module",
"sideEffects": [
diff --git a/src/controllers/controller.bar.js b/src/controllers/controller.bar.js
index 82138f3fb74..554497b3053 100644
--- a/src/controllers/controller.bar.js
+++ b/src/controllers/controller.bar.js
@@ -486,6 +486,27 @@ export default class BarController extends DatasetController {
return this._getStacks(undefined, index).length;
}
+ _getAxisCount() {
+ return this._getAxis().length;
+ }
+
+ getFirstScaleIdForIndexAxis() {
+ const scales = this.chart.scales;
+ const indexScaleId = this.chart.options.indexAxis;
+ return Object.keys(scales).filter(key => scales[key].axis === indexScaleId).shift();
+ }
+
+ _getAxis() {
+ const axis = {};
+ const firstScaleAxisId = this.getFirstScaleIdForIndexAxis();
+ for (const dataset of this.chart.data.datasets) {
+ axis[valueOrDefault(
+ this.chart.options.indexAxis === 'x' ? dataset.xAxisID : dataset.yAxisID, firstScaleAxisId
+ )] = true;
+ }
+ return Object.keys(axis);
+ }
+
/**
* Returns the stack index for the given dataset based on groups and bar visibility.
* @param {number} [datasetIndex] - The dataset index
@@ -618,13 +639,15 @@ export default class BarController extends DatasetController {
const skipNull = options.skipNull;
const maxBarThickness = valueOrDefault(options.maxBarThickness, Infinity);
let center, size;
+ const axisCount = this._getAxisCount();
if (ruler.grouped) {
const stackCount = skipNull ? this._getStackCount(index) : ruler.stackCount;
const range = options.barThickness === 'flex'
- ? computeFlexCategoryTraits(index, ruler, options, stackCount)
- : computeFitCategoryTraits(index, ruler, options, stackCount);
-
- const stackIndex = this._getStackIndex(this.index, this._cachedMeta.stack, skipNull ? index : undefined);
+ ? computeFlexCategoryTraits(index, ruler, options, stackCount * axisCount)
+ : computeFitCategoryTraits(index, ruler, options, stackCount * axisCount);
+ const axisID = this.chart.options.indexAxis === 'x' ? this.getDataset().xAxisID : this.getDataset().yAxisID;
+ const axisNumber = this._getAxis().indexOf(valueOrDefault(axisID, this.getFirstScaleIdForIndexAxis()));
+ const stackIndex = this._getStackIndex(this.index, this._cachedMeta.stack, skipNull ? index : undefined) + axisNumber;
center = range.start + (range.chunk * stackIndex) + (range.chunk / 2);
size = Math.min(maxBarThickness, range.chunk * range.ratio);
} else {
@@ -633,6 +656,7 @@ export default class BarController extends DatasetController {
size = Math.min(maxBarThickness, ruler.min * ruler.ratio);
}
+
return {
base: center - size / 2,
head: center + size / 2,
diff --git a/src/core/core.controller.js b/src/core/core.controller.js
index 47b238da8aa..e0408ae212a 100644
--- a/src/core/core.controller.js
+++ b/src/core/core.controller.js
@@ -6,9 +6,8 @@ import {_detectPlatform} from '../platform/index.js';
import PluginService from './core.plugins.js';
import registry from './core.registry.js';
import Config, {determineAxis, getIndexAxis} from './core.config.js';
-import {retinaScale, _isDomSupported} from '../helpers/helpers.dom.js';
import {each, callback as callCallback, uid, valueOrDefault, _elementsEqual, isNullOrUndef, setsEqual, defined, isFunction, _isClickEvent} from '../helpers/helpers.core.js';
-import {clearCanvas, clipArea, createContext, unclipArea, _isPointInArea} from '../helpers/index.js';
+import {clearCanvas, clipArea, createContext, unclipArea, _isPointInArea, _isDomSupported, retinaScale, getDatasetClipArea} from '../helpers/index.js';
// @ts-ignore
import {version} from '../../package.json';
import {debounce} from '../helpers/helpers.extras.js';
@@ -101,23 +100,6 @@ function determineLastEvent(e, lastEvent, inChartArea, isClick) {
return e;
}
-function getSizeForArea(scale, chartArea, field) {
- return scale.options.clip ? scale[field] : chartArea[field];
-}
-
-function getDatasetArea(meta, chartArea) {
- const {xScale, yScale} = meta;
- if (xScale && yScale) {
- return {
- left: getSizeForArea(xScale, chartArea, 'left'),
- right: getSizeForArea(xScale, chartArea, 'right'),
- top: getSizeForArea(yScale, chartArea, 'top'),
- bottom: getSizeForArea(yScale, chartArea, 'bottom')
- };
- }
- return chartArea;
-}
-
class Chart {
static defaults = defaults;
@@ -800,31 +782,25 @@ class Chart {
*/
_drawDataset(meta) {
const ctx = this.ctx;
- const clip = meta._clip;
- const useClip = !clip.disabled;
- const area = getDatasetArea(meta, this.chartArea);
const args = {
meta,
index: meta.index,
cancelable: true
};
+ // @ts-expect-error
+ const clip = getDatasetClipArea(this, meta);
if (this.notifyPlugins('beforeDatasetDraw', args) === false) {
return;
}
- if (useClip) {
- clipArea(ctx, {
- left: clip.left === false ? 0 : area.left - clip.left,
- right: clip.right === false ? this.width : area.right + clip.right,
- top: clip.top === false ? 0 : area.top - clip.top,
- bottom: clip.bottom === false ? this.height : area.bottom + clip.bottom
- });
+ if (clip) {
+ clipArea(ctx, clip);
}
meta.controller.draw();
- if (useClip) {
+ if (clip) {
unclipArea(ctx);
}
diff --git a/src/elements/element.arc.ts b/src/elements/element.arc.ts
index e2bd26f523b..42f41f045b0 100644
--- a/src/elements/element.arc.ts
+++ b/src/elements/element.arc.ts
@@ -1,9 +1,42 @@
import Element from '../core/core.element.js';
import {_angleBetween, getAngleFromPoint, TAU, HALF_PI, valueOrDefault} from '../helpers/index.js';
-import {PI, _isBetween, _limitValue} from '../helpers/helpers.math.js';
+import {PI, _angleDiff, _normalizeAngle, _isBetween, _limitValue} from '../helpers/helpers.math.js';
import {_readValueToProps} from '../helpers/helpers.options.js';
import type {ArcOptions, Point} from '../types/index.js';
+function clipSelf(ctx: CanvasRenderingContext2D, element: ArcElement, endAngle: number) {
+ const {startAngle, x, y, outerRadius, innerRadius, options} = element;
+ const {borderWidth, borderJoinStyle} = options;
+ const outerAngleClip = Math.min(borderWidth / outerRadius, _normalizeAngle(startAngle - endAngle));
+ ctx.beginPath();
+ ctx.arc(x, y, outerRadius - borderWidth / 2, startAngle + outerAngleClip / 2, endAngle - outerAngleClip / 2);
+
+ if (innerRadius > 0) {
+ const innerAngleClip = Math.min(borderWidth / innerRadius, _normalizeAngle(startAngle - endAngle));
+ ctx.arc(x, y, innerRadius + borderWidth / 2, endAngle - innerAngleClip / 2, startAngle + innerAngleClip / 2, true);
+ } else {
+ const clipWidth = Math.min(borderWidth / 2, outerRadius * _normalizeAngle(startAngle - endAngle));
+
+ if (borderJoinStyle === 'round') {
+ ctx.arc(x, y, clipWidth, endAngle - PI / 2, startAngle + PI / 2, true);
+ } else if (borderJoinStyle === 'bevel') {
+ const r = 2 * clipWidth * clipWidth;
+ const endX = -r * Math.cos(endAngle + PI / 2) + x;
+ const endY = -r * Math.sin(endAngle + PI / 2) + y;
+ const startX = r * Math.cos(startAngle + PI / 2) + x;
+ const startY = r * Math.sin(startAngle + PI / 2) + y;
+ ctx.lineTo(endX, endY);
+ ctx.lineTo(startX, startY);
+ }
+ }
+ ctx.closePath();
+
+ ctx.moveTo(0, 0);
+ ctx.rect(0, 0, ctx.canvas.width, ctx.canvas.height);
+
+ ctx.clip('evenodd');
+}
+
function clipArc(ctx: CanvasRenderingContext2D, element: ArcElement, endAngle: number) {
const {startAngle, pixelMargin, x, y, outerRadius, innerRadius} = element;
@@ -213,7 +246,7 @@ function drawBorder(
circular: boolean,
) {
const {fullCircles, startAngle, circumference, options} = element;
- const {borderWidth, borderJoinStyle, borderDash, borderDashOffset} = options;
+ const {borderWidth, borderJoinStyle, borderDash, borderDashOffset, borderRadius} = options;
const inner = options.borderAlign === 'inner';
if (!borderWidth) {
@@ -246,6 +279,10 @@ function drawBorder(
clipArc(ctx, element, endAngle);
}
+ if (options.selfJoin && endAngle - startAngle >= PI && borderRadius === 0 && borderJoinStyle !== 'miter') {
+ clipSelf(ctx, element, endAngle);
+ }
+
if (!fullCircles) {
pathArc(ctx, element, offset, spacing, endAngle, circular);
ctx.stroke();
@@ -276,6 +313,7 @@ export default class ArcElement extends Element {
spacing: 0,
angle: undefined,
circular: true,
+ selfJoin: false,
};
static defaultRoutes = {
diff --git a/src/helpers/helpers.dataset.ts b/src/helpers/helpers.dataset.ts
new file mode 100644
index 00000000000..000dcfe1977
--- /dev/null
+++ b/src/helpers/helpers.dataset.ts
@@ -0,0 +1,33 @@
+import type {Chart, ChartArea, ChartMeta, Scale, TRBL} from '../types/index.js';
+
+function getSizeForArea(scale: Scale, chartArea: ChartArea, field: keyof ChartArea) {
+ return scale.options.clip ? scale[field] : chartArea[field];
+}
+
+function getDatasetArea(meta: ChartMeta, chartArea: ChartArea): TRBL {
+ const {xScale, yScale} = meta;
+ if (xScale && yScale) {
+ return {
+ left: getSizeForArea(xScale, chartArea, 'left'),
+ right: getSizeForArea(xScale, chartArea, 'right'),
+ top: getSizeForArea(yScale, chartArea, 'top'),
+ bottom: getSizeForArea(yScale, chartArea, 'bottom')
+ };
+ }
+ return chartArea;
+}
+
+export function getDatasetClipArea(chart: Chart, meta: ChartMeta): TRBL | false {
+ const clip = meta._clip;
+ if (clip.disabled) {
+ return false;
+ }
+ const area = getDatasetArea(meta, chart.chartArea);
+
+ return {
+ left: clip.left === false ? 0 : area.left - (clip.left === true ? 0 : clip.left),
+ right: clip.right === false ? chart.width : area.right + (clip.right === true ? 0 : clip.right),
+ top: clip.top === false ? 0 : area.top - (clip.top === true ? 0 : clip.top),
+ bottom: clip.bottom === false ? chart.height : area.bottom + (clip.bottom === true ? 0 : clip.bottom)
+ };
+}
diff --git a/src/helpers/index.ts b/src/helpers/index.ts
index 1917ce740a1..9fde7b85951 100644
--- a/src/helpers/index.ts
+++ b/src/helpers/index.ts
@@ -13,3 +13,4 @@ export * from './helpers.options.js';
export * from './helpers.math.js';
export * from './helpers.rtl.js';
export * from './helpers.segment.js';
+export * from './helpers.dataset.js';
diff --git a/src/plugins/plugin.filler/filler.drawing.js b/src/plugins/plugin.filler/filler.drawing.js
index 2e2fbd2b99e..0a718355da8 100644
--- a/src/plugins/plugin.filler/filler.drawing.js
+++ b/src/plugins/plugin.filler/filler.drawing.js
@@ -1,35 +1,47 @@
-import {clipArea, unclipArea} from '../../helpers/index.js';
+import {clipArea, unclipArea, getDatasetClipArea} from '../../helpers/index.js';
import {_findSegmentEnd, _getBounds, _segments} from './filler.segment.js';
import {_getTarget} from './filler.target.js';
export function _drawfill(ctx, source, area) {
const target = _getTarget(source);
- const {line, scale, axis} = source;
+ const {chart, index, line, scale, axis} = source;
const lineOpts = line.options;
const fillOption = lineOpts.fill;
const color = lineOpts.backgroundColor;
const {above = color, below = color} = fillOption || {};
+ const meta = chart.getDatasetMeta(index);
+ const clip = getDatasetClipArea(chart, meta);
if (target && line.points.length) {
clipArea(ctx, area);
- doFill(ctx, {line, target, above, below, area, scale, axis});
+ doFill(ctx, {line, target, above, below, area, scale, axis, clip});
unclipArea(ctx);
}
}
function doFill(ctx, cfg) {
- const {line, target, above, below, area, scale} = cfg;
+ const {line, target, above, below, area, scale, clip} = cfg;
const property = line._loop ? 'angle' : cfg.axis;
ctx.save();
- if (property === 'x' && below !== above) {
- clipVertical(ctx, target, area.top);
- fill(ctx, {line, target, color: above, scale, property});
- ctx.restore();
- ctx.save();
- clipVertical(ctx, target, area.bottom);
+ let fillColor = below;
+ if (below !== above) {
+ if (property === 'x') {
+ clipVertical(ctx, target, area.top);
+ fill(ctx, {line, target, color: above, scale, property, clip});
+ ctx.restore();
+ ctx.save();
+ clipVertical(ctx, target, area.bottom);
+ } else if (property === 'y') {
+ clipHorizontal(ctx, target, area.left);
+ fill(ctx, {line, target, color: below, scale, property, clip});
+ ctx.restore();
+ ctx.save();
+ clipHorizontal(ctx, target, area.right);
+ fillColor = above;
+ }
}
- fill(ctx, {line, target, color: below, scale, property});
+ fill(ctx, {line, target, color: fillColor, scale, property, clip});
ctx.restore();
}
@@ -64,8 +76,38 @@ function clipVertical(ctx, target, clipY) {
ctx.clip();
}
+function clipHorizontal(ctx, target, clipX) {
+ const {segments, points} = target;
+ let first = true;
+ let lineLoop = false;
+
+ ctx.beginPath();
+ for (const segment of segments) {
+ const {start, end} = segment;
+ const firstPoint = points[start];
+ const lastPoint = points[_findSegmentEnd(start, end, points)];
+ if (first) {
+ ctx.moveTo(firstPoint.x, firstPoint.y);
+ first = false;
+ } else {
+ ctx.lineTo(clipX, firstPoint.y);
+ ctx.lineTo(firstPoint.x, firstPoint.y);
+ }
+ lineLoop = !!target.pathSegment(ctx, segment, {move: lineLoop});
+ if (lineLoop) {
+ ctx.closePath();
+ } else {
+ ctx.lineTo(clipX, lastPoint.y);
+ }
+ }
+
+ ctx.lineTo(clipX, target.first().y);
+ ctx.closePath();
+ ctx.clip();
+}
+
function fill(ctx, cfg) {
- const {line, target, property, color, scale} = cfg;
+ const {line, target, property, color, scale, clip} = cfg;
const segments = _segments(line, target, property);
for (const {source: src, target: tgt, start, end} of segments) {
@@ -75,7 +117,7 @@ function fill(ctx, cfg) {
ctx.save();
ctx.fillStyle = backgroundColor;
- clipBounds(ctx, scale, notShape && _getBounds(property, start, end));
+ clipBounds(ctx, scale, clip, notShape && _getBounds(property, start, end));
ctx.beginPath();
@@ -103,12 +145,35 @@ function fill(ctx, cfg) {
}
}
-function clipBounds(ctx, scale, bounds) {
- const {top, bottom} = scale.chart.chartArea;
+function clipBounds(ctx, scale, clip, bounds) {
+ const chartArea = scale.chart.chartArea;
const {property, start, end} = bounds || {};
- if (property === 'x') {
+
+ if (property === 'x' || property === 'y') {
+ let left, top, right, bottom;
+
+ if (property === 'x') {
+ left = start;
+ top = chartArea.top;
+ right = end;
+ bottom = chartArea.bottom;
+ } else {
+ left = chartArea.left;
+ top = start;
+ right = chartArea.right;
+ bottom = end;
+ }
+
ctx.beginPath();
- ctx.rect(start, top, end - start, bottom - top);
+
+ if (clip) {
+ left = Math.max(left, clip.left);
+ right = Math.min(right, clip.right);
+ top = Math.max(top, clip.top);
+ bottom = Math.min(bottom, clip.bottom);
+ }
+
+ ctx.rect(left, top, right - left, bottom - top);
ctx.clip();
}
}
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 14461328a92..69a4cfccbc5 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -429,6 +429,15 @@ export declare const RadarController: ChartComponent & {
prototype: RadarController;
new (chart: Chart, datasetIndex: number): RadarController;
};
+
+interface ChartMetaClip {
+ left: number | boolean;
+ top: number | boolean;
+ right: number | boolean;
+ bottom: number | boolean;
+ disabled: boolean;
+}
+
interface ChartMetaCommon {
type: string;
controller: DatasetController;
@@ -462,6 +471,7 @@ interface ChartMetaCommon exte
* @param {ChartEvent} args.event - The event object.
* @param {boolean} args.replay - True if this event is replayed from `Chart.update`
* @param {boolean} args.inChartArea - The event position is inside chartArea
+ * @param {boolean} [args.changed] - Set to true if the plugin needs a render. Should only be changed to true, because this args object is passed through all plugins.
* @param {object} options - The plugin options.
*/
- beforeEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, cancelable: true, inChartArea: boolean }, options: O): boolean | void;
+ beforeEvent?(chart: Chart, args: { event: ChartEvent, replay: boolean, changed?: boolean; cancelable: true, inChartArea: boolean }, options: O): boolean | void;
/**
* @desc Called after the `event` has been consumed. Note that this hook
* will not be called if the `event` has been previously discarded.
@@ -1508,7 +1519,7 @@ export declare const Ticks: {
* @param ticks the list of ticks being converted
* @return string representation of the tickValue parameter
*/
- numeric(tickValue: number, index: number, ticks: { value: number }[]): string;
+ numeric(this: Scale, tickValue: number, index: number, ticks: { value: number }[]): string;
/**
* Formatter for logarithmic ticks
* @param tickValue the value to be formatted
@@ -1516,7 +1527,7 @@ export declare const Ticks: {
* @param ticks the list of ticks being converted
* @return string representation of the tickValue parameter
*/
- logarithmic(tickValue: number, index: number, ticks: { value: number }[]): string;
+ logarithmic(this: Scale, tickValue: number, index: number, ticks: { value: number }[]): string;
};
};
@@ -1837,6 +1848,12 @@ export interface ArcBorderRadius {
}
export interface ArcOptions extends CommonElementOptions {
+ /**
+ * If true, Arc can take up 100% of a circular graph without any visual split or cut. This option doesn't support borderRadius and borderJoinStyle miter
+ * @default true
+ */
+ selfJoin: boolean;
+
/**
* Arc stroke alignment.
*/
diff --git a/test/fixtures/controller.bar/stacking/stacked-and-multiple-axis.js b/test/fixtures/controller.bar/stacking/stacked-and-multiple-axis.js
new file mode 100644
index 00000000000..ca5490a9291
--- /dev/null
+++ b/test/fixtures/controller.bar/stacking/stacked-and-multiple-axis.js
@@ -0,0 +1,64 @@
+module.exports = {
+ config: {
+ type: 'bar',
+ data: {
+ labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
+ datasets: [
+ {
+ label: 'Dataset 1',
+ data: [100, 90, 100, 50, 99, 87, 34],
+ backgroundColor: 'rgba(255,99,132,0.8)',
+ stack: 'a',
+ xAxisID: 'x'
+ },
+ {
+ label: 'Dataset 2',
+ data: [20, 25, 30, 32, 58, 14, 12],
+ backgroundColor: 'rgba(54,162,235,0.8)',
+ stack: 'b',
+ xAxisID: 'x2'
+ },
+ {
+ label: 'Dataset 3',
+ data: [80, 30, 40, 60, 70, 80, 47],
+ backgroundColor: 'rgba(75,192,192,0.8)',
+ stack: 'a',
+ xAxisID: 'x3'
+ },
+ {
+ label: 'Dataset 4',
+ data: [80, 30, 40, 60, 70, 80, 47],
+ backgroundColor: 'rgba(54,162,235,0.8)',
+ stack: 'a',
+ xAxisID: 'x3'
+ },
+ ]
+ },
+ options: {
+ plugins: false,
+ barThickness: 'flex',
+ scales: {
+ x: {
+ stacked: true,
+ display: false,
+ },
+ x2: {
+ labels: ['January 2024', 'February 2024', 'March 2024', 'April 2024', 'May 2024', 'June 2024', 'July 2024'],
+ stacked: true,
+ display: false,
+ },
+ x3: {
+ labels: ['January 2025', 'February 2025', 'March 2025', 'April 2025', 'May 2025', 'June 2025', 'July 2025'],
+ stacked: true,
+ display: false,
+ },
+ y: {
+ stacked: true,
+ display: false,
+ }
+ }
+ }
+ },
+ options: {
+ }
+};
diff --git a/test/fixtures/controller.bar/stacking/stacked-and-multiple-axis.png b/test/fixtures/controller.bar/stacking/stacked-and-multiple-axis.png
new file mode 100644
index 00000000000..ea571109e90
Binary files /dev/null and b/test/fixtures/controller.bar/stacking/stacked-and-multiple-axis.png differ
diff --git a/test/fixtures/controller.doughnut/selfJoin/doughnut.js b/test/fixtures/controller.doughnut/selfJoin/doughnut.js
new file mode 100644
index 00000000000..f29939cec2a
--- /dev/null
+++ b/test/fixtures/controller.doughnut/selfJoin/doughnut.js
@@ -0,0 +1,25 @@
+module.exports = {
+ config: {
+ type: 'doughnut',
+ data: {
+ labels: ['Red'],
+ datasets: [
+ {
+ // option in dataset
+ data: [100],
+ borderWidth: 15,
+ backgroundColor: '#FF0000',
+ borderColor: '#000000',
+ borderAlign: 'center',
+ selfJoin: true
+ }
+ ]
+ }
+ },
+ options: {
+ canvas: {
+ height: 256,
+ width: 512
+ }
+ }
+};
diff --git a/test/fixtures/controller.doughnut/selfJoin/doughnut.png b/test/fixtures/controller.doughnut/selfJoin/doughnut.png
new file mode 100644
index 00000000000..af2d4b43873
Binary files /dev/null and b/test/fixtures/controller.doughnut/selfJoin/doughnut.png differ
diff --git a/test/fixtures/controller.doughnut/selfJoin/pie.js b/test/fixtures/controller.doughnut/selfJoin/pie.js
new file mode 100644
index 00000000000..d0187db0917
--- /dev/null
+++ b/test/fixtures/controller.doughnut/selfJoin/pie.js
@@ -0,0 +1,26 @@
+module.exports = {
+ config: {
+ type: 'pie',
+ data: {
+ labels: ['Red'],
+ datasets: [
+ {
+ // option in dataset
+ data: [100],
+ borderWidth: 15,
+ backgroundColor: '#FF0000',
+ borderColor: '#000000',
+ borderAlign: 'center',
+ borderJoinStyle: 'round',
+ selfJoin: true
+ }
+ ]
+ }
+ },
+ options: {
+ canvas: {
+ height: 256,
+ width: 512
+ }
+ }
+};
diff --git a/test/fixtures/controller.doughnut/selfJoin/pie.png b/test/fixtures/controller.doughnut/selfJoin/pie.png
new file mode 100644
index 00000000000..17a2e3b1951
Binary files /dev/null and b/test/fixtures/controller.doughnut/selfJoin/pie.png differ
diff --git a/test/fixtures/plugin.filler/line/above-below-vertical-linechart.js b/test/fixtures/plugin.filler/line/above-below-vertical-linechart.js
new file mode 100644
index 00000000000..82ada178f01
--- /dev/null
+++ b/test/fixtures/plugin.filler/line/above-below-vertical-linechart.js
@@ -0,0 +1,46 @@
+module.exports = {
+ config: {
+ type: 'line',
+ data: {
+ labels: [1, 2, 3, 4],
+ datasets: [
+ {
+ data: [200, 400, 200, 400],
+ cubicInterpolationMode: 'monotone',
+ tension: 0.4,
+ spanGaps: true,
+ borderColor: 'blue',
+ pointRadius: 0,
+ fill: {
+ target: 1,
+ below: 'rgba(255, 0, 0, 0.4)',
+ above: 'rgba(53, 221, 53, 0.4)',
+ }
+ },
+ {
+ data: [400, 200, 400, 200],
+ cubicInterpolationMode: 'monotone',
+ tension: 0.4,
+ spanGaps: true,
+ borderColor: 'orange',
+ pointRadius: 0,
+ },
+ ]
+ },
+ options: {
+ indexAxis: 'y',
+ // maintainAspectRatio: false,
+ plugins: {
+ filler: {
+ propagate: false
+ },
+ datalabels: {
+ display: false
+ },
+ legend: {
+ display: false
+ },
+ }
+ }
+ }
+};
diff --git a/test/fixtures/plugin.filler/line/above-below-vertical-linechart.png b/test/fixtures/plugin.filler/line/above-below-vertical-linechart.png
new file mode 100644
index 00000000000..2052737e792
Binary files /dev/null and b/test/fixtures/plugin.filler/line/above-below-vertical-linechart.png differ
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-x-off.js b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x-off.js
new file mode 100644
index 00000000000..ff437ae80ac
--- /dev/null
+++ b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x-off.js
@@ -0,0 +1,78 @@
+const labels = [1, 2, 3, 4, 5, 6, 7];
+const values = [65, 59, 80, 81, 56, 55, 40];
+
+module.exports = {
+ description: 'https://github.com/chartjs/Chart.js/issues/12052',
+ config: {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ data: values.map(v => v - 10),
+ fill: '1',
+ borderColor: 'rgb(255, 0, 0)',
+ backgroundColor: 'rgba(255, 0, 0, 0.25)',
+ xAxisID: 'x1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(255, 0, 0)',
+ xAxisID: 'x1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(0, 0, 255)',
+ xAxisID: 'x2',
+ },
+ {
+ data: values.map(v => v + 10),
+ fill: '-1',
+ borderColor: 'rgb(0, 0, 255)',
+ backgroundColor: 'rgba(0, 0, 255, 0.25)',
+ xAxisID: 'x2',
+ }
+ ]
+ },
+ options: {
+ clip: false,
+ indexAxis: 'y',
+ animation: false,
+ responsive: false,
+ plugins: {
+ legend: false,
+ title: false,
+ tooltip: false
+ },
+ elements: {
+ point: {
+ radius: 0
+ },
+ line: {
+ cubicInterpolationMode: 'monotone',
+ borderColor: 'transparent',
+ tension: 0
+ }
+ },
+ scales: {
+ x2: {
+ axis: 'x',
+ stack: 'stack',
+ max: 80,
+ display: false,
+ },
+ x1: {
+ min: 50,
+ axis: 'x',
+ stack: 'stack',
+ display: false,
+ },
+ y: {
+ display: false,
+ }
+ }
+ }
+ },
+};
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-x-off.png b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x-off.png
new file mode 100644
index 00000000000..f050a4759f3
Binary files /dev/null and b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x-off.png differ
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-x.js b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x.js
new file mode 100644
index 00000000000..0ba25ac3122
--- /dev/null
+++ b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x.js
@@ -0,0 +1,77 @@
+const labels = [1, 2, 3, 4, 5, 6, 7];
+const values = [65, 59, 80, 81, 56, 55, 40];
+
+module.exports = {
+ description: 'https://github.com/chartjs/Chart.js/issues/12052',
+ config: {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ data: values.map(v => v - 10),
+ fill: '1',
+ borderColor: 'rgb(255, 0, 0)',
+ backgroundColor: 'rgba(255, 0, 0, 0.25)',
+ xAxisID: 'x1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(255, 0, 0)',
+ xAxisID: 'x1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(0, 0, 255)',
+ xAxisID: 'x2',
+ },
+ {
+ data: values.map(v => v + 10),
+ fill: '-1',
+ borderColor: 'rgb(0, 0, 255)',
+ backgroundColor: 'rgba(0, 0, 255, 0.25)',
+ xAxisID: 'x2',
+ }
+ ]
+ },
+ options: {
+ indexAxis: 'y',
+ animation: false,
+ responsive: false,
+ plugins: {
+ legend: false,
+ title: false,
+ tooltip: false
+ },
+ elements: {
+ point: {
+ radius: 0
+ },
+ line: {
+ cubicInterpolationMode: 'monotone',
+ borderColor: 'transparent',
+ tension: 0
+ }
+ },
+ scales: {
+ x2: {
+ axis: 'x',
+ stack: 'stack',
+ max: 80,
+ display: false,
+ },
+ x1: {
+ min: 50,
+ axis: 'x',
+ stack: 'stack',
+ display: false,
+ },
+ y: {
+ display: false,
+ }
+ }
+ }
+ },
+};
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-x.png b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x.png
new file mode 100644
index 00000000000..4f1dfdd6cab
Binary files /dev/null and b/test/fixtures/plugin.filler/line/dataset/clip-bounds-x.png differ
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-y-off.js b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y-off.js
new file mode 100644
index 00000000000..16a9759bb7d
--- /dev/null
+++ b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y-off.js
@@ -0,0 +1,77 @@
+const labels = [1, 2, 3, 4, 5, 6, 7];
+const values = [65, 59, 80, 81, 56, 55, 40];
+
+module.exports = {
+ description: 'https://github.com/chartjs/Chart.js/issues/12052',
+ config: {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ data: values.map(v => v - 10),
+ fill: '1',
+ borderColor: 'rgb(255, 0, 0)',
+ backgroundColor: 'rgba(255, 0, 0, 0.25)',
+ yAxisID: 'y1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(255, 0, 0)',
+ yAxisID: 'y1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(0, 0, 255)',
+ yAxisID: 'y2',
+ },
+ {
+ data: values.map(v => v + 10),
+ fill: '-1',
+ borderColor: 'rgb(0, 0, 255)',
+ backgroundColor: 'rgba(0, 0, 255, 0.25)',
+ yAxisID: 'y2',
+ }
+ ]
+ },
+ options: {
+ clip: false,
+ animation: false,
+ responsive: false,
+ plugins: {
+ legend: false,
+ title: false,
+ tooltip: false
+ },
+ elements: {
+ point: {
+ radius: 0
+ },
+ line: {
+ cubicInterpolationMode: 'monotone',
+ borderColor: 'transparent',
+ tension: 0
+ }
+ },
+ scales: {
+ y2: {
+ axis: 'y',
+ stack: 'stack',
+ max: 80,
+ display: false,
+ },
+ y1: {
+ min: 50,
+ axis: 'y',
+ stack: 'stack',
+ display: false,
+ },
+ x: {
+ display: false,
+ }
+ }
+ }
+ },
+};
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-y-off.png b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y-off.png
new file mode 100644
index 00000000000..a2b8766f84d
Binary files /dev/null and b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y-off.png differ
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-y.js b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y.js
new file mode 100644
index 00000000000..cbfc6d40381
--- /dev/null
+++ b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y.js
@@ -0,0 +1,76 @@
+const labels = [1, 2, 3, 4, 5, 6, 7];
+const values = [65, 59, 80, 81, 56, 55, 40];
+
+module.exports = {
+ description: 'https://github.com/chartjs/Chart.js/issues/12052',
+ config: {
+ type: 'line',
+ data: {
+ labels,
+ datasets: [
+ {
+ data: values.map(v => v - 10),
+ fill: '1',
+ borderColor: 'rgb(255, 0, 0)',
+ backgroundColor: 'rgba(255, 0, 0, 0.25)',
+ yAxisID: 'y1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(255, 0, 0)',
+ yAxisID: 'y1',
+ },
+ {
+ data: values,
+ fill: false,
+ borderColor: 'rgb(0, 0, 255)',
+ yAxisID: 'y2',
+ },
+ {
+ data: values.map(v => v + 10),
+ fill: '-1',
+ borderColor: 'rgb(0, 0, 255)',
+ backgroundColor: 'rgba(0, 0, 255, 0.25)',
+ yAxisID: 'y2',
+ }
+ ]
+ },
+ options: {
+ animation: false,
+ responsive: false,
+ plugins: {
+ legend: false,
+ title: false,
+ tooltip: false
+ },
+ elements: {
+ point: {
+ radius: 0
+ },
+ line: {
+ cubicInterpolationMode: 'monotone',
+ borderColor: 'transparent',
+ tension: 0
+ }
+ },
+ scales: {
+ y2: {
+ axis: 'y',
+ stack: 'stack',
+ max: 80,
+ display: false,
+ },
+ y1: {
+ min: 50,
+ axis: 'y',
+ stack: 'stack',
+ display: false,
+ },
+ x: {
+ display: false,
+ }
+ }
+ }
+ },
+};
diff --git a/test/fixtures/plugin.filler/line/dataset/clip-bounds-y.png b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y.png
new file mode 100644
index 00000000000..137e0315bb2
Binary files /dev/null and b/test/fixtures/plugin.filler/line/dataset/clip-bounds-y.png differ
diff --git a/test/types/ticks/ticks.ts b/test/types/ticks/ticks.ts
new file mode 100644
index 00000000000..a5a9e28bef8
--- /dev/null
+++ b/test/types/ticks/ticks.ts
@@ -0,0 +1,15 @@
+import { Chart, Ticks } from '../../../src/types.js';
+
+// @ts-expect-error The 'this' context... is not assignable to method's 'this' of type 'Scale'.
+Ticks.formatters.numeric(0, 0, [{ value: 0 }]);
+
+const chart = new Chart('test', {
+ type: 'line',
+ data: {
+ datasets: [{
+ data: [{ x: 1, y: 1 }]
+ }]
+ },
+});
+
+Ticks.formatters.numeric.call(chart.scales.x, 0, 0, [{ value: 0 }]);