UNPKG

apexcharts

Version:

A JavaScript Chart Library

818 lines (722 loc) 27.8 kB
// @ts-check import Graphics from '../Graphics' import Series from '../Series' /** * ApexCharts Tooltip.Position Class to move the tooltip based on x and y position. * * @module Tooltip.Position **/ export default class Position { /** * @param {import('./Tooltip').default} tooltipContext */ constructor(tooltipContext) { this.ttCtx = tooltipContext this.w = tooltipContext.w } /** * This will move the crosshair (the vertical/horz line that moves along with mouse) * Along with this, this function also calls the xaxisMove function * @memberof Position * @param {number} cx - point's x position, wherever point's x is, you need to move crosshair * @param {number | null} [j] */ moveXCrosshairs(cx, j = null) { const ttCtx = this.ttCtx const w = this.w const xcrosshairs = ttCtx.getElXCrosshairs() let x = cx - ttCtx.xcrosshairsWidth / 2 const tickAmount = w.labelData.labels.slice().length if (j !== null) { x = (w.layout.gridWidth / tickAmount) * j } if (xcrosshairs !== null && !w.globals.isBarHorizontal) { xcrosshairs.setAttribute('x', String(x)) xcrosshairs.setAttribute('x1', String(x)) xcrosshairs.setAttribute('x2', String(x)) xcrosshairs.setAttribute('y2', String(w.layout.gridHeight)) xcrosshairs.classList.add('apexcharts-active') } if (x < 0) { x = 0 } if (x > w.layout.gridWidth) { x = w.layout.gridWidth } if (ttCtx.isXAxisTooltipEnabled) { let tx = x if ( w.config.xaxis.crosshairs.width === 'tickWidth' || w.config.xaxis.crosshairs.width === 'barWidth' ) { tx = x + ttCtx.xcrosshairsWidth / 2 } this.moveXAxisTooltip(tx) } } /** * This will move the crosshair (the vertical/horz line that moves along with mouse) * Along with this, this function also calls the xaxisMove function * @memberof Position * @param {number} cy - point's y position, wherever point's y is, you need to move crosshair */ moveYCrosshairs(cy) { const ttCtx = this.ttCtx if (ttCtx.ycrosshairs !== null) { Graphics.setAttrs(ttCtx.ycrosshairs, { y1: cy, y2: cy, }) } if (ttCtx.ycrosshairsHidden !== null) { Graphics.setAttrs(ttCtx.ycrosshairsHidden, { y1: cy, y2: cy, }) } } /** ** AxisTooltip is the small rectangle which appears on x axis with x value, when user moves * @memberof Position * @param {number} cx - point's x position, wherever point's x is, you need to move */ moveXAxisTooltip(cx) { const w = this.w const ttCtx = this.ttCtx if (ttCtx.xaxisTooltip !== null && ttCtx.xcrosshairsWidth !== 0) { ttCtx.xaxisTooltip.classList.add('apexcharts-active') // +5 nudges the tooltip down so its text baseline sits in line with // the x-axis labels (the new compact style would otherwise float a few // pixels above them). const cy = ttCtx.xaxisOffY + w.config.xaxis.tooltip.offsetY + w.layout.translateY + 5 + w.config.xaxis.offsetY const xaxisTTText = ttCtx.xaxisTooltip.getBoundingClientRect() const xaxisTTTextWidth = xaxisTTText.width cx = cx - xaxisTTTextWidth / 2 if (!isNaN(cx)) { cx = cx + w.layout.translateX const graphics = new Graphics(this.w) const textRect = graphics.getTextRects( ttCtx.xaxisTooltipText?.innerHTML ?? '', w.config.xaxis.labels.style.fontSize, ) if (ttCtx.xaxisTooltipText) { ttCtx.xaxisTooltipText.style.minWidth = textRect.width + 'px' } ttCtx.xaxisTooltip.style.left = cx + 'px' ttCtx.xaxisTooltip.style.top = cy + 'px' } } } /** * @param {number} index */ moveYAxisTooltip(index) { const w = this.w const ttCtx = this.ttCtx if (ttCtx.yaxisTTEls === null) { ttCtx.yaxisTTEls = /** @type {any[]} */ ([ ...w.dom.baseEl.querySelectorAll('.apexcharts-yaxistooltip'), ]) } const ycrosshairsHiddenRectY1 = parseInt( ttCtx.ycrosshairsHidden?.getAttribute('y1') ?? '0', 10, ) let cy = w.layout.translateY + ycrosshairsHiddenRectY1 if (ttCtx.yaxisTTEls) { const yAxisTTRect = ttCtx.yaxisTTEls[index].getBoundingClientRect() const yAxisTTHeight = yAxisTTRect.height // Center the tooltip horizontally on the actual y-axis labels group // so it "floats over" the labels instead of sitting on top of the // grid. Falls back to `translateYAxisX` when the labels group can't // be measured (e.g. yaxis.show=false but tooltip still drawn). let cx const labelsGroup = /** @type {SVGGElement | null} */ ( w.dom.baseEl.querySelector( `.apexcharts-yaxis[rel='${index}'] .apexcharts-yaxis-texts-g`, ) ) const elWrapRect = w.dom.elWrap.getBoundingClientRect() if (labelsGroup) { const lr = labelsGroup.getBoundingClientRect() if (lr.width > 0) { // Convert labels' screen-coord center to elWrap-local x, then // subtract half the tooltip width so the tooltip is centered on // the labels. const labelsCenterInElWrap = lr.left + lr.width / 2 - elWrapRect.left cx = labelsCenterInElWrap - yAxisTTRect.width / 2 } } if (cx == null) { // Fallback: align the tooltip's outer edge just past the axis line // on the label side. const GAP = 4 cx = w.config.yaxis[index].opposite ? w.globals.translateYAxisX[index] + GAP : w.globals.translateYAxisX[index] - yAxisTTRect.width - GAP } cy = cy - yAxisTTHeight / 2 if ( w.globals.ignoreYAxisIndexes.indexOf(index) === -1 && cy > 0 && cy < w.layout.gridHeight ) { ttCtx.yaxisTTEls[index].classList.add('apexcharts-active') ttCtx.yaxisTTEls[index].style.top = cy + 'px' ttCtx.yaxisTTEls[index].style.left = cx + w.config.yaxis[index].tooltip.offsetX + 'px' } else { ttCtx.yaxisTTEls[index].classList.remove('apexcharts-active') } } } /** ** moves the whole tooltip by changing x, y attrs * @memberof Position * @param {number} cx - point's x position, wherever point's x is, you need to move tooltip * @param {number} cy - point's y position, wherever point's y is, you need to move tooltip * @param {number | null} [markerSize] - point's size */ moveTooltip(cx, cy, markerSize = null) { const ttCtx = this.ttCtx const tooltipEl = ttCtx.getElTooltip() if (!tooltipEl) return const pos = this.computeTooltipPosition(cx, cy, markerSize) if (pos === null) return this.applyTooltipPosition(tooltipEl, pos) } /** * Pure-ish (reads from `this.ttCtx` + `this.w` but performs no DOM writes) * computation of the tooltip box position, edge placement (for arrow), * and arrow vertical offset. Returns null when inputs are not numeric. * * @param {number} cx * @param {number} cy * @param {number | null} [markerSize] * @returns {{ x: number, y: number, placement: 'left'|'right', arrowY: number|null } | null} */ computeTooltipPosition(cx, cy, markerSize = null) { const w = this.w const ttCtx = this.ttCtx const tooltipRect = ttCtx.tooltipRect const arrowEnabled = !!w.config.tooltip.arrow const pointSize = markerSize !== null ? parseFloat(String(markerSize)) : 1 const ttH = tooltipRect.ttHeight || 0 const ttW = tooltipRect.ttWidth || 0 const cxNum = parseFloat(String(cx)) const cyNum = parseFloat(String(cy)) if (isNaN(cxNum)) return null let x = cxNum + pointSize + 5 // Coord-system note: `style.top` positions the tooltip in elWrap-coords, // but `cy` is the data point's y in elGraphical-local SVG coords (the // grid group is translated by translateY inside the SVG). For arrow // mode the box must sit centered on the *actual* point in elWrap-coords, // so we add translateY here — mirroring how x picks up translateX // further down. Legacy (no-arrow) mode preserves the pre-existing // grid-coord behavior to avoid shifting tooltips for existing users. const pointY = cyNum + w.layout.translateY let y = arrowEnabled ? pointY - ttH / 2 + pointSize / 2 : cyNum + pointSize / 2 /** @type {'left'|'right'} */ let placement = 'right' if (x > w.layout.gridWidth / 2) { x = x - ttW - pointSize - 10 placement = 'left' } if (x > w.layout.gridWidth - ttW - 10) { x = w.layout.gridWidth - ttW } if (x < -20) { x = -20 } if (w.config.tooltip.followCursor) { const elGrid = ttCtx.getElGrid() if (!elGrid) return null const seriesBound = elGrid.getBoundingClientRect() x = ttCtx.e.clientX - seriesBound.left if (x > w.layout.gridWidth / 2) { x = x - ttW placement = 'left' } else { placement = 'right' } y = ttCtx.e.clientY + w.layout.translateY - seriesBound.top if (y > w.layout.gridHeight / 2) { y = y - ttH } } else { if (!w.globals.isBarHorizontal) { if (arrowEnabled) { // Arrow mode: clamps run in elWrap-coords (grid box top sits at // translateY, bottom at translateY+gridHeight). const gridTop = w.layout.translateY const gridBottom = w.layout.translateY + w.layout.gridHeight if (y + ttH > gridBottom) { y = gridBottom - ttH } if (y < gridTop) { y = gridTop } } else { // Legacy mode: grid-coords bottom clamp (unchanged). if (ttH / 2 + y > w.layout.gridHeight) { y = w.layout.gridHeight - ttH + w.layout.translateY } } } } if (isNaN(x)) return null x = x + w.layout.translateX // WCAG 2.4.11 Focus Not Obscured: when keyboard nav drives the tooltip, // make sure the tooltip box doesn't sit on top of the focused data // point. If the tooltip's vertical extent overlaps the point, push it // above the point by enough margin to clear the focus stroke. const a11y = w.config?.chart?.accessibility if ( a11y?.enabled && a11y?.keyboard?.navigation?.enabled && w.dom?.baseEl?.querySelector?.('.apexcharts-keyboard-focused') ) { // a11y check works in the same coord space as `y` (elWrap for arrow // mode, grid for legacy). const refPointY = arrowEnabled ? pointY : cyNum const margin = (pointSize || 1) + 12 const tooltipTop = y const tooltipBottom = y + ttH if ( !isNaN(refPointY) && ttH > 0 && tooltipTop < refPointY + margin && tooltipBottom > refPointY - margin ) { y = refPointY - ttH - margin if (y < 0) { y = refPointY + margin } } } // Arrow Y in tooltip-local coords = elWrap-coords point Y minus // elWrap-coords tooltip top. Clamped away from the rounded corners. let arrowY = null if (arrowEnabled && ttH > 0) { const localY = pointY - y const minArrowY = 10 const maxArrowY = ttH - 10 arrowY = Math.max(minArrowY, Math.min(maxArrowY, localY)) } return { x, y, placement, arrowY } } /** * Single DOM-writer used by every positioning path on the main tooltip. * Replaces the duplicated `style.left/top` writes that previously lived * in Position.moveTooltip, Tooltip.drawFixedTooltipRect, and Intersect. * * @param {HTMLElement} tooltipEl * @param {{ * x: number, * y: number, * placement?: 'left'|'right'|'top'|'bottom', * arrowY?: number|null, * arrowX?: number|null, * }} pos */ applyTooltipPosition(tooltipEl, pos) { if (!tooltipEl) return // First paint after the tooltip is shown must NOT animate `left`/`top`, // otherwise the browser interpolates from the prior (often 0,0 or // last-hidden) coordinates and the tooltip visibly slides in from the // wrong place. The CSS rule that adds left/top to the transition list // keys off `data-positioned="true"` — we set the attribute only AFTER // the initial position is committed. // // The `data-positioned`-only guard isn't enough in practice: some // browsers honour the transition rule retroactively when it becomes // active in the same frame as the property change. So we also block // the transition explicitly with an inline `transition-property` // override, force a layout flush, then clear the override on the next // frame. This guarantees zero interpolation on first paint regardless // of how the active class is sequenced. const firstPaint = tooltipEl.dataset.positioned !== 'true' if (firstPaint) { tooltipEl.style.transitionProperty = 'none' } tooltipEl.style.left = pos.x + 'px' tooltipEl.style.top = pos.y + 'px' if (pos.placement) { tooltipEl.dataset.placement = pos.placement } if (pos.arrowY != null) { tooltipEl.style.setProperty('--apx-tt-arrow-y', pos.arrowY + 'px') } if (pos.arrowX != null) { tooltipEl.style.setProperty('--apx-tt-arrow-x', pos.arrowX + 'px') } if (firstPaint) { // Force layout so the position above is committed with transitions // disabled; then in a microtask (after the active class is added by // the caller in the same tick) clear the override so subsequent // moves between data points animate smoothly. void tooltipEl.offsetWidth tooltipEl.dataset.positioned = 'true' requestAnimationFrame(() => { tooltipEl.style.transitionProperty = '' }) } } /** * @param {number} i * @param {number} j */ moveMarkers(i, j) { const w = this.w const ttCtx = this.ttCtx if (w.globals.markers.size[i] > 0) { const allPoints = w.dom.baseEl.querySelectorAll( ` .apexcharts-series[data\\:realIndex='${i}'] .apexcharts-marker`, ) for (let p = 0; p < allPoints.length; p++) { if (parseInt(allPoints[p].getAttribute('rel') ?? '0', 10) === j) { ttCtx.marker.resetPointsSize() ttCtx.marker.enlargeCurrentPoint(j, allPoints[p]) } } } else { ttCtx.marker.resetPointsSize() this.moveDynamicPointOnHover(j, i) } } // This function is used when you need to show markers/points only on hover - // DIFFERENT X VALUES in multiple series /** * @param {number} j * @param {number} capturedSeries */ moveDynamicPointOnHover(j, capturedSeries) { const w = this.w const ttCtx = this.ttCtx let cx = 0 let cy = 0 const graphics = new Graphics(this.w) const pointsArr = w.globals.pointsArray const hoverSize = ttCtx.tooltipUtil.getHoverMarkerSize(capturedSeries) const serType = /** @type {any} */ (w.config.series[capturedSeries]).type if ( serType && (serType === 'column' || serType === 'candlestick' || serType === 'boxPlot') ) { // fix error mentioned in #811 return } cx = pointsArr[capturedSeries]?.[j]?.[0] cy = pointsArr[capturedSeries]?.[j]?.[1] || 0 const point = w.dom.baseEl.querySelector( `.apexcharts-series[data\\:realIndex='${capturedSeries}'] .apexcharts-series-markers path`, ) if (point && cy < w.layout.gridHeight && cy > 0) { const shape = point.getAttribute('shape') ?? 'circle' const path = graphics.getMarkerPath(cx, cy, shape, hoverSize * 1.5) point.setAttribute('d', path) } this.moveXCrosshairs(cx) if (!ttCtx.fixedTooltip) { this.moveTooltip(cx, cy, hoverSize) } } // This function is used when you need to show markers/points only on hover - // SAME X VALUES in multiple series /** * @param {number} j */ moveDynamicPointsOnHover(j) { const ttCtx = this.ttCtx const w = ttCtx.w let cx = 0 let cy = 0 let activeSeries = 0 const pointsArr = w.globals.pointsArray const series = new Series(this.w) const graphics = new Graphics(this.w) activeSeries = series.getActiveConfigSeriesIndex('asc', [ 'line', 'area', 'scatter', 'bubble', ]) const hoverSize = ttCtx.tooltipUtil.getHoverMarkerSize(activeSeries) if (pointsArr[activeSeries]?.[j]) { cx = pointsArr[activeSeries][j][0] cy = pointsArr[activeSeries][j][1] } if (isNaN(cx)) { return } const points = ttCtx.tooltipUtil.getAllMarkers() if (points.length) { for (let p = 0; p < w.seriesData.series.length; p++) { const pointArr = pointsArr[p] if (w.globals.comboCharts) { // in a combo chart, if column charts are present, markers will not match with the number of series, hence this patch to push a null value in points array if (typeof pointArr === 'undefined') { // nodelist to array points.splice(p, 0, null) } } if (pointArr && pointArr.length) { let pcy = pointsArr[p][j][1] let pcy2 points[p].setAttribute('cx', cx) const shape = points[p].getAttribute('shape') ?? 'circle' if (w.config.chart.type === 'rangeArea' && !w.globals.comboCharts) { const rangeStartIndex = j + w.seriesData.series[p].length pcy2 = pointsArr[p][rangeStartIndex][1] const pcyDiff = Math.abs(pcy - pcy2) / 2 pcy = pcy - pcyDiff } if ( pcy !== null && !isNaN(pcy) && pcy < w.layout.gridHeight + hoverSize && pcy + hoverSize > 0 ) { const path = graphics.getMarkerPath(cx, pcy, shape, hoverSize) points[p].setAttribute('d', path) } else { points[p].setAttribute('d', '') } } } } this.moveXCrosshairs(cx) if (!ttCtx.fixedTooltip) { this.moveTooltip(cx, cy || w.layout.gridHeight, hoverSize) } } /** * @param {number} j * @param {number} capturedSeries */ moveStickyTooltipOverBars(j, capturedSeries) { const w = this.w const ttCtx = this.ttCtx let barLen = w.globals.columnSeries ? /** @type {any} */ (w.globals.columnSeries).length : w.seriesData.series.length if (w.config.chart.stacked) { barLen = w.globals.barGroups.length } let i = barLen >= 2 && barLen % 2 === 0 ? Math.floor(barLen / 2) : Math.floor(barLen / 2) + 1 if (w.globals.isBarHorizontal) { const series = new Series(this.w) i = series.getActiveConfigSeriesIndex('desc') + 1 } let jBar = w.dom.baseEl.querySelector( `.apexcharts-bar-series .apexcharts-series[rel='${i}'] path[j='${j}'], .apexcharts-candlestick-series .apexcharts-series[rel='${i}'] path[j='${j}'], .apexcharts-boxPlot-series .apexcharts-series[rel='${i}'] path[j='${j}'], .apexcharts-rangebar-series .apexcharts-series[rel='${i}'] path[j='${j}']`, ) if (!jBar && typeof capturedSeries === 'number') { // Try with captured series index jBar = w.dom.baseEl.querySelector( `.apexcharts-bar-series .apexcharts-series[data\\:realIndex='${capturedSeries}'] path[j='${j}'], .apexcharts-candlestick-series .apexcharts-series[data\\:realIndex='${capturedSeries}'] path[j='${j}'], .apexcharts-boxPlot-series .apexcharts-series[data\\:realIndex='${capturedSeries}'] path[j='${j}'], .apexcharts-rangebar-series .apexcharts-series[data\\:realIndex='${capturedSeries}'] path[j='${j}']`, ) } let bcx = jBar ? parseFloat(jBar.getAttribute('cx') ?? '0') : 0 let bcy = jBar ? parseFloat(jBar.getAttribute('cy') ?? '0') : 0 const bw = jBar ? parseFloat(jBar.getAttribute('barWidth') ?? '0') : 0 const elGrid = ttCtx.getElGrid() if (!elGrid) return const seriesBound = elGrid.getBoundingClientRect() const isBoxOrCandle = jBar && (jBar.classList.contains('apexcharts-candlestick-area') || jBar.classList.contains('apexcharts-boxPlot-area')) if (w.axisFlags.isXNumeric) { // The `cx` attribute on bars is set in bar/DataLabels.js using // `x + barWidth * (visibleSeries + 1)` (numeric path) which does NOT // correspond to the bar's rendered center — especially for stacked // and grouped column charts on a numeric/datetime axis (cx ends up // offset by up to a full barWidth from the actual center). The // legacy `bcx - bw/2` adjustment is a partial fix that only worked // for odd-count series. Use the bar's rendered DOM rect instead so // the data-point center is correct regardless of stack/group layout. if (jBar && !isBoxOrCandle) { const center = this._datapointCenterXFromBars(j, seriesBound) if (center != null) { bcx = center } else { // Fallback to the legacy attribute-based math when no bars are // available (e.g. all series at index `j` collapsed). bcx = bcx - (barLen % 2 !== 0 ? bw / 2 : 0) } } if ( jBar && // fixes apexcharts.js#2354 isBoxOrCandle ) { bcx = bcx - bw / 2 } } else { if (!w.globals.isBarHorizontal) { bcx = ttCtx.xAxisTicksPositions[j - 1] + ttCtx.dataPointsDividedWidth / 2 if (isNaN(bcx)) { bcx = ttCtx.xAxisTicksPositions[j] - ttCtx.dataPointsDividedWidth / 2 } } } if (!w.globals.isBarHorizontal) { if (w.config.tooltip.followCursor) { bcy = ttCtx.e.clientY - seriesBound.top - ttCtx.tooltipRect.ttHeight / 2 } else { if (bcy + ttCtx.tooltipRect.ttHeight + 15 > w.layout.gridHeight) { bcy = w.layout.gridHeight } } } else { bcy = bcy - ttCtx.tooltipRect.ttHeight } if (!w.globals.isBarHorizontal) { this.moveXCrosshairs(bcx) } if (!ttCtx.fixedTooltip) { // Horizontal bar (incl. multi-series shared, funnel, pyramid, timeline, // range-bar horizontal): place tooltip above/below the entire row of // bars at index `j` so it doesn't sit on top of the bar (the legacy // left/right placement put it at the bar's value-end, which reads as // "tooltip goes to the right"). Computed from the union rect of every // bar with `[j='${j}']` across visible series. if (w.globals.isBarHorizontal && !w.config.tooltip.followCursor) { const placed = this.placeHorizontalSharedTooltip(j) if (placed) return } this.moveTooltip(bcx, bcy || w.layout.gridHeight) } } /** * Place tooltip above (or flipped: below) the union rect of all bars at * dataPointIndex `j` for horizontal-bar-likes. Returns true when a * Compute the true horizontal center of dataPointIndex `j` in grid-local * coords from the union of every visible bar's `getBoundingClientRect()`. * Used as a replacement for the (buggy on numeric/datetime xaxis) `cx` * attribute math in `moveStickyTooltipOverBars`. Returns null when no * usable bars are found. * @param {number} j * @param {DOMRect} gridRect * @returns {number | null} */ _datapointCenterXFromBars(j, gridRect) { const w = this.w const bars = w.dom.baseEl.querySelectorAll( `.apexcharts-bar-series path[j='${j}'],` + `.apexcharts-rangebar-series path[j='${j}']`, ) if (!bars.length) return null let unionLeft = Infinity let unionRight = -Infinity for (const bar of bars) { const parent = /** @type {Element|null} */ (bar.parentNode) if (parent?.classList?.contains?.('apexcharts-series-collapsed')) continue const r = /** @type {Element} */ (bar).getBoundingClientRect() if (r.width === 0 && r.height === 0) continue if (r.left < unionLeft) unionLeft = r.left if (r.right > unionRight) unionRight = r.right } if (!isFinite(unionLeft)) return null // grid-local x (no translateX yet — computeTooltipPosition adds that). return (unionLeft + unionRight) / 2 - gridRect.left } /** * Place tooltip above (or flipped: below) the union rect of all bars at * dataPointIndex `j` for horizontal-bar-likes. Returns true when a * placement was applied; false when no bars found (caller falls back). * @param {number} j * @returns {boolean} */ placeHorizontalSharedTooltip(j) { const w = this.w const ttCtx = this.ttCtx const tooltipEl = ttCtx.getElTooltip() if (!tooltipEl) return false const elGrid = ttCtx.getElGrid() if (!elGrid) return false const gridRect = elGrid.getBoundingClientRect() // Match every bar variant that uses the j-attribute and isBarHorizontal: // bar, rangeBar, boxPlot (boxPlot's `horizontal` is enforced false at // config-time, but the selector is harmless). const bars = w.dom.baseEl.querySelectorAll( `.apexcharts-bar-series path[j='${j}'],` + `.apexcharts-rangebar-series path[j='${j}'],` + `.apexcharts-boxPlot-series path[j='${j}']`, ) if (!bars.length) return false let unionLeft = Infinity let unionRight = -Infinity let unionTop = Infinity let unionBottom = -Infinity for (const bar of bars) { // Skip bars belonging to collapsed series (parent has the // `apexcharts-series-collapsed` class). const parent = /** @type {Element|null} */ (bar.parentNode) if (parent?.classList?.contains?.('apexcharts-series-collapsed')) continue const r = /** @type {Element} */ (bar).getBoundingClientRect() if (r.width === 0 && r.height === 0) continue if (r.left < unionLeft) unionLeft = r.left if (r.right > unionRight) unionRight = r.right if (r.top < unionTop) unionTop = r.top if (r.bottom > unionBottom) unionBottom = r.bottom } if (!isFinite(unionLeft)) return false const ttW = ttCtx.tooltipRect.ttWidth || 0 const ttH = ttCtx.tooltipRect.ttHeight || 0 const ARROW_TIP_OVERHANG = 7 // Convert union rect (viewport-coords) into elWrap-coords. The tooltip // is positioned via style.left/top inside elWrap; elGrid is offset from // elWrap by (translateX, translateY). const rowCenterX = (unionLeft + unionRight) / 2 - gridRect.left + w.layout.translateX const rowTopElWrap = unionTop - gridRect.top + w.layout.translateY const rowBottomElWrap = unionBottom - gridRect.top + w.layout.translateY const gridTop = w.layout.translateY const gridBottom = w.layout.translateY + w.layout.gridHeight const gridLeft = w.layout.translateX const gridRight = w.layout.translateX + w.layout.gridWidth /** @type {'top'|'bottom'} */ let placement = 'top' let finalY = rowTopElWrap - ttH - ARROW_TIP_OVERHANG if (finalY < gridTop) { const belowTop = rowBottomElWrap + ARROW_TIP_OVERHANG if (belowTop + ttH <= gridBottom) { placement = 'bottom' finalY = belowTop } } let finalX = rowCenterX - ttW / 2 if (finalX < gridLeft) finalX = gridLeft if (finalX + ttW > gridRight) finalX = gridRight - ttW // Arrow rendering itself is gated upstream (skipped for shared+multi), // but we still pass arrowX/placement so single-series-shared horizontal // (where arrow IS drawn) lines up correctly. const arrowX = Math.max(10, Math.min(ttW - 10, rowCenterX - finalX)) this.applyTooltipPosition(tooltipEl, { x: finalX, y: finalY, placement, arrowY: null, arrowX, }) return true } }