UNPKG

highcharts

Version:
507 lines (506 loc) 24.3 kB
/* * * * (c) 2010-2025 Torstein Honsi * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import DataLabel from '../../Core/Series/DataLabel.js'; import H from '../../Core/Globals.js'; const { composed, noop } = H; import R from '../../Core/Renderer/RendererUtilities.js'; const { distribute } = R; import SeriesRegistry from '../../Core/Series/SeriesRegistry.js'; const { series: Series } = SeriesRegistry; import U from '../../Core/Utilities.js'; const { arrayMax, clamp, defined, pick, pushUnique, relativeLength } = U; /* * * * Composition * * */ var ColumnDataLabel; (function (ColumnDataLabel) { /* * * * Constants * * */ const dataLabelPositioners = { // Based on the value computed in Highcharts' distribute algorithm. radialDistributionY: function (point, dataLabel) { return (dataLabel.dataLabelPosition?.top || 0) + point.distributeBox.pos; }, // Get the x - use the natural x position for labels near the top and // bottom, to prevent the top and botton slice connectors from touching // each other on either side. Based on the value computed in Highcharts' // distribute algorithm. radialDistributionX: function (series, point, y, naturalY, dataLabel) { const pos = dataLabel.dataLabelPosition; return series.getX(y < (pos?.top || 0) + 2 || y > (pos?.bottom || 0) - 2 ? naturalY : y, point.half, point, dataLabel); }, // The dataLabels.distance determines the x position of the label justify: function (point, dataLabel, radius, seriesCenter) { return seriesCenter[0] + (point.half ? -1 : 1) * (radius + (dataLabel.dataLabelPosition?.distance || 0)); }, // Left edges of the left-half labels touch the left edge of the plot // area. Right edges of the right-half labels touch the right edge of // the plot area. alignToPlotEdges: function (dataLabel, half, plotWidth, plotLeft) { const dataLabelWidth = dataLabel.getBBox().width; return half ? dataLabelWidth + plotLeft : plotWidth - dataLabelWidth - plotLeft; }, // Connectors of each side end in the same x position. Labels are // aligned to them. Left edge of the widest left-half label touches the // left edge of the plot area. Right edge of the widest right-half label // touches the right edge of the plot area. alignToConnectors: function (points, half, plotWidth, plotLeft) { let maxDataLabelWidth = 0, dataLabelWidth; // Find widest data label points.forEach(function (point) { dataLabelWidth = point.dataLabel.getBBox().width; if (dataLabelWidth > maxDataLabelWidth) { maxDataLabelWidth = dataLabelWidth; } }); return half ? maxDataLabelWidth + plotLeft : plotWidth - maxDataLabelWidth - plotLeft; } }; /* * * * Functions * * */ /** @private */ function compose(PieSeriesClass) { DataLabel.compose(Series); if (pushUnique(composed, 'PieDataLabel')) { const pieProto = PieSeriesClass.prototype; pieProto.dataLabelPositioners = dataLabelPositioners; pieProto.alignDataLabel = noop; pieProto.drawDataLabels = drawDataLabels; pieProto.getDataLabelPosition = getDataLabelPosition; pieProto.placeDataLabels = placeDataLabels; pieProto.verifyDataLabelOverflow = verifyDataLabelOverflow; } } ColumnDataLabel.compose = compose; /** @private */ function getDataLabelPosition(point, distance) { const halfPI = Math.PI / 2, { start = 0, end = 0 } = point.shapeArgs || {}; let angle = point.angle || 0; // If a large slice is crossing the lowest point, prefer rendering it 45 // degrees out at either lower right or lower left. That's where there's // most likely to be space available and avoid text being truncated // (#22100). Technically this logic should also apply to the top point, // but that is more of an edge case since the default start angle is at // the top. if (distance > 0 && // Crossing the bottom start < halfPI && end > halfPI && // Angle within the bottom quadrant angle > halfPI / 2 && angle < halfPI * 1.5) { angle = angle <= halfPI ? Math.max(halfPI / 2, (start + halfPI) / 2) : Math.min(halfPI * 1.5, (halfPI + end) / 2); } const { center, options } = this, r = center[2] / 2, cosAngle = Math.cos(angle), sinAngle = Math.sin(angle), x = center[0] + cosAngle * r, y = center[1] + sinAngle * r, finalConnectorOffset = Math.min((options.slicedOffset || 0) + (options.borderWidth || 0), distance / 5); // #1678 return { natural: { // Initial position of the data label - it's utilized for // finding the final position for the label x: x + cosAngle * distance, y: y + sinAngle * distance }, computed: { // Used for generating connector path - initialized later in // drawDataLabels function x: undefined, y: undefined }, // Left - pie on the left side of the data label // Right - pie on the right side of the data label // Center - data label overlaps the pie alignment: distance < 0 ? 'center' : point.half ? 'right' : 'left', connectorPosition: { angle, breakAt: { x: x + cosAngle * finalConnectorOffset, y: y + sinAngle * finalConnectorOffset }, touchingSliceAt: { x, y } }, distance }; } /** * Override the base drawDataLabels method by pie specific functionality * @private */ function drawDataLabels() { const series = this, points = series.points, chart = series.chart, plotWidth = chart.plotWidth, plotHeight = chart.plotHeight, plotLeft = chart.plotLeft, maxWidth = Math.round(chart.chartWidth / 3), seriesCenter = series.center, radius = seriesCenter[2] / 2, centerY = seriesCenter[1], halves = [ [], // Right [] // Left ], overflow = [0, 0, 0, 0], // Top, right, bottom, left dataLabelPositioners = series.dataLabelPositioners; let connector, dataLabelWidth, labelHeight, maxLabelDistance = 0; // Get out if not enabled if (!series.visible || !series.hasDataLabels?.()) { return; } // Reset all labels that have been shortened points.forEach((point) => { (point.dataLabels || []).forEach((dataLabel) => { if (dataLabel.shortened) { dataLabel .attr({ width: 'auto' }).css({ width: 'auto', textOverflow: 'clip' }); dataLabel.shortened = false; } }); }); // Run parent method Series.prototype.drawDataLabels.apply(series); points.forEach((point) => { (point.dataLabels || []).forEach((dataLabel, i) => { const r = seriesCenter[2] / 2, dataLabelOptions = dataLabel.options, distance = relativeLength(dataLabelOptions?.distance || 0, r); // Arrange points for collision detection if (i === 0) { halves[point.half].push(point); } // Avoid long labels squeezing the pie size too far down if (!defined(dataLabelOptions?.style?.width)) { if (dataLabel.getBBox().width > maxWidth) { dataLabel.css({ // Use a fraction of the maxWidth to avoid wrapping // close to the end of the string. width: Math.round(maxWidth * 0.7) + 'px' }); dataLabel.shortened = true; } } dataLabel.dataLabelPosition = this.getDataLabelPosition(point, distance); maxLabelDistance = Math.max(maxLabelDistance, distance); }); }); /* Loop over the points in each half, starting from the top and bottom * of the pie to detect overlapping labels. */ halves.forEach((points, halfIdx) => { const length = points.length, positions = []; let top, bottom, size = 0, distributionLength; if (!length) { return; } // Sort by angle series.sortByAngle(points, halfIdx - 0.5); // Only do anti-collision when we have dataLabels outside the pie // and have connectors. (#856) if (maxLabelDistance > 0) { top = Math.max(0, centerY - radius - maxLabelDistance); bottom = Math.min(centerY + radius + maxLabelDistance, chart.plotHeight); points.forEach((point) => { // Check if specific points' label is outside the pie (point.dataLabels || []).forEach((dataLabel) => { const labelPosition = dataLabel.dataLabelPosition; if (labelPosition && labelPosition.distance > 0) { // The point.top depends on point.labelDistance // value. Used for calculation of y value in getX // method labelPosition.top = Math.max(0, centerY - radius - labelPosition.distance); labelPosition.bottom = Math.min(centerY + radius + labelPosition.distance, chart.plotHeight); size = dataLabel.getBBox().height || 21; dataLabel.lineHeight = chart.renderer.fontMetrics(dataLabel.text || dataLabel).h + 2 * dataLabel.padding; point.distributeBox = { target: ((dataLabel.dataLabelPosition ?.natural.y || 0) - labelPosition.top + dataLabel.lineHeight / 2), size, rank: point.y }; positions.push(point.distributeBox); } }); }); distributionLength = bottom + size - top; distribute(positions, distributionLength, distributionLength / 5); // Uncomment this to visualize the boxes /* points.forEach((point): void => { const box = point.distributeBox; point.dlBox?.destroy(); if (box?.pos) { point.dlBox = chart.renderer.rect( chart.plotLeft + this.center[0] + ( halfIdx ? -this.center[2] / 2 - 100 : this.center[2] / 2 ), chart.plotTop + box.pos, 100, box.size ) .attr({ stroke: 'silver', 'stroke-width': 1 }) .add(); } }); // */ } // Now the used slots are sorted, fill them up sequentially points.forEach((point) => { (point.dataLabels || []).forEach((dataLabel) => { const dataLabelOptions = (dataLabel.options || {}), distributeBox = point.distributeBox, labelPosition = dataLabel.dataLabelPosition, naturalY = labelPosition?.natural.y || 0, connectorPadding = dataLabelOptions .connectorPadding || 0, lineHeight = dataLabel.lineHeight || 21, bBox = dataLabel.getBBox(), topOffset = (lineHeight - bBox.height) / 2; let x = 0, y = naturalY, visibility = 'inherit'; if (labelPosition) { if (positions && defined(distributeBox) && labelPosition.distance > 0) { if (typeof distributeBox.pos === 'undefined') { visibility = 'hidden'; } else { labelHeight = distributeBox.size; // Find label's y position y = dataLabelPositioners .radialDistributionY(point, dataLabel); } } // Find label's x position. The justify option is // undocumented in the API - preserve support for it if (dataLabelOptions.justify) { x = dataLabelPositioners.justify(point, dataLabel, radius, seriesCenter); } else { switch (dataLabelOptions.alignTo) { case 'connectors': x = dataLabelPositioners.alignToConnectors(points, halfIdx, plotWidth, plotLeft); break; case 'plotEdges': x = dataLabelPositioners.alignToPlotEdges(dataLabel, halfIdx, plotWidth, plotLeft); break; default: x = dataLabelPositioners.radialDistributionX(series, point, y - topOffset, naturalY, dataLabel); } } // Record the placement and visibility labelPosition.attribs = { visibility, align: labelPosition.alignment }; labelPosition.posAttribs = { x: x + (dataLabelOptions.x || 0) + // (#12985) ({ left: connectorPadding, right: -connectorPadding }[labelPosition.alignment] || 0), y: y + (dataLabelOptions.y || 0) - // (#12985) // Vertically center lineHeight / 2 }; labelPosition.computed.x = x; labelPosition.computed.y = y - topOffset; // Detect overflowing data labels if (pick(dataLabelOptions.crop, true)) { dataLabelWidth = dataLabel.getBBox().width; let sideOverflow; // Overflow left if (x - dataLabelWidth < connectorPadding && halfIdx === 1 // Left half ) { sideOverflow = Math.round(dataLabelWidth - x + connectorPadding); overflow[3] = Math.max(sideOverflow, overflow[3]); // Overflow right } else if (x + dataLabelWidth > plotWidth - connectorPadding && halfIdx === 0 // Right half ) { sideOverflow = Math.round(x + dataLabelWidth - plotWidth + connectorPadding); overflow[1] = Math.max(sideOverflow, overflow[1]); } // Overflow top if (y - labelHeight / 2 < 0) { overflow[0] = Math.max(Math.round(-y + labelHeight / 2), overflow[0]); // Overflow left } else if (y + labelHeight / 2 > plotHeight) { overflow[2] = Math.max(Math.round(y + labelHeight / 2 - plotHeight), overflow[2]); } labelPosition.sideOverflow = sideOverflow; } } }); // For each data label of the point }); // For each point }); // For each half // Do not apply the final placement and draw the connectors until we // have verified that labels are not spilling over. if (arrayMax(overflow) === 0 || this.verifyDataLabelOverflow(overflow)) { // Place the labels in the final position this.placeDataLabels(); this.points.forEach((point) => { (point.dataLabels || []).forEach((dataLabel) => { // #8864: every connector can have individual options const { connectorColor, connectorWidth = 1 } = (dataLabel.options || {}), labelPosition = dataLabel.dataLabelPosition; // Draw the connector if (connectorWidth) { let isNew; connector = dataLabel.connector; if (labelPosition && labelPosition.distance > 0) { isNew = !connector; if (!connector) { dataLabel.connector = connector = chart.renderer .path() .addClass('highcharts-data-label-connector ' + ' highcharts-color-' + point.colorIndex + (point.className ? ' ' + point.className : '')) .add(series.dataLabelsGroup); } if (!chart.styledMode) { connector.attr({ 'stroke-width': connectorWidth, 'stroke': (connectorColor || point.color || "#666666" /* Palette.neutralColor60 */) }); } connector[isNew ? 'attr' : 'animate']({ d: point.getConnectorPath(dataLabel) }); connector.attr({ visibility: labelPosition.attribs?.visibility }); } else if (connector) { dataLabel.connector = connector.destroy(); } } }); }); } } /** * Perform the final placement of the data labels after we have verified * that they fall within the plot area. * @private */ function placeDataLabels() { this.points.forEach((point) => { (point.dataLabels || []).forEach((dataLabel) => { const labelPosition = dataLabel.dataLabelPosition; if (labelPosition) { // Shorten data labels with ellipsis if they still overflow // after the pie has reached minSize (#223). if (labelPosition.sideOverflow) { dataLabel.css({ width: (Math.max(dataLabel.getBBox().width - labelPosition.sideOverflow, 0)) + 'px', textOverflow: (dataLabel.options?.style?.textOverflow || 'ellipsis') }); dataLabel.shortened = true; } dataLabel.attr(labelPosition.attribs); dataLabel[dataLabel.moved ? 'animate' : 'attr'](labelPosition.posAttribs); dataLabel.moved = true; } else if (dataLabel) { dataLabel.attr({ y: -9999 }); } }); // Clear for update delete point.distributeBox; }, this); } /** * Verify whether the data labels are allowed to draw, or we should run more * translation and data label positioning to keep them inside the plot area. * Returns true when data labels are ready to draw. * @private */ function verifyDataLabelOverflow(overflow) { const center = this.center, options = this.options, centerOption = options.center, minSize = options.minSize || 80; let newSize = minSize, // If a size is set, return true and don't try to shrink the pie // to fit the labels. ret = options.size !== null; if (!ret) { // Handle horizontal size and center if (centerOption[0] !== null) { // Fixed center newSize = Math.max(center[2] - Math.max(overflow[1], overflow[3]), minSize); } else { // Auto center newSize = Math.max( // Horizontal overflow center[2] - overflow[1] - overflow[3], minSize); // Horizontal center center[0] += (overflow[3] - overflow[1]) / 2; } // Handle vertical size and center if (centerOption[1] !== null) { // Fixed center newSize = clamp(newSize, minSize, center[2] - Math.max(overflow[0], overflow[2])); } else { // Auto center newSize = clamp(newSize, minSize, // Vertical overflow center[2] - overflow[0] - overflow[2]); // Vertical center center[1] += (overflow[0] - overflow[2]) / 2; } // If the size must be decreased, we need to run translate and // drawDataLabels again if (newSize < center[2]) { center[2] = newSize; center[3] = Math.min(// #3632 options.thickness ? Math.max(0, newSize - options.thickness * 2) : Math.max(0, relativeLength(options.innerSize || 0, newSize)), newSize); // #6647 this.translate(center); if (this.drawDataLabels) { this.drawDataLabels(); } // Else, return true to indicate that the pie and its labels is // within the plot area } else { ret = true; } } return ret; } })(ColumnDataLabel || (ColumnDataLabel = {})); /* * * * Default Export * * */ export default ColumnDataLabel;