From: Jukka Kurkela Date: Sat, 12 Feb 2022 15:22:38 +0000 (+0200) Subject: Refactor filler plugin for easier maintenance (#10040) X-Git-Tag: v3.8.0~42 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=4b542682f344ddf69c27ce8b490a58de38edccb0;p=thirdparty%2FChart.js.git Refactor filler plugin for easier maintenance (#10040) --- diff --git a/src/plugins/plugin.filler.js b/src/plugins/plugin.filler.js deleted file mode 100644 index 75fea2ff7..000000000 --- a/src/plugins/plugin.filler.js +++ /dev/null @@ -1,655 +0,0 @@ -/** - * 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 LineElement from '../elements/element.line'; -import {_boundSegment, _boundSegments} from '../helpers/helpers.segment'; -import {clipArea, unclipArea} from '../helpers/helpers.canvas'; -import {isArray, isFinite, isObject, valueOrDefault} from '../helpers/helpers.core'; -import {TAU, _isBetween, _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 } PointElement - */ - -/** - * @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 {LineElement} 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 {LineElement} line - * @param {number} index - * @param {number} count - */ -function decodeFill(line, index, count) { - const fill = parseFillOption(line); - - if (isObject(fill)) { - return isNaN(fill.value) ? false : fill; - } - - 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', 'shape'].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 (isObject(fill)) { - target = scale.getPixelForValue(fill.value); - } 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.ArcElement 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: TAU}; - ctx.arc(x, y, radius, bounds.end, bounds.start, true); - return !opts.bounds; - } - - interpolate(point) { - const {x, y, radius} = this; - const angle = point.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; - let i, center, value; - - if (fill === 'start') { - value = start; - } else if (fill === 'end') { - value = end; - } else if (isObject(fill)) { - value = fill.value; - } else { - value = scale.getBaseValue(); - } - - if (options.grid.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 findSegmentEnd(start, end, points) { - for (;end > start; end--) { - const point = points[end]; - if (!isNaN(point.x) && !isNaN(point.y)) { - break; - } - } - return end; -} - -function pointsFromSegments(boundary, line) { - const {x = null, y = null} = boundary || {}; - const linePoints = line.points; - const points = []; - line.segments.forEach(({start, end}) => { - end = findSegmentEnd(start, end, linePoints); - const first = linePoints[start]; - const last = linePoints[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: LineElement; }} source - * @return {LineElement} - */ -function buildStackLine(source) { - const {scale, index, line} = source; - const points = []; - const segments = line.segments; - const sourcePoints = line.points; - const linesBelow = getLinesBelow(scale, 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 LineElement({points, options: {}}); -} - -/** - * @param {Scale} scale - * @param {number} index - * @return {LineElement[]} - */ -function getLinesBelow(scale, index) { - const below = []; - const metas = scale.getMatchingVisibleMetas('line'); - - for (let i = 0; i < metas.length; i++) { - const meta = metas[i]; - if (meta.index === index) { - break; - } - if (!meta.hidden) { - below.unshift(meta.dataset); - } - } - return below; -} - -/** - * @param {PointElement[]} points - * @param {PointElement} sourcePoint - * @param {LineElement[]} 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 {LineElement} line - * @param {PointElement} sourcePoint - * @param {string} property - * @returns {{point?: PointElement, 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 (_isBetween(pointValue, firstValue, 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); - } - - if (fill === 'shape') { - return true; - } - - const boundary = computeBoundary(source); - - if (boundary instanceof simpleArc) { - return boundary; - } - - return createBoundaryLine(boundary, line); -} - -/** - * @param {PointElement[] | { x: number; y: number; }} boundary - * @param {LineElement} line - * @return {LineElement?} - */ -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 LineElement({ - 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) { - 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(firstPoint.x, clipY); - ctx.lineTo(firstPoint.x, firstPoint.y); - } - lineLoop = !!target.pathSegment(ctx, segment, {move: lineLoop}); - if (lineLoop) { - ctx.closePath(); - } else { - ctx.lineTo(lastPoint.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 (const segment of segments) { - let {start, end} = segment; - end = findSegmentEnd(start, end, points); - - const bounds = getBounds(property, points[start], points[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[start], - end: points[end] - }); - continue; - } - - // Get all segments from `target` that intersect the bounds of current segment of `line` - const targetSegments = _boundSegments(target, bounds); - - for (const tgt of targetSegments) { - const subBounds = getBounds(property, tpoints[tgt.start], tpoints[tgt.end], tgt.loop); - const fillSources = _boundSegment(segment, points, subBounds); - - for (const fillSource of fillSources) { - parts.push({ - source: fillSource, - target: tgt, - 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); - - for (const {source: src, target: tgt, start, end} of segments) { - const {style: {backgroundColor = color} = {}} = src; - const notShape = target !== true; - - ctx.save(); - ctx.fillStyle = backgroundColor; - - clipBounds(ctx, scale, notShape && getBounds(property, start, end)); - - ctx.beginPath(); - - const lineLoop = !!line.pathSegment(ctx, src); - - let loop; - if (notShape) { - if (lineLoop) { - ctx.closePath(); - } else { - interpolatedLineTo(ctx, target, end, property); - } - - const targetLoop = !!target.pathSegment(ctx, tgt, {move: lineLoop, reverse: true}); - 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' : cfg.axis; - - 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(); -} - -function drawfill(ctx, source, area) { - const target = getTarget(source); - const {line, scale, axis} = 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, axis}); - unclipArea(ctx); - } -} - -export default { - id: 'filler', - - afterDatasetsUpdate(chart, _args, options) { - const count = (chart.data.datasets || []).length; - 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 LineElement) { - source = { - visible: chart.isDatasetVisible(i), - index: i, - fill: decodeFill(line, i, count), - chart, - axis: meta.controller.options.indexAxis, - 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, options.propagate); - } - }, - - beforeDraw(chart, _args, options) { - const draw = options.drawTime === 'beforeDraw'; - const metasets = chart.getSortedVisibleDatasetMetas(); - const area = chart.chartArea; - for (let i = metasets.length - 1; i >= 0; --i) { - const source = metasets[i].$filler; - if (!source) { - continue; - } - - source.line.updateControlPoints(area, source.axis); - if (draw) { - drawfill(chart.ctx, source, area); - } - } - }, - - beforeDatasetsDraw(chart, _args, options) { - if (options.drawTime !== 'beforeDatasetsDraw') { - return; - } - const metasets = chart.getSortedVisibleDatasetMetas(); - for (let i = metasets.length - 1; i >= 0; --i) { - const source = metasets[i].$filler; - if (source) { - drawfill(chart.ctx, source, chart.chartArea); - } - } - }, - - beforeDatasetDraw(chart, args, options) { - const source = args.meta.$filler; - - if (!source || source.fill === false || options.drawTime !== 'beforeDatasetDraw') { - return; - } - - drawfill(chart.ctx, source, chart.chartArea); - }, - - defaults: { - propagate: true, - drawTime: 'beforeDatasetDraw' - } -}; diff --git a/src/plugins/plugin.filler/filler.drawing.js b/src/plugins/plugin.filler/filler.drawing.js new file mode 100644 index 000000000..3fe4c363a --- /dev/null +++ b/src/plugins/plugin.filler/filler.drawing.js @@ -0,0 +1,122 @@ +import {clipArea, unclipArea} from '../../helpers'; +import {_findSegmentEnd, _getBounds, _segments} from './filler.segment'; +import {_getTarget} from './filler.target'; + +export function _drawfill(ctx, source, area) { + const target = _getTarget(source); + const {line, scale, axis} = 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, axis}); + unclipArea(ctx); + } +} + +function doFill(ctx, cfg) { + const {line, target, above, below, area, scale} = 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); + } + fill(ctx, {line, target, color: below, scale, property}); + + ctx.restore(); +} + +function clipVertical(ctx, target, clipY) { + 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(firstPoint.x, clipY); + ctx.lineTo(firstPoint.x, firstPoint.y); + } + lineLoop = !!target.pathSegment(ctx, segment, {move: lineLoop}); + if (lineLoop) { + ctx.closePath(); + } else { + ctx.lineTo(lastPoint.x, clipY); + } + } + + ctx.lineTo(target.first().x, clipY); + ctx.closePath(); + ctx.clip(); +} + +function fill(ctx, cfg) { + const {line, target, property, color, scale} = cfg; + const segments = _segments(line, target, property); + + for (const {source: src, target: tgt, start, end} of segments) { + const {style: {backgroundColor = color} = {}} = src; + const notShape = target !== true; + + ctx.save(); + ctx.fillStyle = backgroundColor; + + clipBounds(ctx, scale, notShape && _getBounds(property, start, end)); + + ctx.beginPath(); + + const lineLoop = !!line.pathSegment(ctx, src); + + let loop; + if (notShape) { + if (lineLoop) { + ctx.closePath(); + } else { + interpolatedLineTo(ctx, target, end, property); + } + + const targetLoop = !!target.pathSegment(ctx, tgt, {move: lineLoop, reverse: true}); + loop = lineLoop && targetLoop; + if (!loop) { + interpolatedLineTo(ctx, target, start, property); + } + } + + ctx.closePath(); + ctx.fill(loop ? 'evenodd' : 'nonzero'); + + ctx.restore(); + } +} + +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); + } +} + diff --git a/src/plugins/plugin.filler/filler.options.js b/src/plugins/plugin.filler/filler.options.js new file mode 100644 index 000000000..bc22cec88 --- /dev/null +++ b/src/plugins/plugin.filler/filler.options.js @@ -0,0 +1,136 @@ +import {isObject, isFinite, valueOrDefault} from '../../helpers/helpers.core'; + +/** + * @typedef { import('../../core/core.scale').default } Scale + * @typedef { import('../../elements/element.line').default } LineElement + * @typedef { import('../../../types/index.esm').FillTarget } FillTarget + * @typedef { import('../../../types/index.esm').ComplexFillTarget } ComplexFillTarget + */ + +export 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; +} + +/** + * @param {LineElement} line + * @param {number} index + * @param {number} count + */ +export function _decodeFill(line, index, count) { + const fill = parseFillOption(line); + + if (isObject(fill)) { + return isNaN(fill.value) ? false : fill; + } + + let target = parseFloat(fill); + + if (isFinite(target) && Math.floor(target) === target) { + return decodeTargetIndex(fill[0], index, target, count); + } + + return ['origin', 'start', 'end', 'stack', 'shape'].indexOf(fill) >= 0 && fill; +} + +function decodeTargetIndex(firstCh, index, target, count) { + if (firstCh === '-' || firstCh === '+') { + target = index + target; + } + + if (target === index || target < 0 || target >= count) { + return false; + } + + return target; +} + +/** + * @param {FillTarget | ComplexFillTarget} fill + * @param {Scale} scale + * @returns {number | null} + */ +export function _getTargetPixel(fill, scale) { + let pixel = null; + if (fill === 'start') { + pixel = scale.bottom; + } else if (fill === 'end') { + pixel = scale.top; + } else if (isObject(fill)) { + // @ts-ignore + pixel = scale.getPixelForValue(fill.value); + } else if (scale.getBasePixel) { + pixel = scale.getBasePixel(); + } + return pixel; +} + +/** + * @param {FillTarget | ComplexFillTarget} fill + * @param {Scale} scale + * @param {number} startValue + * @returns {number | undefined} + */ +export function _getTargetValue(fill, scale, startValue) { + let value; + + if (fill === 'start') { + value = startValue; + } else if (fill === 'end') { + value = scale.options.reverse ? scale.min : scale.max; + } else if (isObject(fill)) { + // @ts-ignore + value = fill.value; + } else { + value = scale.getBaseValue(); + } + return value; +} + +/** + * @param {LineElement} 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; +} diff --git a/src/plugins/plugin.filler/filler.segment.js b/src/plugins/plugin.filler/filler.segment.js new file mode 100644 index 000000000..8d78f6937 --- /dev/null +++ b/src/plugins/plugin.filler/filler.segment.js @@ -0,0 +1,99 @@ +import {_boundSegment, _boundSegments, _normalizeAngle} from '../../helpers'; + +export function _segments(line, target, property) { + const segments = line.segments; + const points = line.points; + const tpoints = target.points; + const parts = []; + + for (const segment of segments) { + let {start, end} = segment; + end = _findSegmentEnd(start, end, points); + + const bounds = _getBounds(property, points[start], points[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[start], + end: points[end] + }); + continue; + } + + // Get all segments from `target` that intersect the bounds of current segment of `line` + const targetSegments = _boundSegments(target, bounds); + + for (const tgt of targetSegments) { + const subBounds = _getBounds(property, tpoints[tgt.start], tpoints[tgt.end], tgt.loop); + const fillSources = _boundSegment(segment, points, subBounds); + + for (const fillSource of fillSources) { + parts.push({ + source: fillSource, + target: tgt, + start: { + [property]: _getEdge(bounds, subBounds, 'start', Math.max) + }, + end: { + [property]: _getEdge(bounds, subBounds, 'end', Math.min) + } + }); + } + } + } + return parts; +} + +export 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}; +} + +export function _pointsFromSegments(boundary, line) { + const {x = null, y = null} = boundary || {}; + const linePoints = line.points; + const points = []; + line.segments.forEach(({start, end}) => { + end = _findSegmentEnd(start, end, linePoints); + const first = linePoints[start]; + const last = linePoints[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; +} + +export function _findSegmentEnd(start, end, points) { + for (;end > start; end--) { + const point = points[end]; + if (!isNaN(point.x) && !isNaN(point.y)) { + break; + } + } + return end; +} + +function _getEdge(a, b, prop, fn) { + if (a && b) { + return fn(a[prop], b[prop]); + } + return a ? a[prop] : b ? b[prop] : 0; +} diff --git a/src/plugins/plugin.filler/filler.target.js b/src/plugins/plugin.filler/filler.target.js new file mode 100644 index 000000000..e7dbf3dfb --- /dev/null +++ b/src/plugins/plugin.filler/filler.target.js @@ -0,0 +1,121 @@ +import {LineElement} from '../../elements'; +import {isArray, isFinite} from '../../helpers'; +import {_getTargetPixel, _getTargetValue} from './filler.options'; +import {_pointsFromSegments} from './filler.segment'; +import {_buildStackLine} from './filler.target.stack'; +import {simpleArc} from './simpleArc'; + +/** + * @typedef { import('../../core/core.controller').default } Chart + * @typedef { import('../../core/core.scale').default } Scale + * @typedef { import('../../elements/element.point').default } PointElement + */ + +export function _getTarget(source) { + const {chart, fill, line} = source; + + if (isFinite(fill)) { + return getLineByIndex(chart, fill); + } + + if (fill === 'stack') { + return _buildStackLine(source); + } + + if (fill === 'shape') { + return true; + } + + const boundary = computeBoundary(source); + + if (boundary instanceof simpleArc) { + return boundary; + } + + return _createBoundaryLine(boundary, line); +} + +/** + * @param {PointElement[] | { x: number; y: number; }} boundary + * @param {LineElement} line + * @return {LineElement?} + */ +export 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 LineElement({ + points, + options: {tension: 0}, + _loop, + _fullLoop: _loop + }) : null; +} + +/** + * @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; +} + +function computeBoundary(source) { + const scale = source.scale || {}; + + if (scale.getPointPositionForValue) { + return computeCircularBoundary(source); + } + return computeLinearBoundary(source); +} + + +function computeLinearBoundary(source) { + const {scale = {}, fill} = source; + const pixel = _getTargetPixel(fill, scale); + + if (isFinite(pixel)) { + const horizontal = scale.isHorizontal(); + + return { + x: horizontal ? pixel : null, + y: horizontal ? null : pixel + }; + } + + return null; +} + +function computeCircularBoundary(source) { + const {scale, fill} = source; + const options = scale.options; + const length = scale.getLabels().length; + const start = options.reverse ? scale.max : scale.min; + const value = _getTargetValue(fill, scale, start); + const target = []; + + if (options.grid.circular) { + const center = scale.getPointPositionForValue(0, start); + return new simpleArc({ + x: center.x, + y: center.y, + radius: scale.getDistanceFromCenterForValue(value) + }); + } + + for (let i = 0; i < length; ++i) { + target.push(scale.getPointPositionForValue(i, value)); + } + return target; +} + diff --git a/src/plugins/plugin.filler/filler.target.stack.js b/src/plugins/plugin.filler/filler.target.stack.js new file mode 100644 index 000000000..dcb974b84 --- /dev/null +++ b/src/plugins/plugin.filler/filler.target.stack.js @@ -0,0 +1,110 @@ +/** + * @typedef { import('../../core/core.controller').default } Chart + * @typedef { import('../../core/core.scale').default } Scale + * @typedef { import('../../elements/element.point').default } PointElement + */ + +import {LineElement} from '../../elements'; +import {_isBetween} from '../../helpers'; +import {_createBoundaryLine} from './filler.target'; + +/** + * @param {{ chart: Chart; scale: Scale; index: number; line: LineElement; }} source + * @return {LineElement} + */ +export function _buildStackLine(source) { + const {scale, index, line} = source; + const points = []; + const segments = line.segments; + const sourcePoints = line.points; + const linesBelow = getLinesBelow(scale, 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 LineElement({points, options: {}}); +} + +/** + * @param {Scale} scale + * @param {number} index + * @return {LineElement[]} + */ +function getLinesBelow(scale, index) { + const below = []; + const metas = scale.getMatchingVisibleMetas('line'); + + for (let i = 0; i < metas.length; i++) { + const meta = metas[i]; + if (meta.index === index) { + break; + } + if (!meta.hidden) { + below.unshift(meta.dataset); + } + } + return below; +} + +/** + * @param {PointElement[]} points + * @param {PointElement} sourcePoint + * @param {LineElement[]} 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 {LineElement} line + * @param {PointElement} sourcePoint + * @param {string} property + * @returns {{point?: PointElement, 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 (_isBetween(pointValue, firstValue, lastValue)) { + first = pointValue === firstValue; + last = pointValue === lastValue; + break; + } + } + return {first, last, point}; +} diff --git a/src/plugins/plugin.filler/index.js b/src/plugins/plugin.filler/index.js new file mode 100644 index 000000000..7cbf5935d --- /dev/null +++ b/src/plugins/plugin.filler/index.js @@ -0,0 +1,94 @@ +/** + * 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 LineElement from '../../elements/element.line'; +import {_drawfill} from './filler.drawing'; +import {_decodeFill, _resolveTarget} from './filler.options'; + +export default { + id: 'filler', + + afterDatasetsUpdate(chart, _args, options) { + const count = (chart.data.datasets || []).length; + 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 LineElement) { + source = { + visible: chart.isDatasetVisible(i), + index: i, + fill: _decodeFill(line, i, count), + chart, + axis: meta.controller.options.indexAxis, + 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, options.propagate); + } + }, + + beforeDraw(chart, _args, options) { + const draw = options.drawTime === 'beforeDraw'; + const metasets = chart.getSortedVisibleDatasetMetas(); + const area = chart.chartArea; + for (let i = metasets.length - 1; i >= 0; --i) { + const source = metasets[i].$filler; + if (!source) { + continue; + } + + source.line.updateControlPoints(area, source.axis); + if (draw) { + _drawfill(chart.ctx, source, area); + } + } + }, + + beforeDatasetsDraw(chart, _args, options) { + if (options.drawTime !== 'beforeDatasetsDraw') { + return; + } + const metasets = chart.getSortedVisibleDatasetMetas(); + for (let i = metasets.length - 1; i >= 0; --i) { + const source = metasets[i].$filler; + if (source) { + _drawfill(chart.ctx, source, chart.chartArea); + } + } + }, + + beforeDatasetDraw(chart, args, options) { + const source = args.meta.$filler; + + if (!source || source.fill === false || options.drawTime !== 'beforeDatasetDraw') { + return; + } + + _drawfill(chart.ctx, source, chart.chartArea); + }, + + defaults: { + propagate: true, + drawTime: 'beforeDatasetDraw' + } +}; diff --git a/src/plugins/plugin.filler/simpleArc.js b/src/plugins/plugin.filler/simpleArc.js new file mode 100644 index 000000000..0606a5200 --- /dev/null +++ b/src/plugins/plugin.filler/simpleArc.js @@ -0,0 +1,27 @@ +import {TAU} from '../../helpers'; + +// TODO: use elements.ArcElement instead +export 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: TAU}; + ctx.arc(x, y, radius, bounds.end, bounds.start, true); + return !opts.bounds; + } + + interpolate(point) { + const {x, y, radius} = this; + const angle = point.angle; + return { + x: x + Math.cos(angle) * radius, + y: y + Math.sin(angle) * radius, + angle + }; + } +}