apexcharts
Version:
A JavaScript Chart Library
276 lines (258 loc) • 9.35 kB
JavaScript
// @ts-check
/**
* Trellis (#22, P3): `tooltip: 'grid'`, one card that reads the whole grid.
*
* One card near the cursor with one row per panel at the hovered x. Rows are
* computed FROM THE SPLIT, not scraped from sibling tooltip DOM: the group's
* per-panel value fill is position-based and never fills a panel that sits
* BESIDE the hovered one (the same stacked-layout assumption as D7, measured:
* only the hovered COLUMN's tooltips carry values). The split already
* re-emitted every panel against the union x list (D5), so the hovered
* panel's data-point index addresses the same x in EVERY panel, mounted or
* not, which also makes grid rows complete under virtualization.
*
* The hovered index comes from the same plot mapping the real tooltip uses
* (`AxisMapping.screenXToPlotPx` + the divisor rules from
* tooltip/Utils.getNearestValues); the title is read from the hovered
* panel's own tooltip (its native fill path works for the hovered chart), so
* datetime/locale x formatting stays the library's own.
*
* Driven by a continuous rAF hover loop (D9: the tooltip pipeline lands
* asynchronously after events); the loop stops on mouseleave.
*
* @module modules/trellis/TrellisTooltip
*/
import { BrowserAPIs } from '../../ssr/BrowserAPIs.js'
import AxisMapping from '../AxisMapping'
/** Card offset from the pointer, px. */
const CURSOR_PAD = 14
/** Types whose hover divisor counts bins, not points (tooltip/Utils). */
const BAR_FAMILY = ['bar', 'column', 'rangeBar', 'candlestick', 'boxPlot']
export default class TrellisTooltip {
/**
* @param {import('./Trellis').default} trellis
*/
constructor(trellis) {
this.trellis = trellis
/** @type {HTMLElement|null} */
this.el = null
/** @type {number} */
this._raf = 0
/** @type {any} */
this._lastEvent = null
}
/**
* Build the card element and start the hover loop.
* @param {HTMLElement} elGrid
* @param {HTMLElement} elWrap
*/
wire(elGrid, elWrap) {
const card = BrowserAPIs.createElement('div')
card.className = 'apexcharts-trellis-tooltip apexcharts-theme-light'
card.setAttribute('role', 'status')
elWrap.appendChild(card)
this.el = card
const loop = () => {
this._update(elWrap)
this._raf = this._lastEvent ? requestAnimationFrame(loop) : 0
}
const move = (/** @type {any} */ e) => {
this._lastEvent = e
if (!this._raf) this._raf = requestAnimationFrame(loop)
}
const leave = () => {
this._lastEvent = null
if (this._raf) {
cancelAnimationFrame(this._raf)
this._raf = 0
}
card.classList.remove('apexcharts-trellis-tooltip-active')
}
elGrid.addEventListener('mousemove', move, { passive: true })
elGrid.addEventListener('mouseleave', leave, { passive: true })
}
/**
* The hovered data-point index in the union x space, or -1 when the
* pointer is outside the hovered panel's plot. Mirrors the divisor rules
* of tooltip/Utils.getNearestValues (trellis panels are never combos).
* @param {any} chart the hovered panel's instance
* @param {number} clientX
* @param {number} clientY
*/
_hoverIndex(chart, clientX, clientY) {
const w = chart.w
const n = w.globals.dataPoints
if (!n || n < 1) return -1
const gridWidth = w.layout.gridWidth
if (!gridWidth) return -1
const hoverX = AxisMapping.screenXToPlotPx(w, clientX)
const edgePad = w.globals.barPadForNumericAxis || 0
if (hoverX < -edgePad || hoverX > gridWidth + edgePad) return -1
// Vertical bound: the panel's own grid box.
const gridEl =
w.dom && w.dom.elGridRect
? w.dom.elGridRect
: chart.el && chart.el.querySelector
? chart.el.querySelector('.apexcharts-grid')
: null
if (gridEl && gridEl.getBoundingClientRect) {
const r = gridEl.getBoundingClientRect()
if (r.height && (clientY < r.top || clientY > r.bottom)) return -1
}
const barish = BAR_FAMILY.indexOf(w.config.chart.type) !== -1
let j
if (barish && !w.config.xaxis.convertedCatToNumeric) {
j = Math.ceil(hoverX / (gridWidth / n)) - 1
} else {
j = Math.round(hoverX / (gridWidth / Math.max(1, n - 1)))
}
return Math.max(0, Math.min(n - 1, j))
}
/**
* One series' formatted value in one panel at index j, or null when there
* is nothing to show. Formatter chain: tooltip.y formatter, then the yaxis
* labels formatter, then the raw value.
* @param {import('./TrellisSplit').TrellisSlice} slice
* @param {string} name
* @param {number} nameIdx trellis-wide series index (formatter arg)
* @param {number} j
*/
_valueAt(slice, name, nameIdx, j) {
const s = slice.series.find((sr) => sr.name === name)
if (!s || !Array.isArray(s.data)) return null
const d = s.data[j]
if (d === null || d === undefined) return null
/** @type {any} */
let y = d
if (Array.isArray(d)) y = d[1]
else if (typeof d === 'object') y = d.y
if (y === null || y === undefined) return null
const cfg = this.trellis.w.config
const ttY = Array.isArray(cfg.tooltip?.y)
? cfg.tooltip.y[nameIdx]
: cfg.tooltip?.y
const yaxis0 = Array.isArray(cfg.yaxis) ? cfg.yaxis[0] : cfg.yaxis
const formatter =
(ttY && typeof ttY.formatter === 'function' && ttY.formatter) ||
(yaxis0 &&
yaxis0.labels &&
typeof yaxis0.labels.formatter === 'function' &&
yaxis0.labels.formatter) ||
null
/** @param {any} v */
const fmt = (v) => {
if (formatter) {
try {
return String(
formatter(v, { seriesIndex: nameIdx, dataPointIndex: j, w: null }),
)
} catch (e) {
return String(v)
}
}
return String(v)
}
return Array.isArray(y) ? y.map(fmt).join(' / ') : fmt(y)
}
/**
* One reconcile pass: resolve the hovered panel and index, then one row
* per panel from the split.
* @param {HTMLElement} elWrap
*/
_update(elWrap) {
const t = this.trellis
const card = this.el
const e = this._lastEvent
if (!card || !e || !t.split) return
const cell =
e.target && e.target.closest
? e.target.closest('.apexcharts-trellis-cell')
: null
const hovered = cell
? t.panels.find((p) => p.cellEl === cell)
: undefined
const j =
hovered && hovered.chart
? this._hoverIndex(hovered.chart, e.clientX, e.clientY)
: -1
if (!hovered || j === -1) {
card.classList.remove('apexcharts-trellis-tooltip-active')
return
}
// The hovered panel's own tooltip title carries the library's formatted
// x for this index (its native fill works for the hovered chart).
const titleEl = cell
? cell.querySelector('.apexcharts-tooltip-title')
: null
const title = titleEl && titleEl.textContent ? titleEl.textContent : ''
const names = t.split.seriesNames
const scales = t.scales
/** @type {string[]} */
const rows = []
t.split.panels.forEach((slice) => {
/** @type {string[]} */
const vals = []
names.forEach((name, ni) => {
const v = this._valueAt(slice, name, ni, j)
if (v === null) return
const color = scales ? scales.colorOf(name) : '#008FFB'
vals.push(
`<span class="apexcharts-trellis-tooltip-val"><span class="apexcharts-trellis-tooltip-marker" style="background:${escapeAttr(
color,
)}"></span>${escapeHtml(v)}</span>`,
)
})
const isHovered = slice.key === hovered.key
rows.push(
`<div class="apexcharts-trellis-tooltip-row${
isHovered ? ' apexcharts-trellis-tooltip-row-active' : ''
}" data-key="${escapeAttr(slice.key)}">` +
`<span class="apexcharts-trellis-tooltip-key">${escapeHtml(
slice.key,
)}</span>` +
`<span class="apexcharts-trellis-tooltip-vals">${vals.join(
'',
)}</span>` +
`</div>`,
)
})
let html = ''
if (title) {
html += `<div class="apexcharts-tooltip-title">${escapeHtml(title)}</div>`
}
html += rows.join('')
card.innerHTML = html
card.classList.add('apexcharts-trellis-tooltip-active')
// Position near the cursor, clamped inside the wrap.
const wrapRect = elWrap.getBoundingClientRect()
let x = e.clientX - wrapRect.left + CURSOR_PAD
let y = e.clientY - wrapRect.top + CURSOR_PAD
const cw = card.offsetWidth
const ch = card.offsetHeight
if (x + cw > wrapRect.width - 4) x = Math.max(4, x - cw - CURSOR_PAD * 2)
if (y + ch > wrapRect.height - 4) y = Math.max(4, y - ch - CURSOR_PAD * 2)
card.style.left = `${Math.round(x)}px`
card.style.top = `${Math.round(y)}px`
}
destroy() {
if (this._raf) {
cancelAnimationFrame(this._raf)
this._raf = 0
}
this._lastEvent = null
if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el)
this.el = null
}
}
/** @param {string} s */
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
/** @param {string} s */
function escapeAttr(s) {
return String(s).replace(/"/g, '"')
}