/*! * @energiency/chartjs-plugin-piechart-outlabels v1.3.1 * http://www.chartjs.org * (c) 2017-2022 @energiency/chartjs-plugin-piechart-outlabels contributors * Released under the MIT license */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('chart.js'), require('chart.js/helpers')) : typeof define === 'function' && define.amd ? define(['chart.js', 'chart.js/helpers'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ChartPieChartOutlabels = factory(global.Chart, global.Chart.helpers)); })(this, (function (chart_js, helpers) { 'use strict'; /** * @module Options */ var customDefaults = { PLUGIN_KEY: '$outlabels', /** * The color used to draw the background of the label rect. * @member {String|Array|Function|null} * @default null (adaptive background) */ backgroundColor: function(context) { return context.dataset.backgroundColor; }, /** * The color used to draw the border of the label rect. * @member {String|Array|Function|null} * @default null (adaptive border color) */ borderColor: function(context) { return context.dataset.backgroundColor; }, /** * The color used to draw the line between label and arc of the chart. * @member {String|Array|Function|null} * @default null (adaptive line color) */ lineColor: function(context) { return context.dataset.backgroundColor; }, /** * The border radius used to add rounded corners to the label rect. * @member {Number|Array|Function} * @default 0 (not rounded) */ borderRadius: 0, /** * The border width of the surrounding frame. * @member {Number|Array|Function} * @default 0 (no border) */ borderWidth: 0, /** * The width (thickness) of the line between label and chart arc. * @member {Number|Array|Function} * @default 2 */ lineWidth: 2, /** * The color used to draw the label text. * @member {String|Array|Function} * @default white */ color: 'white', /** * Whether to display labels global (boolean) or per data (function) * @member {Boolean|Array|Function} * @default true */ display: true, /** * The font options used to draw the label text. * @member {Object|Array|Function} * @prop {Boolean} font.family - defaults to Chart.defaults.global.defaultFontFamily * @prop {Boolean} font.size - defaults to Chart.defaults.global.defaultFontSize * @prop {Boolean} font.style - defaults to Chart.defaults.global.defaultFontStyle * @prop {Boolean} font.weight - defaults to 'normal' * @prop {Boolean} font.maxSize - defaults to undefined (unlimited) * @prop {Boolean} font.minSize - defaults to undefined (unlimited) * @prop {Boolean} font.resizable - defaults to true * @default Chart.defaults.global.defaultFont.* */ font: { family: undefined, size: undefined, style: undefined, weight: null, maxSize: null, minSize: null, resizable: true, }, /** * The line height (in pixel) to use for multi-lines labels. * @member {Number|Array|Function|undefined} * @default 1.2 */ lineHeight: 1.2, /** * The padding (in pixels) to apply between the text and the surrounding frame. * @member {Number|Object|Array|Function} * @prop {Number} padding.top - Space above the text. * @prop {Number} padding.right - Space on the right of the text. * @prop {Number} padding.bottom - Space below the text. * @prop {Number} padding.left - Space on the left of the text. * @default 4 (all values) */ padding: { top: 2, right: 2, bottom: 2, left: 2 }, /** * Text alignment for multi-lines labels ('left'|'right'|'start'|'center'|'end'). * @member {String|Array|Function} * @default 'center' */ textAlign: 'center', /** * The radius of distance where the label will be drawn * @member {Number|Array|Function|undefined} * @default 30 */ stretch: 12, /** * The length of the horizontal part of line between label and chart arc. * @member {Number} * @default 30 */ horizontalStrechPad: 12, /** * The text of the label. * @member {String} * @default '%l %p' (label name and value percentage) */ text: '%l %p', /** * The max level of zoom (out) for pie/doughnut chart in percent. * @member {Number} * @default 50 (%) */ maxZoomOutPercentage: 50, /** * The count of numbers after the point separator for float values of percent property * @member {Number} * @default 1 */ percentPrecision: 1, /** * The count of numbers after the point separator for float values of value property * @member {Number} * @default 3 */ valuePrecision: 3 }; var positioners = { center: function(arc, stretch) { var angle = (arc.startAngle + arc.endAngle) / 2; var cosA = Math.cos(angle); var sinA = Math.sin(angle); var d = arc.outerRadius; var stretchedD = d + stretch; return { x: arc.x + cosA * stretchedD, y: arc.y + sinA * stretchedD, d: stretchedD, arc: arc, anchor: { x: arc.x + cosA * d, y: arc.y + sinA * d, }, copy: { x: arc.x + cosA * stretchedD, y: arc.y + sinA * stretchedD } }; }, moveFromAnchor: function(center, dist) { var arc = center.arc; var d = center.d; var angle = (arc.startAngle + arc.endAngle) / 2; var cosA = Math.cos(angle); var sinA = Math.sin(angle); d += dist; return { x: arc.x + cosA * d, y: arc.y + sinA * d, d: d, arc: arc, anchor: center.anchor, copy: { x: arc.x + cosA * d, y: arc.y + sinA * d } }; } }; function toFontString(font) { if (!font || helpers.isNullOrUndef(font.size) || helpers.isNullOrUndef(font.family)) { return null; } return (font.style ? font.style + ' ' : '') + (font.weight ? font.weight + ' ' : '') + font.size + 'px ' + font.family; } function textSize(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 }; } function adaptTextSizeToHeight(height, minimum, maximum) { var size = (height / 100) * 2.5; if (minimum && size < minimum) { return minimum; } if (maximum && size > maximum) { return maximum; } return size; } function parseFont(value, height) { var size = helpers.valueOrDefault(value.size, chart_js.Chart.defaults.defaultFontSize); if (value.resizable) { size = adaptTextSizeToHeight(height, value.minSize, value.maxSize); } var font = { family: helpers.valueOrDefault(value.family, chart_js.Chart.defaults.defaultFontFamily), lineHeight: helpers.toLineHeight(value.lineHeight, size), size: size, style: helpers.valueOrDefault(value.style, chart_js.Chart.defaults.defaultFontStyle), weight: helpers.valueOrDefault(value.weight, null), string: '' }; font.string = toFontString(font); return font; } var PLUGIN_KEY$1 = customDefaults.PLUGIN_KEY; var classes = { OutLabel: function(chart, index, ctx, config, context) { // Check whether the label should be displayed if (!helpers.resolve([config.display, true], context, index)) { throw new Error('Label display property is set to false.'); } // Init text var value = context.dataset.data[index]; var label = context.labels[index]; var text = helpers.resolve([config.text, customDefaults.text], context, index); /* Replace label marker */ text = text.replace(/%l/gi, label); /* Replace value marker with possible precision value */ (text.match(/%v\.?(\d*)/gi) || []).map(function(val) { var prec = val.replace(/%v\./gi, ''); if (prec.length) { return +prec; } return config.valuePrecision || customDefaults.valuePrecision; }).forEach(function(val) { text = text.replace(/%v\.?(\d*)/i, value.toFixed(val)); }); /* Replace percent marker with possible precision value */ (text.match(/%p\.?(\d*)/gi) || []).map(function(val) { var prec = val.replace(/%p\./gi, ''); if (prec.length) { return +prec; } return config.percentPrecision || customDefaults.percentPrecision; }).forEach(function(val) { text = text.replace(/%p\.?(\d*)/i, (context.percent * 100).toFixed(val) + '%'); }); // Count lines var lines = text.match(/[^\r\n]+/g) || []; // Remove unnecessary spaces for (var i = 0; i < lines.length; ++i) { lines[i] = lines[i].trim(); } /* ===================== CONSTRUCTOR ==================== */ this.init = function(text, lines) { // If everything ok -> begin initializing this.encodedText = config.text; this.text = text; this.lines = lines; this.label = label; this.value = value; this.ctx = ctx; // Init style this.style = { backgroundColor: helpers.resolve([config.backgroundColor, customDefaults.backgroundColor, 'black'], context, index), borderColor: helpers.resolve([config.borderColor, customDefaults.borderColor, 'black'], context, index), borderRadius: helpers.resolve([config.borderRadius, 0], context, index), borderWidth: helpers.resolve([config.borderWidth, 0], context, index), lineWidth: helpers.resolve([config.lineWidth, 2], context, index), lineColor: helpers.resolve([config.lineColor, customDefaults.lineColor, 'black'], context, index), color: helpers.resolve([config.color, 'white'], context, index), font: parseFont(helpers.resolve([config.font, {resizable: true}]), ctx.canvas.style.height.slice(0, -2)), padding: helpers.toPadding(helpers.resolve([config.padding, 0], context, index)), textAlign: helpers.resolve([config.textAlign, 'left'], context, index), }; this.stretch = helpers.resolve([config.stretch, customDefaults.stretch], context, index); this.horizontalStrechPad = helpers.resolve([config.horizontalStrechPad, customDefaults.horizontalStrechPad], context, index); this.size = textSize(ctx, this.lines, this.style.font); this.offsetStep = this.size.width / 20; this.offset = { x: 0, y: 0 }; this.predictedOffset = this.offset; /*var angle = -((el._model.startAngle + el._model.endAngle) / 2) / (Math.PI); var val = Math.abs(angle - Math.trunc(angle)); if (val > 0.45 && val < 0.55) { this.predictedOffset.x = 0; } else if (angle <= 0.45 && angle >= -0.45) { this.predictedOffset.x = this.size.width / 2; } else if (angle >= -1.45 && angle <= -0.55) { this.predictedOffset.x = -this.size.width / 2; }*/ }; this.init(text, lines); /* COMPUTING RECTS PART */ this.computeLabelRect = function() { var width = this.textRect.width + 2 * this.style.borderWidth + this.style.padding.left + this.style.padding.right; var height = this.textRect.height + 2 * this.style.borderWidth + this.style.padding.top + this.style.padding.bottom; var x = this.textRect.x - this.style.borderWidth; var y = this.textRect.y - this.style.borderWidth; return { x: x, y: y, width: width, height: height, isLeft: this.textRect.isLeft, isTop: this.textRect.isTop }; }; this.computeTextRect = function() { const isLeft = this.center.x - this.center.anchor.x < 0; const isTop = this.center.y - this.center.anchor.y < 0; const shift = isLeft ? -(this.horizontalStrechPad + this.size.width) : this.horizontalStrechPad; return { x: this.center.x - this.style.padding.left + shift, y: this.center.y - (this.size.height / 2), width: this.size.width, height: this.size.height, isLeft, isTop }; }; this.roundedRect = function (ctx, x, y, width, height, radius) { var TL, TR, BR, BL; // Minimum horizontal value: half of the width var mH = r => Math.min(r, width / 2); // Minimum vertical value: half of the width var mV = r => Math.min(r, height / 2); if (radius instanceof Array) { // [TL, TR, BR, BL] TL = radius[0]; TR = radius[1]; BR = radius[2]; BL = radius[3]; } else { TL = TR = BR = BL = radius; } ctx.beginPath(); ctx.moveTo(x + mH(TL), y); ctx.lineTo(x + width - mH(TR), y); ctx.quadraticCurveTo(x + width, y, x + width, y + mV(TR)); ctx.lineTo(x + width, y + height - mV(BR)); ctx.quadraticCurveTo(x + width, y + height, x + width - mH(BR), y + height); ctx.lineTo(x + mH(BL), y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - mV(BL)); ctx.lineTo(x, y + mV(TL)); ctx.quadraticCurveTo(x, y, x + mH(TL), y); ctx.closePath(); } this.getPoints = function() { return [ { x: this.labelRect.x, y: this.labelRect.y }, { x: this.labelRect.x + this.labelRect.width, y: this.labelRect.y }, { x: this.labelRect.x + this.labelRect.width, y: this.labelRect.y + this.labelRect.height }, { x: this.labelRect.x, y: this.labelRect.y + this.labelRect.height } ]; }; this.containsPoint = function(point) { let offset = 5; return this.labelRect.x - offset <= point.x && point.x <= this.labelRect.x + this.labelRect.width + offset && this.labelRect.y - offset <= point.y && point.y <= this.labelRect.y + this.labelRect.height + offset; }; /* ======================= DRAWING ======================= */ // Draw label text this.drawText = function() { var align = this.style.textAlign; var font = this.style.font; var lh = font.lineHeight; var color = this.style.color; var ilen = this.lines.length; var x, y, idx; if (!ilen || !color) { return; } x = this.textRect.x; y = this.textRect.y + lh / 2; if (align === 'center') { x += this.textRect.width / 2; } else if (align === 'end' || align === 'right') { x += this.textRect.width; } this.ctx.font = this.style.font.string; this.ctx.fillStyle = color; this.ctx.textAlign = align; this.ctx.textBaseline = 'middle'; for (idx = 0; idx < ilen; ++idx) { this.ctx.fillText( this.lines[idx], Math.round(x) + this.style.padding.left, Math.round(y), Math.round(this.textRect.width) ); y += lh; } }; this.ccw = function(A, B, C) { return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x); }; this.intersects = function(A, B, C, D) { return this.ccw(A, C, D) !== this.ccw(B, C, D) && this.ccw(A, B, C) !== this.ccw(A, B, D); }; // Draw label box this.drawLabel = function() { ctx.beginPath(); this.roundedRect( this.ctx, Math.round(this.labelRect.x), Math.round(this.labelRect.y), Math.round(this.labelRect.width), Math.round(this.labelRect.height), this.style.borderRadius ); this.ctx.closePath(); if (this.style.backgroundColor) { this.ctx.fillStyle = this.style.backgroundColor || 'black'; this.ctx.fill(); } if (this.style.borderColor && this.style.borderWidth) { this.ctx.strokeStyle = this.style.borderColor; this.ctx.lineWidth = this.style.borderWidth; this.ctx.lineJoin = 'miter'; this.ctx.stroke(); } }; this.drawLine = function() { if (!this.lines.length) { return; } this.ctx.save(); this.ctx.strokeStyle = this.style.lineColor; this.ctx.lineWidth = this.style.lineWidth; this.ctx.lineJoin = 'miter'; this.ctx.beginPath(); this.ctx.moveTo(this.center.anchor.x, this.center.anchor.y); this.ctx.lineTo(this.center.copy.x, this.center.copy.y); this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(this.center.copy.x, this.center.copy.y); const xOffset = this.textRect.width + this.style.padding.width; const intersect = this.intersects(this.textRect, { x: this.textRect.x + this.textRect.width, y: this.textRect.y + this.textRect.height, }, this.center.copy, { x: this.textRect.x, y: this.textRect.y + this.textRect.height / 2 }); this.ctx.lineTo(this.textRect.x + (intersect ? xOffset : 0), this.textRect.y + this.textRect.height / 2); this.ctx.stroke(); this.ctx.restore(); }; this.draw = function() { if (chart.getDataVisibility(index)) { this.drawLabel(); this.drawText(); this.drawLine(); } }; // eslint-disable-next-line max-statements this.update = function(view, elements, max) { this.center = positioners.center(view, this.stretch); this.moveLabelToOffset(); this.center.x += this.offset.x; this.center.y += this.offset.y; var valid = false; while (!valid) { this.textRect = this.computeTextRect(); this.labelRect = this.computeLabelRect(); var rectPoints = this.getPoints(); valid = true; for (var e = 0; e < max; ++e) { var element = elements[e][PLUGIN_KEY$1]; if (!element || !chart.getDataVisibility(index)) { continue; } var elPoints = element.getPoints(); for (var p = 0; p < rectPoints.length; ++p) { if (element.containsPoint(rectPoints[p])) { valid = false; break; } if (this.containsPoint(elPoints[p])) { valid = false; break; } } } if (!valid) { this.center = positioners.moveFromAnchor(this.center, 1); this.center.x += this.offset.x; this.center.y += this.offset.y; } } }; this.moveLabelToOffset = function() { if (this.predictedOffset.x <= 0 && this.offset.x > this.predictedOffset.x) { this.offset.x -= this.offsetStep; if (this.offset.x <= this.predictedOffset.x) { this.offset.x = this.predictedOffset.x; } } else if (this.predictedOffset.x >= 0 && this.offset.x < this.predictedOffset.x) { this.offset.x += this.offsetStep; if (this.offset.x >= this.predictedOffset.x) { this.offset.x = this.predictedOffset.x; } } }; } }; chart_js.defaults.plugins.outlabels = customDefaults; var PLUGIN_KEY = customDefaults.PLUGIN_KEY; function configure(dataset, options) { var override = dataset.outlabels; var config = {}; if (override === false) { return null; } if (override === true) { override = {}; } return Object.assign({}, config, options, override); } var plugin = { id: 'outlabels', resize: function(chart) { chart.sizeChanged = true; }, afterUpdate: (chart) => { const ctrl = chart._metasets[0].controller; var meta = ctrl.getMeta(); var elements = meta.data || []; const rect = { x1: Infinity, x2: 0, y1: Infinity, y2: 0 }; elements.forEach((el, index) => { const outlabelPlugin = el[PLUGIN_KEY]; if (!outlabelPlugin) { return; } outlabelPlugin.update(el, elements, index); const x = outlabelPlugin.labelRect.x + (!outlabelPlugin.labelRect.isLeft ? 0 : outlabelPlugin.labelRect.width); const y = outlabelPlugin.labelRect.y + (outlabelPlugin.labelRect.isTop ? 0 : outlabelPlugin.labelRect.height); if (x < rect.x1) { rect.x1 = x; } if (x > rect.x2) { rect.x2 = x; } if (y < rect.y1) { rect.y1 = y; } if (y > rect.y2) { rect.y2 = y; } }); var max = chart.options.maxZoomOutPercentage || customDefaults.maxZoomOutPercentage; const maxDeltas = [ chart.chartArea.left - rect.x1, chart.chartArea.top - rect.y1, rect.x2 - chart.chartArea.right, rect.y2 - chart.chartArea.bottom ]; const diff = Math.max(...maxDeltas.filter(x => x > 0), 0); const percent = diff * 100 / ctrl.outerRadius; ctrl.outerRadius -= percent < max ? diff : max * 100 / ctrl.outerRadius; ctrl.innerRadius = ctrl.outerRadius / 2; ctrl.updateElements(meta.data, 0, meta.data.length, 'resize'); }, afterDatasetUpdate: function(chart, args, options) { var labels = chart.config.data.labels; var dataset = chart.data.datasets[args.index]; var config = configure(dataset, options); var display = config && config.display; var elements = args.meta.data || []; var ctx = chart.ctx; var el, label, percent, newLabel, context, i; ctx.save(); for (i = 0; i < elements.length; ++i) { el = elements[i]; label = el[PLUGIN_KEY]; percent = dataset.data[i] / args.meta.total; newLabel = null; if (display && el && !el.hidden) { try { context = { chart: chart, dataIndex: i, dataset: dataset, labels: labels, datasetIndex: args.index, percent: percent }; newLabel = new classes.OutLabel(chart, i, ctx, config, context); } catch (e) { newLabel = null; } } if ( label && newLabel && !chart.sizeChanged && (label.label === newLabel.label) && (label.encodedText === newLabel.encodedText) ) { newLabel.offset = label.offset; } el[PLUGIN_KEY] = newLabel; } ctx.restore(); chart.sizeChanged = false; }, afterDatasetDraw: function(chart, args) { var elements = args.meta.data || []; var ctx = chart.ctx; elements.forEach((el, index) => { const outlabelPlugin = el[PLUGIN_KEY]; if (!outlabelPlugin) { return; } outlabelPlugin.update(el, elements, index); outlabelPlugin.draw(ctx); }); }, }; return plugin; }));