diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..9ca61c7 --- /dev/null +++ b/bower.json @@ -0,0 +1,8 @@ +{ + "name": "chartjs-plugin-datalabels", + "description": "Chart.js plugin to display labels on data elements", + "homepage": "https://chartjs-plugin-datalabels.netlify.app", + "license": "MIT", + "version": "2.0.0-rc.1", + "main": "dist/chartjs-plugin-datalabels.js" +} diff --git a/dist/chartjs-plugin-datalabels.esm.js b/dist/chartjs-plugin-datalabels.esm.js new file mode 100644 index 0000000..8b35a11 --- /dev/null +++ b/dist/chartjs-plugin-datalabels.esm.js @@ -0,0 +1,1353 @@ +/*! + * chartjs-plugin-datalabels v2.0.0-rc.1 + * https://chartjs-plugin-datalabels.netlify.app + * (c) 2017-2021 chartjs-plugin-datalabels contributors + * Released under the MIT license + */ +import { isNullOrUndef, merge, toFont, resolve, toPadding, valueOrDefault, callback, isObject, each } from 'chart.js/helpers'; +import { defaults as defaults$1, ArcElement, PointElement, BarElement } from 'chart.js'; + +var devicePixelRatio = (function() { + if (typeof window !== 'undefined') { + if (window.devicePixelRatio) { + return window.devicePixelRatio; + } + + // devicePixelRatio is undefined on IE10 + // https://stackoverflow.com/a/20204180/8837887 + // https://github.com/chartjs/chartjs-plugin-datalabels/issues/85 + var screen = window.screen; + if (screen) { + return (screen.deviceXDPI || 1) / (screen.logicalXDPI || 1); + } + } + + return 1; +}()); + +var utils = { + // @todo move this in Chart.helpers.toTextLines + toTextLines: function(inputs) { + var lines = []; + var input; + + inputs = [].concat(inputs); + while (inputs.length) { + input = inputs.pop(); + if (typeof input === 'string') { + lines.unshift.apply(lines, input.split('\n')); + } else if (Array.isArray(input)) { + inputs.push.apply(inputs, input); + } else if (!isNullOrUndef(inputs)) { + lines.unshift('' + input); + } + } + + return lines; + }, + + // @todo move this in Chart.helpers.canvas.textSize + // @todo cache calls of measureText if font doesn't change?! + textSize: function(ctx, lines, font) { + var items = [].concat(lines); + var ilen = items.length; + var prev = ctx.font; + var width = 0; + var i; + + ctx.font = font.string; + + for (i = 0; i < ilen; ++i) { + width = Math.max(ctx.measureText(items[i]).width, width); + } + + ctx.font = prev; + + return { + height: ilen * font.lineHeight, + width: width + }; + }, + + /** + * Returns value bounded by min and max. This is equivalent to max(min, min(value, max)). + * @todo move this method in Chart.helpers.bound + * https://doc.qt.io/qt-5/qtglobal.html#qBound + */ + bound: function(min, value, max) { + return Math.max(min, Math.min(value, max)); + }, + + /** + * Returns an array of pair [value, state] where state is: + * * -1: value is only in a0 (removed) + * * 1: value is only in a1 (added) + */ + arrayDiff: function(a0, a1) { + var prev = a0.slice(); + var updates = []; + var i, j, ilen, v; + + for (i = 0, ilen = a1.length; i < ilen; ++i) { + v = a1[i]; + j = prev.indexOf(v); + + if (j === -1) { + updates.push([v, 1]); + } else { + prev.splice(j, 1); + } + } + + for (i = 0, ilen = prev.length; i < ilen; ++i) { + updates.push([prev[i], -1]); + } + + return updates; + }, + + /** + * https://github.com/chartjs/chartjs-plugin-datalabels/issues/70 + */ + rasterize: function(v) { + return Math.round(v * devicePixelRatio) / devicePixelRatio; + } +}; + +function orient(point, origin) { + var x0 = origin.x; + var y0 = origin.y; + + if (x0 === null) { + return {x: 0, y: -1}; + } + if (y0 === null) { + return {x: 1, y: 0}; + } + + var dx = point.x - x0; + var dy = point.y - y0; + var ln = Math.sqrt(dx * dx + dy * dy); + + return { + x: ln ? dx / ln : 0, + y: ln ? dy / ln : -1 + }; +} + +function aligned(x, y, vx, vy, align) { + switch (align) { + case 'center': + vx = vy = 0; + break; + case 'bottom': + vx = 0; + vy = 1; + break; + case 'right': + vx = 1; + vy = 0; + break; + case 'left': + vx = -1; + vy = 0; + break; + case 'top': + vx = 0; + vy = -1; + break; + case 'start': + vx = -vx; + vy = -vy; + break; + case 'end': + // keep natural orientation + break; + default: + // clockwise rotation (in degree) + align *= (Math.PI / 180); + vx = Math.cos(align); + vy = Math.sin(align); + break; + } + + return { + x: x, + y: y, + vx: vx, + vy: vy + }; +} + +// Line clipping (Cohen–Sutherland algorithm) +// https://en.wikipedia.org/wiki/Cohen–Sutherland_algorithm + +var R_INSIDE = 0; +var R_LEFT = 1; +var R_RIGHT = 2; +var R_BOTTOM = 4; +var R_TOP = 8; + +function region(x, y, rect) { + var res = R_INSIDE; + + if (x < rect.left) { + res |= R_LEFT; + } else if (x > rect.right) { + res |= R_RIGHT; + } + if (y < rect.top) { + res |= R_TOP; + } else if (y > rect.bottom) { + res |= R_BOTTOM; + } + + return res; +} + +function clipped(segment, area) { + var x0 = segment.x0; + var y0 = segment.y0; + var x1 = segment.x1; + var y1 = segment.y1; + var r0 = region(x0, y0, area); + var r1 = region(x1, y1, area); + var r, x, y; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (!(r0 | r1) || (r0 & r1)) { + // both points inside or on the same side: no clipping + break; + } + + // at least one point is outside + r = r0 || r1; + + if (r & R_TOP) { + x = x0 + (x1 - x0) * (area.top - y0) / (y1 - y0); + y = area.top; + } else if (r & R_BOTTOM) { + x = x0 + (x1 - x0) * (area.bottom - y0) / (y1 - y0); + y = area.bottom; + } else if (r & R_RIGHT) { + y = y0 + (y1 - y0) * (area.right - x0) / (x1 - x0); + x = area.right; + } else if (r & R_LEFT) { + y = y0 + (y1 - y0) * (area.left - x0) / (x1 - x0); + x = area.left; + } + + if (r === r0) { + x0 = x; + y0 = y; + r0 = region(x0, y0, area); + } else { + x1 = x; + y1 = y; + r1 = region(x1, y1, area); + } + } + + return { + x0: x0, + x1: x1, + y0: y0, + y1: y1 + }; +} + +function compute$1(range, config) { + var anchor = config.anchor; + var segment = range; + var x, y; + + if (config.clamp) { + segment = clipped(segment, config.area); + } + + if (anchor === 'start') { + x = segment.x0; + y = segment.y0; + } else if (anchor === 'end') { + x = segment.x1; + y = segment.y1; + } else { + x = (segment.x0 + segment.x1) / 2; + y = (segment.y0 + segment.y1) / 2; + } + + return aligned(x, y, range.vx, range.vy, config.align); +} + +var positioners = { + arc: function(el, config) { + var angle = (el.startAngle + el.endAngle) / 2; + var vx = Math.cos(angle); + var vy = Math.sin(angle); + var r0 = el.innerRadius; + var r1 = el.outerRadius; + + return compute$1({ + x0: el.x + vx * r0, + y0: el.y + vy * r0, + x1: el.x + vx * r1, + y1: el.y + vy * r1, + vx: vx, + vy: vy + }, config); + }, + + point: function(el, config) { + var v = orient(el, config.origin); + var rx = v.x * el.options.radius; + var ry = v.y * el.options.radius; + + return compute$1({ + x0: el.x - rx, + y0: el.y - ry, + x1: el.x + rx, + y1: el.y + ry, + vx: v.x, + vy: v.y + }, config); + }, + + bar: function(el, config) { + var v = orient(el, config.origin); + var x = el.x; + var y = el.y; + var sx = 0; + var sy = 0; + + if (el.horizontal) { + x = Math.min(el.x, el.base); + sx = Math.abs(el.base - el.x); + } else { + y = Math.min(el.y, el.base); + sy = Math.abs(el.base - el.y); + } + + return compute$1({ + x0: x, + y0: y + sy, + x1: x + sx, + y1: y, + vx: v.x, + vy: v.y + }, config); + }, + + fallback: function(el, config) { + var v = orient(el, config.origin); + + return compute$1({ + x0: el.x, + y0: el.y, + x1: el.x, + y1: el.y, + vx: v.x, + vy: v.y + }, config); + } +}; + +var rasterize = utils.rasterize; + +function boundingRects(model) { + var borderWidth = model.borderWidth || 0; + var padding = model.padding; + var th = model.size.height; + var tw = model.size.width; + var tx = -tw / 2; + var ty = -th / 2; + + return { + frame: { + x: tx - padding.left - borderWidth, + y: ty - padding.top - borderWidth, + w: tw + padding.width + borderWidth * 2, + h: th + padding.height + borderWidth * 2 + }, + text: { + x: tx, + y: ty, + w: tw, + h: th + } + }; +} + +function getScaleOrigin(el, context) { + var scale = context.chart.getDatasetMeta(context.datasetIndex).vScale; + + if (!scale) { + return null; + } + + if (scale.xCenter !== undefined && scale.yCenter !== undefined) { + return {x: scale.xCenter, y: scale.yCenter}; + } + + var pixel = scale.getBasePixel(); + return el.horizontal ? + {x: pixel, y: null} : + {x: null, y: pixel}; +} + +function getPositioner(el) { + if (el instanceof ArcElement) { + return positioners.arc; + } + if (el instanceof PointElement) { + return positioners.point; + } + if (el instanceof BarElement) { + return positioners.bar; + } + return positioners.fallback; +} + +function drawRoundedRect(ctx, x, y, w, h, radius) { + var HALF_PI = Math.PI / 2; + + if (radius) { + var r = Math.min(radius, h / 2, w / 2); + var left = x + r; + var top = y + r; + var right = x + w - r; + var bottom = y + h - r; + + ctx.moveTo(x, top); + if (left < right && top < bottom) { + ctx.arc(left, top, r, -Math.PI, -HALF_PI); + ctx.arc(right, top, r, -HALF_PI, 0); + ctx.arc(right, bottom, r, 0, HALF_PI); + ctx.arc(left, bottom, r, HALF_PI, Math.PI); + } else if (left < right) { + ctx.moveTo(left, y); + ctx.arc(right, top, r, -HALF_PI, HALF_PI); + ctx.arc(left, top, r, HALF_PI, Math.PI + HALF_PI); + } else if (top < bottom) { + ctx.arc(left, top, r, -Math.PI, 0); + ctx.arc(left, bottom, r, 0, Math.PI); + } else { + ctx.arc(left, top, r, -Math.PI, Math.PI); + } + ctx.closePath(); + ctx.moveTo(x, y); + } else { + ctx.rect(x, y, w, h); + } +} + +function drawFrame(ctx, rect, model) { + var bgColor = model.backgroundColor; + var borderColor = model.borderColor; + var borderWidth = model.borderWidth; + + if (!bgColor && (!borderColor || !borderWidth)) { + return; + } + + ctx.beginPath(); + + drawRoundedRect( + ctx, + rasterize(rect.x) + borderWidth / 2, + rasterize(rect.y) + borderWidth / 2, + rasterize(rect.w) - borderWidth, + rasterize(rect.h) - borderWidth, + model.borderRadius); + + ctx.closePath(); + + if (bgColor) { + ctx.fillStyle = bgColor; + ctx.fill(); + } + + if (borderColor && borderWidth) { + ctx.strokeStyle = borderColor; + ctx.lineWidth = borderWidth; + ctx.lineJoin = 'miter'; + ctx.stroke(); + } +} + +function textGeometry(rect, align, font) { + var h = font.lineHeight; + var w = rect.w; + var x = rect.x; + var y = rect.y + h / 2; + + if (align === 'center') { + x += w / 2; + } else if (align === 'end' || align === 'right') { + x += w; + } + + return { + h: h, + w: w, + x: x, + y: y + }; +} + +function drawTextLine(ctx, text, cfg) { + var shadow = ctx.shadowBlur; + var stroked = cfg.stroked; + var x = rasterize(cfg.x); + var y = rasterize(cfg.y); + var w = rasterize(cfg.w); + + if (stroked) { + ctx.strokeText(text, x, y, w); + } + + if (cfg.filled) { + if (shadow && stroked) { + // Prevent drawing shadow on both the text stroke and fill, so + // if the text is stroked, remove the shadow for the text fill. + ctx.shadowBlur = 0; + } + + ctx.fillText(text, x, y, w); + + if (shadow && stroked) { + ctx.shadowBlur = shadow; + } + } +} + +function drawText(ctx, lines, rect, model) { + var align = model.textAlign; + var color = model.color; + var filled = !!color; + var font = model.font; + var ilen = lines.length; + var strokeColor = model.textStrokeColor; + var strokeWidth = model.textStrokeWidth; + var stroked = strokeColor && strokeWidth; + var i; + + if (!ilen || (!filled && !stroked)) { + return; + } + + // Adjust coordinates based on text alignment and line height + rect = textGeometry(rect, align, font); + + ctx.font = font.string; + ctx.textAlign = align; + ctx.textBaseline = 'middle'; + ctx.shadowBlur = model.textShadowBlur; + ctx.shadowColor = model.textShadowColor; + + if (filled) { + ctx.fillStyle = color; + } + if (stroked) { + ctx.lineJoin = 'round'; + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = strokeColor; + } + + for (i = 0, ilen = lines.length; i < ilen; ++i) { + drawTextLine(ctx, lines[i], { + stroked: stroked, + filled: filled, + w: rect.w, + x: rect.x, + y: rect.y + rect.h * i + }); + } +} + +var Label = function(config, ctx, el, index) { + var me = this; + + me._config = config; + me._index = index; + me._model = null; + me._rects = null; + me._ctx = ctx; + me._el = el; +}; + +merge(Label.prototype, { + /** + * @private + */ + _modelize: function(display, lines, config, context) { + var me = this; + var index = me._index; + var font = toFont(resolve([config.font, {}], context, index)); + var color = resolve([config.color, defaults$1.color], context, index); + + return { + align: resolve([config.align, 'center'], context, index), + anchor: resolve([config.anchor, 'center'], context, index), + area: context.chart.chartArea, + backgroundColor: resolve([config.backgroundColor, null], context, index), + borderColor: resolve([config.borderColor, null], context, index), + borderRadius: resolve([config.borderRadius, 0], context, index), + borderWidth: resolve([config.borderWidth, 0], context, index), + clamp: resolve([config.clamp, false], context, index), + clip: resolve([config.clip, false], context, index), + color: color, + display: display, + font: font, + lines: lines, + offset: resolve([config.offset, 0], context, index), + opacity: resolve([config.opacity, 1], context, index), + origin: getScaleOrigin(me._el, context), + padding: toPadding(resolve([config.padding, 0], context, index)), + positioner: getPositioner(me._el), + rotation: resolve([config.rotation, 0], context, index) * (Math.PI / 180), + size: utils.textSize(me._ctx, lines, font), + textAlign: resolve([config.textAlign, 'start'], context, index), + textShadowBlur: resolve([config.textShadowBlur, 0], context, index), + textShadowColor: resolve([config.textShadowColor, color], context, index), + textStrokeColor: resolve([config.textStrokeColor, color], context, index), + textStrokeWidth: resolve([config.textStrokeWidth, 0], context, index) + }; + }, + + update: function(context) { + var me = this; + var model = null; + var rects = null; + var index = me._index; + var config = me._config; + var value, label, lines; + + // We first resolve the display option (separately) to avoid computing + // other options in case the label is hidden (i.e. display: false). + var display = resolve([config.display, true], context, index); + + if (display) { + value = context.dataset.data[index]; + label = valueOrDefault(callback(config.formatter, [value, context]), value); + lines = isNullOrUndef(label) ? [] : utils.toTextLines(label); + + if (lines.length) { + model = me._modelize(display, lines, config, context); + rects = boundingRects(model); + } + } + + me._model = model; + me._rects = rects; + }, + + geometry: function() { + return this._rects ? this._rects.frame : {}; + }, + + rotation: function() { + return this._model ? this._model.rotation : 0; + }, + + visible: function() { + return this._model && this._model.opacity; + }, + + model: function() { + return this._model; + }, + + draw: function(chart, center) { + var me = this; + var ctx = chart.ctx; + var model = me._model; + var rects = me._rects; + var area; + + if (!this.visible()) { + return; + } + + ctx.save(); + + if (model.clip) { + area = model.area; + ctx.beginPath(); + ctx.rect( + area.left, + area.top, + area.right - area.left, + area.bottom - area.top); + ctx.clip(); + } + + ctx.globalAlpha = utils.bound(0, model.opacity, 1); + ctx.translate(rasterize(center.x), rasterize(center.y)); + ctx.rotate(model.rotation); + + drawFrame(ctx, rects.frame, model); + drawText(ctx, model.lines, rects.text, model); + + ctx.restore(); + } +}); + +var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991; // eslint-disable-line es/no-number-minsafeinteger +var MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; // eslint-disable-line es/no-number-maxsafeinteger + +function rotated(point, center, angle) { + var cos = Math.cos(angle); + var sin = Math.sin(angle); + var cx = center.x; + var cy = center.y; + + return { + x: cx + cos * (point.x - cx) - sin * (point.y - cy), + y: cy + sin * (point.x - cx) + cos * (point.y - cy) + }; +} + +function projected(points, axis) { + var min = MAX_INTEGER; + var max = MIN_INTEGER; + var origin = axis.origin; + var i, pt, vx, vy, dp; + + for (i = 0; i < points.length; ++i) { + pt = points[i]; + vx = pt.x - origin.x; + vy = pt.y - origin.y; + dp = axis.vx * vx + axis.vy * vy; + min = Math.min(min, dp); + max = Math.max(max, dp); + } + + return { + min: min, + max: max + }; +} + +function toAxis(p0, p1) { + var vx = p1.x - p0.x; + var vy = p1.y - p0.y; + var ln = Math.sqrt(vx * vx + vy * vy); + + return { + vx: (p1.x - p0.x) / ln, + vy: (p1.y - p0.y) / ln, + origin: p0, + ln: ln + }; +} + +var HitBox = function() { + this._rotation = 0; + this._rect = { + x: 0, + y: 0, + w: 0, + h: 0 + }; +}; + +merge(HitBox.prototype, { + center: function() { + var r = this._rect; + return { + x: r.x + r.w / 2, + y: r.y + r.h / 2 + }; + }, + + update: function(center, rect, rotation) { + this._rotation = rotation; + this._rect = { + x: rect.x + center.x, + y: rect.y + center.y, + w: rect.w, + h: rect.h + }; + }, + + contains: function(point) { + var me = this; + var margin = 1; + var rect = me._rect; + + point = rotated(point, me.center(), -me._rotation); + + return !(point.x < rect.x - margin + || point.y < rect.y - margin + || point.x > rect.x + rect.w + margin * 2 + || point.y > rect.y + rect.h + margin * 2); + }, + + // Separating Axis Theorem + // https://gamedevelopment.tutsplus.com/tutorials/collision-detection-using-the-separating-axis-theorem--gamedev-169 + intersects: function(other) { + var r0 = this._points(); + var r1 = other._points(); + var axes = [ + toAxis(r0[0], r0[1]), + toAxis(r0[0], r0[3]) + ]; + var i, pr0, pr1; + + if (this._rotation !== other._rotation) { + // Only separate with r1 axis if the rotation is different, + // else it's enough to separate r0 and r1 with r0 axis only! + axes.push( + toAxis(r1[0], r1[1]), + toAxis(r1[0], r1[3]) + ); + } + + for (i = 0; i < axes.length; ++i) { + pr0 = projected(r0, axes[i]); + pr1 = projected(r1, axes[i]); + + if (pr0.max < pr1.min || pr1.max < pr0.min) { + return false; + } + } + + return true; + }, + + /** + * @private + */ + _points: function() { + var me = this; + var rect = me._rect; + var angle = me._rotation; + var center = me.center(); + + return [ + rotated({x: rect.x, y: rect.y}, center, angle), + rotated({x: rect.x + rect.w, y: rect.y}, center, angle), + rotated({x: rect.x + rect.w, y: rect.y + rect.h}, center, angle), + rotated({x: rect.x, y: rect.y + rect.h}, center, angle) + ]; + } +}); + +function coordinates(el, model, geometry) { + var point = model.positioner(el, model); + var vx = point.vx; + var vy = point.vy; + + if (!vx && !vy) { + // if aligned center, we don't want to offset the center point + return {x: point.x, y: point.y}; + } + + var w = geometry.w; + var h = geometry.h; + + // take in account the label rotation + var rotation = model.rotation; + var dx = Math.abs(w / 2 * Math.cos(rotation)) + Math.abs(h / 2 * Math.sin(rotation)); + var dy = Math.abs(w / 2 * Math.sin(rotation)) + Math.abs(h / 2 * Math.cos(rotation)); + + // scale the unit vector (vx, vy) to get at least dx or dy equal to + // w or h respectively (else we would calculate the distance to the + // ellipse inscribed in the bounding rect) + var vs = 1 / Math.max(Math.abs(vx), Math.abs(vy)); + dx *= vx * vs; + dy *= vy * vs; + + // finally, include the explicit offset + dx += model.offset * vx; + dy += model.offset * vy; + + return { + x: point.x + dx, + y: point.y + dy + }; +} + +function collide(labels, collider) { + var i, j, s0, s1; + + // IMPORTANT Iterate in the reverse order since items at the end of the + // list have an higher weight/priority and thus should be less impacted + // by the overlapping strategy. + + for (i = labels.length - 1; i >= 0; --i) { + s0 = labels[i].$layout; + + for (j = i - 1; j >= 0 && s0._visible; --j) { + s1 = labels[j].$layout; + + if (s1._visible && s0._box.intersects(s1._box)) { + collider(s0, s1); + } + } + } + + return labels; +} + +function compute(labels) { + var i, ilen, label, state, geometry, center, proxy; + + // Initialize labels for overlap detection + for (i = 0, ilen = labels.length; i < ilen; ++i) { + label = labels[i]; + state = label.$layout; + + if (state._visible) { + // Chart.js 3 removed el._model in favor of getProps(), making harder to + // abstract reading values in positioners. Also, using string arrays to + // read values (i.e. var {a,b,c} = el.getProps(["a","b","c"])) would make + // positioners inefficient in the normal case (i.e. not the final values) + // and the code a bit ugly, so let's use a Proxy instead. + proxy = new Proxy(label._el, {get: (el, p) => el.getProps([p], true)[p]}); + + geometry = label.geometry(); + center = coordinates(proxy, label.model(), geometry); + state._box.update(center, geometry, label.rotation()); + } + } + + // Auto hide overlapping labels + return collide(labels, function(s0, s1) { + var h0 = s0._hidable; + var h1 = s1._hidable; + + if ((h0 && h1) || h1) { + s1._visible = false; + } else if (h0) { + s0._visible = false; + } + }); +} + +var layout = { + prepare: function(datasets) { + var labels = []; + var i, j, ilen, jlen, label; + + for (i = 0, ilen = datasets.length; i < ilen; ++i) { + for (j = 0, jlen = datasets[i].length; j < jlen; ++j) { + label = datasets[i][j]; + labels.push(label); + label.$layout = { + _box: new HitBox(), + _hidable: false, + _visible: true, + _set: i, + _idx: j + }; + } + } + + // TODO New `z` option: labels with a higher z-index are drawn + // of top of the ones with a lower index. Lowest z-index labels + // are also discarded first when hiding overlapping labels. + labels.sort(function(a, b) { + var sa = a.$layout; + var sb = b.$layout; + + return sa._idx === sb._idx + ? sb._set - sa._set + : sb._idx - sa._idx; + }); + + this.update(labels); + + return labels; + }, + + update: function(labels) { + var dirty = false; + var i, ilen, label, model, state; + + for (i = 0, ilen = labels.length; i < ilen; ++i) { + label = labels[i]; + model = label.model(); + state = label.$layout; + state._hidable = model && model.display === 'auto'; + state._visible = label.visible(); + dirty |= state._hidable; + } + + if (dirty) { + compute(labels); + } + }, + + lookup: function(labels, point) { + var i, state; + + // IMPORTANT Iterate in the reverse order since items at the end of + // the list have an higher z-index, thus should be picked first. + + for (i = labels.length - 1; i >= 0; --i) { + state = labels[i].$layout; + + if (state && state._visible && state._box.contains(point)) { + return labels[i]; + } + } + + return null; + }, + + draw: function(chart, labels) { + var i, ilen, label, state, geometry, center; + + for (i = 0, ilen = labels.length; i < ilen; ++i) { + label = labels[i]; + state = label.$layout; + + if (state._visible) { + geometry = label.geometry(); + center = coordinates(label._el, label.model(), geometry); + state._box.update(center, geometry, label.rotation()); + label.draw(chart, center); + } + } + } +}; + +var formatter = function(value) { + if (isNullOrUndef(value)) { + return null; + } + + var label = value; + var keys, klen, k; + if (isObject(value)) { + if (!isNullOrUndef(value.label)) { + label = value.label; + } else if (!isNullOrUndef(value.r)) { + label = value.r; + } else { + label = ''; + keys = Object.keys(value); + for (k = 0, klen = keys.length; k < klen; ++k) { + label += (k !== 0 ? ', ' : '') + keys[k] + ': ' + value[keys[k]]; + } + } + } + + return '' + label; +}; + +/** + * IMPORTANT: make sure to also update tests and TypeScript definition + * files (`/test/specs/defaults.spec.js` and `/types/options.d.ts`) + */ + +var defaults = { + align: 'center', + anchor: 'center', + backgroundColor: null, + borderColor: null, + borderRadius: 0, + borderWidth: 0, + clamp: false, + clip: false, + color: undefined, + display: true, + font: { + family: undefined, + lineHeight: 1.2, + size: undefined, + style: undefined, + weight: null + }, + formatter: formatter, + labels: undefined, + listeners: {}, + offset: 4, + opacity: 1, + padding: { + top: 4, + right: 4, + bottom: 4, + left: 4 + }, + rotation: 0, + textAlign: 'start', + textStrokeColor: undefined, + textStrokeWidth: 0, + textShadowBlur: 0, + textShadowColor: undefined +}; + +/** + * @see https://github.com/chartjs/Chart.js/issues/4176 + */ + +var EXPANDO_KEY = '$datalabels'; +var DEFAULT_KEY = '$default'; + +function configure(dataset, options) { + var override = dataset.datalabels; + var listeners = {}; + var configs = []; + var labels, keys; + + if (override === false) { + return null; + } + if (override === true) { + override = {}; + } + + options = merge({}, [options, override]); + labels = options.labels || {}; + keys = Object.keys(labels); + delete options.labels; + + if (keys.length) { + keys.forEach(function(key) { + if (labels[key]) { + configs.push(merge({}, [ + options, + labels[key], + {_key: key} + ])); + } + }); + } else { + // Default label if no "named" label defined. + configs.push(options); + } + + // listeners: {: {: }} + listeners = configs.reduce(function(target, config) { + each(config.listeners || {}, function(fn, event) { + target[event] = target[event] || {}; + target[event][config._key || DEFAULT_KEY] = fn; + }); + + delete config.listeners; + return target; + }, {}); + + return { + labels: configs, + listeners: listeners + }; +} + +function dispatchEvent(chart, listeners, label) { + if (!listeners) { + return; + } + + var context = label.$context; + var groups = label.$groups; + var callback$1; + + if (!listeners[groups._set]) { + return; + } + + callback$1 = listeners[groups._set][groups._key]; + if (!callback$1) { + return; + } + + if (callback(callback$1, [context]) === true) { + // Users are allowed to tweak the given context by injecting values that can be + // used in scriptable options to display labels differently based on the current + // event (e.g. highlight an hovered label). That's why we update the label with + // the output context and schedule a new chart render by setting it dirty. + chart[EXPANDO_KEY]._dirty = true; + label.update(context); + } +} + +function dispatchMoveEvents(chart, listeners, previous, label) { + var enter, leave; + + if (!previous && !label) { + return; + } + + if (!previous) { + enter = true; + } else if (!label) { + leave = true; + } else if (previous !== label) { + leave = enter = true; + } + + if (leave) { + dispatchEvent(chart, listeners.leave, previous); + } + if (enter) { + dispatchEvent(chart, listeners.enter, label); + } +} + +function handleMoveEvents(chart, event) { + var expando = chart[EXPANDO_KEY]; + var listeners = expando._listeners; + var previous, label; + + if (!listeners.enter && !listeners.leave) { + return; + } + + if (event.type === 'mousemove') { + label = layout.lookup(expando._labels, event); + } else if (event.type !== 'mouseout') { + return; + } + + previous = expando._hovered; + expando._hovered = label; + dispatchMoveEvents(chart, listeners, previous, label); +} + +function handleClickEvents(chart, event) { + var expando = chart[EXPANDO_KEY]; + var handlers = expando._listeners.click; + var label = handlers && layout.lookup(expando._labels, event); + if (label) { + dispatchEvent(chart, handlers, label); + } +} + +var plugin = { + id: 'datalabels', + + defaults: defaults, + + beforeInit: function(chart) { + chart[EXPANDO_KEY] = { + _actives: [] + }; + }, + + beforeUpdate: function(chart) { + var expando = chart[EXPANDO_KEY]; + expando._listened = false; + expando._listeners = {}; // {: {: {: }}} + expando._datasets = []; // per dataset labels: [Label[]] + expando._labels = []; // layouted labels: Label[] + }, + + afterDatasetUpdate: function(chart, args, options) { + var datasetIndex = args.index; + var expando = chart[EXPANDO_KEY]; + var labels = expando._datasets[datasetIndex] = []; + var visible = chart.isDatasetVisible(datasetIndex); + var dataset = chart.data.datasets[datasetIndex]; + var config = configure(dataset, options); + var elements = args.meta.data || []; + var ctx = chart.ctx; + var i, j, ilen, jlen, cfg, key, el, label; + + ctx.save(); + + for (i = 0, ilen = elements.length; i < ilen; ++i) { + el = elements[i]; + el[EXPANDO_KEY] = []; + + if (visible && el && chart.getDataVisibility(i) && !el.skip) { + for (j = 0, jlen = config.labels.length; j < jlen; ++j) { + cfg = config.labels[j]; + key = cfg._key; + + label = new Label(cfg, ctx, el, i); + label.$groups = { + _set: datasetIndex, + _key: key || DEFAULT_KEY + }; + label.$context = { + active: false, + chart: chart, + dataIndex: i, + dataset: dataset, + datasetIndex: datasetIndex + }; + + label.update(label.$context); + el[EXPANDO_KEY].push(label); + labels.push(label); + } + } + } + + ctx.restore(); + + // Store listeners at the chart level and per event type to optimize + // cases where no listeners are registered for a specific event. + merge(expando._listeners, config.listeners, { + merger: function(event, target, source) { + target[event] = target[event] || {}; + target[event][args.index] = source[event]; + expando._listened = true; + } + }); + }, + + afterUpdate: function(chart, options) { + chart[EXPANDO_KEY]._labels = layout.prepare( + chart[EXPANDO_KEY]._datasets, + options); + }, + + // Draw labels on top of all dataset elements + // https://github.com/chartjs/chartjs-plugin-datalabels/issues/29 + // https://github.com/chartjs/chartjs-plugin-datalabels/issues/32 + afterDatasetsDraw: function(chart) { + layout.draw(chart, chart[EXPANDO_KEY]._labels); + }, + + beforeEvent: function(chart, args) { + // If there is no listener registered for this chart, `listened` will be false, + // meaning we can immediately ignore the incoming event and avoid useless extra + // computation for users who don't implement label interactions. + if (chart[EXPANDO_KEY]._listened) { + var event = args.event; + switch (event.type) { + case 'mousemove': + case 'mouseout': + handleMoveEvents(chart, event); + break; + case 'click': + handleClickEvents(chart, event); + break; + } + } + }, + + afterEvent: function(chart) { + var expando = chart[EXPANDO_KEY]; + var previous = expando._actives; + var actives = expando._actives = chart.getActiveElements(); + var updates = utils.arrayDiff(previous, actives); + var i, ilen, j, jlen, update, label, labels; + + for (i = 0, ilen = updates.length; i < ilen; ++i) { + update = updates[i]; + if (update[1]) { + labels = update[0].element[EXPANDO_KEY] || []; + for (j = 0, jlen = labels.length; j < jlen; ++j) { + label = labels[j]; + label.$context.active = (update[1] === 1); + label.update(label.$context); + } + } + } + + if (expando._dirty || updates.length) { + layout.update(expando._labels); + chart.render(); + } + + delete expando._dirty; + } +}; + +export default plugin; diff --git a/dist/chartjs-plugin-datalabels.js b/dist/chartjs-plugin-datalabels.js new file mode 100644 index 0000000..73c619a --- /dev/null +++ b/dist/chartjs-plugin-datalabels.js @@ -0,0 +1,1358 @@ +/*! + * chartjs-plugin-datalabels v2.0.0-rc.1 + * https://chartjs-plugin-datalabels.netlify.app + * (c) 2017-2021 chartjs-plugin-datalabels contributors + * Released under the MIT license + */ +(function (global, factory) { +typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('chart.js/helpers'), require('chart.js')) : +typeof define === 'function' && define.amd ? define(['chart.js/helpers', 'chart.js'], factory) : +(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ChartDataLabels = factory(global.Chart.helpers, global.Chart)); +}(this, (function (helpers, chart_js) { 'use strict'; + +var devicePixelRatio = (function() { + if (typeof window !== 'undefined') { + if (window.devicePixelRatio) { + return window.devicePixelRatio; + } + + // devicePixelRatio is undefined on IE10 + // https://stackoverflow.com/a/20204180/8837887 + // https://github.com/chartjs/chartjs-plugin-datalabels/issues/85 + var screen = window.screen; + if (screen) { + return (screen.deviceXDPI || 1) / (screen.logicalXDPI || 1); + } + } + + return 1; +}()); + +var utils = { + // @todo move this in Chart.helpers.toTextLines + toTextLines: function(inputs) { + var lines = []; + var input; + + inputs = [].concat(inputs); + while (inputs.length) { + input = inputs.pop(); + if (typeof input === 'string') { + lines.unshift.apply(lines, input.split('\n')); + } else if (Array.isArray(input)) { + inputs.push.apply(inputs, input); + } else if (!helpers.isNullOrUndef(inputs)) { + lines.unshift('' + input); + } + } + + return lines; + }, + + // @todo move this in Chart.helpers.canvas.textSize + // @todo cache calls of measureText if font doesn't change?! + textSize: function(ctx, lines, font) { + var items = [].concat(lines); + var ilen = items.length; + var prev = ctx.font; + var width = 0; + var i; + + ctx.font = font.string; + + for (i = 0; i < ilen; ++i) { + width = Math.max(ctx.measureText(items[i]).width, width); + } + + ctx.font = prev; + + return { + height: ilen * font.lineHeight, + width: width + }; + }, + + /** + * Returns value bounded by min and max. This is equivalent to max(min, min(value, max)). + * @todo move this method in Chart.helpers.bound + * https://doc.qt.io/qt-5/qtglobal.html#qBound + */ + bound: function(min, value, max) { + return Math.max(min, Math.min(value, max)); + }, + + /** + * Returns an array of pair [value, state] where state is: + * * -1: value is only in a0 (removed) + * * 1: value is only in a1 (added) + */ + arrayDiff: function(a0, a1) { + var prev = a0.slice(); + var updates = []; + var i, j, ilen, v; + + for (i = 0, ilen = a1.length; i < ilen; ++i) { + v = a1[i]; + j = prev.indexOf(v); + + if (j === -1) { + updates.push([v, 1]); + } else { + prev.splice(j, 1); + } + } + + for (i = 0, ilen = prev.length; i < ilen; ++i) { + updates.push([prev[i], -1]); + } + + return updates; + }, + + /** + * https://github.com/chartjs/chartjs-plugin-datalabels/issues/70 + */ + rasterize: function(v) { + return Math.round(v * devicePixelRatio) / devicePixelRatio; + } +}; + +function orient(point, origin) { + var x0 = origin.x; + var y0 = origin.y; + + if (x0 === null) { + return {x: 0, y: -1}; + } + if (y0 === null) { + return {x: 1, y: 0}; + } + + var dx = point.x - x0; + var dy = point.y - y0; + var ln = Math.sqrt(dx * dx + dy * dy); + + return { + x: ln ? dx / ln : 0, + y: ln ? dy / ln : -1 + }; +} + +function aligned(x, y, vx, vy, align) { + switch (align) { + case 'center': + vx = vy = 0; + break; + case 'bottom': + vx = 0; + vy = 1; + break; + case 'right': + vx = 1; + vy = 0; + break; + case 'left': + vx = -1; + vy = 0; + break; + case 'top': + vx = 0; + vy = -1; + break; + case 'start': + vx = -vx; + vy = -vy; + break; + case 'end': + // keep natural orientation + break; + default: + // clockwise rotation (in degree) + align *= (Math.PI / 180); + vx = Math.cos(align); + vy = Math.sin(align); + break; + } + + return { + x: x, + y: y, + vx: vx, + vy: vy + }; +} + +// Line clipping (Cohen–Sutherland algorithm) +// https://en.wikipedia.org/wiki/Cohen–Sutherland_algorithm + +var R_INSIDE = 0; +var R_LEFT = 1; +var R_RIGHT = 2; +var R_BOTTOM = 4; +var R_TOP = 8; + +function region(x, y, rect) { + var res = R_INSIDE; + + if (x < rect.left) { + res |= R_LEFT; + } else if (x > rect.right) { + res |= R_RIGHT; + } + if (y < rect.top) { + res |= R_TOP; + } else if (y > rect.bottom) { + res |= R_BOTTOM; + } + + return res; +} + +function clipped(segment, area) { + var x0 = segment.x0; + var y0 = segment.y0; + var x1 = segment.x1; + var y1 = segment.y1; + var r0 = region(x0, y0, area); + var r1 = region(x1, y1, area); + var r, x, y; + + // eslint-disable-next-line no-constant-condition + while (true) { + if (!(r0 | r1) || (r0 & r1)) { + // both points inside or on the same side: no clipping + break; + } + + // at least one point is outside + r = r0 || r1; + + if (r & R_TOP) { + x = x0 + (x1 - x0) * (area.top - y0) / (y1 - y0); + y = area.top; + } else if (r & R_BOTTOM) { + x = x0 + (x1 - x0) * (area.bottom - y0) / (y1 - y0); + y = area.bottom; + } else if (r & R_RIGHT) { + y = y0 + (y1 - y0) * (area.right - x0) / (x1 - x0); + x = area.right; + } else if (r & R_LEFT) { + y = y0 + (y1 - y0) * (area.left - x0) / (x1 - x0); + x = area.left; + } + + if (r === r0) { + x0 = x; + y0 = y; + r0 = region(x0, y0, area); + } else { + x1 = x; + y1 = y; + r1 = region(x1, y1, area); + } + } + + return { + x0: x0, + x1: x1, + y0: y0, + y1: y1 + }; +} + +function compute$1(range, config) { + var anchor = config.anchor; + var segment = range; + var x, y; + + if (config.clamp) { + segment = clipped(segment, config.area); + } + + if (anchor === 'start') { + x = segment.x0; + y = segment.y0; + } else if (anchor === 'end') { + x = segment.x1; + y = segment.y1; + } else { + x = (segment.x0 + segment.x1) / 2; + y = (segment.y0 + segment.y1) / 2; + } + + return aligned(x, y, range.vx, range.vy, config.align); +} + +var positioners = { + arc: function(el, config) { + var angle = (el.startAngle + el.endAngle) / 2; + var vx = Math.cos(angle); + var vy = Math.sin(angle); + var r0 = el.innerRadius; + var r1 = el.outerRadius; + + return compute$1({ + x0: el.x + vx * r0, + y0: el.y + vy * r0, + x1: el.x + vx * r1, + y1: el.y + vy * r1, + vx: vx, + vy: vy + }, config); + }, + + point: function(el, config) { + var v = orient(el, config.origin); + var rx = v.x * el.options.radius; + var ry = v.y * el.options.radius; + + return compute$1({ + x0: el.x - rx, + y0: el.y - ry, + x1: el.x + rx, + y1: el.y + ry, + vx: v.x, + vy: v.y + }, config); + }, + + bar: function(el, config) { + var v = orient(el, config.origin); + var x = el.x; + var y = el.y; + var sx = 0; + var sy = 0; + + if (el.horizontal) { + x = Math.min(el.x, el.base); + sx = Math.abs(el.base - el.x); + } else { + y = Math.min(el.y, el.base); + sy = Math.abs(el.base - el.y); + } + + return compute$1({ + x0: x, + y0: y + sy, + x1: x + sx, + y1: y, + vx: v.x, + vy: v.y + }, config); + }, + + fallback: function(el, config) { + var v = orient(el, config.origin); + + return compute$1({ + x0: el.x, + y0: el.y, + x1: el.x, + y1: el.y, + vx: v.x, + vy: v.y + }, config); + } +}; + +var rasterize = utils.rasterize; + +function boundingRects(model) { + var borderWidth = model.borderWidth || 0; + var padding = model.padding; + var th = model.size.height; + var tw = model.size.width; + var tx = -tw / 2; + var ty = -th / 2; + + return { + frame: { + x: tx - padding.left - borderWidth, + y: ty - padding.top - borderWidth, + w: tw + padding.width + borderWidth * 2, + h: th + padding.height + borderWidth * 2 + }, + text: { + x: tx, + y: ty, + w: tw, + h: th + } + }; +} + +function getScaleOrigin(el, context) { + var scale = context.chart.getDatasetMeta(context.datasetIndex).vScale; + + if (!scale) { + return null; + } + + if (scale.xCenter !== undefined && scale.yCenter !== undefined) { + return {x: scale.xCenter, y: scale.yCenter}; + } + + var pixel = scale.getBasePixel(); + return el.horizontal ? + {x: pixel, y: null} : + {x: null, y: pixel}; +} + +function getPositioner(el) { + if (el instanceof chart_js.ArcElement) { + return positioners.arc; + } + if (el instanceof chart_js.PointElement) { + return positioners.point; + } + if (el instanceof chart_js.BarElement) { + return positioners.bar; + } + return positioners.fallback; +} + +function drawRoundedRect(ctx, x, y, w, h, radius) { + var HALF_PI = Math.PI / 2; + + if (radius) { + var r = Math.min(radius, h / 2, w / 2); + var left = x + r; + var top = y + r; + var right = x + w - r; + var bottom = y + h - r; + + ctx.moveTo(x, top); + if (left < right && top < bottom) { + ctx.arc(left, top, r, -Math.PI, -HALF_PI); + ctx.arc(right, top, r, -HALF_PI, 0); + ctx.arc(right, bottom, r, 0, HALF_PI); + ctx.arc(left, bottom, r, HALF_PI, Math.PI); + } else if (left < right) { + ctx.moveTo(left, y); + ctx.arc(right, top, r, -HALF_PI, HALF_PI); + ctx.arc(left, top, r, HALF_PI, Math.PI + HALF_PI); + } else if (top < bottom) { + ctx.arc(left, top, r, -Math.PI, 0); + ctx.arc(left, bottom, r, 0, Math.PI); + } else { + ctx.arc(left, top, r, -Math.PI, Math.PI); + } + ctx.closePath(); + ctx.moveTo(x, y); + } else { + ctx.rect(x, y, w, h); + } +} + +function drawFrame(ctx, rect, model) { + var bgColor = model.backgroundColor; + var borderColor = model.borderColor; + var borderWidth = model.borderWidth; + + if (!bgColor && (!borderColor || !borderWidth)) { + return; + } + + ctx.beginPath(); + + drawRoundedRect( + ctx, + rasterize(rect.x) + borderWidth / 2, + rasterize(rect.y) + borderWidth / 2, + rasterize(rect.w) - borderWidth, + rasterize(rect.h) - borderWidth, + model.borderRadius); + + ctx.closePath(); + + if (bgColor) { + ctx.fillStyle = bgColor; + ctx.fill(); + } + + if (borderColor && borderWidth) { + ctx.strokeStyle = borderColor; + ctx.lineWidth = borderWidth; + ctx.lineJoin = 'miter'; + ctx.stroke(); + } +} + +function textGeometry(rect, align, font) { + var h = font.lineHeight; + var w = rect.w; + var x = rect.x; + var y = rect.y + h / 2; + + if (align === 'center') { + x += w / 2; + } else if (align === 'end' || align === 'right') { + x += w; + } + + return { + h: h, + w: w, + x: x, + y: y + }; +} + +function drawTextLine(ctx, text, cfg) { + var shadow = ctx.shadowBlur; + var stroked = cfg.stroked; + var x = rasterize(cfg.x); + var y = rasterize(cfg.y); + var w = rasterize(cfg.w); + + if (stroked) { + ctx.strokeText(text, x, y, w); + } + + if (cfg.filled) { + if (shadow && stroked) { + // Prevent drawing shadow on both the text stroke and fill, so + // if the text is stroked, remove the shadow for the text fill. + ctx.shadowBlur = 0; + } + + ctx.fillText(text, x, y, w); + + if (shadow && stroked) { + ctx.shadowBlur = shadow; + } + } +} + +function drawText(ctx, lines, rect, model) { + var align = model.textAlign; + var color = model.color; + var filled = !!color; + var font = model.font; + var ilen = lines.length; + var strokeColor = model.textStrokeColor; + var strokeWidth = model.textStrokeWidth; + var stroked = strokeColor && strokeWidth; + var i; + + if (!ilen || (!filled && !stroked)) { + return; + } + + // Adjust coordinates based on text alignment and line height + rect = textGeometry(rect, align, font); + + ctx.font = font.string; + ctx.textAlign = align; + ctx.textBaseline = 'middle'; + ctx.shadowBlur = model.textShadowBlur; + ctx.shadowColor = model.textShadowColor; + + if (filled) { + ctx.fillStyle = color; + } + if (stroked) { + ctx.lineJoin = 'round'; + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = strokeColor; + } + + for (i = 0, ilen = lines.length; i < ilen; ++i) { + drawTextLine(ctx, lines[i], { + stroked: stroked, + filled: filled, + w: rect.w, + x: rect.x, + y: rect.y + rect.h * i + }); + } +} + +var Label = function(config, ctx, el, index) { + var me = this; + + me._config = config; + me._index = index; + me._model = null; + me._rects = null; + me._ctx = ctx; + me._el = el; +}; + +helpers.merge(Label.prototype, { + /** + * @private + */ + _modelize: function(display, lines, config, context) { + var me = this; + var index = me._index; + var font = helpers.toFont(helpers.resolve([config.font, {}], context, index)); + var color = helpers.resolve([config.color, chart_js.defaults.color], context, index); + + return { + align: helpers.resolve([config.align, 'center'], context, index), + anchor: helpers.resolve([config.anchor, 'center'], context, index), + area: context.chart.chartArea, + backgroundColor: helpers.resolve([config.backgroundColor, null], context, index), + borderColor: helpers.resolve([config.borderColor, null], context, index), + borderRadius: helpers.resolve([config.borderRadius, 0], context, index), + borderWidth: helpers.resolve([config.borderWidth, 0], context, index), + clamp: helpers.resolve([config.clamp, false], context, index), + clip: helpers.resolve([config.clip, false], context, index), + color: color, + display: display, + font: font, + lines: lines, + offset: helpers.resolve([config.offset, 0], context, index), + opacity: helpers.resolve([config.opacity, 1], context, index), + origin: getScaleOrigin(me._el, context), + padding: helpers.toPadding(helpers.resolve([config.padding, 0], context, index)), + positioner: getPositioner(me._el), + rotation: helpers.resolve([config.rotation, 0], context, index) * (Math.PI / 180), + size: utils.textSize(me._ctx, lines, font), + textAlign: helpers.resolve([config.textAlign, 'start'], context, index), + textShadowBlur: helpers.resolve([config.textShadowBlur, 0], context, index), + textShadowColor: helpers.resolve([config.textShadowColor, color], context, index), + textStrokeColor: helpers.resolve([config.textStrokeColor, color], context, index), + textStrokeWidth: helpers.resolve([config.textStrokeWidth, 0], context, index) + }; + }, + + update: function(context) { + var me = this; + var model = null; + var rects = null; + var index = me._index; + var config = me._config; + var value, label, lines; + + // We first resolve the display option (separately) to avoid computing + // other options in case the label is hidden (i.e. display: false). + var display = helpers.resolve([config.display, true], context, index); + + if (display) { + value = context.dataset.data[index]; + label = helpers.valueOrDefault(helpers.callback(config.formatter, [value, context]), value); + lines = helpers.isNullOrUndef(label) ? [] : utils.toTextLines(label); + + if (lines.length) { + model = me._modelize(display, lines, config, context); + rects = boundingRects(model); + } + } + + me._model = model; + me._rects = rects; + }, + + geometry: function() { + return this._rects ? this._rects.frame : {}; + }, + + rotation: function() { + return this._model ? this._model.rotation : 0; + }, + + visible: function() { + return this._model && this._model.opacity; + }, + + model: function() { + return this._model; + }, + + draw: function(chart, center) { + var me = this; + var ctx = chart.ctx; + var model = me._model; + var rects = me._rects; + var area; + + if (!this.visible()) { + return; + } + + ctx.save(); + + if (model.clip) { + area = model.area; + ctx.beginPath(); + ctx.rect( + area.left, + area.top, + area.right - area.left, + area.bottom - area.top); + ctx.clip(); + } + + ctx.globalAlpha = utils.bound(0, model.opacity, 1); + ctx.translate(rasterize(center.x), rasterize(center.y)); + ctx.rotate(model.rotation); + + drawFrame(ctx, rects.frame, model); + drawText(ctx, model.lines, rects.text, model); + + ctx.restore(); + } +}); + +var MIN_INTEGER = Number.MIN_SAFE_INTEGER || -9007199254740991; // eslint-disable-line es/no-number-minsafeinteger +var MAX_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991; // eslint-disable-line es/no-number-maxsafeinteger + +function rotated(point, center, angle) { + var cos = Math.cos(angle); + var sin = Math.sin(angle); + var cx = center.x; + var cy = center.y; + + return { + x: cx + cos * (point.x - cx) - sin * (point.y - cy), + y: cy + sin * (point.x - cx) + cos * (point.y - cy) + }; +} + +function projected(points, axis) { + var min = MAX_INTEGER; + var max = MIN_INTEGER; + var origin = axis.origin; + var i, pt, vx, vy, dp; + + for (i = 0; i < points.length; ++i) { + pt = points[i]; + vx = pt.x - origin.x; + vy = pt.y - origin.y; + dp = axis.vx * vx + axis.vy * vy; + min = Math.min(min, dp); + max = Math.max(max, dp); + } + + return { + min: min, + max: max + }; +} + +function toAxis(p0, p1) { + var vx = p1.x - p0.x; + var vy = p1.y - p0.y; + var ln = Math.sqrt(vx * vx + vy * vy); + + return { + vx: (p1.x - p0.x) / ln, + vy: (p1.y - p0.y) / ln, + origin: p0, + ln: ln + }; +} + +var HitBox = function() { + this._rotation = 0; + this._rect = { + x: 0, + y: 0, + w: 0, + h: 0 + }; +}; + +helpers.merge(HitBox.prototype, { + center: function() { + var r = this._rect; + return { + x: r.x + r.w / 2, + y: r.y + r.h / 2 + }; + }, + + update: function(center, rect, rotation) { + this._rotation = rotation; + this._rect = { + x: rect.x + center.x, + y: rect.y + center.y, + w: rect.w, + h: rect.h + }; + }, + + contains: function(point) { + var me = this; + var margin = 1; + var rect = me._rect; + + point = rotated(point, me.center(), -me._rotation); + + return !(point.x < rect.x - margin + || point.y < rect.y - margin + || point.x > rect.x + rect.w + margin * 2 + || point.y > rect.y + rect.h + margin * 2); + }, + + // Separating Axis Theorem + // https://gamedevelopment.tutsplus.com/tutorials/collision-detection-using-the-separating-axis-theorem--gamedev-169 + intersects: function(other) { + var r0 = this._points(); + var r1 = other._points(); + var axes = [ + toAxis(r0[0], r0[1]), + toAxis(r0[0], r0[3]) + ]; + var i, pr0, pr1; + + if (this._rotation !== other._rotation) { + // Only separate with r1 axis if the rotation is different, + // else it's enough to separate r0 and r1 with r0 axis only! + axes.push( + toAxis(r1[0], r1[1]), + toAxis(r1[0], r1[3]) + ); + } + + for (i = 0; i < axes.length; ++i) { + pr0 = projected(r0, axes[i]); + pr1 = projected(r1, axes[i]); + + if (pr0.max < pr1.min || pr1.max < pr0.min) { + return false; + } + } + + return true; + }, + + /** + * @private + */ + _points: function() { + var me = this; + var rect = me._rect; + var angle = me._rotation; + var center = me.center(); + + return [ + rotated({x: rect.x, y: rect.y}, center, angle), + rotated({x: rect.x + rect.w, y: rect.y}, center, angle), + rotated({x: rect.x + rect.w, y: rect.y + rect.h}, center, angle), + rotated({x: rect.x, y: rect.y + rect.h}, center, angle) + ]; + } +}); + +function coordinates(el, model, geometry) { + var point = model.positioner(el, model); + var vx = point.vx; + var vy = point.vy; + + if (!vx && !vy) { + // if aligned center, we don't want to offset the center point + return {x: point.x, y: point.y}; + } + + var w = geometry.w; + var h = geometry.h; + + // take in account the label rotation + var rotation = model.rotation; + var dx = Math.abs(w / 2 * Math.cos(rotation)) + Math.abs(h / 2 * Math.sin(rotation)); + var dy = Math.abs(w / 2 * Math.sin(rotation)) + Math.abs(h / 2 * Math.cos(rotation)); + + // scale the unit vector (vx, vy) to get at least dx or dy equal to + // w or h respectively (else we would calculate the distance to the + // ellipse inscribed in the bounding rect) + var vs = 1 / Math.max(Math.abs(vx), Math.abs(vy)); + dx *= vx * vs; + dy *= vy * vs; + + // finally, include the explicit offset + dx += model.offset * vx; + dy += model.offset * vy; + + return { + x: point.x + dx, + y: point.y + dy + }; +} + +function collide(labels, collider) { + var i, j, s0, s1; + + // IMPORTANT Iterate in the reverse order since items at the end of the + // list have an higher weight/priority and thus should be less impacted + // by the overlapping strategy. + + for (i = labels.length - 1; i >= 0; --i) { + s0 = labels[i].$layout; + + for (j = i - 1; j >= 0 && s0._visible; --j) { + s1 = labels[j].$layout; + + if (s1._visible && s0._box.intersects(s1._box)) { + collider(s0, s1); + } + } + } + + return labels; +} + +function compute(labels) { + var i, ilen, label, state, geometry, center, proxy; + + // Initialize labels for overlap detection + for (i = 0, ilen = labels.length; i < ilen; ++i) { + label = labels[i]; + state = label.$layout; + + if (state._visible) { + // Chart.js 3 removed el._model in favor of getProps(), making harder to + // abstract reading values in positioners. Also, using string arrays to + // read values (i.e. var {a,b,c} = el.getProps(["a","b","c"])) would make + // positioners inefficient in the normal case (i.e. not the final values) + // and the code a bit ugly, so let's use a Proxy instead. + proxy = new Proxy(label._el, {get: (el, p) => el.getProps([p], true)[p]}); + + geometry = label.geometry(); + center = coordinates(proxy, label.model(), geometry); + state._box.update(center, geometry, label.rotation()); + } + } + + // Auto hide overlapping labels + return collide(labels, function(s0, s1) { + var h0 = s0._hidable; + var h1 = s1._hidable; + + if ((h0 && h1) || h1) { + s1._visible = false; + } else if (h0) { + s0._visible = false; + } + }); +} + +var layout = { + prepare: function(datasets) { + var labels = []; + var i, j, ilen, jlen, label; + + for (i = 0, ilen = datasets.length; i < ilen; ++i) { + for (j = 0, jlen = datasets[i].length; j < jlen; ++j) { + label = datasets[i][j]; + labels.push(label); + label.$layout = { + _box: new HitBox(), + _hidable: false, + _visible: true, + _set: i, + _idx: j + }; + } + } + + // TODO New `z` option: labels with a higher z-index are drawn + // of top of the ones with a lower index. Lowest z-index labels + // are also discarded first when hiding overlapping labels. + labels.sort(function(a, b) { + var sa = a.$layout; + var sb = b.$layout; + + return sa._idx === sb._idx + ? sb._set - sa._set + : sb._idx - sa._idx; + }); + + this.update(labels); + + return labels; + }, + + update: function(labels) { + var dirty = false; + var i, ilen, label, model, state; + + for (i = 0, ilen = labels.length; i < ilen; ++i) { + label = labels[i]; + model = label.model(); + state = label.$layout; + state._hidable = model && model.display === 'auto'; + state._visible = label.visible(); + dirty |= state._hidable; + } + + if (dirty) { + compute(labels); + } + }, + + lookup: function(labels, point) { + var i, state; + + // IMPORTANT Iterate in the reverse order since items at the end of + // the list have an higher z-index, thus should be picked first. + + for (i = labels.length - 1; i >= 0; --i) { + state = labels[i].$layout; + + if (state && state._visible && state._box.contains(point)) { + return labels[i]; + } + } + + return null; + }, + + draw: function(chart, labels) { + var i, ilen, label, state, geometry, center; + + for (i = 0, ilen = labels.length; i < ilen; ++i) { + label = labels[i]; + state = label.$layout; + + if (state._visible) { + geometry = label.geometry(); + center = coordinates(label._el, label.model(), geometry); + state._box.update(center, geometry, label.rotation()); + label.draw(chart, center); + } + } + } +}; + +var formatter = function(value) { + if (helpers.isNullOrUndef(value)) { + return null; + } + + var label = value; + var keys, klen, k; + if (helpers.isObject(value)) { + if (!helpers.isNullOrUndef(value.label)) { + label = value.label; + } else if (!helpers.isNullOrUndef(value.r)) { + label = value.r; + } else { + label = ''; + keys = Object.keys(value); + for (k = 0, klen = keys.length; k < klen; ++k) { + label += (k !== 0 ? ', ' : '') + keys[k] + ': ' + value[keys[k]]; + } + } + } + + return '' + label; +}; + +/** + * IMPORTANT: make sure to also update tests and TypeScript definition + * files (`/test/specs/defaults.spec.js` and `/types/options.d.ts`) + */ + +var defaults = { + align: 'center', + anchor: 'center', + backgroundColor: null, + borderColor: null, + borderRadius: 0, + borderWidth: 0, + clamp: false, + clip: false, + color: undefined, + display: true, + font: { + family: undefined, + lineHeight: 1.2, + size: undefined, + style: undefined, + weight: null + }, + formatter: formatter, + labels: undefined, + listeners: {}, + offset: 4, + opacity: 1, + padding: { + top: 4, + right: 4, + bottom: 4, + left: 4 + }, + rotation: 0, + textAlign: 'start', + textStrokeColor: undefined, + textStrokeWidth: 0, + textShadowBlur: 0, + textShadowColor: undefined +}; + +/** + * @see https://github.com/chartjs/Chart.js/issues/4176 + */ + +var EXPANDO_KEY = '$datalabels'; +var DEFAULT_KEY = '$default'; + +function configure(dataset, options) { + var override = dataset.datalabels; + var listeners = {}; + var configs = []; + var labels, keys; + + if (override === false) { + return null; + } + if (override === true) { + override = {}; + } + + options = helpers.merge({}, [options, override]); + labels = options.labels || {}; + keys = Object.keys(labels); + delete options.labels; + + if (keys.length) { + keys.forEach(function(key) { + if (labels[key]) { + configs.push(helpers.merge({}, [ + options, + labels[key], + {_key: key} + ])); + } + }); + } else { + // Default label if no "named" label defined. + configs.push(options); + } + + // listeners: {: {: }} + listeners = configs.reduce(function(target, config) { + helpers.each(config.listeners || {}, function(fn, event) { + target[event] = target[event] || {}; + target[event][config._key || DEFAULT_KEY] = fn; + }); + + delete config.listeners; + return target; + }, {}); + + return { + labels: configs, + listeners: listeners + }; +} + +function dispatchEvent(chart, listeners, label) { + if (!listeners) { + return; + } + + var context = label.$context; + var groups = label.$groups; + var callback; + + if (!listeners[groups._set]) { + return; + } + + callback = listeners[groups._set][groups._key]; + if (!callback) { + return; + } + + if (helpers.callback(callback, [context]) === true) { + // Users are allowed to tweak the given context by injecting values that can be + // used in scriptable options to display labels differently based on the current + // event (e.g. highlight an hovered label). That's why we update the label with + // the output context and schedule a new chart render by setting it dirty. + chart[EXPANDO_KEY]._dirty = true; + label.update(context); + } +} + +function dispatchMoveEvents(chart, listeners, previous, label) { + var enter, leave; + + if (!previous && !label) { + return; + } + + if (!previous) { + enter = true; + } else if (!label) { + leave = true; + } else if (previous !== label) { + leave = enter = true; + } + + if (leave) { + dispatchEvent(chart, listeners.leave, previous); + } + if (enter) { + dispatchEvent(chart, listeners.enter, label); + } +} + +function handleMoveEvents(chart, event) { + var expando = chart[EXPANDO_KEY]; + var listeners = expando._listeners; + var previous, label; + + if (!listeners.enter && !listeners.leave) { + return; + } + + if (event.type === 'mousemove') { + label = layout.lookup(expando._labels, event); + } else if (event.type !== 'mouseout') { + return; + } + + previous = expando._hovered; + expando._hovered = label; + dispatchMoveEvents(chart, listeners, previous, label); +} + +function handleClickEvents(chart, event) { + var expando = chart[EXPANDO_KEY]; + var handlers = expando._listeners.click; + var label = handlers && layout.lookup(expando._labels, event); + if (label) { + dispatchEvent(chart, handlers, label); + } +} + +var plugin = { + id: 'datalabels', + + defaults: defaults, + + beforeInit: function(chart) { + chart[EXPANDO_KEY] = { + _actives: [] + }; + }, + + beforeUpdate: function(chart) { + var expando = chart[EXPANDO_KEY]; + expando._listened = false; + expando._listeners = {}; // {: {: {: }}} + expando._datasets = []; // per dataset labels: [Label[]] + expando._labels = []; // layouted labels: Label[] + }, + + afterDatasetUpdate: function(chart, args, options) { + var datasetIndex = args.index; + var expando = chart[EXPANDO_KEY]; + var labels = expando._datasets[datasetIndex] = []; + var visible = chart.isDatasetVisible(datasetIndex); + var dataset = chart.data.datasets[datasetIndex]; + var config = configure(dataset, options); + var elements = args.meta.data || []; + var ctx = chart.ctx; + var i, j, ilen, jlen, cfg, key, el, label; + + ctx.save(); + + for (i = 0, ilen = elements.length; i < ilen; ++i) { + el = elements[i]; + el[EXPANDO_KEY] = []; + + if (visible && el && chart.getDataVisibility(i) && !el.skip) { + for (j = 0, jlen = config.labels.length; j < jlen; ++j) { + cfg = config.labels[j]; + key = cfg._key; + + label = new Label(cfg, ctx, el, i); + label.$groups = { + _set: datasetIndex, + _key: key || DEFAULT_KEY + }; + label.$context = { + active: false, + chart: chart, + dataIndex: i, + dataset: dataset, + datasetIndex: datasetIndex + }; + + label.update(label.$context); + el[EXPANDO_KEY].push(label); + labels.push(label); + } + } + } + + ctx.restore(); + + // Store listeners at the chart level and per event type to optimize + // cases where no listeners are registered for a specific event. + helpers.merge(expando._listeners, config.listeners, { + merger: function(event, target, source) { + target[event] = target[event] || {}; + target[event][args.index] = source[event]; + expando._listened = true; + } + }); + }, + + afterUpdate: function(chart, options) { + chart[EXPANDO_KEY]._labels = layout.prepare( + chart[EXPANDO_KEY]._datasets, + options); + }, + + // Draw labels on top of all dataset elements + // https://github.com/chartjs/chartjs-plugin-datalabels/issues/29 + // https://github.com/chartjs/chartjs-plugin-datalabels/issues/32 + afterDatasetsDraw: function(chart) { + layout.draw(chart, chart[EXPANDO_KEY]._labels); + }, + + beforeEvent: function(chart, args) { + // If there is no listener registered for this chart, `listened` will be false, + // meaning we can immediately ignore the incoming event and avoid useless extra + // computation for users who don't implement label interactions. + if (chart[EXPANDO_KEY]._listened) { + var event = args.event; + switch (event.type) { + case 'mousemove': + case 'mouseout': + handleMoveEvents(chart, event); + break; + case 'click': + handleClickEvents(chart, event); + break; + } + } + }, + + afterEvent: function(chart) { + var expando = chart[EXPANDO_KEY]; + var previous = expando._actives; + var actives = expando._actives = chart.getActiveElements(); + var updates = utils.arrayDiff(previous, actives); + var i, ilen, j, jlen, update, label, labels; + + for (i = 0, ilen = updates.length; i < ilen; ++i) { + update = updates[i]; + if (update[1]) { + labels = update[0].element[EXPANDO_KEY] || []; + for (j = 0, jlen = labels.length; j < jlen; ++j) { + label = labels[j]; + label.$context.active = (update[1] === 1); + label.update(label.$context); + } + } + } + + if (expando._dirty || updates.length) { + layout.update(expando._labels); + chart.render(); + } + + delete expando._dirty; + } +}; + +return plugin; + +}))); diff --git a/dist/chartjs-plugin-datalabels.min.js b/dist/chartjs-plugin-datalabels.min.js new file mode 100644 index 0000000..1ad9a27 --- /dev/null +++ b/dist/chartjs-plugin-datalabels.min.js @@ -0,0 +1,7 @@ +/*! + * chartjs-plugin-datalabels v2.0.0-rc.1 + * https://chartjs-plugin-datalabels.netlify.app + * (c) 2017-2021 chartjs-plugin-datalabels contributors + * Released under the MIT license + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("chart.js/helpers"),require("chart.js")):"function"==typeof define&&define.amd?define(["chart.js/helpers","chart.js"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).ChartDataLabels=e(t.Chart.helpers,t.Chart)}(this,(function(t,e){"use strict";var r=function(){if("undefined"!=typeof window){if(window.devicePixelRatio)return window.devicePixelRatio;var t=window.screen;if(t)return(t.deviceXDPI||1)/(t.logicalXDPI||1)}return 1}(),a=function(e){var r,a=[];for(e=[].concat(e);e.length;)"string"==typeof(r=e.pop())?a.unshift.apply(a,r.split("\n")):Array.isArray(r)?e.push.apply(e,r):t.isNullOrUndef(e)||a.unshift(""+r);return a},o=function(t,e,r){var a,o=[].concat(e),n=o.length,i=t.font,l=0;for(t.font=r.string,a=0;ar.right&&(a|=2),er.bottom&&(a|=4),a}function u(t,e){var r,a,o=e.anchor,n=t;return e.clamp&&(n=function(t,e){for(var r,a,o,n=t.x0,i=t.y0,l=t.x1,u=t.y1,d=s(n,i,e),c=s(l,u,e);d|c&&!(d&c);)8&(r=d||c)?(a=n+(l-n)*(e.top-i)/(u-i),o=e.top):4&r?(a=n+(l-n)*(e.bottom-i)/(u-i),o=e.bottom):2&r?(o=i+(u-i)*(e.right-n)/(l-n),a=e.right):1&r&&(o=i+(u-i)*(e.left-n)/(l-n),a=e.left),r===d?d=s(n=a,i=o,e):c=s(l=a,u=o,e);return{x0:n,x1:l,y0:i,y1:u}}(n,e.area)),"start"===o?(r=n.x0,a=n.y0):"end"===o?(r=n.x1,a=n.y1):(r=(n.x0+n.x1)/2,a=(n.y0+n.y1)/2),function(t,e,r,a,o){switch(o){case"center":r=a=0;break;case"bottom":r=0,a=1;break;case"right":r=1,a=0;break;case"left":r=-1,a=0;break;case"top":r=0,a=-1;break;case"start":r=-r,a=-a;break;case"end":break;default:o*=Math.PI/180,r=Math.cos(o),a=Math.sin(o)}return{x:t,y:e,vx:r,vy:a}}(r,a,t.vx,t.vy,e.align)}var d=function(t,e){var r=(t.startAngle+t.endAngle)/2,a=Math.cos(r),o=Math.sin(r),n=t.innerRadius,i=t.outerRadius;return u({x0:t.x+a*n,y0:t.y+o*n,x1:t.x+a*i,y1:t.y+o*i,vx:a,vy:o},e)},c=function(t,e){var r=l(t,e.origin),a=r.x*t.options.radius,o=r.y*t.options.radius;return u({x0:t.x-a,y0:t.y-o,x1:t.x+a,y1:t.y+o,vx:r.x,vy:r.y},e)},h=function(t,e){var r=l(t,e.origin),a=t.x,o=t.y,n=0,i=0;return t.horizontal?(a=Math.min(t.x,t.base),n=Math.abs(t.base-t.x)):(o=Math.min(t.y,t.base),i=Math.abs(t.base-t.y)),u({x0:a,y0:o+i,x1:a+n,y1:o,vx:r.x,vy:r.y},e)},f=function(t,e){var r=l(t,e.origin);return u({x0:t.x,y0:t.y,x1:t.x,y1:t.y,vx:r.x,vy:r.y},e)},x=function(t){return Math.round(t*r)/r};function y(t,e){var r=e.chart.getDatasetMeta(e.datasetIndex).vScale;if(!r)return null;if(void 0!==r.xCenter&&void 0!==r.yCenter)return{x:r.xCenter,y:r.yCenter};var a=r.getBasePixel();return t.horizontal?{x:a,y:null}:{x:null,y:a}}function v(t,e,r){var a=r.backgroundColor,o=r.borderColor,n=r.borderWidth;(a||o&&n)&&(t.beginPath(),function(t,e,r,a,o,n){var i=Math.PI/2;if(n){var l=Math.min(n,o/2,a/2),s=e+l,u=r+l,d=e+a-l,c=r+o-l;t.moveTo(e,u),sr.x+r.w+2||t.y>r.y+r.h+2)},intersects:function(t){var e,r,a,o=this._points(),n=t._points(),i=[M(o[0],o[1]),M(o[0],o[3])];for(this._rotation!==t._rotation&&i.push(M(n[0],n[1]),M(n[0],n[3])),e=0;et.getProps([e],!0)[e]}),n=a.geometry(),i=$(l,a.model(),n),o._box.update(i,n,a.rotation()));(function(t,e){var r,a,o,n;for(r=t.length-1;r>=0;--r)for(o=t[r].$layout,a=r-1;a>=0&&o._visible;--a)(n=t[a].$layout)._visible&&o._box.intersects(n._box)&&e(o,n)})(t,(function(t,e){var r=t._hidable,a=e._hidable;r&&a||a?e._visible=!1:r&&(t._visible=!1)}))}(t)},lookup:function(t,e){var r,a;for(r=t.length-1;r>=0;--r)if((a=t[r].$layout)&&a._visible&&a._box.contains(e))return t[r];return null},draw:function(t,e){var r,a,o,n,i,l;for(r=0,a=e.length;r