import Element from '../core/core.element.js'; import {_bezierInterpolation, _pointInLine, _steppedInterpolation} from '../helpers/helpers.interpolation.js'; import {_computeSegments, _boundSegments} from '../helpers/helpers.segment.js'; import {_steppedLineTo, _bezierCurveTo} from '../helpers/helpers.canvas.js'; import {_updateBezierControlPoints} from '../helpers/helpers.curve.js'; import {valueOrDefault} from '../helpers/index.js'; /** * @typedef { import('./element.point.js').default } PointElement */ function setStyle(ctx, options, style = options) { ctx.lineCap = valueOrDefault(style.borderCapStyle, options.borderCapStyle); ctx.setLineDash(valueOrDefault(style.borderDash, options.borderDash)); ctx.lineDashOffset = valueOrDefault(style.borderDashOffset, options.borderDashOffset); ctx.lineJoin = valueOrDefault(style.borderJoinStyle, options.borderJoinStyle); ctx.lineWidth = valueOrDefault(style.borderWidth, options.borderWidth); ctx.strokeStyle = valueOrDefault(style.borderColor, options.borderColor); } function lineTo(ctx, previous, target) { ctx.lineTo(target.x, target.y); } function getLineMethod(options) { if (options.stepped) { return _steppedLineTo; } if (options.tension || options.cubicInterpolationMode === 'monotone') { return _bezierCurveTo; } return lineTo; } function pathVars(points, segment, params = {}) { const count = points.length; const {start: paramsStart = 0, end: paramsEnd = count - 1} = params; const {start: segmentStart, end: segmentEnd} = segment; const start = Math.max(paramsStart, segmentStart); const end = Math.min(paramsEnd, segmentEnd); const outside = paramsStart < segmentStart && paramsEnd < segmentStart || paramsStart > segmentEnd && paramsEnd > segmentEnd; return { count, start, loop: segment.loop, ilen: end < start && !outside ? count + end - start : end - start }; } /** * 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|Path2D} ctx - Context * @param {LineElement} 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 {boolean} params.move - move to starting point (vs line to it) * @param {boolean} params.reverse - path the segment from end to start * @param {number} params.start - limit segment to points starting from `start` index * @param {number} params.end - limit segment to points ending at `start` + `count` index */ function pathSegment(ctx, line, segment, params) { const {points, options} = line; const {count, start, loop, ilen} = pathVars(points, segment, params); const lineMethod = getLineMethod(options); // eslint-disable-next-line prefer-const let {move = true, reverse} = params || {}; 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; } else if (move) { ctx.moveTo(point.x, point.y); move = false; } else { lineMethod(ctx, prev, point, reverse, options.stepped); } prev = point; } if (loop) { point = points[(start + (reverse ? ilen : 0)) % count]; lineMethod(ctx, prev, point, reverse, options.stepped); } 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|Path2D} ctx - Context * @param {LineElement} 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 {boolean} params.move - move to starting point (vs line to it) * @param {boolean} params.reverse - path the segment from end to start * @param {number} params.start - limit segment to points starting from `start` index * @param {number} params.end - limit segment to points ending at `start` + `count` index */ function fastPathSegment(ctx, line, segment, params) { const points = line.points; const {count, start, ilen} = pathVars(points, segment, params); const {move = true, reverse} = params || {}; let avgX = 0; let countX = 0; let i, point, prevX, minY, maxY, lastY; const pointIndex = (index) => (start + (reverse ? ilen - index : index)) % count; const drawX = () => { if (minY !== maxY) { // Draw line to maxY and minY, using the average x-coordinate ctx.lineTo(avgX, maxY); ctx.lineTo(avgX, minY); // 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); } }; if (move) { point = points[pointIndex(0)]; ctx.moveTo(point.x, point.y); } for (i = 0; i <= ilen; ++i) { point = points[pointIndex(i)]; if (point.skip) { // If there is a skipped point inside a segment, spanGaps must be true continue; } const x = point.x; const y = point.y; const truncX = x | 0; // truncated x-coordinate if (truncX === prevX) { // Determine `minY` / `maxY` and `avgX` while we stay within same x-position 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 { drawX(); // Draw line to next x-position, using the first (or only) // y-value in that group ctx.lineTo(x, y); prevX = truncX; countX = 0; minY = maxY = y; } // Keep track of the last y-value in group lastY = y; } drawX(); } /** * @param {LineElement} line - the line * @returns {function} * @private */ function _getSegmentMethod(line) { const opts = line.options; const borderDash = opts.borderDash && opts.borderDash.length; const useFastPath = !line._decimated && !line._loop && !opts.tension && opts.cubicInterpolationMode !== 'monotone' && !opts.stepped && !borderDash; return useFastPath ? fastPathSegment : pathSegment; } /** * @private */ function _getInterpolationMethod(options) { if (options.stepped) { return _steppedInterpolation; } if (options.tension || options.cubicInterpolationMode === 'monotone') { return _bezierInterpolation; } return _pointInLine; } function strokePathWithCache(ctx, line, start, count) { let path = line._path; if (!path) { path = line._path = new Path2D(); if (line.path(path, start, count)) { path.closePath(); } } setStyle(ctx, line.options); ctx.stroke(path); } function strokePathDirect(ctx, line, start, count) { const {segments, options} = line; const segmentMethod = _getSegmentMethod(line); for (const segment of segments) { setStyle(ctx, options, segment.style); ctx.beginPath(); if (segmentMethod(ctx, line, segment, {start, end: start + count - 1})) { ctx.closePath(); } ctx.stroke(); } } const usePath2D = typeof Path2D === 'function'; function draw(ctx, line, start, count) { if (usePath2D && !line.options.segment) { strokePathWithCache(ctx, line, start, count); } else { strokePathDirect(ctx, line, start, count); } } export default class LineElement extends Element { static id = 'line'; /** * @type {any} */ static defaults = { borderCapStyle: 'butt', borderDash: [], borderDashOffset: 0, borderJoinStyle: 'miter', borderWidth: 3, capBezierPoints: true, cubicInterpolationMode: 'default', fill: false, spanGaps: false, stepped: false, tension: 0, }; /** * @type {any} */ static defaultRoutes = { backgroundColor: 'backgroundColor', borderColor: 'borderColor' }; static descriptors = { _scriptable: true, _indexable: (name) => name !== 'borderDash' && name !== 'fill', }; constructor(cfg) { super(); this.animated = true; this.options = undefined; this._chart = undefined; this._loop = undefined; this._fullLoop = undefined; this._path = undefined; this._points = undefined; this._segments = undefined; this._decimated = false; this._pointsUpdated = false; this._datasetIndex = undefined; if (cfg) { Object.assign(this, cfg); } } updateControlPoints(chartArea, indexAxis) { const options = this.options; if ((options.tension || options.cubicInterpolationMode === 'monotone') && !options.stepped && !this._pointsUpdated) { const loop = options.spanGaps ? this._loop : this._fullLoop; _updateBezierControlPoints(this._points, options, chartArea, loop, indexAxis); this._pointsUpdated = true; } } set points(points) { this._points = points; delete this._segments; delete this._path; this._pointsUpdated = false; } get points() { return this._points; } get segments() { return this._segments || (this._segments = _computeSegments(this, this.options.segment)); } /** * First non-skipped point on this line * @returns {PointElement|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 {PointElement|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 {PointElement} point - the reference point * @param {string} property - the property to match on * @returns {PointElement|undefined} */ interpolate(point, property) { const options = this.options; const value = point[property]; const points = this.points; const segments = _boundSegments(this, {property, start: value, end: value}); if (!segments.length) { return; } 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])); const interpolated = _interpolate(p1, p2, t, options.stepped); interpolated[property] = point[property]; result.push(interpolated); } return result.length === 1 ? result[0] : result; } /** * 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 {boolean} params.move - move to starting point (vs line to it) * @param {boolean} params.reverse - path the segment from end to start * @param {number} params.start - limit segment to points starting from `start` index * @param {number} params.end - limit segment to points ending at `start` + `count` index * @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); } /** * Append all segments of this line to current path. * @param {CanvasRenderingContext2D|Path2D} ctx * @param {number} [start] * @param {number} [count] * @returns {undefined|boolean} - true if line is a full loop (path should be closed) */ path(ctx, start, count) { const segments = this.segments; const segmentMethod = _getSegmentMethod(this); let loop = this._loop; start = start || 0; count = count || (this.points.length - start); for (const segment of segments) { loop &= segmentMethod(ctx, this, segment, {start, end: start + count - 1}); } return !!loop; } /** * Draw * @param {CanvasRenderingContext2D} ctx * @param {object} chartArea * @param {number} [start] * @param {number} [count] */ draw(ctx, chartArea, start, count) { const options = this.options || {}; const points = this.points || []; if (points.length && options.borderWidth) { ctx.save(); draw(ctx, this, start, count); ctx.restore(); } if (this.animated) { // When line is animated, the control points and path are not cached. this._pointsUpdated = false; this._path = undefined; } } }