apexcharts
Version:
A JavaScript Chart Library
886 lines (822 loc) • 34.4 kB
JavaScript
// @ts-check
import Utils from '../../utils/Utils'
import Series from '../Series'
import { BrowserAPIs } from '../../ssr/BrowserAPIs.js'
import { Environment } from '../../utils/Environment.js'
const SVG_NS = 'http://www.w3.org/2000/svg'
/**
* Renders a continuous color gradient strip + hover indicator arrow inside the
* legend wrap, replacing the default categorical legend for heatmaps when
* `plotOptions.heatmap.colorScale.gradientLegend.enabled` is true.
*
* The strip orientation follows `chart.legend.position`:
* - top/bottom → horizontal strip, min on the left
* - left/right → vertical strip, min at the bottom (thermometer-style)
*
* The arrow is positioned along the strip when the user hovers a heatmap cell,
* driven by the existing `dataPointMouseEnter` / `dataPointMouseLeave` events.
*/
export default class HeatmapGradientLegend {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx
/** @type {any} */
this.svgEl = null
/** @type {any} */
this.arrowEl = null
/** @type {HTMLElement|null} */
this.hoverValueEl = null
/** @type {number} */
this._min = 0
/** @type {number} */
this._max = 0
/** @type {any} */
this._geom = null
/** @type {any[]} */
this._bandHitEls = []
/** @type {number} Currently highlighted band index (-1 = none). */
this._activeBandIndex = -1
this._onCellEnter = this._onCellEnter.bind(this)
this._onCellLeave = this._onCellLeave.bind(this)
this._onBandEnter = this._onBandEnter.bind(this)
this._onBandLeave = this._onBandLeave.bind(this)
}
/** Default value formatter for min/max labels and the hover tooltip. */
_getFormatter() {
const cfg = this.w.config.plotOptions.heatmap.colorScale.gradientLegend
if (typeof cfg.formatter === 'function') return cfg.formatter
return (/** @type {number} */ v) => {
if (!Number.isFinite(v)) return String(v)
const abs = Math.abs(v)
if (abs >= 1000) return v.toFixed(0)
if (abs >= 10) return v.toFixed(1)
return v.toFixed(2)
}
}
/**
* True when the user has opted into the gradient legend variant.
* @param {any} w
*/
static isEnabled(w) {
const cfg = w?.config?.plotOptions?.heatmap?.colorScale?.gradientLegend
return !!(cfg && cfg.enabled)
}
/**
* Build the gradient legend DOM into `elLegendWrap`.
* Caller is responsible for clearing the wrap first.
*/
draw() {
const w = this.w
const elLegendWrap = /** @type {HTMLElement} */ (w.dom.elLegendWrap)
if (!elLegendWrap) return
const cfg = w.config.plotOptions.heatmap.colorScale.gradientLegend
const position = w.config.legend.position
const isVertical = position === 'left' || position === 'right'
// Geometry — pads give space for end labels and the arrow.
const arrowSize = cfg.arrow?.size ?? 8
const arrowGutter = arrowSize + 4
// Horizontal placements: labels sit to the left/right of the strip, so we
// need horizontal label padding. Vertical placements: labels sit above and
// below — needs vertical padding instead, but the SVG must also be wide
// enough for the centered labels (e.g. "100", "-30") to fit without
// clipping. The minimum width below comfortably fits ~5 chars at 11px.
const labelPadAlongStrip = cfg.showLabels ? 28 : 4
const labelPadAcrossStrip = cfg.showLabels ? 20 : 4
const minLabelWidth = cfg.showLabels ? 44 : 0
const stripLength = this._resolveStripLength(isVertical ? cfg.height : cfg.width, isVertical)
const stripThickness = cfg.thickness
// Total SVG canvas adds room for the arrow on the chart-facing edge and
// labels at the ends of the strip.
const svgWidth = isVertical
? Math.max(stripThickness + arrowGutter + 4, minLabelWidth)
: stripLength + labelPadAlongStrip * 2
const svgHeight = isVertical
? stripLength + labelPadAcrossStrip * 2
: stripThickness + arrowGutter + 4
// Strip origin in SVG coords.
// - Horizontal: arrow above the strip pointing down (for both top/bottom
// placement, the arrow sits on the chart-facing side; for top placement
// we flip below).
// - Vertical: arrow to the side of the strip pointing toward it. The
// chart-facing side depends on whether the legend is on left or right.
// For vertical, center the strip+arrow group within the wider SVG so
// centered labels above/below have equal slack on either side.
const verticalGroupWidth = stripThickness + arrowGutter
const verticalGroupLeftPad = (svgWidth - verticalGroupWidth) / 2
const stripX = isVertical
? position === 'left'
? verticalGroupLeftPad
: verticalGroupLeftPad + arrowGutter
: labelPadAlongStrip
const stripY = isVertical
? labelPadAcrossStrip
: position === 'top'
? arrowGutter
: 4
// Build SVG.
const svg = BrowserAPIs.createElementNS(SVG_NS, 'svg')
svg.setAttribute('class', 'apexcharts-heatmap-gradient-legend')
svg.setAttribute('width', String(svgWidth))
svg.setAttribute('height', String(svgHeight))
svg.setAttribute('overflow', 'visible')
// Gradient definition.
const defs = BrowserAPIs.createElementNS(SVG_NS, 'defs')
const gradId = `apexcharts-heatmap-gradient-${w.globals.cuid}`
const linearGrad = BrowserAPIs.createElementNS(SVG_NS, 'linearGradient')
linearGrad.setAttribute('id', gradId)
// Horizontal: gradient runs left→right (min→max).
// Vertical: gradient runs bottom→top (min→max, thermometer style).
if (isVertical) {
linearGrad.setAttribute('x1', '0')
linearGrad.setAttribute('y1', '1')
linearGrad.setAttribute('x2', '0')
linearGrad.setAttribute('y2', '0')
} else {
linearGrad.setAttribute('x1', '0')
linearGrad.setAttribute('y1', '0')
linearGrad.setAttribute('x2', '1')
linearGrad.setAttribute('y2', '0')
}
const { min, max, stops, bands } = this._computeStops()
this._min = min
this._max = max
stops.forEach((s) => {
const stopEl = BrowserAPIs.createElementNS(SVG_NS, 'stop')
stopEl.setAttribute('offset', `${(s.percent * 100).toFixed(2)}%`)
stopEl.setAttribute('stop-color', s.color)
linearGrad.appendChild(stopEl)
})
defs.appendChild(linearGrad)
svg.appendChild(defs)
// Strip rectangle.
const rect = BrowserAPIs.createElementNS(SVG_NS, 'rect')
rect.setAttribute('x', String(stripX))
rect.setAttribute('y', String(stripY))
rect.setAttribute('width', String(isVertical ? stripThickness : stripLength))
rect.setAttribute('height', String(isVertical ? stripLength : stripThickness))
rect.setAttribute('rx', '2')
rect.setAttribute('fill', `url(#${gradId})`)
svg.appendChild(rect)
// End labels.
if (cfg.showLabels) {
const labelColor =
cfg.labelStyle?.colors ||
(Array.isArray(w.config.legend.labels.colors)
? w.config.legend.labels.colors[0]
: w.config.legend.labels.colors) ||
w.config.chart.foreColor
const labelFontSize = cfg.labelStyle?.fontSize || '11px'
const labelFontFamily =
cfg.labelStyle?.fontFamily || w.config.chart.fontFamily
const fmt = this._getFormatter()
/**
* @param {string|number} text
* @param {number} x
* @param {number} y
* @param {string} anchor
*/
const makeLabel = (text, x, y, anchor) => {
const t = BrowserAPIs.createElementNS(SVG_NS, 'text')
t.setAttribute('x', String(x))
t.setAttribute('y', String(y))
t.setAttribute('text-anchor', anchor)
t.setAttribute('dominant-baseline', 'middle')
t.setAttribute('fill', labelColor)
t.setAttribute('font-size', labelFontSize)
if (labelFontFamily) t.setAttribute('font-family', labelFontFamily)
t.textContent = String(text)
return t
}
if (isVertical) {
// Min at the bottom, max at the top.
const midX = stripX + stripThickness / 2
svg.appendChild(makeLabel(fmt(min), midX, stripY + stripLength + 10, 'middle'))
svg.appendChild(makeLabel(fmt(max), midX, stripY - 10, 'middle'))
} else {
const midY = stripY + stripThickness / 2
svg.appendChild(makeLabel(fmt(min), stripX - 6, midY, 'end'))
svg.appendChild(makeLabel(fmt(max), stripX + stripLength + 6, midY, 'start'))
}
}
// Arrow indicator (hidden until hover).
const arrowColor = cfg.arrow?.color || w.config.chart.foreColor
const arrow = this._buildArrow(arrowSize, arrowColor, position)
svg.appendChild(arrow)
this.arrowEl = arrow
// Per-band hover hit-regions (ranges mode only): hovering a band highlights
// the cells in that range and dims the rest — parity with the categorical
// legend. Gated on the same `legend.onItemHover.highlightDataSeries` flag,
// so both legend variants share one behavior switch. Appended last (above
// the strip + arrow) so they reliably receive the pointer.
this._bandHitEls = []
if (w.config.legend.onItemHover.highlightDataSeries && bands.length > 0) {
bands.forEach((/** @type {any} */ b) => {
const hit = BrowserAPIs.createElementNS(SVG_NS, 'rect')
if (isVertical) {
// p=0 at the bottom (min), p=1 at the top (max).
const yTop = stripY + stripLength - b.p2 * stripLength
const yBot = stripY + stripLength - b.p1 * stripLength
hit.setAttribute('x', String(stripX))
hit.setAttribute('y', String(yTop))
hit.setAttribute('width', String(stripThickness))
hit.setAttribute('height', String(Math.max(0, yBot - yTop)))
} else {
hit.setAttribute('x', String(stripX + b.p1 * stripLength))
hit.setAttribute('y', String(stripY))
hit.setAttribute(
'width',
String(Math.max(0, (b.p2 - b.p1) * stripLength)),
)
hit.setAttribute('height', String(stripThickness))
}
hit.setAttribute('fill', 'transparent')
hit.setAttribute('class', 'apexcharts-heatmap-gradient-band')
hit.setAttribute('data:range-index', String(b.index))
hit.style.cursor = 'pointer'
svg.appendChild(hit)
this._bandHitEls.push(hit)
})
}
// Stash geometry for the hover handler + post-layout repositioning.
this._geom = {
isVertical,
position,
stripX,
stripY,
stripLength,
stripThickness,
arrowSize,
svgWidth,
svgHeight,
}
// Hover value tooltip — a tiny floating label that tracks the arrow.
if (cfg.showHoverValue) {
const tt = BrowserAPIs.createElement('div')
tt.classList.add('apexcharts-heatmap-gradient-legend-value')
tt.style.position = 'absolute'
tt.style.fontSize =
cfg.labelStyle?.fontSize || '11px'
tt.style.fontFamily =
cfg.labelStyle?.fontFamily || w.config.chart.fontFamily || ''
tt.style.color = w.config.chart.foreColor
tt.style.background = 'rgba(0,0,0,0.65)'
tt.style.color = '#fff'
tt.style.padding = '2px 6px'
tt.style.borderRadius = '3px'
tt.style.pointerEvents = 'none'
tt.style.whiteSpace = 'nowrap'
tt.style.opacity = '0'
tt.style.transition = 'opacity 120ms ease'
this.hoverValueEl = tt
}
// Mount.
elLegendWrap.classList.add('apexcharts-heatmap-gradient-legend-wrap')
elLegendWrap.classList.add(
'apx-legend-position-' + position,
)
elLegendWrap.appendChild(svg)
if (this.hoverValueEl) elLegendWrap.appendChild(this.hoverValueEl)
this.svgEl = svg
this._applyWrapAlignment(elLegendWrap, position, isVertical, svgWidth, svgHeight)
this._attachHoverListeners()
this._attachBandHoverListeners()
}
/**
* Resolve a configured length (number = px, string ending in '%' =
* percentage of the chart's SVG width/height) to a pixel length.
* @param {number|string} value
* @param {boolean} isVertical
* @returns {number}
*/
_resolveStripLength(value, isVertical) {
const w = this.w
const basis = isVertical
? w.globals.svgHeight || w.config.chart.height || 300
: w.globals.svgWidth || w.config.chart.width || 600
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed.endsWith('%')) {
const pct = parseFloat(trimmed) || 0
return Math.max(20, (basis * pct) / 100)
}
const n = parseFloat(trimmed)
return Number.isFinite(n) ? n : 200
}
if (typeof value === 'number' && Number.isFinite(value)) return value
return 200
}
/**
* Position the legend wrap and align the gradient strip within it. The
* wrap spans the chart's long axis (full width for top/bottom; full
* height for left/right) and uses flexbox to honor the `align` config.
* Bypasses the standard `setLegendWrapXY` which sizes the wrap to its
* content.
* @param {HTMLElement} elLegendWrap
* @param {'top'|'right'|'bottom'|'left'} position
* @param {boolean} isVertical
* @param {number} svgWidth
* @param {number} svgHeight
*/
_applyWrapAlignment(elLegendWrap, position, isVertical, svgWidth, svgHeight) {
const w = this.w
const cfg = w.config.plotOptions.heatmap.colorScale.gradientLegend
const align = cfg.align || 'center'
// Inset from the chart's outer edge so labels never bleed off-canvas.
// For `align: 'start'` / `'end'` this is the gap between the strip and
// the chart edge; for `'center'` it's a safety margin.
const edgePad = 12
const chartWidth = w.globals.svgWidth || w.config.chart.width || 600
const chartHeight = w.globals.svgHeight || w.config.chart.height || 300
const userOffsetX = w.config.legend.offsetX || 0
const userOffsetY = w.config.legend.offsetY || 0
// Content-sized wrap (matches the SVG dimensions exactly) — avoids
// covering the whole chart and confusing `getLegendDimensions()`.
// `.apexcharts-legend` ships `overflow: auto` which would show scroll
// bars around the strip; we explicitly override to `visible` so the
// hover-value tooltip (an absolutely-positioned sibling that may sit
// outside the wrap) renders without clipping or scrollers.
elLegendWrap.style.position = 'absolute'
elLegendWrap.style.display = 'block'
elLegendWrap.style.overflow = 'visible'
elLegendWrap.style.padding = '0'
elLegendWrap.style.width = svgWidth + 'px'
elLegendWrap.style.height = svgHeight + 'px'
elLegendWrap.style.right = 'auto'
elLegendWrap.style.bottom = 'auto'
if (isVertical) {
// Position along the vertical (long) axis based on align.
const availableHeight = chartHeight - svgHeight - edgePad * 2
let y
if (align === 'start') y = edgePad
else if (align === 'end') y = edgePad + Math.max(0, availableHeight)
else y = edgePad + Math.max(0, availableHeight) / 2
elLegendWrap.style.top = y + userOffsetY + 'px'
// Pin to chart's left or right edge.
if (position === 'left') {
elLegendWrap.style.left = edgePad + userOffsetX + 'px'
} else {
elLegendWrap.style.left =
chartWidth - svgWidth - edgePad + userOffsetX + 'px'
}
} else {
// Position along the horizontal (long) axis based on align.
const availableWidth = chartWidth - svgWidth - edgePad * 2
let x
if (align === 'start') x = edgePad
else if (align === 'end') x = edgePad + Math.max(0, availableWidth)
else x = edgePad + Math.max(0, availableWidth) / 2
elLegendWrap.style.left = x + userOffsetX + 'px'
// Pin to chart's top or bottom edge.
if (position === 'top') {
elLegendWrap.style.top = edgePad + userOffsetY + 'px'
} else {
elLegendWrap.style.top =
chartHeight - svgHeight - edgePad + userOffsetY + 'px'
}
}
}
/**
* Re-position the strip once the final layout is known.
*
* `_applyWrapAlignment` (called during `draw()`, before `plotCoords()`) can
* only pin to the chart's outer edge. This runs after layout — when
* `translateX/Y`, `gridWidth/Height` and `xAxisHeight` are populated — and:
* - centers the strip within its reserved band on the perpendicular axis
* (between the title and the plot for `top`; the x-axis and the chart
* bottom for `bottom`; the chart edge and the plot for `left`/`right`),
* so the slack is split evenly instead of dumped on one side, and
* - aligns it along the plot's own extent (so `align: 'center'` centers
* over the heatmap, not the whole canvas).
* Honors `legend.offsetX/offsetY` for user nudging. Safe to call repeatedly.
*/
repositionToPlot() {
if (!Environment.isBrowser()) return
const w = this.w
const g = w.globals
const wrap = /** @type {HTMLElement} */ (w.dom.elLegendWrap)
if (!wrap || !this._geom) return
if (!Number.isFinite(g.gridWidth) || !Number.isFinite(g.gridHeight)) return
const { isVertical, position, svgWidth, svgHeight, stripX, stripY, stripThickness } =
this._geom
const align =
w.config.plotOptions.heatmap.colorScale.gradientLegend.align || 'center'
const ox = w.config.legend.offsetX || 0
const oy = w.config.legend.offsetY || 0
// Title/subtitle area reserved at the top of the chart — the far boundary
// of the band for `position: 'top'`.
const dimHelpers = this.ctx?.dimensions?.dimHelpers
const titleArea = dimHelpers
? dimHelpers.getTitleSubtitleCoords('title').height +
dimHelpers.getTitleSubtitleCoords('subtitle').height
: 0
const xAxisArea = w.layout.xAxisHeight || 0
/**
* @param {number} extent plot length along the strip's long axis
* @param {number} size strip wrap size along that axis
*/
const alongOffset = (extent, size) => {
const avail = Math.max(0, extent - size)
if (align === 'start') return 0
if (align === 'end') return avail
return avail / 2
}
if (isVertical) {
// Along axis = vertical: center over the plot height.
wrap.style.top =
g.translateY + alongOffset(g.gridHeight, svgHeight) + oy + 'px'
// Perpendicular = horizontal: center the strip in the side band.
const bandStart = position === 'left' ? 0 : g.translateX + g.gridWidth
const bandEnd = position === 'left' ? g.translateX : g.svgWidth
const stripCenter = (bandStart + bandEnd) / 2
wrap.style.left =
stripCenter - stripX - stripThickness / 2 + ox + 'px'
} else {
// Along axis = horizontal: center over the plot width.
wrap.style.left =
g.translateX + alongOffset(g.gridWidth, svgWidth) + ox + 'px'
// Perpendicular = vertical: center the strip in the top/bottom band.
const bandStart =
position === 'top' ? titleArea : g.translateY + g.gridHeight + xAxisArea
const bandEnd = position === 'top' ? g.translateY : g.svgHeight
const stripCenter = (bandStart + bandEnd) / 2
wrap.style.top =
stripCenter - stripY - stripThickness / 2 + oy + 'px'
}
// The style-space centering above can land too close to the plot when the
// reserved band is barely wider than the strip (notably `position: right`),
// because the wrap's positioning origin differs from the SVG transform
// origin by a layout-dependent offset. Correct it with a real measurement —
// but only once the chart is painted, since the freshly-built grid/strip
// aren't laid out yet at create()-time (getBoundingClientRect would be 0).
BrowserAPIs.requestAnimationFrame(() => this._enforceMinPlotGap())
}
/**
* Guarantee a minimum gap between the strip's chart-facing edge and the plot.
* Measured in viewport space (immune to the wrap↔SVG coordinate offset) and
* applied as a *relative* shift to the wrap's current position, so it only
* nudges a strip that ended up too close — placements with ample room are
* left exactly where centering put them. Runs post-paint (see caller).
*/
_enforceMinPlotGap() {
const w = this.w
const wrap = /** @type {HTMLElement} */ (w.dom.elLegendWrap)
const strip = this.svgEl && this.svgEl.querySelector('rect')
const grid = w.dom.baseEl.querySelector('.apexcharts-grid')
if (!wrap || !strip || !grid || !this._geom) return
const s = strip.getBoundingClientRect()
const gr = grid.getBoundingClientRect()
// Not laid out yet (e.g. detached / zero-size) — nothing reliable to do.
if (!s.width || !s.height || !gr.width || !gr.height) return
const MIN_GAP = 16
const { isVertical, position } = this._geom
if (isVertical) {
const gap = position === 'left' ? gr.left - s.right : s.left - gr.right
if (gap < MIN_GAP) {
const curLeft = parseFloat(wrap.style.left) || 0
const shift = MIN_GAP - gap
wrap.style.left =
curLeft + (position === 'left' ? -shift : shift) + 'px'
}
} else {
const gap = position === 'top' ? gr.top - s.bottom : s.top - gr.bottom
if (gap < MIN_GAP) {
const curTop = parseFloat(wrap.style.top) || 0
const shift = MIN_GAP - gap
wrap.style.top = curTop + (position === 'top' ? -shift : shift) + 'px'
}
}
}
/**
* Tear down listeners (called before re-render).
*/
destroy() {
for (let i = 0; i < this._bandHitEls.length; i++) {
const el = this._bandHitEls[i]
el.removeEventListener?.('mousemove', this._onBandEnter)
el.removeEventListener?.('mouseout', this._onBandLeave)
}
this._bandHitEls = []
this._activeBandIndex = -1
if (!this.ctx?.events) return
try {
this.ctx.events.removeEventListener?.(
'dataPointMouseEnter',
this._onCellEnter,
)
this.ctx.events.removeEventListener?.(
'dataPointMouseLeave',
this._onCellLeave,
)
} catch (_) {
// ignore
}
}
/** Wire mousemove/mouseout on each per-band hit-region (ranges mode). */
_attachBandHoverListeners() {
if (!Environment.isBrowser()) return
for (let i = 0; i < this._bandHitEls.length; i++) {
const el = this._bandHitEls[i]
el.addEventListener('mousemove', this._onBandEnter)
el.addEventListener('mouseout', this._onBandLeave)
}
}
/**
* Hovering a gradient band highlights its cells and dims the rest. Guarded
* so the repeated mousemove stream only re-applies on an actual band change.
* @param {Event} e
*/
_onBandEnter(e) {
const w = this.w
const target = /** @type {Element} */ (e.currentTarget)
const idx = parseInt(target.getAttribute('data:range-index') ?? '-1', 10)
if (idx < 0 || idx === this._activeBandIndex) return
this._activeBandIndex = idx
this.ctx?.events?.fireEvent?.('legendHover', [this.ctx, idx, w])
new Series(w).highlightRangeInSeries(idx, 'highlight')
}
/** Leaving a band clears the highlight. */
_onBandLeave() {
if (this._activeBandIndex < 0) return
const idx = this._activeBandIndex
this._activeBandIndex = -1
new Series(this.w).highlightRangeInSeries(idx, 'reset')
}
_attachHoverListeners() {
if (!Environment.isBrowser()) return
if (!this.ctx?.events?.addEventListener) return
this.ctx.events.addEventListener(
'dataPointMouseEnter',
this._onCellEnter,
)
this.ctx.events.addEventListener(
'dataPointMouseLeave',
this._onCellLeave,
)
}
/**
* dataPointMouseEnter fires as `(e, ctx, { seriesIndex, dataPointIndex, w })`.
* Graphics._fireEvent forwards listener args in the same shape.
* @param {...any} args
*/
_onCellEnter(...args) {
const w = this.w
if (!this.arrowEl) return
// The opts object is the last argument.
const opts = args[args.length - 1]
if (!opts || typeof opts !== 'object') return
const i = opts.seriesIndex
const j = opts.dataPointIndex
if (typeof i !== 'number' || typeof j !== 'number') return
if (w.config.chart.type !== 'heatmap') return
const row = w.seriesData?.series?.[i]
const val = row?.[j]
if (val == null || Number.isNaN(val)) return
this._positionArrow(val)
}
_onCellLeave() {
if (!this.arrowEl) return
this.arrowEl.setAttribute('opacity', '0')
if (this.hoverValueEl) {
this.hoverValueEl.style.opacity = '0'
}
}
/**
* Move the arrow to the position corresponding to `val` along the strip.
* @param {number} val
*/
_positionArrow(val) {
if (!this.arrowEl || !this._geom) return
const { isVertical, position, stripX, stripY, stripLength, stripThickness, arrowSize } =
this._geom
const min = this._min
const max = this._max
const span = max - min
let pct
if (span === 0) {
pct = 0.5
} else {
pct = (val - min) / span
}
if (pct < 0) pct = 0
if (pct > 1) pct = 1
if (isVertical) {
// Bottom = min, top = max → invert pct for Y coord.
const yCenter = stripY + stripLength - pct * stripLength
// Arrow tip touches the strip edge; arrow extends outward.
let tipX, baseX
if (position === 'left') {
// Strip is on the left of arrow. Arrow points left.
// Wait, position='left' means legend is on the chart's left side, so
// the strip's right edge faces the chart. Arrow sits on the right of
// the strip, pointing right toward the chart.
tipX = stripX + stripThickness
baseX = tipX + arrowSize
} else {
// position === 'right' — chart is on the left of legend, so strip's
// left edge faces the chart. Arrow on the left of strip pointing left.
tipX = stripX
baseX = tipX - arrowSize
}
const points = [
`${tipX},${yCenter}`,
`${baseX},${yCenter - arrowSize / 2}`,
`${baseX},${yCenter + arrowSize / 2}`,
].join(' ')
this.arrowEl.setAttribute('points', points)
} else {
const xCenter = stripX + pct * stripLength
let tipY, baseY
if (position === 'top') {
// Strip's bottom edge faces the chart. Arrow below strip pointing up.
tipY = stripY + stripThickness
baseY = tipY + arrowSize
} else {
// position === 'bottom' — strip's top edge faces the chart. Arrow
// above strip pointing down.
tipY = stripY
baseY = tipY - arrowSize
}
const points = [
`${xCenter},${tipY}`,
`${xCenter - arrowSize / 2},${baseY}`,
`${xCenter + arrowSize / 2},${baseY}`,
].join(' ')
this.arrowEl.setAttribute('points', points)
}
this.arrowEl.setAttribute('opacity', '1')
if (this.hoverValueEl) {
const fmt = this._getFormatter()
this.hoverValueEl.textContent = fmt(val)
// Position the tooltip near the arrow (in legend-wrap local coords).
if (isVertical) {
const yCenter = stripY + stripLength - pct * stripLength
if (position === 'left') {
this.hoverValueEl.style.left = `${stripX + stripThickness + arrowSize + 8}px`
} else {
this.hoverValueEl.style.left = `${stripX - arrowSize - 8}px`
this.hoverValueEl.style.transform = 'translateX(-100%)'
}
this.hoverValueEl.style.top = `${yCenter - 9}px`
} else {
const xCenter = stripX + pct * stripLength
this.hoverValueEl.style.left = `${xCenter}px`
this.hoverValueEl.style.transform = 'translateX(-50%)'
if (position === 'top') {
this.hoverValueEl.style.top = `${stripY + stripThickness + arrowSize + 8}px`
} else {
this.hoverValueEl.style.top = `${stripY - arrowSize - 18}px`
}
}
this.hoverValueEl.style.opacity = '1'
}
}
/**
* @param {number} size
* @param {string} color
* @param {'top'|'right'|'bottom'|'left'} _position
*/
_buildArrow(size, color, _position) {
const polygon = BrowserAPIs.createElementNS(SVG_NS, 'polygon')
polygon.setAttribute('fill', color)
polygon.setAttribute('opacity', '0')
polygon.setAttribute('class', 'apexcharts-heatmap-gradient-arrow')
polygon.setAttribute('points', '0,0 0,0 0,0')
// Pure indicator — must never intercept pointer events from the band
// hit-regions sitting beneath it.
polygon.setAttribute('pointer-events', 'none')
return polygon
}
/**
* Build gradient stops + return effective min/max.
* - If `colorScale.ranges` is set, stops are placed at each range boundary
* so the gradient reflects the user's discrete palette.
* - Otherwise, samples N stops from the same shadeColor function the cells
* use, so the strip visually matches the heatmap.
* @returns {{ min: number, max: number, stops: Array<{percent:number,color:string}>, bands: Array<{index:number,p1:number,p2:number}> }}
*/
_computeStops() {
const w = this.w
const cs = w.config.plotOptions.heatmap.colorScale
const cfg = cs.gradientLegend
// Legend.init() runs *before* coreCalculations populates globals.minY/maxY,
// so we can't rely on those here. Derive the value range directly from
// the already-parsed series matrix (written by _writeParsedSeriesData).
let dataMin = Infinity
let dataMax = -Infinity
const rows = w.seriesData?.series || []
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (!row) continue
for (let j = 0; j < row.length; j++) {
const v = row[j]
if (v == null || Number.isNaN(v)) continue
if (v < dataMin) dataMin = v
if (v > dataMax) dataMax = v
}
}
if (!Number.isFinite(dataMin)) dataMin = 0
if (!Number.isFinite(dataMax)) dataMax = 0
// Apply colorScale.min/max overrides (same expand-not-clamp semantics as
// determineColor — keeps behavior consistent with the cells).
let min = dataMin
let max = dataMax
if (typeof cs.min !== 'undefined') {
min = cs.min < dataMin ? cs.min : dataMin
}
if (typeof cs.max !== 'undefined') {
max = cs.max > dataMax ? cs.max : dataMax
}
/** @type {Array<{percent:number,color:string}>} */
const stops = []
/**
* Fractional band extents along the strip, carrying the band's index in
* the *original* (unsorted) `colorScale.ranges` array — so the hover
* handler can pass it straight to `Series.highlightRangeInSeries`, which
* indexes that same array.
* @type {Array<{index:number,p1:number,p2:number}>}
*/
const bands = []
if (cs.ranges && cs.ranges.length > 0) {
// Use the ranges as the palette. Tag each range with its original index
// before sorting so band hit-regions stay aligned with the unsorted
// ranges array.
const ranges = cs.ranges
.map((/** @type {any} */ r, /** @type {number} */ originalIndex) => ({
...r,
_originalIndex: originalIndex,
}))
.sort((/** @type {any} */ a, /** @type {any} */ b) => a.from - b.from)
const lo = ranges[0].from
const hi = ranges[ranges.length - 1].to
min = lo
max = hi
const span = hi - lo || 1
ranges.forEach((/** @type {any} */ r) => {
const p1 = (r.from - lo) / span
const p2 = (r.to - lo) / span
// Anchor each range color at its midpoint so the strip blends smoothly
// green→blue→yellow→red — matching the shaded cells, which vary
// continuously within each range. SVG clamps the first/last stop colors
// past their offsets, so one midpoint stop per range gives solid ends
// plus smooth middles.
stops.push({ percent: (p1 + p2) / 2, color: r.color })
// Hit-regions always span the true range boundaries, regardless of how
// the gradient itself is drawn.
bands.push({ index: r._originalIndex, p1, p2 })
})
} else {
// Sample the shade function. Use the first series' base color as the
// reference (matches single-row heatmaps; multi-row heatmaps with
// per-row colors don't have a single canonical gradient — first color
// is the documented behavior).
const baseColor = w.globals.colors[0] || '#008FFB'
const utils = new Utils()
const shadeIntensity =
w.config.plotOptions.heatmap.shadeIntensity ?? 0.5
const hasNegs = /** @type {any} */ (w.globals).hasNegs
const n = Math.max(2, cfg.stops || 16)
for (let s = 0; s < n; s++) {
const t = s / (n - 1) // 0..1
// Map t to the shade percent the cell at that t would receive.
// determineColor computes percent = 100*val/(|min|+|max|), so the cell
// sitting at value v = min + t*(max-min) gets percent_v.
const v = min + t * (max - min)
const total = Math.abs(max) + Math.abs(min)
const percent_v = total === 0 ? 0 : (100 * v) / total
let colorShadePercent
if (hasNegs) {
if (w.config.plotOptions.heatmap.reverseNegativeShade) {
colorShadePercent =
percent_v < 0
? (percent_v / 100) * (shadeIntensity * 1.25)
: (1 - percent_v / 100) * (shadeIntensity * 1.25)
} else {
colorShadePercent =
percent_v <= 0
? 1 - (1 + percent_v / 100) * shadeIntensity
: (1 - percent_v / 100) * shadeIntensity
}
} else {
colorShadePercent = 1 - percent_v / 100
}
// Clamp to a reasonable range to avoid color blowout.
if (colorShadePercent > 1) colorShadePercent = 1
if (colorShadePercent < -1) colorShadePercent = -1
const shaded = w.config.plotOptions.heatmap.enableShades
? utils.shadeColor(
w.config.theme.mode === 'dark'
? colorShadePercent * -1
: colorShadePercent,
baseColor,
)
: baseColor
stops.push({ percent: t, color: shaded })
}
}
return { min, max, stops, bands }
}
}