mirror of
https://github.com/chartjs/Chart.js.git
synced 2025-12-08 20:36:08 +00:00
* Add tests and fix _boundSegment * Use interpolate for finding points below * Remove _refPoints logic (getTarget in draw)
588 lines
13 KiB
JavaScript
588 lines
13 KiB
JavaScript
/**
|
|
* Plugin based on discussion from the following Chart.js issues:
|
|
* @see https://github.com/chartjs/Chart.js/issues/2380#issuecomment-279961569
|
|
* @see https://github.com/chartjs/Chart.js/issues/2440#issuecomment-256461897
|
|
*/
|
|
|
|
import Line from '../elements/element.line';
|
|
import {_boundSegment, _boundSegments} from '../helpers/helpers.segment';
|
|
import {clipArea, unclipArea} from '../helpers/helpers.canvas';
|
|
import {isArray, isFinite, valueOrDefault} from '../helpers/helpers.core';
|
|
import {_normalizeAngle} from '../helpers/helpers.math';
|
|
|
|
/**
|
|
* @typedef { import('../core/core.controller').default } Chart
|
|
* @typedef { import('../core/core.scale').default } Scale
|
|
* @typedef { import("../elements/element.point").default } Point
|
|
*/
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {number} index
|
|
*/
|
|
function getLineByIndex(chart, index) {
|
|
const meta = chart.getDatasetMeta(index);
|
|
const visible = meta && chart.isDatasetVisible(index);
|
|
return visible ? meta.dataset : null;
|
|
}
|
|
|
|
/**
|
|
* @param {Line} line
|
|
*/
|
|
function parseFillOption(line) {
|
|
const options = line.options;
|
|
const fillOption = options.fill;
|
|
let fill = valueOrDefault(fillOption && fillOption.target, fillOption);
|
|
|
|
if (fill === undefined) {
|
|
fill = !!options.backgroundColor;
|
|
}
|
|
|
|
if (fill === false || fill === null) {
|
|
return false;
|
|
}
|
|
|
|
if (fill === true) {
|
|
return 'origin';
|
|
}
|
|
return fill;
|
|
}
|
|
|
|
/**
|
|
* @param {Line} line
|
|
* @param {number} index
|
|
* @param {number} count
|
|
*/
|
|
function decodeFill(line, index, count) {
|
|
const fill = parseFillOption(line);
|
|
let target = parseFloat(fill);
|
|
|
|
if (isFinite(target) && Math.floor(target) === target) {
|
|
if (fill[0] === '-' || fill[0] === '+') {
|
|
target = index + target;
|
|
}
|
|
|
|
if (target === index || target < 0 || target >= count) {
|
|
return false;
|
|
}
|
|
|
|
return target;
|
|
}
|
|
|
|
return ['origin', 'start', 'end', 'stack'].indexOf(fill) >= 0 && fill;
|
|
}
|
|
|
|
function computeLinearBoundary(source) {
|
|
const {scale = {}, fill} = source;
|
|
let target = null;
|
|
let horizontal;
|
|
|
|
if (fill === 'start') {
|
|
target = scale.bottom;
|
|
} else if (fill === 'end') {
|
|
target = scale.top;
|
|
} else if (scale.getBasePixel) {
|
|
target = scale.getBasePixel();
|
|
}
|
|
|
|
if (isFinite(target)) {
|
|
horizontal = scale.isHorizontal();
|
|
return {
|
|
x: horizontal ? target : null,
|
|
y: horizontal ? null : target
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// TODO: use elements.Arc instead
|
|
class simpleArc {
|
|
constructor(opts) {
|
|
this.x = opts.x;
|
|
this.y = opts.y;
|
|
this.radius = opts.radius;
|
|
}
|
|
|
|
pathSegment(ctx, bounds, opts) {
|
|
const {x, y, radius} = this;
|
|
bounds = bounds || {start: 0, end: Math.PI * 2};
|
|
if (opts.reverse) {
|
|
ctx.arc(x, y, radius, bounds.end, bounds.start, true);
|
|
} else {
|
|
ctx.arc(x, y, radius, bounds.start, bounds.end);
|
|
}
|
|
return !opts.bounds;
|
|
}
|
|
|
|
interpolate(point, property) {
|
|
const {x, y, radius} = this;
|
|
const angle = point.angle;
|
|
if (property === 'angle') {
|
|
return {
|
|
x: x + Math.cos(angle) * radius,
|
|
y: y + Math.sin(angle) * radius,
|
|
angle
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
function computeCircularBoundary(source) {
|
|
const {scale, fill} = source;
|
|
const options = scale.options;
|
|
const length = scale.getLabels().length;
|
|
const target = [];
|
|
const start = options.reverse ? scale.max : scale.min;
|
|
const end = options.reverse ? scale.min : scale.max;
|
|
const value = fill === 'start' ? start : fill === 'end' ? end : scale.getBaseValue();
|
|
let i, center;
|
|
|
|
if (options.gridLines.circular) {
|
|
center = scale.getPointPositionForValue(0, start);
|
|
return new simpleArc({
|
|
x: center.x,
|
|
y: center.y,
|
|
radius: scale.getDistanceFromCenterForValue(value)
|
|
});
|
|
}
|
|
|
|
for (i = 0; i < length; ++i) {
|
|
target.push(scale.getPointPositionForValue(i, value));
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function computeBoundary(source) {
|
|
const scale = source.scale || {};
|
|
|
|
if (scale.getPointPositionForValue) {
|
|
return computeCircularBoundary(source);
|
|
}
|
|
return computeLinearBoundary(source);
|
|
}
|
|
|
|
function pointsFromSegments(boundary, line) {
|
|
const {x = null, y = null} = boundary || {};
|
|
const linePoints = line.points;
|
|
const points = [];
|
|
line.segments.forEach((segment) => {
|
|
const first = linePoints[segment.start];
|
|
const last = linePoints[segment.end];
|
|
if (y !== null) {
|
|
points.push({x: first.x, y});
|
|
points.push({x: last.x, y});
|
|
} else if (x !== null) {
|
|
points.push({x, y: first.y});
|
|
points.push({x, y: last.y});
|
|
}
|
|
});
|
|
return points;
|
|
}
|
|
|
|
/**
|
|
* @param {{ chart: Chart; scale: Scale; index: number; line: Line; }} source
|
|
* @return {Line}
|
|
*/
|
|
function buildStackLine(source) {
|
|
const {chart, scale, index, line} = source;
|
|
const points = [];
|
|
const segments = line.segments;
|
|
const sourcePoints = line.points;
|
|
const linesBelow = getLinesBelow(chart, index);
|
|
linesBelow.push(createBoundaryLine({x: null, y: scale.bottom}, line));
|
|
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const segment = segments[i];
|
|
for (let j = segment.start; j <= segment.end; j++) {
|
|
addPointsBelow(points, sourcePoints[j], linesBelow);
|
|
}
|
|
}
|
|
return new Line({points, options: {}});
|
|
}
|
|
|
|
const isLineAndNotInHideAnimation = (meta) => meta.type === 'line' && !meta.hidden;
|
|
|
|
/**
|
|
* @param {Chart} chart
|
|
* @param {number} index
|
|
* @return {Line[]}
|
|
*/
|
|
function getLinesBelow(chart, index) {
|
|
const below = [];
|
|
const metas = chart.getSortedVisibleDatasetMetas();
|
|
|
|
for (let i = 0; i < metas.length; i++) {
|
|
const meta = metas[i];
|
|
if (meta.index === index) {
|
|
break;
|
|
}
|
|
if (isLineAndNotInHideAnimation(meta)) {
|
|
below.unshift(meta.dataset);
|
|
}
|
|
}
|
|
return below;
|
|
}
|
|
|
|
/**
|
|
* @param {Point[]} points
|
|
* @param {Point} sourcePoint
|
|
* @param {Line[]} linesBelow
|
|
*/
|
|
function addPointsBelow(points, sourcePoint, linesBelow) {
|
|
const postponed = [];
|
|
for (let j = 0; j < linesBelow.length; j++) {
|
|
const line = linesBelow[j];
|
|
const {first, last, point} = findPoint(line, sourcePoint, 'x');
|
|
|
|
if (!point || (first && last)) {
|
|
continue;
|
|
}
|
|
if (first) {
|
|
// First point of an segment -> need to add another point before this,
|
|
// from next line below.
|
|
postponed.unshift(point);
|
|
} else {
|
|
points.push(point);
|
|
if (!last) {
|
|
// In the middle of an segment, no need to add more points.
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
points.push(...postponed);
|
|
}
|
|
|
|
/**
|
|
* @param {Line} line
|
|
* @param {Point} sourcePoint
|
|
* @param {string} property
|
|
* @returns {{point?: Point, first?: boolean, last?: boolean}}
|
|
*/
|
|
function findPoint(line, sourcePoint, property) {
|
|
const point = line.interpolate(sourcePoint, property);
|
|
if (!point) {
|
|
return {};
|
|
}
|
|
|
|
const pointValue = point[property];
|
|
const segments = line.segments;
|
|
const linePoints = line.points;
|
|
let first = false;
|
|
let last = false;
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const segment = segments[i];
|
|
const firstValue = linePoints[segment.start][property];
|
|
const lastValue = linePoints[segment.end][property];
|
|
if (pointValue >= firstValue && pointValue <= lastValue) {
|
|
first = pointValue === firstValue;
|
|
last = pointValue === lastValue;
|
|
break;
|
|
}
|
|
}
|
|
return {first, last, point};
|
|
}
|
|
|
|
function getTarget(source) {
|
|
const {chart, fill, line} = source;
|
|
|
|
if (isFinite(fill)) {
|
|
return getLineByIndex(chart, fill);
|
|
}
|
|
|
|
if (fill === 'stack') {
|
|
return buildStackLine(source);
|
|
}
|
|
|
|
const boundary = computeBoundary(source);
|
|
|
|
if (boundary instanceof simpleArc) {
|
|
return boundary;
|
|
}
|
|
|
|
return createBoundaryLine(boundary, line);
|
|
}
|
|
|
|
/**
|
|
* @param {Point[] | { x: number; y: number; }} boundary
|
|
* @param {Line} line
|
|
* @return {Line?}
|
|
*/
|
|
function createBoundaryLine(boundary, line) {
|
|
let points = [];
|
|
let _loop = false;
|
|
|
|
if (isArray(boundary)) {
|
|
_loop = true;
|
|
// @ts-ignore
|
|
points = boundary;
|
|
} else {
|
|
points = pointsFromSegments(boundary, line);
|
|
}
|
|
|
|
return points.length ? new Line({
|
|
points,
|
|
options: {tension: 0},
|
|
_loop,
|
|
_fullLoop: _loop
|
|
}) : null;
|
|
}
|
|
|
|
function resolveTarget(sources, index, propagate) {
|
|
const source = sources[index];
|
|
let fill = source.fill;
|
|
const visited = [index];
|
|
let target;
|
|
|
|
if (!propagate) {
|
|
return fill;
|
|
}
|
|
|
|
while (fill !== false && visited.indexOf(fill) === -1) {
|
|
if (!isFinite(fill)) {
|
|
return fill;
|
|
}
|
|
|
|
target = sources[fill];
|
|
if (!target) {
|
|
return false;
|
|
}
|
|
|
|
if (target.visible) {
|
|
return fill;
|
|
}
|
|
|
|
visited.push(fill);
|
|
fill = target.fill;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function _clip(ctx, target, clipY) {
|
|
ctx.beginPath();
|
|
target.path(ctx);
|
|
ctx.lineTo(target.last().x, clipY);
|
|
ctx.lineTo(target.first().x, clipY);
|
|
ctx.closePath();
|
|
ctx.clip();
|
|
}
|
|
|
|
function getBounds(property, first, last, loop) {
|
|
if (loop) {
|
|
return;
|
|
}
|
|
let start = first[property];
|
|
let end = last[property];
|
|
|
|
if (property === 'angle') {
|
|
start = _normalizeAngle(start);
|
|
end = _normalizeAngle(end);
|
|
}
|
|
return {property, start, end};
|
|
}
|
|
|
|
function _getEdge(a, b, prop, fn) {
|
|
if (a && b) {
|
|
return fn(a[prop], b[prop]);
|
|
}
|
|
return a ? a[prop] : b ? b[prop] : 0;
|
|
}
|
|
|
|
function _segments(line, target, property) {
|
|
const segments = line.segments;
|
|
const points = line.points;
|
|
const tpoints = target.points;
|
|
const parts = [];
|
|
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const segment = segments[i];
|
|
const bounds = getBounds(property, points[segment.start], points[segment.end], segment.loop);
|
|
|
|
if (!target.segments) {
|
|
// Special case for boundary not supporting `segments` (simpleArc)
|
|
// Bounds are provided as `target` for partial circle, or undefined for full circle
|
|
parts.push({
|
|
source: segment,
|
|
target: bounds,
|
|
start: points[segment.start],
|
|
end: points[segment.end]
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Get all segments from `target` that intersect the bounds of current segment of `line`
|
|
const subs = _boundSegments(target, bounds);
|
|
|
|
for (let j = 0; j < subs.length; ++j) {
|
|
const sub = subs[j];
|
|
const subBounds = getBounds(property, tpoints[sub.start], tpoints[sub.end], sub.loop);
|
|
const fillSources = _boundSegment(segment, points, subBounds);
|
|
|
|
for (let k = 0; k < fillSources.length; k++) {
|
|
parts.push({
|
|
source: fillSources[k],
|
|
target: sub,
|
|
start: {
|
|
[property]: _getEdge(bounds, subBounds, 'start', Math.max)
|
|
},
|
|
end: {
|
|
[property]: _getEdge(bounds, subBounds, 'end', Math.min)
|
|
}
|
|
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
function clipBounds(ctx, scale, bounds) {
|
|
const {top, bottom} = scale.chart.chartArea;
|
|
const {property, start, end} = bounds || {};
|
|
if (property === 'x') {
|
|
ctx.beginPath();
|
|
ctx.rect(start, top, end - start, bottom - top);
|
|
ctx.clip();
|
|
}
|
|
}
|
|
|
|
function interpolatedLineTo(ctx, target, point, property) {
|
|
const interpolatedPoint = target.interpolate(point, property);
|
|
if (interpolatedPoint) {
|
|
ctx.lineTo(interpolatedPoint.x, interpolatedPoint.y);
|
|
}
|
|
}
|
|
|
|
function _fill(ctx, cfg) {
|
|
const {line, target, property, color, scale} = cfg;
|
|
const segments = _segments(line, target, property);
|
|
|
|
ctx.fillStyle = color;
|
|
for (let i = 0, ilen = segments.length; i < ilen; ++i) {
|
|
const {source: src, target: tgt, start, end} = segments[i];
|
|
|
|
ctx.save();
|
|
|
|
clipBounds(ctx, scale, getBounds(property, start, end));
|
|
|
|
ctx.beginPath();
|
|
|
|
const lineLoop = !!line.pathSegment(ctx, src);
|
|
if (lineLoop) {
|
|
ctx.closePath();
|
|
} else {
|
|
interpolatedLineTo(ctx, target, end, property);
|
|
}
|
|
|
|
const targetLoop = !!target.pathSegment(ctx, tgt, {move: lineLoop, reverse: true});
|
|
const loop = lineLoop && targetLoop;
|
|
if (!loop) {
|
|
interpolatedLineTo(ctx, target, start, property);
|
|
}
|
|
|
|
ctx.closePath();
|
|
ctx.fill(loop ? 'evenodd' : 'nonzero');
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
function doFill(ctx, cfg) {
|
|
const {line, target, above, below, area, scale} = cfg;
|
|
const property = line._loop ? 'angle' : 'x';
|
|
|
|
ctx.save();
|
|
|
|
if (property === 'x' && below !== above) {
|
|
_clip(ctx, target, area.top);
|
|
_fill(ctx, {line, target, color: above, scale, property});
|
|
ctx.restore();
|
|
ctx.save();
|
|
_clip(ctx, target, area.bottom);
|
|
}
|
|
_fill(ctx, {line, target, color: below, scale, property});
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
export default {
|
|
id: 'filler',
|
|
|
|
afterDatasetsUpdate(chart, options) {
|
|
const count = (chart.data.datasets || []).length;
|
|
const propagate = options.propagate;
|
|
const sources = [];
|
|
let meta, i, line, source;
|
|
|
|
for (i = 0; i < count; ++i) {
|
|
meta = chart.getDatasetMeta(i);
|
|
line = meta.dataset;
|
|
source = null;
|
|
|
|
if (line && line.options && line instanceof Line) {
|
|
source = {
|
|
visible: chart.isDatasetVisible(i),
|
|
index: i,
|
|
fill: decodeFill(line, i, count),
|
|
chart,
|
|
scale: meta.vScale,
|
|
line
|
|
};
|
|
}
|
|
|
|
meta.$filler = source;
|
|
sources.push(source);
|
|
}
|
|
|
|
for (i = 0; i < count; ++i) {
|
|
source = sources[i];
|
|
if (!source || source.fill === false) {
|
|
continue;
|
|
}
|
|
|
|
source.fill = resolveTarget(sources, i, propagate);
|
|
}
|
|
},
|
|
|
|
beforeDatasetsDraw(chart) {
|
|
const metasets = chart.getSortedVisibleDatasetMetas();
|
|
const area = chart.chartArea;
|
|
let i, meta;
|
|
|
|
for (i = metasets.length - 1; i >= 0; --i) {
|
|
meta = metasets[i].$filler;
|
|
|
|
if (meta) {
|
|
meta.line.updateControlPoints(area);
|
|
}
|
|
}
|
|
},
|
|
|
|
beforeDatasetDraw(chart, args) {
|
|
const area = chart.chartArea;
|
|
const ctx = chart.ctx;
|
|
const source = args.meta.$filler;
|
|
|
|
if (!source || source.fill === false) {
|
|
return;
|
|
}
|
|
|
|
const target = getTarget(source);
|
|
const {line, scale} = source;
|
|
const lineOpts = line.options;
|
|
const fillOption = lineOpts.fill;
|
|
const color = lineOpts.backgroundColor;
|
|
const {above = color, below = color} = fillOption || {};
|
|
if (target && line.points.length) {
|
|
clipArea(ctx, area);
|
|
doFill(ctx, {line, target, above, below, area, scale});
|
|
unclipArea(ctx);
|
|
}
|
|
},
|
|
|
|
defaults: {
|
|
propagate: true
|
|
}
|
|
};
|