diff --git a/docs/general/performance.md b/docs/general/performance.md index 1d970a6f9bb..3e5669b121a 100644 --- a/docs/general/performance.md +++ b/docs/general/performance.md @@ -89,7 +89,7 @@ new Chart(ctx, { ### Automatic data decimation during draw -Line element will automatically decimate data, when the following conditions are met: `tension` is `0`, `steppedLine` is `false` (default), `fill` is `false` and `borderDash` is `[]` (default).` +Line element will automatically decimate data, when the following conditions are met: `tension` is `0`, `steppedLine` is `false` (default) and `borderDash` is `[]` (default).` This improves rendering speed by skipping drawing of invisible line segments. ```javascript @@ -109,6 +109,26 @@ new Chart(ctx, { }); ``` +### Enable spanGaps + +If you have a lot of data points, it can be more performant to enable `spanGaps`. This disables segmentation of the line, which can be an unneeded step. + +To enable `spanGaps`: + +```javascript +new Chart(ctx, { + type: 'line', + data: { + datasets: [{ + spanGaps: true // enable for a single dataset + }] + }, + options: { + spanGaps: true // enable for all datasets + } +}); +``` + ### Disable Line Drawing If you have a lot of data points, it can be more performant to disable rendering of the line for a dataset and only draw points. Doing this means that there is less to draw on the canvas which will improve render performance. diff --git a/src/controllers/controller.line.js b/src/controllers/controller.line.js index 9713d3b5d38..dd8eded5bf0 100644 --- a/src/controllers/controller.line.js +++ b/src/controllers/controller.line.js @@ -77,9 +77,8 @@ module.exports = DatasetController.extend({ // Update Line if (showLine && mode !== 'resize') { - const properties = { - _children: points, + points, options: me._resolveDatasetElementOptions() }; diff --git a/src/controllers/controller.radar.js b/src/controllers/controller.radar.js index 617677ce2f8..ab1f18c1cd1 100644 --- a/src/controllers/controller.radar.js +++ b/src/controllers/controller.radar.js @@ -90,10 +90,11 @@ module.exports = DatasetController.extend({ const meta = me._cachedMeta; const line = meta.dataset; const points = meta.data || []; - + const labels = meta.iScale._getLabels(); const properties = { - _children: points, + points, _loop: true, + _fullLoop: labels.length === points.length, options: me._resolveDatasetElementOptions() }; @@ -122,10 +123,11 @@ module.exports = DatasetController.extend({ const y = reset ? scale.yCenter : pointPosition.y; const properties = { - x: x, - y: y, + x, + y, + angle: pointPosition.angle, skip: isNaN(x) || isNaN(y), - options, + options }; me._updateElement(point, index, properties, mode); diff --git a/src/core/core.scale.js b/src/core/core.scale.js index 164ee564a6d..db7c10aed81 100644 --- a/src/core/core.scale.js +++ b/src/core/core.scale.js @@ -876,12 +876,9 @@ class Scale extends Element { } getBaseValue() { - var me = this; - var min = me.min; - var max = me.max; + const {min, max} = this; - return me.beginAtZero ? 0 : - min < 0 && max < 0 ? max : + return min < 0 && max < 0 ? max : min > 0 && max > 0 ? min : 0; } diff --git a/src/elements/element.line.js b/src/elements/element.line.js index 67bd08e1e34..18b730ee3e8 100644 --- a/src/elements/element.line.js +++ b/src/elements/element.line.js @@ -2,10 +2,12 @@ import defaults from '../core/core.defaults'; import Element from '../core/core.element'; -import helpers from '../helpers'; +import {_bezierInterpolation, _pointInLine, _steppedInterpolation} from '../helpers/helpers.interpolation'; +import {_computeSegments, _boundSegments} from '../helpers/helpers.segment'; +import {_steppedLineTo, _bezierCurveTo} from '../helpers/helpers.canvas'; +import {_updateBezierControlPoints} from '../helpers/helpers.curve'; const defaultColor = defaults.global.defaultColor; -const isPointInArea = helpers.canvas._isPointInArea; defaults._set('global', { elements: { @@ -24,134 +26,141 @@ defaults._set('global', { } }); -function startAtGap(points, spanGaps) { - let closePath = true; - let previous = points.length && points[0]; - let index, point; - - for (index = 1; index < points.length; ++index) { - // If there is a gap in the (looping) line, start drawing from that gap - point = points[index]; - if (!point.skip && previous.skip) { - points = points.slice(index).concat(points.slice(0, index)); - closePath = spanGaps; - break; - } - previous = point; - } - - points.closePath = closePath; - return points; +function setStyle(ctx, vm) { + ctx.lineCap = vm.borderCapStyle; + ctx.setLineDash(vm.borderDash); + ctx.lineDashOffset = vm.borderDashOffset; + ctx.lineJoin = vm.borderJoinStyle; + ctx.lineWidth = vm.borderWidth; + ctx.strokeStyle = vm.borderColor; } -function setStyle(ctx, options) { - ctx.lineCap = options.borderCapStyle; - ctx.setLineDash(options.borderDash); - ctx.lineDashOffset = options.borderDashOffset; - ctx.lineJoin = options.borderJoinStyle; - ctx.lineWidth = options.borderWidth; - ctx.strokeStyle = options.borderColor; -} - -function bezierCurveTo(ctx, previous, target, flip) { - ctx.bezierCurveTo( - flip ? previous.controlPointPreviousX : previous.controlPointNextX, - flip ? previous.controlPointPreviousY : previous.controlPointNextY, - flip ? target.controlPointNextX : target.controlPointPreviousX, - flip ? target.controlPointNextY : target.controlPointPreviousY, - target.x, - target.y); +function lineTo(ctx, previous, target) { + ctx.lineTo(target.x, target.y); } -function steppedLineTo(ctx, previous, target, flip, mode) { - if (mode === 'middle') { - const midpoint = (previous.x + target.x) / 2.0; - ctx.lineTo(midpoint, flip ? target.y : previous.y); - ctx.lineTo(midpoint, flip ? previous.y : target.y); - } else if ((mode === 'after' && !flip) || (mode !== 'after' && flip)) { - ctx.lineTo(previous.x, target.y); - } else { - ctx.lineTo(target.x, previous.y); +function getLineMethod(options) { + if (options.steppedLine) { + return _steppedLineTo; } - ctx.lineTo(target.x, target.y); -} -function normalPath(ctx, points, spanGaps, options) { - const steppedLine = options.steppedLine; - const lineMethod = steppedLine ? steppedLineTo : bezierCurveTo; - let move = true; - let index, currentVM, previousVM; + if (options.tension) { + return _bezierCurveTo; + } - for (index = 0; index < points.length; ++index) { - currentVM = points[index]; + return lineTo; +} - if (currentVM.skip) { - move = move || !spanGaps; +/** + * Create path from points, grouping by truncated x-coordinate + * Points need to be in order by x-coordinate for this to work efficiently + * @param {CanvasRenderingContext2D} ctx - Context + * @param {Line} line + * @param {object} segment + * @param {number} segment.start - start index of the segment, referring the points array + * @param {number} segment.end - end index of the segment, referring the points array + * @param {boolean} segment.loop - indicates that the segment is a loop + * @param {object} params + * @param {object} params.move - move to starting point (vs line to it) + * @param {object} params.reverse - path the segment from end to start + */ +function pathSegment(ctx, line, segment, params) { + const {start, end, loop} = segment; + const {points, options} = line; + const lineMethod = getLineMethod(options); + const count = points.length; + let {move = true, reverse} = params || {}; + let ilen = end < start ? count + end - start : end - start; + let i, point, prev; + + for (i = 0; i <= ilen; ++i) { + point = points[(start + (reverse ? ilen - i : i)) % count]; + + if (point.skip) { + // If there is a skipped point inside a segment, spanGaps must be true continue; - } - if (move) { - ctx.moveTo(currentVM.x, currentVM.y); + } else if (move) { + ctx.moveTo(point.x, point.y); move = false; - } else if (options.tension || steppedLine) { - lineMethod(ctx, previousVM, currentVM, false, steppedLine); } else { - ctx.lineTo(currentVM.x, currentVM.y); + lineMethod(ctx, prev, point, reverse, options.steppedLine); } - previousVM = currentVM; + + prev = point; } + + if (loop) { + point = points[(start + (reverse ? ilen : 0)) % count]; + lineMethod(ctx, prev, point, reverse, options.steppedLine); + } + + return !!loop; } /** * Create path from points, grouping by truncated x-coordinate * Points need to be in order by x-coordinate for this to work efficiently * @param {CanvasRenderingContext2D} ctx - Context - * @param {Point[]} points - Points defining the line - * @param {boolean} spanGaps - Are gaps spanned over + * @param {Line} line + * @param {object} segment + * @param {number} segment.start - start index of the segment, referring the points array + * @param {number} segment.end - end index of the segment, referring the points array + * @param {boolean} segment.loop - indicates that the segment is a loop + * @param {object} params + * @param {object} params.move - move to starting point (vs line to it) + * @param {object} params.reverse - path the segment from end to start */ -function fastPath(ctx, points, spanGaps) { - let move = true; - let count = 0; +function fastPathSegment(ctx, line, segment, params) { + const points = line.points; + const count = points.length; + const {start, end} = segment; + let {move = true, reverse} = params || {}; + let ilen = end < start ? count + end - start : end - start; let avgX = 0; - let index, vm, truncX, x, y, prevX, minY, maxY, lastY; + let countX = 0; + let i, point, prevX, minY, maxY, lastY; - for (index = 0; index < points.length; ++index) { - vm = points[index]; + if (move) { + point = points[(start + (reverse ? ilen : 0)) % count]; + ctx.moveTo(point.x, point.y); + } + + for (i = 0; i <= ilen; ++i) { + point = points[(start + (reverse ? ilen - i : i)) % count]; - // If point is skipped, we either move to next (not skipped) point - // or line to it if spanGaps is true. `move` can already be true. - if (vm.skip) { - move = move || !spanGaps; + if (point.skip) { + // If there is a skipped point inside a segment, spanGaps must be true continue; } - x = vm.x; - y = vm.y; - truncX = x | 0; // truncated x-coordinate + const x = point.x; + const y = point.y; + const truncX = x | 0; // truncated x-coordinate - if (move) { - ctx.moveTo(x, y); - move = false; - } else if (truncX === prevX) { + if (truncX === prevX) { // Determine `minY` / `maxY` and `avgX` while we stay within same x-position - minY = Math.min(y, minY); - maxY = Math.max(y, maxY); - // For first point in group, count is `0`, so average will be `x` / 1. - avgX = (count * avgX + x) / ++count; + if (y < minY) { + minY = y; + } else if (y > maxY) { + maxY = y; + } + // For first point in group, countX is `0`, so average will be `x` / 1. + avgX = (countX * avgX + x) / ++countX; } else { if (minY !== maxY) { // Draw line to maxY and minY, using the average x-coordinate ctx.lineTo(avgX, maxY); ctx.lineTo(avgX, minY); - // Move to y-value of last point in group. So the line continues - // from correct position. - ctx.moveTo(avgX, lastY); + // Line to y-value of last point in group. So the line continues + // from correct position. Not using move, to have solid path. + ctx.lineTo(avgX, lastY); } // Draw line to next x-position, using the first (or only) // y-value in that group ctx.lineTo(x, y); prevX = truncX; - count = 0; + countX = 0; minY = maxY = y; } // Keep track of the last y-value in group @@ -159,64 +168,23 @@ function fastPath(ctx, points, spanGaps) { } } -function useFastPath(options) { - return options.tension === 0 && !options.steppedLine && !options.fill && !options.borderDash.length; -} - -function capControlPoint(pt, min, max) { - return Math.max(Math.min(pt, max), min); +function _getSegmentMethod(line) { + const opts = line.options; + const borderDash = opts.borderDash && opts.borderDash.length; + const useFastPath = !line._loop && !opts.tension && !opts.steppedLine && !borderDash; + return useFastPath ? fastPathSegment : pathSegment; } -function capBezierPoints(points, area) { - var i, ilen, model; - for (i = 0, ilen = points.length; i < ilen; ++i) { - model = points[i]; - if (isPointInArea(model, area)) { - if (i > 0 && isPointInArea(points[i - 1], area)) { - model.controlPointPreviousX = capControlPoint(model.controlPointPreviousX, area.left, area.right); - model.controlPointPreviousY = capControlPoint(model.controlPointPreviousY, area.top, area.bottom); - } - if (i < points.length - 1 && isPointInArea(points[i + 1], area)) { - model.controlPointNextX = capControlPoint(model.controlPointNextX, area.left, area.right); - model.controlPointNextY = capControlPoint(model.controlPointNextY, area.top, area.bottom); - } - } +function _getInterpolationMethod(options) { + if (options.steppedLine) { + return _steppedInterpolation; } -} -function updateBezierControlPoints(points, options, area, loop) { - var i, ilen, point, controlPoints; - - // Only consider points that are drawn in case the spanGaps option is used - if (options.spanGaps) { - points = points.filter(function(pt) { - return !pt.skip; - }); + if (options.tension) { + return _bezierInterpolation; } - if (options.cubicInterpolationMode === 'monotone') { - helpers.curve.splineCurveMonotone(points); - } else { - let prev = loop ? points[points.length - 1] : points[0]; - for (i = 0, ilen = points.length; i < ilen; ++i) { - point = points[i]; - controlPoints = helpers.curve.splineCurve( - prev, - point, - points[Math.min(i + 1, ilen - (loop ? 0 : 1)) % ilen], - options.tension - ); - point.controlPointPreviousX = controlPoints.previous.x; - point.controlPointPreviousY = controlPoints.previous.y; - point.controlPointNextX = controlPoints.next.x; - point.controlPointNextY = controlPoints.next.y; - prev = point; - } - } - - if (options.capBezierPoints) { - capBezierPoints(points, area); - } + return _pointInLine; } class Line extends Element { @@ -232,40 +200,127 @@ class Line extends Element { } const options = me.options; if (options.tension && !options.steppedLine) { - updateBezierControlPoints(me._children, options, chartArea, me._loop); + const loop = options.spanGaps ? me._loop : me._fullLoop; + _updateBezierControlPoints(me._points, options, chartArea, loop); } } - drawPath(ctx, area) { + set points(points) { + this._points = points; + delete this._segments; + } + + get points() { + return this._points; + } + + get segments() { + return this._segments || (this._segments = _computeSegments(this)); + } + + /** + * First non-skipped point on this line + * @returns {Point|undefined} + */ + first() { + const segments = this.segments; + const points = this.points; + return segments.length && points[segments[0].start]; + } + + /** + * Last non-skipped point on this line + * @returns {Point|undefined} + */ + last() { + const segments = this.segments; + const points = this.points; + const count = segments.length; + return count && points[segments[count - 1].end]; + } + + /** + * Interpolate a point in this line at the same value on `property` as + * the reference `point` provided + * @param {Point} point - the reference point + * @param {string} property - the property to match on + * @returns {Point|undefined} + */ + interpolate(point, property) { const me = this; const options = me.options; - const spanGaps = options.spanGaps; - let closePath = me._loop; - let points = me._children; + const value = point[property]; + const points = me.points; + const segments = _boundSegments(me, {property, start: value, end: value}); - if (!points.length) { + if (!segments.length) { return; } - if (closePath) { - points = startAtGap(points, spanGaps); - closePath = points.closePath; + const result = []; + const _interpolate = _getInterpolationMethod(options); + let i, ilen; + for (i = 0, ilen = segments.length; i < ilen; ++i) { + const {start, end} = segments[i]; + const p1 = points[start]; + const p2 = points[end]; + if (p1 === p2) { + result.push(p1); + continue; + } + const t = Math.abs((value - p1[property]) / (p2[property] - p1[property])); + let interpolated = _interpolate(p1, p2, t, options.steppedLine); + interpolated[property] = point[property]; + result.push(interpolated); } + return result.lenght === 1 ? result[0] : result; + } - if (useFastPath(options)) { - fastPath(ctx, points, spanGaps); - } else { - me.updateControlPoints(area); - normalPath(ctx, points, spanGaps, options); - } + /** + * Append a segment of this line to current path. + * @param {CanvasRenderingContext2D} ctx + * @param {object} segment + * @param {number} segment.start - start index of the segment, referring the points array + * @param {number} segment.end - end index of the segment, referring the points array + * @param {boolean} segment.loop - indicates that the segment is a loop + * @param {object} params + * @param {object} params.move - move to starting point (vs line to it) + * @param {object} params.reverse - path the segment from end to start + * @returns {undefined|boolean} - true if the segment is a full loop (path should be closed) + */ + pathSegment(ctx, segment, params) { + const segmentMethod = _getSegmentMethod(this); + return segmentMethod(ctx, this, segment, params); + } - return closePath; + /** + * Append all segments of this line to current path. + * @param {CanvasRenderingContext2D} ctx + * @param {object} params + * @param {object} params.move - move to starting point (vs line to it) + * @param {object} params.reverse - path the segment from end to start + * @returns {undefined|boolean} - true if line is a full loop (path should be closed) + */ + path(ctx, params) { + const me = this; + const segments = me.segments; + const ilen = segments.length; + const segmentMethod = _getSegmentMethod(me); + let loop = me._loop; + for (let i = 0; i < ilen; ++i) { + loop &= segmentMethod(ctx, me, segments[i], params); + } + return !!loop; } - draw(ctx, area) { + /** + * Draw + * @param {CanvasRenderingContext2D} ctx + */ + draw(ctx) { const me = this; - if (!me._children.length) { + if (!me.points.length) { return; } @@ -275,7 +330,7 @@ class Line extends Element { ctx.beginPath(); - if (me.drawPath(ctx, area)) { + if (me.path(ctx)) { ctx.closePath(); } diff --git a/src/helpers/helpers.canvas.js b/src/helpers/helpers.canvas.js index 8c3b92e09b5..481eb6c0122 100644 --- a/src/helpers/helpers.canvas.js +++ b/src/helpers/helpers.canvas.js @@ -174,11 +174,14 @@ export function unclipArea(ctx) { * @private */ export function _steppedLineTo(ctx, previous, target, flip, mode) { + if (!previous) { + return ctx.lineTo(target.x, target.y); + } if (mode === 'middle') { const midpoint = (previous.x + target.x) / 2.0; - ctx.lineTo(midpoint, flip ? target.y : previous.y); - ctx.lineTo(midpoint, flip ? previous.y : target.y); - } else if ((mode === 'after' && !flip) || (mode !== 'after' && flip)) { + ctx.lineTo(midpoint, previous.y); + ctx.lineTo(midpoint, target.y); + } else if (mode === 'after' ^ flip) { ctx.lineTo(previous.x, target.y); } else { ctx.lineTo(target.x, previous.y); @@ -190,6 +193,9 @@ export function _steppedLineTo(ctx, previous, target, flip, mode) { * @private */ export function _bezierCurveTo(ctx, previous, target, flip) { + if (!previous) { + return ctx.lineTo(target.x, target.y); + } ctx.bezierCurveTo( flip ? previous.controlPointPreviousX : previous.controlPointNextX, flip ? previous.controlPointPreviousY : previous.controlPointNextY, diff --git a/src/helpers/helpers.curve.js b/src/helpers/helpers.curve.js index d4bf75823bc..58e0952b993 100644 --- a/src/helpers/helpers.curve.js +++ b/src/helpers/helpers.curve.js @@ -1,4 +1,5 @@ import {almostEquals, sign} from './helpers.math'; +import {_isPointInArea} from './helpers.canvas'; const EPSILON = Number.EPSILON || 1e-14; @@ -128,3 +129,60 @@ export function splineCurveMonotone(points) { } } } + +function capControlPoint(pt, min, max) { + return Math.max(Math.min(pt, max), min); +} + +function capBezierPoints(points, area) { + var i, ilen, point; + for (i = 0, ilen = points.length; i < ilen; ++i) { + point = points[i]; + if (!_isPointInArea(point, area)) { + continue; + } + if (i > 0 && _isPointInArea(points[i - 1], area)) { + point.controlPointPreviousX = capControlPoint(point.controlPointPreviousX, area.left, area.right); + point.controlPointPreviousY = capControlPoint(point.controlPointPreviousY, area.top, area.bottom); + } + if (i < points.length - 1 && _isPointInArea(points[i + 1], area)) { + point.controlPointNextX = capControlPoint(point.controlPointNextX, area.left, area.right); + point.controlPointNextY = capControlPoint(point.controlPointNextY, area.top, area.bottom); + } + } +} + +export function _updateBezierControlPoints(points, options, area, loop) { + var i, ilen, point, controlPoints; + + // Only consider points that are drawn in case the spanGaps option is used + if (options.spanGaps) { + points = points.filter(function(pt) { + return !pt.skip; + }); + } + + if (options.cubicInterpolationMode === 'monotone') { + splineCurveMonotone(points); + } else { + let prev = loop ? points[points.length - 1] : points[0]; + for (i = 0, ilen = points.length; i < ilen; ++i) { + point = points[i]; + controlPoints = splineCurve( + prev, + point, + points[Math.min(i + 1, ilen - (loop ? 0 : 1)) % ilen], + options.tension + ); + point.controlPointPreviousX = controlPoints.previous.x; + point.controlPointPreviousY = controlPoints.previous.y; + point.controlPointNextX = controlPoints.next.x; + point.controlPointNextY = controlPoints.next.y; + prev = point; + } + } + + if (options.capBezierPoints) { + capBezierPoints(points, area); + } +} diff --git a/src/helpers/helpers.interpolation.js b/src/helpers/helpers.interpolation.js new file mode 100644 index 00000000000..c8400b42621 --- /dev/null +++ b/src/helpers/helpers.interpolation.js @@ -0,0 +1,37 @@ +'use strict'; + +/** + * @private + */ +export function _pointInLine(p1, p2, t) { + return { + x: p1.x + t * (p2.x - p1.x), + y: p1.y + t * (p2.y - p1.y) + }; +} + +/** + * @private + */ +export function _steppedInterpolation(p1, p2, t, mode) { + return { + x: p1.x + t * (p2.x - p1.x), + y: mode === 'middle' ? t < 0.5 ? p1.y : p2.y + : mode === 'after' ? t < 1 ? p1.y : p2.y + : t > 0 ? p2.y : p1.y + }; +} + +/** + * @private + */ +export function _bezierInterpolation(p1, p2, t) { + const cp1 = {x: p1.controlPointNextX, y: p1.controlPointNextY}; + const cp2 = {x: p2.controlPointPreviousX, y: p2.controlPointPreviousY}; + const a = _pointInLine(p1, cp1, t); + const b = _pointInLine(cp1, cp2, t); + const c = _pointInLine(cp2, p2, t); + const d = _pointInLine(a, b, t); + const e = _pointInLine(b, c, t); + return _pointInLine(d, e, t); +} diff --git a/src/helpers/helpers.math.js b/src/helpers/helpers.math.js index 1ad309ccd07..d5a5680b8c5 100644 --- a/src/helpers/helpers.math.js +++ b/src/helpers/helpers.math.js @@ -2,6 +2,10 @@ import {isFinite as isFiniteNumber} from './helpers.core'; +const PI = Math.PI; +const TAU = 2 * PI; +const PITAU = TAU + PI; + /** * @alias Chart.helpers.math * @namespace @@ -41,7 +45,6 @@ export const log10 = Math.log10 || function(x) { return isPowerOf10 ? powerOf10 : exponent; }; - export function isNumber(n) { return !isNaN(parseFloat(n)) && isFinite(n); } @@ -128,3 +131,33 @@ export function getAngleFromPoint(centrePoint, anglePoint) { export function distanceBetweenPoints(pt1, pt2) { return Math.sqrt(Math.pow(pt2.x - pt1.x, 2) + Math.pow(pt2.y - pt1.y, 2)); } + +/** + * Shortest distance between angles, in either direction. + * @private + */ +export function _angleDiff(a, b) { + return (a - b + PITAU) % TAU - PI; +} + +/** + * Normalize angle to be between 0 and 2*PI + * @private + */ +export function _normalizeAngle(a) { + return (a % TAU + TAU) % TAU; +} + +/** + * @private + */ +export function _angleBetween(angle, start, end) { + const a = _normalizeAngle(angle); + const s = _normalizeAngle(start); + const e = _normalizeAngle(end); + const angleToStart = _normalizeAngle(s - a); + const angleToEnd = _normalizeAngle(e - a); + const startToAngle = _normalizeAngle(a - s); + const endToAngle = _normalizeAngle(a - e); + return a === s || a === e || (angleToStart > angleToEnd && startToAngle < endToAngle); +} diff --git a/src/helpers/helpers.segment.js b/src/helpers/helpers.segment.js new file mode 100644 index 00000000000..bd0099b2faf --- /dev/null +++ b/src/helpers/helpers.segment.js @@ -0,0 +1,228 @@ +'use strict'; + +import {_angleBetween, _angleDiff, _normalizeAngle} from './helpers.math'; + +function propertyFn(property) { + if (property === 'angle') { + return { + between: _angleBetween, + compare: _angleDiff, + normalize: _normalizeAngle, + }; + } + return { + between: (n, s, e) => n >= s && n <= e, + compare: (a, b) => a - b, + normalize: x => x + }; +} + +function makeSubSegment(start, end, loop, count) { + return { + start: start % count, + end: end % count, + loop: loop && (end - start + 1) % count === 0 + }; +} + +function getSegment(segment, points, bounds) { + const {property, start: startBound, end: endBound} = bounds; + const {between, normalize} = propertyFn(property); + const count = points.length; + let {start, end, loop} = segment; + let i, ilen; + + if (loop) { + start += count; + end += count; + for (i = 0, ilen = count; i < ilen; ++i) { + if (!between(normalize(points[start % count][property]), startBound, endBound)) { + break; + } + start--; + end--; + } + start %= count; + end %= count; + } + + if (end < start) { + end += count; + } + return {start, end, loop}; +} + +/** + * Returns the sub-segment(s) of a line segment that fall in the given bounds + * @param {object} segment + * @param {number} segment.start - start index of the segment, referring the points array + * @param {number} segment.end - end index of the segment, referring the points array + * @param {boolean} segment.loop - indicates that the segment is a loop + * @param {Point[]} points - the points that this segment refers to + * @param {object} bounds + * @param {string} bounds.property - the property of a `Point` we are bounding. `x`, `y` or `angle`. + * @param {number} bounds.start - start value of the property + * @param {number} bounds.end - end value of the property + **/ +export function _boundSegment(segment, points, bounds) { + if (!bounds) { + return [segment]; + } + + const {property, start: startBound, end: endBound} = bounds; + const count = points.length; + const {compare, between, normalize} = propertyFn(property); + const {start, end, loop} = getSegment(segment, points, bounds); + const result = []; + let inside = false; + let subStart = null; + let i, value, point, prev; + + for (i = start; i <= end; ++i) { + point = points[i % count]; + + if (point.skip) { + continue; + } + + value = normalize(point[property]); + inside = between(value, startBound, endBound); + + if (subStart === null && inside) { + subStart = i > start && compare(value, startBound) > 0 ? prev : i; + } + + if (subStart !== null && (!inside || compare(value, endBound) === 0)) { + result.push(makeSubSegment(subStart, i, loop, count)); + subStart = null; + } + prev = i; + } + + if (subStart !== null) { + result.push(makeSubSegment(subStart, end, loop, count)); + } + + return result; +} + +/** + * Returns the segments of the line that are inside given bounds + * @param {Line} line + * @param {object} bounds + * @param {string} bounds.property - the property we are bounding with. `x`, `y` or `angle`. + * @param {number} bounds.start - start value of the `property` + * @param {number} bounds.end - end value of the `property` + */ +export function _boundSegments(line, bounds) { + const result = []; + + for (let segment of line.segments) { + let sub = _boundSegment(segment, line.points, bounds); + if (sub.length) { + result.push(...sub); + } + } + return result; +} + +/** + * Find start and end index of a line. + */ +function findStartAndEnd(points, count, loop, spanGaps) { + let start = 0; + let end = count - 1; + + if (loop && !spanGaps) { + // loop and not spaning gaps, first find a gap to start from + while (start < count && !points[start].skip) { + start++; + } + } + + // find first non skipped point (after the first gap possibly) + while (start < count && points[start].skip) { + start++; + } + + // if we looped to count, start needs to be 0 + start %= count; + + if (loop) { + // loop will go past count, if start > 0 + end += start; + } + + while (end > start && points[end % count].skip) { + end--; + } + + // end could be more than count, normalize + end %= count; + + return {start, end}; +} + +/** + * Compute solid segments from Points, when spanGaps === false + * @param {Point[]} points - the points + * @param {number} start - start index + * @param {number} max - max index (can go past count on a loop) + * @param {boolean} loop - boolean indicating that this would be a loop if no gaps are found + */ +function solidSegments(points, start, max, loop) { + const count = points.length; + const result = []; + let last = start; + let prev = points[start]; + let end; + + for (end = start + 1; end <= max; ++end) { + const cur = points[end % count]; + if (cur.skip) { + if (!prev.skip) { + loop = false; + result.push({start: start % count, end: (end - 1) % count, loop}); + start = last = null; + } + } else { + last = end; + if (prev.skip) { + start = end; + } + } + prev = cur; + } + + if (last !== null) { + result.push({start: start % count, end: last % count, loop}); + } + + return result; +} + +/** + * Compute the continuous segments that define the whole line + * There can be skipped points within a segment, if spanGaps is true. + * @param {Line} line + */ +export function _computeSegments(line) { + const points = line.points; + const spanGaps = line.options.spanGaps; + const count = points.length; + + if (!count) { + return []; + } + + const loop = !!line._loop; + const {start, end} = findStartAndEnd(points, count, loop, spanGaps); + + if (spanGaps) { + return [{start, end, loop}]; + } + + const max = end < start ? end + count : end; + const completeLoop = !!line._fullLoop && start === 0 && end === count - 1; + return solidSegments(points, start, max, completeLoop); +} diff --git a/src/plugins/plugin.filler.js b/src/plugins/plugin.filler.js index 2ddda44de7b..8735f15dbb8 100644 --- a/src/plugins/plugin.filler.js +++ b/src/plugins/plugin.filler.js @@ -6,9 +6,12 @@ 'use strict'; -var defaults = require('../core/core.defaults'); -var elements = require('../elements/index'); -var helpers = require('../helpers/index'); +import defaults from '../core/core.defaults'; +import Line from '../elements/element.line'; +import {_boundSegment, _boundSegments} from '../helpers/helpers.segment'; +import {clipArea, unclipArea} from '../helpers/helpers.canvas'; +import {valueOrDefault, isFinite, isArray, extend} from '../helpers/helpers.core'; +import {_normalizeAngle} from '../helpers/helpers.math'; defaults._set('global', { plugins: { @@ -18,50 +21,19 @@ defaults._set('global', { } }); -var mappers = { - dataset: function(source) { - var index = source.fill; - var chart = source.chart; - var meta = chart.getDatasetMeta(index); - var visible = meta && chart.isDatasetVisible(index); - var points = (visible && meta.dataset._children) || []; - var length = points.length || 0; - - return !length ? null : function(point, i) { - return (i < length && points[i]) || null; - }; - }, - - boundary: function(source) { - var boundary = source.boundary; - var x = boundary ? boundary.x : null; - var y = boundary ? boundary.y : null; - - if (helpers.isArray(boundary)) { - return function(point, i) { - return boundary[i]; - }; - } - - return function(point) { - return { - x: x === null ? point.x : x, - y: y === null ? point.y : y, - boundary: true - }; - }; - } -}; +function getLineByIndex(chart, index) { + const meta = chart.getDatasetMeta(index); + const visible = meta && chart.isDatasetVisible(index); + return visible ? meta.dataset : null; +} -// @todo if (fill[0] === '#') -function decodeFill(el, index, count) { - var model = el.options || {}; - var fillOption = model.fill; - var fill = fillOption && typeof fillOption.target !== 'undefined' ? fillOption.target : fillOption; - var target; +function parseFillOption(line) { + const options = line.options; + const fillOption = options.fill; + let fill = valueOrDefault(fillOption && fillOption.target, fillOption); if (fill === undefined) { - fill = !!model.backgroundColor; + fill = !!options.backgroundColor; } if (fill === false || fill === null) { @@ -71,8 +43,14 @@ function decodeFill(el, index, count) { if (fill === true) { return 'origin'; } + return fill; +} + +// @todo if (fill[0] === '#') +function decodeFill(line, index, count) { + const fill = parseFillOption(line); + let target = parseFloat(fill, 10); - target = parseFloat(fill, 10); if (isFinite(target) && Math.floor(target) === target) { if (fill[0] === '-' || fill[0] === '+') { target = index + target; @@ -85,94 +63,88 @@ function decodeFill(el, index, count) { return target; } - switch (fill) { - // compatibility - case 'bottom': - return 'start'; - case 'top': - return 'end'; - case 'zero': - return 'origin'; - // supported boundaries - case 'origin': - case 'start': - case 'end': - return fill; - // invalid fill values - default: - return false; - } + return ['origin', 'start', 'end'].indexOf(fill) >= 0 ? fill : false; } function computeLinearBoundary(source) { - var model = source.el || {}; - var scale = source.scale || {}; - var fill = source.fill; + const {scale = {}, fill} = source; var target = null; var horizontal; - if (isFinite(fill)) { - return null; - } - - // Backward compatibility: until v3, we still need to support boundary values set on - // the model (scaleTop, scaleBottom and scaleZero) because some external plugins and - // controllers might still use it (e.g. the Smith chart). - if (fill === 'start') { - target = model.scaleBottom === undefined ? scale.bottom : model.scaleBottom; + target = scale.bottom; } else if (fill === 'end') { - target = model.scaleTop === undefined ? scale.top : model.scaleTop; - } else if (model.scaleZero !== undefined) { - target = model.scaleZero; + target = scale.top; } else if (scale.getBasePixel) { target = scale.getBasePixel(); } - if (target !== undefined && target !== null) { - if (target.x !== undefined && target.y !== undefined) { - return target; + 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) { + extend(this, opts); + } + + 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; + } - if (helpers.isFinite(target)) { - horizontal = scale.isHorizontal(); + interpolate(point, property) { + const {x, y, radius} = this; + const angle = point.angle; + if (property === 'angle') { return { - x: horizontal ? target : null, - y: horizontal ? null : target, - boundary: true + x: x + Math.cos(angle) * radius, + y: y + Math.sin(angle) * radius, + angle }; } } - - return null; } function computeCircularBoundary(source) { - var scale = source.scale; - var options = scale.options; - var length = scale.chart.data.labels.length; - var fill = source.fill; - var target = []; - var start, end, center, i, point; - - if (!length) { - return null; - } + const {scale, fill} = source; + const options = scale.options; + const length = scale._getLabels().length; + const target = []; + let start, end, value, i, center; start = options.reverse ? scale.max : scale.min; end = options.reverse ? scale.min : scale.max; - center = scale.getPointPositionForValue(0, start); + + value = fill === 'start' ? start + : fill === 'end' ? end + : scale.getBaseValue(); + + 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) { - point = fill === 'start' || fill === 'end' - ? scale.getPointPositionForValue(i, fill === 'start' ? start : end) - : scale.getBasePosition(i); - if (options.gridLines.circular) { - point.cx = center.x; - point.cy = center.y; - point.angle = scale.getIndexAngle(i) - Math.PI / 2; - } - point.boundary = true; - target.push(point); + target.push(scale.getPointPositionForValue(i, value)); } return target; } @@ -186,6 +158,48 @@ function computeBoundary(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; +} + +function getTarget(source) { + const {chart, fill, line} = source; + + if (isFinite(fill)) { + return getLineByIndex(chart, fill); + } + + const boundary = computeBoundary(source); + let points = []; + let _loop = false; + + if (boundary instanceof simpleArc) { + return boundary; + } + + if (isArray(boundary)) { + _loop = true; + 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) { var source = sources[index]; var fill = source.fill; @@ -217,219 +231,170 @@ function resolveTarget(sources, index, propagate) { return false; } -function createMapper(source) { - var fill = source.fill; - var type = 'dataset'; +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(); +} - if (fill === false) { - return null; +function getBounds(property, first, last, loop) { + if (loop) { + return; } + let start = first[property]; + let end = last[property]; - if (!isFinite(fill)) { - type = 'boundary'; + if (property === 'angle') { + start = _normalizeAngle(start); + end = _normalizeAngle(end); } - - return mappers[type](source); + return {property, start, end}; } -function isDrawable(point) { - return point && !point.skip; +function _getEdge(a, b, prop, fn) { + if (a && b) { + return fn(a[prop], b[prop]); + } + return a ? a[prop] : b ? b[prop] : 0; } -function fillPointsSets(ctx, curve0, curve1, len0, len1, area, pointSets) { - const fillAreaPointsSet = []; - const clipAboveAreaPointsSet = []; - const clipBelowAreaPointsSet = []; - const radialSet = []; - const jointPoint = {}; - let i, cx, cy, r; +function _segments(line, target, property) { + const points = line.points; + const tpoints = target.points; + const parts = []; + + for (let segment of line.segments) { + 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; + } - if (!len0 || !len1) { - return; - } - clipAboveAreaPointsSet.push({x: curve1[len1 - 1].x, y: area.top}); - clipBelowAreaPointsSet.push({x: curve0[0].x, y: area.top}); - clipBelowAreaPointsSet.push(curve0[0]); - - // building first area curve (normal) - fillAreaPointsSet.push(curve0[0]); - for (i = 1; i < len0; ++i) { - curve0[i].flip = false; - fillAreaPointsSet.push(curve0[i]); - clipBelowAreaPointsSet.push(curve0[i]); - } + // Get all segments from `target` that intersect the bounds of current segment of `line` + const subs = _boundSegments(target, bounds); + + for (let sub of subs) { + const subBounds = getBounds(property, tpoints[sub.start], tpoints[sub.end], sub.loop); + const fillSources = _boundSegment(segment, points, subBounds); + + for (let source of fillSources) { + parts.push({ + source, + target: sub, + start: { + [property]: _getEdge(bounds, subBounds, 'start', Math.max) + }, + end: { + [property]: _getEdge(bounds, subBounds, 'end', Math.min) + } - if (curve1[0].angle !== undefined) { - pointSets.fill.push(fillAreaPointsSet); - cx = curve1[0].cx; - cy = curve1[0].cy; - r = Math.sqrt(Math.pow(curve1[0].x - cx, 2) + Math.pow(curve1[0].y - cy, 2)); - for (i = len1 - 1; i > 0; --i) { - radialSet.push({cx: cx, cy: cy, radius: r, startAngle: curve1[i].angle, endAngle: curve1[i - 1].angle}); - } - if (radialSet.length) { - pointSets.fill.push(radialSet); - } - return; - } - // joining the two area curves - for (var key in curve1[len1 - 1]) { - if (Object.prototype.hasOwnProperty.call(curve1[len1 - 1], key)) { - jointPoint[key] = curve1[len1 - 1][key]; + }); + } } } - jointPoint.joint = true; - fillAreaPointsSet.push(jointPoint); - - // building opposite area curve (reverse) - for (i = len1 - 1; i > 0; --i) { - curve1[i].flip = true; - clipAboveAreaPointsSet.push(curve1[i]); - curve1[i - 1].flip = true; - fillAreaPointsSet.push(curve1[i - 1]); - } - clipAboveAreaPointsSet.push(curve1[0]); - clipAboveAreaPointsSet.push({x: curve1[0].x, y: area.top}); - clipBelowAreaPointsSet.push({x: curve0[len0 - 1].x, y: area.top}); - - pointSets.clipAbove.push(clipAboveAreaPointsSet); - pointSets.clipBelow.push(clipBelowAreaPointsSet); - pointSets.fill.push(fillAreaPointsSet); + return parts; } -function clipAndFill(ctx, clippingPointsSets, fillingPointsSets, color, stepped, tension) { - const lineTo = stepped ? helpers.canvas._steppedLineTo : helpers.canvas._bezierCurveTo; - let i, ilen, j, jlen, set, target; - if (clippingPointsSets) { - ctx.save(); +function clipBounds(ctx, scale, bounds) { + const {top, bottom} = scale.chart.chartArea; + const {property, start, end} = bounds || {}; + if (property === 'x') { ctx.beginPath(); - for (i = 0, ilen = clippingPointsSets.length; i < ilen; i++) { - set = clippingPointsSets[i]; - // Have edge lines straight - ctx.moveTo(set[0].x, set[0].y); - ctx.lineTo(set[1].x, set[1].y); - for (j = 2, jlen = set.length; j < jlen - 1; j++) { - target = set[j]; - if (!target.boundary && (tension || stepped)) { - lineTo(ctx, set[j - 1], target, target.flip, stepped); - } else { - ctx.lineTo(target.x, target.y); - } - } - ctx.lineTo(set[j].x, set[j].y); - } - ctx.closePath(); + ctx.rect(start, top, end - start, bottom - top); ctx.clip(); - ctx.beginPath(); } - for (i = 0, ilen = fillingPointsSets.length; i < ilen; i++) { - set = fillingPointsSets[i]; - if (set[0].startAngle !== undefined) { - for (j = 0, jlen = set.length; j < jlen; j++) { - ctx.arc(set[j].cx, set[j].cy, set[j].radius, set[j].startAngle, set[j].endAngle, true); - } - } else { - ctx.moveTo(set[0].x, set[0].y); - for (j = 1, jlen = set.length; j < jlen; j++) { - if (set[j].joint) { - ctx.lineTo(set[j].x, set[j].y); - } else { - target = set[j]; - if (!target.boundary && (tension || stepped)) { - lineTo(ctx, set[j - 1], target, target.flip, stepped); - } else { - ctx.lineTo(target.x, target.y); - } - } - } - } +} + +function interpolatedLineTo(ctx, target, point, property) { + const interpolatedPoint = target.interpolate(point, property); + if (interpolatedPoint) { + ctx.lineTo(interpolatedPoint.x, interpolatedPoint.y); } - ctx.closePath(); - ctx.fillStyle = color; - ctx.fill(); - ctx.restore(); } -function doFill(ctx, points, mapper, colors, el, area) { - const count = points.length; - const options = el.options; - const loop = el._loop; - const span = options.spanGaps; - const stepped = options.steppedLine; - const tension = options.tension; - let curve0 = []; - let curve1 = []; - let len0 = 0; - let len1 = 0; - let pointSets = {clipBelow: [], clipAbove: [], fill: []}; - let i, ilen, index, p0, p1, d0, d1, loopOffset; +function _fill(ctx, cfg) { + const {line, target, property, color, scale} = cfg; + const segments = _segments(cfg.line, cfg.target, property); - ctx.save(); - ctx.beginPath(); + ctx.fillStyle = color; + for (let i = 0, ilen = segments.length; i < ilen; ++i) { + const {source: src, target: tgt, start, end} = segments[i]; + + ctx.save(); - for (i = 0, ilen = count; i < ilen; ++i) { - index = i % count; - p0 = points[index]; - p1 = mapper(p0, index); - d0 = isDrawable(p0); - d1 = isDrawable(p1); + clipBounds(ctx, scale, getBounds(property, start, end)); - if (loop && loopOffset === undefined && d0) { - loopOffset = i + 1; - ilen = count + loopOffset; + ctx.beginPath(); + + let loop = !!line.pathSegment(ctx, src); + if (loop) { + ctx.closePath(); + } else { + interpolatedLineTo(ctx, target, end, property); } - if (d0 && d1) { - len0 = curve0.push(p0); - len1 = curve1.push(p1); - } else if (len0 && len1) { - if (!span) { - fillPointsSets(ctx, curve0, curve1, len0, len1, area, pointSets); - len0 = len1 = 0; - curve0 = []; - curve1 = []; - } else { - if (d0) { - curve0.push(p0); - } - if (d1) { - curve1.push(p1); - } - } + loop &= target.pathSegment(ctx, tgt, {move: loop, reverse: true}); + if (!loop) { + interpolatedLineTo(ctx, target, start, property); } + + ctx.closePath(); + ctx.fill(loop ? 'evenodd' : 'nonzero'); + + ctx.restore(); } +} - fillPointsSets(ctx, curve0, curve1, len0, len1, area, pointSets); +function doFill(ctx, cfg) { + const {line, target, above, below, area, scale} = cfg; + const property = line._loop ? 'angle' : 'x'; - if (colors.below !== colors.above) { - clipAndFill(ctx, pointSets.clipAbove, pointSets.fill, colors.above, stepped, tension); - clipAndFill(ctx, pointSets.clipBelow, pointSets.fill, colors.below, stepped, tension); - } else { - clipAndFill(ctx, false, pointSets.fill, colors.above, stepped, tension); + 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(); } -module.exports = { +export default { id: 'filler', afterDatasetsUpdate: function(chart, options) { var count = (chart.data.datasets || []).length; var propagate = options.propagate; var sources = []; - var meta, i, el, source; + var meta, i, line, source; for (i = 0; i < count; ++i) { meta = chart.getDatasetMeta(i); - el = meta.dataset; + line = meta.dataset; source = null; - if (el && el.options && el instanceof elements.Line) { + if (line && line.options && line instanceof Line) { source = { visible: chart.isDatasetVisible(i), - fill: decodeFill(el, i, count), + fill: decodeFill(line, i, count), chart: chart, - scale: meta.yScale || meta.rScale, - el: el + scale: meta.vScale, + line }; } @@ -439,13 +404,12 @@ module.exports = { for (i = 0; i < count; ++i) { source = sources[i]; - if (!source) { + if (!source || source.fill === false) { continue; } source.fill = resolveTarget(sources, i, propagate); - source.boundary = computeBoundary(source); - source.mapper = createMapper(source); + source.target = source.fill !== false && getTarget(source); } }, @@ -453,40 +417,31 @@ module.exports = { const metasets = chart._getSortedVisibleDatasetMetas(); const area = chart.chartArea; const ctx = chart.ctx; - var meta, i, el, options, points, mapper, color, colors, fillOption; + let i, meta; for (i = metasets.length - 1; i >= 0; --i) { meta = metasets[i].$filler; - if (!meta || !meta.visible) { - continue; + if (meta) { + meta.line.updateControlPoints(area); } - meta.el.updateControlPoints(area); } for (i = metasets.length - 1; i >= 0; --i) { meta = metasets[i].$filler; - if (!meta || !meta.visible) { + if (!meta || meta.fill === false) { continue; } - - el = meta.el; - options = el.options; - points = el._children || []; - mapper = meta.mapper; - fillOption = options.fill; - color = options.backgroundColor || defaults.global.defaultColor; - - colors = {above: color, below: color}; - if (fillOption && typeof fillOption === 'object') { - colors.above = fillOption.above || color; - colors.below = fillOption.below || color; - } - if (mapper && points.length) { - helpers.canvas.clipArea(ctx, area); - doFill(ctx, points, mapper, colors, el, area); - helpers.canvas.unclipArea(ctx); + const {line, target, scale} = meta; + const lineOpts = line.options; + const fillOption = lineOpts.fill; + const color = lineOpts.backgroundColor || defaults.global.defaultColor; + const {above = color, below = color} = fillOption || {}; + if (target && line.points.length) { + clipArea(ctx, area); + doFill(ctx, {line, target, above, below, area, scale}); + unclipArea(ctx); } } } diff --git a/src/scales/scale.radialLinear.js b/src/scales/scale.radialLinear.js index 944826a7022..ddf7fac1317 100644 --- a/src/scales/scale.radialLinear.js +++ b/src/scales/scale.radialLinear.js @@ -407,10 +407,11 @@ class RadialLinearScale extends LinearScaleBase { getPointPosition(index, distanceFromCenter) { var me = this; - var thisAngle = me.getIndexAngle(index) - (Math.PI / 2); + var angle = me.getIndexAngle(index) - (Math.PI / 2); return { - x: Math.cos(thisAngle) * distanceFromCenter + me.xCenter, - y: Math.sin(thisAngle) * distanceFromCenter + me.yCenter + x: Math.cos(angle) * distanceFromCenter + me.xCenter, + y: Math.sin(angle) * distanceFromCenter + me.yCenter, + angle }; } @@ -419,15 +420,7 @@ class RadialLinearScale extends LinearScaleBase { } getBasePosition(index) { - var me = this; - var min = me.min; - var max = me.max; - - return me.getPointPositionForValue(index || 0, - me.beginAtZero ? 0 : - min < 0 && max < 0 ? max : - min > 0 && max > 0 ? min : - 0); + return this.getPointPositionForValue(index || 0, this.getBaseValue()); } /** diff --git a/test/fixtures/plugin.filler/fill-line-dataset-interpolated.js b/test/fixtures/plugin.filler/fill-line-dataset-interpolated.js new file mode 100644 index 00000000000..ca216e37e58 --- /dev/null +++ b/test/fixtures/plugin.filler/fill-line-dataset-interpolated.js @@ -0,0 +1,69 @@ +const data1 = []; +const data2 = []; +const data3 = []; +for (let i = 0; i < 200; i++) { + const a = i / Math.PI / 10; + + data1.push({x: i, y: i < 86 || i > 104 && i < 178 ? Math.sin(a) : NaN}); + + if (i % 10 === 0) { + data2.push({x: i, y: Math.cos(a)}); + } + + if (i % 15 === 0) { + data3.push({x: i, y: Math.cos(a + Math.PI / 2)}); + } +} + +module.exports = { + config: { + type: 'line', + data: { + datasets: [{ + borderColor: 'rgba(255, 0, 0, 0.5)', + backgroundColor: 'rgba(255, 0, 0, 0.25)', + data: data1, + fill: false, + }, { + borderColor: 'rgba(0, 0, 255, 0.5)', + backgroundColor: 'rgba(0, 0, 255, 0.25)', + data: data2, + fill: 0, + }, { + borderColor: 'rgba(0, 255, 0, 0.5)', + backgroundColor: 'rgba(0, 255, 0, 0.25)', + data: data3, + fill: 1, + }] + }, + options: { + animation: false, + responsive: false, + legend: false, + title: false, + datasets: { + line: { + lineTension: 0.4, + borderWidth: 1, + pointRadius: 1.5, + } + }, + scales: { + x: { + type: 'linear', + display: false + }, + y: { + type: 'linear', + display: false + } + } + } + }, + options: { + canvas: { + height: 512, + width: 512 + } + } +}; diff --git a/test/fixtures/plugin.filler/fill-line-dataset-interpolated.png b/test/fixtures/plugin.filler/fill-line-dataset-interpolated.png new file mode 100644 index 00000000000..fab42a40710 Binary files /dev/null and b/test/fixtures/plugin.filler/fill-line-dataset-interpolated.png differ diff --git a/test/fixtures/plugin.filler/fill-line-dataset-span-dual.png b/test/fixtures/plugin.filler/fill-line-dataset-span-dual.png index d5600b76788..a7b705ced47 100644 Binary files a/test/fixtures/plugin.filler/fill-line-dataset-span-dual.png and b/test/fixtures/plugin.filler/fill-line-dataset-span-dual.png differ diff --git a/test/fixtures/plugin.filler/fill-line-dataset-span.png b/test/fixtures/plugin.filler/fill-line-dataset-span.png index 7c8a856c678..780ce79f33a 100644 Binary files a/test/fixtures/plugin.filler/fill-line-dataset-span.png and b/test/fixtures/plugin.filler/fill-line-dataset-span.png differ diff --git a/test/fixtures/plugin.filler/fill-line-dataset-spline-span-below.png b/test/fixtures/plugin.filler/fill-line-dataset-spline-span-below.png index 45e15c5d8ad..68a20e2eeea 100644 Binary files a/test/fixtures/plugin.filler/fill-line-dataset-spline-span-below.png and b/test/fixtures/plugin.filler/fill-line-dataset-spline-span-below.png differ diff --git a/test/fixtures/plugin.filler/fill-line-dataset-spline-span.png b/test/fixtures/plugin.filler/fill-line-dataset-spline-span.png index 5f66359f3b5..97cbb6d2f50 100644 Binary files a/test/fixtures/plugin.filler/fill-line-dataset-spline-span.png and b/test/fixtures/plugin.filler/fill-line-dataset-spline-span.png differ diff --git a/test/fixtures/plugin.filler/fill-line-dataset-stepped.json b/test/fixtures/plugin.filler/fill-line-dataset-stepped.json new file mode 100644 index 00000000000..c6a681539e0 --- /dev/null +++ b/test/fixtures/plugin.filler/fill-line-dataset-stepped.json @@ -0,0 +1,62 @@ +{ + "config": { + "type": "line", + "data": { + "labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"], + "datasets": [{ + "backgroundColor": "rgba(255, 0, 0, 0.25)", + "steppedLine": true, + "data": [null, null, 0, -1, 0, 1, 0, -1, 0], + "fill": 1 + }, { + "backgroundColor": "rgba(0, 255, 0, 0.25)", + "steppedLine": "after", + "data": [1, 0, null, 1, 0, null, -1, 0, 1], + "fill": "+1" + }, { + "backgroundColor": "rgba(0, 0, 255, 0.25)", + "steppedLine": "before", + "data": [0, 2, 0, -2, 0, 2, 0], + "fill": 3 + }, { + "backgroundColor": "rgba(255, 0, 255, 0.25)", + "steppedLine": "middle", + "data": [2, 0, -2, 0, 2, 0, -2, 0, 2], + "fill": "-2" + }, { + "backgroundColor": "rgba(255, 255, 0, 0.25)", + "steppedLine": false, + "data": [3, 1, -1, -3, -1, 1, 3, 1, -1], + "fill": "-1" + }] + }, + "options": { + "responsive": false, + "spanGaps": false, + "legend": false, + "title": false, + "scales": { + "x": { + "display": false + }, + "y": { + "display": false + } + }, + "elements": { + "point": { + "radius": 0 + }, + "line": { + "borderColor": "black" + } + } + } + }, + "options": { + "canvas": { + "height": 256, + "width": 512 + } + } +} diff --git a/test/fixtures/plugin.filler/fill-line-dataset-stepped.png b/test/fixtures/plugin.filler/fill-line-dataset-stepped.png new file mode 100644 index 00000000000..e6dc9e7d9bc Binary files /dev/null and b/test/fixtures/plugin.filler/fill-line-dataset-stepped.png differ diff --git a/test/fixtures/plugin.filler/fill-radar-dataset-border.png b/test/fixtures/plugin.filler/fill-radar-dataset-border.png index fbc4e772c11..ee53eccaa89 100644 Binary files a/test/fixtures/plugin.filler/fill-radar-dataset-border.png and b/test/fixtures/plugin.filler/fill-radar-dataset-border.png differ diff --git a/test/fixtures/plugin.filler/fill-radar-dataset-span.png b/test/fixtures/plugin.filler/fill-radar-dataset-span.png index 48e7e8930be..c7da86373bc 100644 Binary files a/test/fixtures/plugin.filler/fill-radar-dataset-span.png and b/test/fixtures/plugin.filler/fill-radar-dataset-span.png differ diff --git a/test/fixtures/plugin.filler/fill-radar-dataset-spline.png b/test/fixtures/plugin.filler/fill-radar-dataset-spline.png index 3dbc538656f..c9e9b0b9f06 100644 Binary files a/test/fixtures/plugin.filler/fill-radar-dataset-spline.png and b/test/fixtures/plugin.filler/fill-radar-dataset-spline.png differ diff --git a/test/fixtures/plugin.filler/fill-radar-dataset.png b/test/fixtures/plugin.filler/fill-radar-dataset.png index 450ce2e0a73..a6cb6593ea6 100644 Binary files a/test/fixtures/plugin.filler/fill-radar-dataset.png and b/test/fixtures/plugin.filler/fill-radar-dataset.png differ diff --git a/test/specs/element.line.tests.js b/test/specs/element.line.tests.js index 5697c481a23..8e8b3c90794 100644 --- a/test/specs/element.line.tests.js +++ b/test/specs/element.line.tests.js @@ -4,12 +4,10 @@ describe('Chart.elements.Line', function() { it('should be constructed', function() { var line = new Chart.elements.Line({ - _datasetindex: 2, - _points: [1, 2, 3, 4] + points: [1, 2, 3, 4] }); expect(line).not.toBe(undefined); - expect(line._datasetindex).toBe(2); - expect(line._points).toEqual([1, 2, 3, 4]); + expect(line.points).toEqual([1, 2, 3, 4]); }); }); diff --git a/test/specs/helpers.math.tests.js b/test/specs/helpers.math.tests.js index 87ff63b163a..66c00df6335 100644 --- a/test/specs/helpers.math.tests.js +++ b/test/specs/helpers.math.tests.js @@ -106,4 +106,36 @@ describe('Chart.helpers.math', function() { expect(math.isNumber(undefined)).toBe(false); expect(math.isNumber('cbc')).toBe(false); }); + + it('should compute shortest distance between angles', function() { + expect(math._angleDiff(1, 2)).toEqual(-1); + expect(math._angleDiff(2, 1)).toEqual(1); + expect(math._angleDiff(0, 3.15)).toBeCloseTo(3.13, 2); + expect(math._angleDiff(0, 3.13)).toEqual(-3.13); + expect(math._angleDiff(6.2, 0)).toBeCloseTo(-0.08, 2); + expect(math._angleDiff(6.3, 0)).toBeCloseTo(0.02, 2); + expect(math._angleDiff(4 * Math.PI, -4 * Math.PI)).toBeCloseTo(0, 4); + expect(math._angleDiff(4 * Math.PI, -3 * Math.PI)).toBeCloseTo(-3.14, 2); + expect(math._angleDiff(6.28, 3.1)).toBeCloseTo(-3.1, 2); + expect(math._angleDiff(6.28, 3.2)).toBeCloseTo(3.08, 2); + }); + + it('should normalize angles correctly', function() { + expect(math._normalizeAngle(-Math.PI)).toEqual(Math.PI); + expect(math._normalizeAngle(Math.PI)).toEqual(Math.PI); + expect(math._normalizeAngle(2)).toEqual(2); + expect(math._normalizeAngle(5 * Math.PI)).toEqual(Math.PI); + expect(math._normalizeAngle(-50 * Math.PI)).toBeCloseTo(6.28, 2); + }); + + it('should determine if angle is between boundaries', function() { + expect(math._angleBetween(2, 1, 3)).toBeTrue(); + expect(math._angleBetween(2, 3, 1)).toBeFalse(); + expect(math._angleBetween(-3.14, 2, 4)).toBeTrue(); + expect(math._angleBetween(-3.14, 4, 2)).toBeFalse(); + expect(math._angleBetween(0, -1, 1)).toBeTrue(); + expect(math._angleBetween(-1, 0, 1)).toBeFalse(); + expect(math._angleBetween(-15 * Math.PI, 3.1, 3.2)).toBeTrue(); + expect(math._angleBetween(15 * Math.PI, -3.2, -3.1)).toBeTrue(); + }); });