UNPKG

highcharts

Version:
1,247 lines 53.4 kB
/* * * * (c) 2010-2024 Torstein Honsi * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import A from './Animation/AnimationUtilities.js'; const { animObject } = A; import F from './Templating.js'; const { format } = F; import H from './Globals.js'; const { composed, dateFormats, doc, isSafari } = H; import R from './Renderer/RendererUtilities.js'; const { distribute } = R; import RendererRegistry from './Renderer/RendererRegistry.js'; import U from './Utilities.js'; const { addEvent, clamp, css, discardElement, extend, fireEvent, getAlignFactor, isArray, isNumber, isObject, isString, merge, pick, pushUnique, splat, syncTimeout } = U; /* * * * Class * * */ /* eslint-disable no-invalid-this, valid-jsdoc */ /** * Tooltip of a chart. * * @class * @name Highcharts.Tooltip * * @param {Highcharts.Chart} chart * The chart instance. * * @param {Highcharts.TooltipOptions} options * Tooltip options. * * @param {Highcharts.Pointer} pointer * The pointer instance. */ class Tooltip { /* * * * Constructors * * */ constructor(chart, options, pointer) { /* * * * Properties * * */ this.allowShared = true; this.crosshairs = []; this.distance = 0; this.isHidden = true; this.isSticky = false; this.options = {}; this.outside = false; this.chart = chart; this.init(chart, options); this.pointer = pointer; } /* * * * Functions * * */ /** * Build the body (lines) of the tooltip by iterating over the items and * returning one entry for each item, abstracting this functionality allows * to easily overwrite and extend it. * * @private * @function Highcharts.Tooltip#bodyFormatter */ bodyFormatter(points) { return points.map((point) => { const tooltipOptions = point.series.tooltipOptions, formatPrefix = point.formatPrefix || 'point'; return (tooltipOptions[formatPrefix + 'Formatter'] || point.tooltipFormatter).call(point, tooltipOptions[formatPrefix + 'Format'] || ''); }); } /** * Destroy the single tooltips in a split tooltip. * If the tooltip is active then it is not destroyed, unless forced to. * * @private * @function Highcharts.Tooltip#cleanSplit * * @param {boolean} [force] * Force destroy all tooltips. */ cleanSplit(force) { this.chart.series.forEach(function (series) { const tt = series?.tt; if (tt) { if (!tt.isActive || force) { series.tt = tt.destroy(); } else { tt.isActive = false; } } }); } /** * In case no user defined formatter is given, this will be used. Note that * the context here is an object holding point, series, x, y etc. * * @function Highcharts.Tooltip#defaultFormatter * * @param {Highcharts.Tooltip} tooltip * * @return {string|Array<string>} * Returns a string (single tooltip and shared) * or an array of strings (split tooltip) */ defaultFormatter(tooltip) { const hoverPoints = this.points || splat(this); let s; // Build the header s = [tooltip.headerFooterFormatter(hoverPoints[0])]; // Build the values s = s.concat(tooltip.bodyFormatter(hoverPoints)); // Footer s.push(tooltip.headerFooterFormatter(hoverPoints[0], true)); return s; } /** * Removes and destroys the tooltip and its elements. * * @function Highcharts.Tooltip#destroy */ destroy() { // Destroy and clear local variables if (this.label) { this.label = this.label.destroy(); } if (this.split) { this.cleanSplit(true); if (this.tt) { this.tt = this.tt.destroy(); } } if (this.renderer) { this.renderer = this.renderer.destroy(); discardElement(this.container); } U.clearTimeout(this.hideTimer); } /** * Extendable method to get the anchor position of the tooltip * from a point or set of points * * @private * @function Highcharts.Tooltip#getAnchor */ getAnchor(points, mouseEvent) { const { chart, pointer } = this, inverted = chart.inverted, plotTop = chart.plotTop, plotLeft = chart.plotLeft; let ret; points = splat(points); // If reversedStacks are false the tooltip position should be taken from // the last point (#17948) if (points[0].series?.yAxis && !points[0].series.yAxis.options.reversedStacks) { points = points.slice().reverse(); } // When tooltip follows mouse, relate the position to the mouse if (this.followPointer && mouseEvent) { if (typeof mouseEvent.chartX === 'undefined') { mouseEvent = pointer.normalize(mouseEvent); } ret = [ mouseEvent.chartX - plotLeft, mouseEvent.chartY - plotTop ]; // Some series types use a specificly calculated tooltip position for // each point } else if (points[0].tooltipPos) { ret = points[0].tooltipPos; // Calculate the average position and adjust for axis positions } else { let chartX = 0, chartY = 0; points.forEach(function (point) { const pos = point.pos(true); if (pos) { chartX += pos[0]; chartY += pos[1]; } }); chartX /= points.length; chartY /= points.length; // When shared, place the tooltip next to the mouse (#424) if (this.shared && points.length > 1 && mouseEvent) { if (inverted) { chartX = mouseEvent.chartX; } else { chartY = mouseEvent.chartY; } } // Use the average position for multiple points ret = [chartX - plotLeft, chartY - plotTop]; } return ret.map(Math.round); } /** * Get the CSS class names for the tooltip's label. Styles the label * by `colorIndex` or user-defined CSS. * * @function Highcharts.Tooltip#getClassName * * @return {string} * The class names. */ getClassName(point, isSplit, isHeader) { const options = this.options, series = point.series, seriesOptions = series.options; return [ options.className, 'highcharts-label', isHeader && 'highcharts-tooltip-header', isSplit ? 'highcharts-tooltip-box' : 'highcharts-tooltip', !isHeader && 'highcharts-color-' + pick(point.colorIndex, series.colorIndex), seriesOptions?.className ].filter(isString).join(' '); } /** * Creates the Tooltip label element if it does not exist, then returns it. * * @function Highcharts.Tooltip#getLabel * * @return {Highcharts.SVGElement} * Tooltip label */ getLabel({ anchorX, anchorY } = { anchorX: 0, anchorY: 0 }) { const tooltip = this, styledMode = this.chart.styledMode, options = this.options, doSplit = this.split && this.allowShared; let container = this.container, renderer = this.chart.renderer; // If changing from a split tooltip to a non-split tooltip, we must // destroy it in order to get the SVG right. #13868. if (this.label) { const wasSplit = !this.label.hasClass('highcharts-label'); if ((!doSplit && wasSplit) || (doSplit && !wasSplit)) { this.destroy(); } } if (!this.label) { if (this.outside) { const chart = this.chart, chartStyle = chart.options.chart.style, Renderer = RendererRegistry.getRendererType(); /** * Reference to the tooltip's container, when * [Highcharts.Tooltip#outside] is set to true, otherwise * it's undefined. * * @name Highcharts.Tooltip#container * @type {Highcharts.HTMLDOMElement|undefined} */ this.container = container = H.doc.createElement('div'); container.className = ('highcharts-tooltip-container ' + (chart.renderTo.className.match(/(highcharts[a-zA-Z0-9-]+)\s?/gm) || [].join(' '))); // We need to set pointerEvents = 'none' as otherwise it makes // the area under the tooltip non-hoverable even after the // tooltip disappears, #19035. css(container, { position: 'absolute', top: '1px', pointerEvents: 'none', zIndex: Math.max(this.options.style.zIndex || 0, (chartStyle?.zIndex || 0) + 3) }); /** * Reference to the tooltip's renderer, when * [Highcharts.Tooltip#outside] is set to true, otherwise * it's undefined. * * @name Highcharts.Tooltip#renderer * @type {Highcharts.SVGRenderer|undefined} */ this.renderer = renderer = new Renderer(container, 0, 0, chartStyle, void 0, void 0, renderer.styledMode); } // Create the label if (doSplit) { this.label = renderer.g('tooltip'); } else { this.label = renderer .label('', anchorX, anchorY, options.shape || 'callout', void 0, void 0, options.useHTML, void 0, 'tooltip') .attr({ padding: options.padding, r: options.borderRadius }); if (!styledMode) { this.label .attr({ fill: options.backgroundColor, 'stroke-width': options.borderWidth || 0 }) // #2301, #2657 .css(options.style) .css({ pointerEvents: (options.style.pointerEvents || (this.shouldStickOnContact() ? 'auto' : 'none')) }); } } // Split tooltip use updateTooltipContainer to position the tooltip // container. if (tooltip.outside) { const label = this.label; [label.xSetter, label.ySetter].forEach((setter, i) => { label[i ? 'ySetter' : 'xSetter'] = (value) => { setter.call(label, tooltip.distance); label[i ? 'y' : 'x'] = value; if (container) { container.style[i ? 'top' : 'left'] = `${value}px`; } }; }); } this.label .attr({ zIndex: 8 }) .shadow(options.shadow ?? !options.fixed) .add(); } if (container && !container.parentElement) { H.doc.body.appendChild(container); } return this.label; } /** * Get the total area available area to place the tooltip * * @private */ getPlayingField() { const { body, documentElement } = doc, { chart, distance, outside } = this; return { width: outside ? // Subtract distance to prevent scrollbars Math.max(body.scrollWidth, documentElement.scrollWidth, body.offsetWidth, documentElement.offsetWidth, documentElement.clientWidth) - (2 * distance) - 2 : chart.chartWidth, height: outside ? Math.max(body.scrollHeight, documentElement.scrollHeight, body.offsetHeight, documentElement.offsetHeight, documentElement.clientHeight) : chart.chartHeight }; } /** * Place the tooltip in a chart without spilling over and not covering the * point itself. * * @function Highcharts.Tooltip#getPosition * * @param {number} boxWidth * Width of the tooltip box. * * @param {number} boxHeight * Height of the tooltip box. * * @param {Highcharts.Point} point * Tooltip related point. * * @return {Highcharts.PositionObject} * Recommended position of the tooltip. */ getPosition(boxWidth, boxHeight, point) { const { distance, chart, outside, pointer } = this, { inverted, plotLeft, plotTop, polar } = chart, { plotX = 0, plotY = 0 } = point, ret = {}, // Don't use h if chart isn't inverted (#7242) ??? h = (inverted && point.h) || 0, // #4117 ? { height: outerHeight, width: outerWidth } = this.getPlayingField(), chartPosition = pointer.getChartPosition(), scaleX = (val) => (val * chartPosition.scaleX), scaleY = (val) => (val * chartPosition.scaleY), // Build parameter arrays for firstDimension()/secondDimension() buildDimensionArray = (dim) => { const isX = dim === 'x'; return [ dim, // Dimension - x or y isX ? outerWidth : outerHeight, isX ? boxWidth : boxHeight ].concat(outside ? [ // If we are using tooltip.outside, we need to scale the // position to match scaling of the container in case there // is a transform/zoom on the container. #11329 isX ? scaleX(boxWidth) : scaleY(boxHeight), isX ? chartPosition.left - distance + scaleX(plotX + plotLeft) : chartPosition.top - distance + scaleY(plotY + plotTop), 0, isX ? outerWidth : outerHeight ] : [ // Not outside, no scaling is needed isX ? boxWidth : boxHeight, isX ? plotX + plotLeft : plotY + plotTop, isX ? plotLeft : plotTop, isX ? plotLeft + chart.plotWidth : plotTop + chart.plotHeight ]); }; let first = buildDimensionArray('y'), second = buildDimensionArray('x'), swapped; // Handle negative points or reversed axis (#13780) let flipped = !!point.negative; if (!polar && chart.hoverSeries?.yAxis?.reversed) { flipped = !flipped; } // The far side is right or bottom const preferFarSide = !this.followPointer && pick(point.ttBelow, polar ? false : !inverted === flipped), // #4984 /* * Handle the preferred dimension. When the preferred dimension is * tooltip on top or bottom of the point, it will look for space * there. * * @private */ firstDimension = function (dim, outerSize, innerSize, scaledInnerSize, // #11329 point, min, max) { const scaledDist = outside ? (dim === 'y' ? scaleY(distance) : scaleX(distance)) : distance, scaleDiff = (innerSize - scaledInnerSize) / 2, roomLeft = scaledInnerSize < point - distance, roomRight = point + distance + scaledInnerSize < outerSize, alignedLeft = point - scaledDist - innerSize + scaleDiff, alignedRight = point + scaledDist - scaleDiff; if (preferFarSide && roomRight) { ret[dim] = alignedRight; } else if (!preferFarSide && roomLeft) { ret[dim] = alignedLeft; } else if (roomLeft) { ret[dim] = Math.min(max - scaledInnerSize, alignedLeft - h < 0 ? alignedLeft : alignedLeft - h); } else if (roomRight) { ret[dim] = Math.max(min, alignedRight + h + innerSize > outerSize ? alignedRight : alignedRight + h); } else { ret[dim] = 0; return false; } }, /* * Handle the secondary dimension. If the preferred dimension is * tooltip on top or bottom of the point, the second dimension is to * align the tooltip above the point, trying to align center but * allowing left or right align within the chart box. * * @private */ secondDimension = function (dim, outerSize, innerSize, scaledInnerSize, // #11329 point) { // Too close to the edge, return false and swap dimensions if (point < distance || point > outerSize - distance) { return false; } // Align left/top if (point < innerSize / 2) { ret[dim] = 1; // Align right/bottom } else if (point > outerSize - scaledInnerSize / 2) { ret[dim] = outerSize - scaledInnerSize - 2; // Align center } else { ret[dim] = point - innerSize / 2; } }, /* * Swap the dimensions */ swap = function (count) { [first, second] = [second, first]; swapped = count; }, run = () => { if (firstDimension.apply(0, first) !== false) { if (secondDimension.apply(0, second) === false && !swapped) { swap(true); run(); } } else if (!swapped) { swap(true); run(); } else { ret.x = ret.y = 0; } }; // Under these conditions, prefer the tooltip on the side of the point if ((inverted && !polar) || this.len > 1) { swap(); } run(); return ret; } /** * Place the tooltip when `position.fixed` is true. This is called both for * single tooltips, and for partial tooltips when `split`. * * @private */ getFixedPosition(boxWidth, boxHeight, point) { const series = point.series, { chart, options, split } = this, position = options.position, relativeToOption = position.relativeTo, noPane = options.shared || series?.yAxis?.isRadial && (relativeToOption === 'pane' || !relativeToOption), relativeTo = noPane ? 'plotBox' : relativeToOption, bounds = relativeTo === 'chart' ? chart.renderer : chart[relativeTo] || chart.getClipBox(series, true); return { x: bounds.x + (bounds.width - boxWidth) * getAlignFactor(position.align) + position.x, y: bounds.y + (bounds.height - boxHeight) * getAlignFactor(position.verticalAlign) + (!split && position.y || 0) }; } /** * Hides the tooltip with a fade out animation. * * @function Highcharts.Tooltip#hide * * @param {number} [delay] * The fade out in milliseconds. If no value is provided the value * of the tooltip.hideDelay option is used. A value of 0 disables * the fade out animation. */ hide(delay) { const tooltip = this; // Disallow duplicate timers (#1728, #1766) U.clearTimeout(this.hideTimer); delay = pick(delay, this.options.hideDelay); if (!this.isHidden) { this.hideTimer = syncTimeout(function () { const label = tooltip.getLabel(); // If there is a delay, fade out with the default duration. If // the hideDelay is 0, we assume no animation is wanted, so we // pass 0 duration. #12994. tooltip.getLabel().animate({ opacity: 0 }, { duration: delay ? 150 : delay, complete: () => { // #3088, assuming we're only using this for tooltips label.hide(); // Clear the container for outside tooltip (#18490) if (tooltip.container) { tooltip.container.remove(); } } }); tooltip.isHidden = true; }, delay); } } /** * Initialize tooltip. * * @private * @function Highcharts.Tooltip#init * * @param {Highcharts.Chart} chart * The chart instance. * * @param {Highcharts.TooltipOptions} options * Tooltip options. */ init(chart, options) { /** * Chart of the tooltip. * * @readonly * @name Highcharts.Tooltip#chart * @type {Highcharts.Chart} */ this.chart = chart; /** * Used tooltip options. * * @readonly * @name Highcharts.Tooltip#options * @type {Highcharts.TooltipOptions} */ this.options = options; /** * List of crosshairs. * * @private * @readonly * @name Highcharts.Tooltip#crosshairs * @type {Array<null>} */ this.crosshairs = []; /** * Tooltips are initially hidden. * * @private * @readonly * @name Highcharts.Tooltip#isHidden * @type {boolean} */ this.isHidden = true; /** * True, if the tooltip is split into one label per series, with the * header close to the axis. * * @readonly * @name Highcharts.Tooltip#split * @type {boolean|undefined} */ this.split = options.split && !chart.inverted && !chart.polar; /** * When the tooltip is shared, the entire plot area will capture mouse * movement or touch events. * * @readonly * @name Highcharts.Tooltip#shared * @type {boolean|undefined} */ this.shared = options.shared || this.split; /** * Whether to allow the tooltip to render outside the chart's SVG * element box. By default (false), the tooltip is rendered within the * chart's SVG element, which results in the tooltip being aligned * inside the chart area. * * @readonly * @name Highcharts.Tooltip#outside * @type {boolean} * * @todo * Split tooltip does not support outside in the first iteration. Should * not be too complicated to implement. */ this.outside = pick(options.outside, Boolean(chart.scrollablePixelsX || chart.scrollablePixelsY)); } shouldStickOnContact(pointerEvent) { return !!(!this.followPointer && this.options.stickOnContact && (!pointerEvent || this.pointer.inClass(pointerEvent.target, 'highcharts-tooltip'))); } /** * Moves the tooltip with a soft animation to a new position. * * @private * @function Highcharts.Tooltip#move * * @param {number} x * * @param {number} y * * @param {number} anchorX * * @param {number} anchorY */ move(x, y, anchorX, anchorY) { const { followPointer, options } = this, animation = animObject(!followPointer && !this.isHidden && !options.fixed && options.animation), skipAnchor = followPointer || (this.len || 0) > 1, attr = { x, y }; if (!skipAnchor) { attr.anchorX = anchorX; attr.anchorY = anchorY; } else { // Clear anchor with NaN to prevent animation (#22295) attr.anchorX = attr.anchorY = NaN; } animation.step = () => this.drawTracker(); this.getLabel().animate(attr, animation); } /** * Refresh the tooltip's text and position. * * @function Highcharts.Tooltip#refresh * * @param {Highcharts.Point|Array<Highcharts.Point>} pointOrPoints * Either a point or an array of points. * * @param {Highcharts.PointerEventObject} [mouseEvent] * Mouse event, that is responsible for the refresh and should be * used for the tooltip update. */ refresh(pointOrPoints, mouseEvent) { const tooltip = this, { chart, options, pointer, shared } = this, points = splat(pointOrPoints), point = points[0], formatString = options.format, formatter = options.formatter || tooltip.defaultFormatter, styledMode = chart.styledMode; let wasShared = tooltip.allowShared; if (!options.enabled || !point.series) { // #16820 return; } U.clearTimeout(this.hideTimer); // A switch saying if this specific tooltip configuration allows shared // or split modes tooltip.allowShared = !(!isArray(pointOrPoints) && pointOrPoints.series && pointOrPoints.series.noSharedTooltip); wasShared = wasShared && !tooltip.allowShared; // Get the reference point coordinates (pie charts use tooltipPos) tooltip.followPointer = (!tooltip.split && point.series.tooltipOptions.followPointer); const anchor = tooltip.getAnchor(pointOrPoints, mouseEvent), x = anchor[0], y = anchor[1]; // Shared tooltip, array is sent over if (shared && tooltip.allowShared) { pointer.applyInactiveState(points); // Now set hover state for the chosen ones: points.forEach((item) => item.setState('hover')); point.points = points; } this.len = points.length; // #6128 const text = isString(formatString) ? format(formatString, point, chart) : formatter.call(point, tooltip); // Reset the preliminary circular references point.points = void 0; // Register the current series const currentSeries = point.series; this.distance = pick(currentSeries.tooltipOptions.distance, 16); // Update the inner HTML if (text === false) { this.hide(); } else { // Update text if (tooltip.split && tooltip.allowShared) { // #13868 this.renderSplit(text, points); } else { let checkX = x; let checkY = y; if (mouseEvent && pointer.isDirectTouch) { checkX = mouseEvent.chartX - chart.plotLeft; checkY = mouseEvent.chartY - chart.plotTop; } // #11493, #13095 if (chart.polar || currentSeries.options.clip === false || points.some((p) => // #16004 pointer.isDirectTouch || // ##17929 p.series.shouldShowTooltip(checkX, checkY))) { const label = tooltip.getLabel(wasShared && tooltip.tt || {}); // Prevent the tooltip from flowing over the chart box // (#6659) if (!options.style.width || styledMode) { label.css({ width: (this.outside ? this.getPlayingField() : chart.spacingBox).width + 'px' }); } label.attr({ // Add class before the label BBox calculation (#21035) 'class': tooltip.getClassName(point), text: text && text.join ? text.join('') : text }); // When the length of the label has increased, immediately // update the x position to prevent tooltip from flowing // outside the viewport during animation (#21371) if (this.outside) { label.attr({ x: clamp(label.x || 0, 0, this.getPlayingField().width - (label.width || 0) - 1) }); } if (!styledMode) { label.attr({ stroke: (options.borderColor || point.color || currentSeries.color || "#666666" /* Palette.neutralColor60 */) }); } tooltip.updatePosition({ plotX: x, plotY: y, negative: point.negative, ttBelow: point.ttBelow, series: currentSeries, h: anchor[2] || 0 }); } else { tooltip.hide(); return; } } // Show it if (tooltip.isHidden && tooltip.label) { tooltip.label.attr({ opacity: 1 }).show(); } tooltip.isHidden = false; } fireEvent(this, 'refresh'); } /** * Render the split tooltip. Loops over each point's text and adds * a label next to the point, then uses the distribute function to * find best non-overlapping positions. * * @private * @function Highcharts.Tooltip#renderSplit * * @param {string|Array<(boolean|string)>} labels * * @param {Array<Highcharts.Point>} points */ renderSplit(labels, points) { const tooltip = this; const { chart, chart: { chartWidth, chartHeight, plotHeight, plotLeft, plotTop, scrollablePixelsY = 0, scrollablePixelsX, styledMode }, distance, options, options: { fixed, position, positioner }, pointer } = tooltip; const { scrollLeft = 0, scrollTop = 0 } = chart.scrollablePlotArea?.scrollingContainer || {}; // The area which the tooltip should be limited to. Limit to scrollable // plot area if enabled, otherwise limit to the chart container. If // outside is true it should be the whole viewport const bounds = (tooltip.outside && typeof scrollablePixelsX !== 'number') ? doc.documentElement.getBoundingClientRect() : { left: scrollLeft, right: scrollLeft + chartWidth, top: scrollTop, bottom: scrollTop + chartHeight }; const tooltipLabel = tooltip.getLabel(); const ren = this.renderer || chart.renderer; const headerTop = Boolean(chart.xAxis[0]?.opposite); const { left: chartLeft, top: chartTop } = pointer.getChartPosition(); const hasFixedPosition = positioner || fixed; let distributionBoxTop = plotTop + scrollTop; let headerHeight = 0; let adjustedPlotHeight = plotHeight - scrollablePixelsY; /** * Calculates the anchor position for the partial tooltip * * @private * @param {Highcharts.Point} point The point related to the tooltip * @return {Object} Returns an object with anchorX and anchorY */ function getAnchor(point) { const { isHeader, plotX = 0, plotY = 0, series } = point; let anchorX; let anchorY; if (isHeader) { // Set anchorX to plotX anchorX = Math.max(plotLeft + plotX, plotLeft); // Set anchorY to center of visible plot area. anchorY = plotTop + plotHeight / 2; } else { const { xAxis, yAxis } = series; // Set anchorX to plotX. Limit to within xAxis. anchorX = xAxis.pos + clamp(plotX, -distance, xAxis.len + distance); // Set anchorY, limit to the scrollable plot area if (series.shouldShowTooltip(0, yAxis.pos - plotTop + plotY, { ignoreX: true })) { anchorY = yAxis.pos + plotY; } } // Limit values to plot area anchorX = clamp(anchorX, bounds.left - distance, bounds.right + distance); return { anchorX, anchorY }; } /** * Calculate the position of the partial tooltip * @private */ const defaultPositioner = function (boxWidth, boxHeight, point, anchor = [0, 0], alignedLeft = true) { let x, y; if (point.isHeader) { y = headerTop ? 0 : adjustedPlotHeight; x = clamp(anchor[0] - (boxWidth / 2), bounds.left, bounds.right - boxWidth - (tooltip.outside ? chartLeft : 0)); } else if (fixed && point) { const pos = tooltip.getFixedPosition(boxWidth, boxHeight, point); x = pos.x; y = pos.y - distributionBoxTop; } else { y = anchor[1] - distributionBoxTop; x = alignedLeft ? anchor[0] - boxWidth - distance : anchor[0] + distance; x = clamp(x, alignedLeft ? x : bounds.left, bounds.right); } // NOTE: y is relative to distributionBoxTop return { x, y }; }; /** * Updates the attributes and styling of the partial tooltip. Creates a * new partial tooltip if it does not exists. * * @private * @param {Highcharts.SVGElement|undefined} partialTooltip * The partial tooltip to update * @param {Highcharts.Point} point * The point related to the partial tooltip * @param {boolean|string} str The text for the partial tooltip * @return {Highcharts.SVGElement} Returns the updated partial tooltip */ function updatePartialTooltip(partialTooltip, point, str) { let tt = partialTooltip; const { isHeader, series } = point, ttOptions = series.tooltipOptions || options; if (!tt) { const attribs = { padding: ttOptions.padding, r: ttOptions.borderRadius }; if (!styledMode) { attribs.fill = ttOptions.backgroundColor; attribs['stroke-width'] = ttOptions.borderWidth ?? (fixed && !isHeader ? 0 : 1); } tt = ren .label('', 0, 0, (ttOptions[isHeader ? 'headerShape' : 'shape']) || (fixed && !isHeader ? 'rect' : 'callout'), void 0, void 0, ttOptions.useHTML) .addClass(tooltip.getClassName(point, true, isHeader)) .attr(attribs) .add(tooltipLabel); } tt.isActive = true; tt.attr({ text: str }); if (!styledMode) { tt.css(ttOptions.style) .attr({ stroke: (ttOptions.borderColor || point.color || series.color || "#333333" /* Palette.neutralColor80 */) }); } return tt; } // Graceful degradation for legacy formatters if (isString(labels)) { labels = [false, labels]; } // Create the individual labels for header and points, ignore footer let boxes = labels.slice(0, points.length + 1).reduce(function (boxes, str, i) { if (str !== false && str !== '') { const point = (points[i - 1] || { // Item 0 is the header. Instead of this, we could also // use the crosshair label isHeader: true, plotX: points[0].plotX, plotY: plotHeight, series: {} }); const isHeader = point.isHeader; // Store the tooltip label reference on the series const owner = isHeader ? tooltip : point.series; const tt = owner.tt = updatePartialTooltip(owner.tt, point, str.toString()); // Get X position now, so we can move all to the other side in // case of overflow const bBox = tt.getBBox(); const boxWidth = bBox.width + tt.strokeWidth(); if (isHeader) { headerHeight = bBox.height; adjustedPlotHeight += headerHeight; if (headerTop) { distributionBoxTop -= headerHeight; } } const { anchorX, anchorY } = getAnchor(point); if (typeof anchorY === 'number') { const size = bBox.height + 1, boxPosition = (positioner || defaultPositioner).call(tooltip, boxWidth, size, point, [anchorX, anchorY]); boxes.push({ // 0-align to the top, 1-align to the bottom align: hasFixedPosition ? 0 : void 0, anchorX, anchorY, boxWidth, point, rank: pick(boxPosition.rank, isHeader ? 1 : 0), size, target: boxPosition.y, tt, x: boxPosition.x }); } else { // Hide tooltips which anchorY is outside the visible plot // area tt.isActive = false; } } return boxes; }, []); // Realign the tooltips towards the right if there is not enough space // to the left and there is space to the right if (!hasFixedPosition && boxes.some((box) => { // Always realign if the beginning of a label is outside bounds const { outside } = tooltip; const boxStart = (outside ? chartLeft : 0) + box.anchorX; if (boxStart < bounds.left && boxStart + box.boxWidth < bounds.right) { return true; } // Otherwise, check if there is more space available to the right return boxStart < (chartLeft - bounds.left) + box.boxWidth && bounds.right - boxStart > boxStart; })) { boxes = boxes.map((box) => { const { x, y } = defaultPositioner.call(this, box.boxWidth, box.size, box.point, [box.anchorX, box.anchorY], false); return extend(box, { target: y, x }); }); } // Clean previous run (for missing points) tooltip.cleanSplit(); // Distribute and put in place distribute(boxes, adjustedPlotHeight); const boxExtremes = { left: chartLeft, right: chartLeft }; // Get the extremes from series tooltips boxes.forEach(function (box) { const { x, boxWidth, isHeader } = box; if (!isHeader) { if (tooltip.outside && chartLeft + x < boxExtremes.left) { boxExtremes.left = chartLeft + x; } if (!isHeader && tooltip.outside && boxExtremes.left + boxWidth > boxExtremes.right) { boxExtremes.right = chartLeft + x; } } }); boxes.forEach(function (box) { const { x, anchorX, anchorY, pos, point: { isHeader } } = box; const attributes = { visibility: typeof pos === 'undefined' ? 'hidden' : 'inherit', x, /* NOTE: y should equal pos to be consistent with !split * tooltip, but is currently relative to plotTop. Is left as is * to avoid breaking change. Remove distributionBoxTop to make * it consistent. */ y: (pos || 0) + distributionBoxTop + (fixed && position.y || 0), anchorX, anchorY }; // Handle left-aligned tooltips overflowing the chart area if (tooltip.outside && x < anchorX) { const offset = chartLeft - boxExtremes.left; // Skip this if there is no overflow if (offset > 0) { if (!isHeader) { attributes.x = x + offset; attributes.anchorX = anchorX + offset; } if (isHeader) { attributes.x = (boxExtremes.right - boxExtremes.left) / 2; attributes.anchorX = anchorX + offset; } } } // Put the label in place box.tt.attr(attributes); }); /* If we have a separate tooltip container, then update the necessary * container properties. * Test that tooltip has its own container and renderer before executing * the operation. */ const { container, outside, renderer } = tooltip; if (outside && container && renderer) { // Set container size to fit the bounds const { width, height, x, y } = tooltipLabel.getBBox(); renderer.setSize(width + x, height + y, false); // Position the tooltip container to the chart container container.style.left = boxExtremes.left + 'px'; container.style.top = chartTop + 'px'; } // Workaround for #18927, artefacts left by the shadows of split // tooltips in Safari v16 (2023). Check again with later versions if we // can remove this. if (isSafari) { tooltipLabel.attr({ // Force a redraw of the whole group by chaining the opacity // slightly opacity: tooltipLabel.opacity === 1 ? 0.999 : 1 }); } } /** * If the `stickOnContact` option is active, this will add a tracker shape. * * @private * @function Highcharts.Tooltip#drawTracker */ drawTracker() { const tooltip = this; if (!this.shouldStickOnContact()) { if (tooltip.tracker) { tooltip.tracker = tooltip.tracker.destroy(); } return; } const chart = tooltip.chart; const label = tooltip.label; const points = tooltip.shared ? chart.hoverPoints : chart.hoverPoint; if (!label || !points) { return; } const box = { x: 0, y: 0, width: 0, height: 0 }; // Combine anchor and tooltip const anchorPos = this.getAnchor(points); const labelBBox = label.getBBox(); anchorPos[0] += chart.plotLeft - (label.translateX || 0); anchorPos[1] += chart.plotTop - (label.translateY || 0); // When the mouse pointer is between the anchor point and the label, // the label should stick. box.x = Math.min(0, anchorPos[0]); box.y = Math.min(0, anchorPos[1]); box.width = (anchorPos[0] < 0 ? Math.max(Math.abs(anchorPos[0]), labelBBox.width - anchorPos[0]) : Math.max(Math.abs(anchorPos[0]), labelBBox.width)); box.height = (anchorPos[1] < 0 ? Math.max(Math.abs(anchorPos[1]), labelBBox.height - Math.abs(anchorPos[1])) : Math.max(Math.abs(anchorPos[1]), labelBBox.height)); if (tooltip.tracker) { tooltip.tracker.attr(box); } else { tooltip.tracker = label.renderer .rect(box) .addClass('highcharts-tracker') .add(label); if (!chart.styledMode) { tooltip.tracker.attr({ fill: 'rgba(0,0,0,0)' }); } } } /** * @private */ styledModeFormat(formatString) { return formatString .replace('style="font-size: 0.8em"', 'class="highcharts-header"') .replace(/style="color:{(point|series)\.color}"/g, 'class="highcharts-color-{$1.colorIndex} ' + '{series.options.className} ' + '{point.options.className}"'); } /** * Format the footer/header of the tooltip * #3397: abstraction to enable formatting of footer and header * * @private * @function Highcharts.Tooltip#headerFooterFormatter */ headerFooterFormatter(point, isFooter) { const series = point.series, tooltipOptions = series.tooltipOptions, xAxis = series.xAxis, dateTime = xAxis?.dateTime, e = { isFooter, point }; let xDateFormat = tooltipOptions.xDateFormat || '', formatString = tooltipOptions[isFooter ? 'footerFormat' : 'headerFormat']; fireEvent(this, 'headerFormatter', e, function (e) { // Guess the best date format based on the closest point distance // (#568, #3418) if (dateTime && !xDateFormat && isNumber(point.key)) { xDateFormat = dateTime.getXDateFormat(point.key, tooltipOptions.dateTimeLabelFormats); } // Insert the footer date format if any if (dateTime && xDateFormat) { if (isObject(xDateFormat)) { const format = xDateFormat; dateFormats[0] = (timestamp) => series.chart.time.dateFormat(format, timestamp); xDateFormat = '%0'; } (point.tooltipDateKeys || ['key']).forEach((key) => { formatString = formatString.replace(new RegExp('point\\.' + key + '([ \\)}])'), `(point.${key}:${xDateFormat})$1`); }); } // Replace default header style with class name if (series.chart.styledMode) { formatString = this.styledModeFormat(formatString); } e.text = format(formatString, point, this.chart); }); return e.text || ''; } /** * Updates the tooltip with the provided tooltip options. * * @function Highcharts.Tooltip#update * * @param {Highcharts.TooltipOptions} options * The tooltip options to update. */ update(options) { this.destroy(); this.init(this.chart, merge(true, this.options, options)); } /** * Find the new position and perform the move * * @private * @function Highcharts.Tooltip#updatePosition * * @param {Highcharts.Point} point */ updatePosition(point) { const { chart, container, distance, options, pointer, renderer } = this, label = this.getLabel(), { height = 0, width = 0 } = label, { fixed, positioner } = options, // Needed for outside: true (#11688) { left, top, scaleX, scaleY } = pointer.getChartPosition(), pos = (positioner || (fixed && this.getFixedPosition) || this.getPosition).call(this, width, height, point), doc = H.doc; let anchorX = (point.plotX || 0) + chart.plotLeft, anchorY = (point.plotY || 0) + chart.plotTop, pad; // Set the renderer size dynamically to prevent document size to change. // Renderer only exists when tooltip is outside. if (renderer && container) { // Corrects positions, occurs with tooltip positioner (#16944) if (positioner || fixed) { const { scrollLeft = 0, scrollTop = 0 } = chart .scrollablePlotArea?.scrollingContainer || {}; pos.x += scrollLeft + left - distance; pos.y += scrollTop + top - distance; } // Pad it by the border width and distance. Add 2 to make room for // the default shadow (#19314). pad = (options.borderWidth || 0) + 2 * distance + 2; renderer.setSize( // Clamp width to keep tooltip in viewport (#21698) // and subtract one since tooltip container has 'left: 1px;' clamp(width + pad, 0, doc.documentElement.clientWidth) - 1, height + pad, false); // Anchor and tooltip container need scaling if chart container has // scale transform/css zoom. #11329. if (scaleX !== 1 || scaleY !== 1) { css(container, { transform: `scale(${scaleX}, ${scaleY})` }); anchorX *= scaleX; anchorY