apexcharts
Version:
A JavaScript Chart Library
1,511 lines (1,328 loc) • 53.9 kB
JavaScript
// @ts-check
import Graphics from './Graphics'
import Utils from './../utils/Utils'
import Toolbar from './Toolbar'
import AxisMapping from './AxisMapping'
import { Box } from '../svg/index'
// Wheel-zoom feel: scrolling this many deltaY pixels doubles (or halves) the
// visible x-window. 240px is about two discrete wheel notches per 2x zoom
// (~1.4x per notch): steep enough to traverse a large dataset in a few flicks,
// gentle enough that a trackpad's stream of small deltas feels continuous.
const WHEEL_ZOOM_PIXELS_PER_2X = 240
/**
* ApexCharts Zoom Class for handling zooming and panning on axes based charts.
*
* @module ZoomPanSelection
**/
export default class ZoomPanSelection extends Toolbar {
/**
* @param {import('../types/internal').ChartStateW} w
* @param {import('../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
super(w, ctx)
this.w = w
this.ctx = ctx // needed: toolbar ref, fires events with ctx
this.dragged = false
this.graphics = new Graphics(this.w)
this.eventList = [
'mousedown',
'mouseleave',
'mousemove',
'touchstart',
'touchmove',
'mouseup',
'touchend',
'wheel',
]
this.clientX = 0
this.clientY = 0
this.startX = 0
this.endX = 0
this.dragX = 0
this.startY = 0
this.endY = 0
this.dragY = 0
this.moveDirection = 'none'
}
/** @param {{xyRatios: any}} opts */
init({ xyRatios }) {
const w = this.w
const me = this
// fastUpdate refreshes this field in place when a data-only update changes
// the domain (the instance survives the fast path; only a full render
// recreates it). Always read this.xyRatios, never an init-captured copy.
this.xyRatios = xyRatios
this.zoomRect = this.graphics.drawRect(0, 0, 0, 0)
this.selectionRect = this.graphics.drawRect(0, 0, 0, 0)
this.constraints = new Box(0, 0, w.layout.gridWidth, w.layout.gridHeight)
this.zoomRect.node.classList.add('apexcharts-zoom-rect')
this.selectionRect.node.classList.add('apexcharts-selection-rect')
w.dom.Paper.add(this.zoomRect)
w.dom.Paper.add(this.selectionRect)
if (w.config.chart.selection.type === 'x') {
this.slDraggableRect = this.selectionRect
.draggable({
minX: 0,
minY: 0,
maxX: w.layout.gridWidth,
maxY: w.layout.gridHeight,
})
.on('dragmove.namespace', this.selectionDragging.bind(this, 'dragging'))
} else if (w.config.chart.selection.type === 'y') {
this.slDraggableRect = this.selectionRect
.draggable({
minX: 0,
maxX: w.layout.gridWidth,
})
.on('dragmove.namespace', this.selectionDragging.bind(this, 'dragging'))
} else {
this.slDraggableRect = this.selectionRect
.draggable()
.on('dragmove.namespace', this.selectionDragging.bind(this, 'dragging'))
}
this.preselectedSelection()
this.hoverArea = /** @type {Element} */ (
w.dom.baseEl.querySelector(`${w.globals.chartClass} .apexcharts-svg`)
)
if (!this.hoverArea) return
this.hoverArea.classList.add('apexcharts-zoomable')
this.eventList.forEach((event) => {
this.hoverArea?.addEventListener(
event,
me.svgMouseEvents.bind(me, xyRatios),
{
capture: false,
passive: true,
},
)
})
if (
w.config.chart.zoom.enabled &&
w.config.chart.zoom.allowMouseWheelZoom
) {
this.hoverArea.addEventListener('wheel', me.mouseWheelEvent.bind(me), {
capture: false,
passive: false,
})
}
// Momentum: passive:false touch listeners (following the wheel-listener
// template above) so two-finger pinch and horizontal pan can preventDefault
// the browser's native page zoom/scroll. Every _updateOptions destroys and
// recreates this instance (see Destroy.clear), and applying a gesture frame
// triggers exactly that re-render, so ALL gesture state lives on w.interact
// (which survives re-renders) rather than on the instance. The browser keeps
// delivering the touch sequence to its original target node, whose listener
// is bound to the now-detached old instance; that old instance keeps driving
// the gesture using the persistent w state. init() just (re)binds listeners
// to the current hover area for the NEXT gesture; old nodes are GC'd.
if (this._momentumEnabled()) {
;['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(
(event) => {
this.hoverArea?.addEventListener(event, me.momentumTouch.bind(me), {
capture: false,
passive: false,
})
},
)
}
}
// remove the event listeners which were previously added on hover area
destroy() {
// Momentum: no teardown here. destroy() runs on every update (isUpdating),
// not only on a real destroy, so cancelling inertia here would kill a glide
// on its first frame. The inertia loop self-terminates on
// w.globals.isDestroyed (set only on a full destroy), and the gesture state
// on w.interact is discarded with w when the chart is truly destroyed.
if (this.slDraggableRect) {
this.slDraggableRect.draggable(false)
this.slDraggableRect.off()
this.selectionRect.off()
}
this.selectionRect = null
this.zoomRect = null
}
/**
* @param {import('../types/internal').XYRatios} xyRatios
* @param {any} e
*/
svgMouseEvents(xyRatios, e) {
const w = this.w
const toolbar = this.ctx.toolbar
// Momentum owns multi-touch and any in-progress touch gesture: stand down
// the single-touch pan/selection path so they do not fight. (momentum.busy
// also covers touchend, where e.touches has already emptied.) Mouse events
// carry no e.touches and never set momentum.busy, so they are unaffected.
if (w.interact.momentum && w.interact.momentum.busy) return
if (this._momentumEnabled() && e.touches && e.touches.length > 1) {
return
}
const zoomtype = w.interact.zoomEnabled
? w.config.chart.zoom.type
: w.config.chart.selection.type
const autoSelected = w.config.chart.toolbar.autoSelected
// Shift temporarily flips zoom<->pan, restoring the auto-selected tool on
// release. 'measure' is not a zoom/pan tool (it captures the plot via its
// own pane), so it opts out of this shift dance entirely.
if (autoSelected !== 'measure') {
if (e.shiftKey) {
this.shiftWasPressed = true
toolbar.enableZoomPanFromToolbar(autoSelected === 'pan' ? 'zoom' : 'pan')
} else {
if (this.shiftWasPressed) {
toolbar.enableZoomPanFromToolbar(autoSelected)
this.shiftWasPressed = false
}
}
}
if (!e.target) return
const tc = e.target.classList
let pc
if (e.target.parentNode && e.target.parentNode !== null) {
pc = e.target.parentNode.classList
}
const falsePositives =
tc.contains('apexcharts-legend-marker') ||
tc.contains('apexcharts-legend-text') ||
(pc && pc.contains('apexcharts-toolbar'))
if (falsePositives) return
this.clientX =
e.type === 'touchmove' || e.type === 'touchstart'
? e.touches[0].clientX
: e.type === 'touchend'
? e.changedTouches[0].clientX
: e.clientX
this.clientY =
e.type === 'touchmove' || e.type === 'touchstart'
? e.touches[0].clientY
: e.type === 'touchend'
? e.changedTouches[0].clientY
: e.clientY
if ((e.type === 'mousedown' && e.which === 1) || e.type === 'touchstart') {
const gridRectDim = this._gridRect()
if (!gridRectDim) return
this.startX = this._screenXToPlotPx(this.clientX)
this.startY = this.clientY - gridRectDim.top
this.dragged = false
this.w.interact.mousedown = true
}
if ((e.type === 'mousemove' && e.which === 1) || e.type === 'touchmove') {
this.dragged = true
if (w.interact.panEnabled) {
w.interact.selection = null
if (this.w.interact.mousedown) {
this.panDragging({
context: this,
zoomtype,
xyRatios: this.xyRatios,
})
}
} else {
if (
(this.w.interact.mousedown && w.interact.zoomEnabled) ||
(this.w.interact.mousedown && w.interact.selectionEnabled)
) {
this.selection = this.selectionDrawing({
context: this,
zoomtype,
})
}
}
}
if (
e.type === 'mouseup' ||
e.type === 'touchend' ||
e.type === 'mouseleave'
) {
this.handleMouseUp({ zoomtype })
}
this.makeSelectionRectDraggable()
}
/** @param {{ zoomtype?: any, isResized?: any }} opts */
handleMouseUp({ zoomtype, isResized }) {
const w = this.w
// we will be calling getBoundingClientRect on each mousedown/mousemove/mouseup
const gridRectDim = this._gridRect()
if (gridRectDim && (this.w.interact.mousedown || isResized)) {
// user released the drag, now do all the calculations
this.endX = this._screenXToPlotPx(this.clientX)
this.endY = this.clientY - gridRectDim.top
this.dragX = Math.abs(this.endX - this.startX)
this.dragY = Math.abs(this.endY - this.startY)
if (w.interact.zoomEnabled || w.interact.selectionEnabled) {
this.selectionDrawn({
context: this,
zoomtype,
})
}
// if (w.interact.panEnabled && w.config.xaxis.convertedCatToNumeric) {
// this.delayedPanScrolled()
// }
}
if (w.interact.zoomEnabled) {
this.hideSelectionRect(this.selectionRect)
}
this.dragged = false
this.w.interact.mousedown = false
}
// ---------------------------------------------------------------------------
// Wheel zoom: continuous, cursor-anchored zoom on mouse wheel / trackpad.
//
// Each wheel event multiplies a pending zoom factor scaled to its deltaY (so
// a trackpad's stream of tiny deltas and a discrete wheel's ±100 notches both
// feel proportional), and the accumulated factor is applied at most once per
// animation frame through the same immediate, animation-free fast path the
// touch pinch uses (_applyXRange). Deliberately instant, trading-chart style:
// no per-step morph and no easing between steps (an animated variant was
// tried and rejected). The original implementation instead ran a fixed
// 0.5x/1.5x animated update at most once per 400ms and dropped every wheel
// event in between, which read as lag on continuous scrolling.
//
// Like Momentum (see the comment above momentumTouch), applying a frame
// triggers _updateOptions, which destroys and recreates this instance
// mid-gesture, so all wheel-gesture state lives on w.interact.wheel rather
// than on the instance.
// ---------------------------------------------------------------------------
/** Lazily-created, re-render-surviving wheel-gesture state. */
_wheel() {
const it = this.w.interact
if (!it.wheel) {
it.wheel = {
factor: 1,
clientX: 0,
/** @type {number|null} */ rafId: null,
/** @type {any} */ endTimer: null,
}
}
return it.wheel
}
/**
* @param {any} e
*/
mouseWheelEvent(e) {
e.preventDefault()
const st = this._wheel()
// normalize the delta to pixels (deltaMode 1 = lines, 2 = pages)
let dy = e.deltaY
if (e.deltaMode === 1) dy *= 33
else if (e.deltaMode === 2) dy *= 330
// scroll up (dy < 0) shrinks the window => zoom in
st.factor *= Math.pow(2, dy / WHEEL_ZOOM_PIXELS_PER_2X)
st.clientX = e.clientX
if (st.rafId == null) {
st.rafId = requestAnimationFrame(() => this._applyWheelZoom())
}
// the zoomed callback fires once per gesture (like pinch), not per event
if (st.endTimer) clearTimeout(st.endTimer)
st.endTimer = setTimeout(() => this._endWheelZoom(), 150)
}
/**
* Apply the zoom factor accumulated since the last animation frame, keeping
* the data value under the cursor pinned (both zooming in and out).
*/
_applyWheelZoom() {
const w = this.w
const st = this._wheel()
st.rafId = null
const scale = st.factor
st.factor = 1
if (scale === 1 || w.globals.isDestroyed) return
const gridRectDim = this._gridRect()
if (!gridRectDim || !gridRectDim.width) return
const { min, max } = this._currentXWindow()
const range = max - min
const mouseX = Math.min(
Math.max((st.clientX - gridRectDim.left) / gridRectDim.width, 0),
1,
)
let newRange = range * scale
// Floor the zoom-in window at ~2 data points' worth of x-span (minXDiff is
// the smallest gap between consecutive x values), not a fixed 1% of the
// full data span: a span-relative floor stopped wheel-zoom far short of the
// toolbar's zoom-in button on large series. Cap zoom-out at the full domain
// so scrolling out past the data doesn't keep re-rendering.
const bounds = this._clampBounds()
if (bounds) {
const minXDiff =
w.globals.minXDiff > 0 && isFinite(w.globals.minXDiff)
? w.globals.minXDiff
: 0
const minRange = Math.max(minXDiff * 2, (bounds.max - bounds.min) * 1e-6)
if (newRange < minRange) newRange = minRange
if (newRange > bounds.max - bounds.min) newRange = bounds.max - bounds.min
}
const anchor = min + mouseX * range
let newMinX = anchor - mouseX * newRange
let newMaxX = newMinX + newRange
// already at the zoom floor or the full domain: nothing to re-render
const eps = range * 1e-9
if (Math.abs(newMinX - min) < eps && Math.abs(newMaxX - max) < eps) return
if (isNaN(newMinX) || isNaN(newMaxX)) return
const beforeZoomRange = this.getBeforeZoomRange(
{ min: newMinX, max: newMaxX },
/** @type {any} */ (undefined),
)
if (beforeZoomRange && beforeZoomRange.xaxis) {
newMinX = beforeZoomRange.xaxis.min
newMaxX = beforeZoomRange.xaxis.max
}
this._applyXRange(newMinX, newMaxX, true)
}
/** Fire the zoomed callback once the wheel gesture settles (mirrors _endPinch). */
_endWheelZoom() {
const w = this.w
const st = this._wheel()
st.endTimer = null
if (w.globals.isDestroyed || !w.interact.zoomed) return
const { min, max } = this._currentXWindow()
const yaxis = w.globals.initialConfig
? Utils.clone(w.globals.initialConfig.yaxis)
: []
const toolbar = this.ctx.toolbar
if (toolbar) toolbar.zoomCallback({ min, max }, yaxis)
}
makeSelectionRectDraggable() {
const w = this.w
if (!this.selectionRect) return
const rectDim = this.selectionRect.node.getBoundingClientRect()
if (rectDim.width > 0 && rectDim.height > 0) {
this.selectionRect.select(false).resize(false)
this.selectionRect
/**
* @param {Element} group
* @param {number} p
* @param {number} index
* @param {any[]} pointArr
* @param {string} handleName
*/
.select({
createRot: () => {},
updateRot: () => {},
createHandle: (
/** @type {any} */ group,
/** @type {any} */ p,
/** @type {any} */ index,
/** @type {any} */ pointArr,
/** @type {any} */ handleName,
) => {
if (handleName === 'l' || handleName === 'r')
return group
.circle(8)
.css({ 'stroke-width': 1, stroke: '#333', fill: '#fff' })
/**
* @param {Element} group
* @param {number} p
*/
return group.circle(0)
},
updateHandle: (/** @type {any} */ group, /** @type {any} */ p) => {
return group.center(p[0], p[1])
},
})
.resize()
.on('resize', () => {
if (w.interact.selectionEnabled) {
// A handle resize re-reports the selection through the SAME shared
// mapping (and debounce) as the rect-body drag. The old path
// (handleMouseUp -> selectionDrawn) is gated by `dragged`, which is
// false during a pure resize, so the reported range / crossfilter
// went stale while the rect visibly grew (e.g. you could never
// resize the brush out to cover the last column). Persist the new
// rect too so a re-render keeps it.
w.interact.selection = {
x: parseFloat(this.selectionRect.node.getAttribute('x')),
y: parseFloat(this.selectionRect.node.getAttribute('y')),
width: parseFloat(this.selectionRect.node.getAttribute('width')),
height: parseFloat(this.selectionRect.node.getAttribute('height')),
}
clearTimeout(this.w.globals.selectionResizeTimer ?? undefined)
this.w.globals.selectionResizeTimer = window.setTimeout(() => {
this._emitSelectionFromRect()
}, 30)
} else {
const zoomtype = w.interact.zoomEnabled
? w.config.chart.zoom.type
: w.config.chart.selection.type
this.handleMouseUp({ zoomtype, isResized: true })
}
})
}
}
preselectedSelection() {
const w = this.w
const xyRatios = this.xyRatios
if (!w.interact.zoomEnabled) {
if (
typeof w.interact.selection !== 'undefined' &&
w.interact.selection !== null
) {
this.drawSelectionRect({
...w.interact.selection,
translateX: w.layout.translateX,
translateY: w.layout.translateY,
})
} else {
if (
w.config.chart.selection.xaxis.min !== undefined &&
w.config.chart.selection.xaxis.max !== undefined
) {
// Same mapping as bar placement (AxisMapping): x/width in plot-origin
// px so the preselected rect is pixel-exact against the bars.
let x = AxisMapping.dataXToPx(w, w.config.chart.selection.xaxis.min)
let width =
AxisMapping.dataXToPx(w, w.config.chart.selection.xaxis.max) - x
if (w.axisFlags.isRangeBar) {
// rangebars put datetime data in y axis
x =
(w.config.chart.selection.xaxis.min -
w.globals.yAxisScale[0].niceMin) /
xyRatios.invertedYRatio
width =
(w.config.chart.selection.xaxis.max -
w.config.chart.selection.xaxis.min) /
xyRatios.invertedYRatio
}
const selectionRect = {
x,
y: 0,
width,
height: w.layout.gridHeight,
translateX: w.layout.translateX,
translateY: w.layout.translateY,
selectionEnabled: true,
}
this.drawSelectionRect(selectionRect)
this.makeSelectionRectDraggable()
if (typeof w.config.chart.events.selection === 'function') {
w.config.chart.events.selection(this.ctx, {
xaxis: {
min: w.config.chart.selection.xaxis.min,
max: w.config.chart.selection.xaxis.max,
},
yaxis: {},
})
}
}
}
}
}
/** @param {{x: any, y: any, width: any, height: any, translateX: any, translateY: any}} opts */
drawSelectionRect({ x, y, width, height, translateX = 0, translateY = 0 }) {
const w = this.w
const zoomRect = this.zoomRect
const selectionRect = this.selectionRect
if (this.dragged || w.interact.selection !== null) {
const scalingAttrs = {
transform: 'translate(' + translateX + ', ' + translateY + ')',
}
// change styles based on zoom or selection
// zoom is Enabled and user has dragged, so draw blue rect
if (w.interact.zoomEnabled && this.dragged) {
if (width < 0) width = 1 // fixes apexcharts.js#1168
zoomRect.attr({
x,
y,
width,
height,
fill: w.config.chart.zoom.zoomedArea.fill.color,
'fill-opacity': w.config.chart.zoom.zoomedArea.fill.opacity,
stroke: w.config.chart.zoom.zoomedArea.stroke.color,
'stroke-width': w.config.chart.zoom.zoomedArea.stroke.width,
'stroke-opacity': w.config.chart.zoom.zoomedArea.stroke.opacity,
})
Graphics.setAttrs(zoomRect.node, scalingAttrs)
}
// selection is enabled
if (w.interact.selectionEnabled) {
selectionRect.attr({
x,
y,
width: width > 0 ? width : 0,
height: height > 0 ? height : 0,
fill: w.config.chart.selection.fill.color,
'fill-opacity': w.config.chart.selection.fill.opacity,
stroke: w.config.chart.selection.stroke.color,
'stroke-width': w.config.chart.selection.stroke.width,
'stroke-dasharray': w.config.chart.selection.stroke.dashArray,
'stroke-opacity': w.config.chart.selection.stroke.opacity,
})
Graphics.setAttrs(selectionRect.node, scalingAttrs)
}
}
}
/**
* @param {any} rect
*/
hideSelectionRect(rect) {
if (rect) {
rect.attr({
x: 0,
y: 0,
width: 0,
height: 0,
})
}
}
selectionDrawing(/** @type {any} */ { context, zoomtype }) {
const w = this.w
const me = context
const gridRectDim = this._gridRect()
if (!gridRectDim) return
const startX = me.startX - 1
const startY = me.startY
let inversedX = false
let inversedY = false
const left = this._screenXToPlotPx(me.clientX)
const top = me.clientY - gridRectDim.top
let selectionWidth = left - startX
let selectionHeight = top - startY
/** @type {any} */
let selectionRect = {
translateX: w.layout.translateX,
translateY: w.layout.translateY,
}
if (Math.abs(selectionWidth + startX) > w.layout.gridWidth) {
// user dragged the mouse outside drawing area to the right
selectionWidth = w.layout.gridWidth - startX
} else if (left < 0) {
// user dragged the mouse outside drawing area to the left
selectionWidth = startX
}
// inverse selection X
if (startX > left) {
inversedX = true
selectionWidth = Math.abs(selectionWidth)
}
// inverse selection Y
if (startY > top) {
inversedY = true
selectionHeight = Math.abs(selectionHeight)
}
if (zoomtype === 'x') {
selectionRect = {
x: inversedX ? startX - selectionWidth : startX,
y: 0,
width: selectionWidth,
height: w.layout.gridHeight,
}
} else if (zoomtype === 'y') {
selectionRect = {
x: 0,
y: inversedY ? startY - selectionHeight : startY,
width: w.layout.gridWidth,
height: selectionHeight,
}
} else {
selectionRect = {
x: inversedX ? startX - selectionWidth : startX,
y: inversedY ? startY - selectionHeight : startY,
width: selectionWidth,
height: selectionHeight,
}
}
selectionRect = {
...selectionRect,
translateX: w.layout.translateX,
translateY: w.layout.translateY,
}
me.drawSelectionRect(selectionRect)
me.selectionDragging('resizing')
return selectionRect
}
/**
* @param {string} type
* @param {CustomEvent} e
*/
selectionDragging(type, e) {
const w = this.w
if (!e) return
e.preventDefault()
const { handler, box } = e.detail
const constraints = /** @type {any} */ (this.constraints)
let { x, y } = box
if (x < constraints.x) {
x = constraints.x
}
if (y < constraints.y) {
y = constraints.y
}
if (box.x2 > constraints.x2) {
x = constraints.x2 - box.w
}
if (box.y2 > constraints.y2) {
y = constraints.y2 - box.h
}
handler.move(x, y)
const selRect = this.selectionRect
let timerInterval = 0
if (type === 'resizing') {
timerInterval = 30
}
// update selection when selection rect is dragged
/**
* @param {string} attr
*/
const getSelAttr = (attr) => {
return parseFloat(selRect.node.getAttribute(attr))
}
const draggedProps = {
x: getSelAttr('x'),
y: getSelAttr('y'),
width: getSelAttr('width'),
height: getSelAttr('height'),
}
w.interact.selection = draggedProps
// update selection ends
// Run the range recompute when the user has a selection callback OR Linked
// Views (#4) crossfilter is on, so dragging/resizing the persistent
// selection rect re-crossfilters even without a user events.selection.
// Covers both highlight mode (link.enabled) and filter mode (link.dimension).
const link = w.config.chart.link
const linkActive = !!(
link && (link.enabled || typeof link.dimension === 'function')
)
if (
(typeof w.config.chart.events.selection === 'function' || linkActive) &&
w.interact.selectionEnabled
) {
// a small debouncer is required when resizing to avoid freezing the chart
clearTimeout(this.w.globals.selectionResizeTimer ?? undefined)
this.w.globals.selectionResizeTimer = window.setTimeout(() => {
this._emitSelectionFromRect()
}, timerInterval)
}
}
/**
* Recompute the reported x/y range from the CURRENT persistent selection rect
* (via the shared AxisMapping) and notify listeners: chart.events.selection,
* brushScrolled, and the crossfilter coordinator. Shared by the rect-body drag
* (selectionDragging) and the handle resize (makeSelectionRectDraggable) so
* every gesture re-reports through ONE mapping and the reported range always
* matches the rect the user sees. No dragged/threshold gate: reaching here
* already means the user moved or resized the persistent rect.
*/
_emitSelectionFromRect() {
const w = this.w
if (!w.interact.selectionEnabled) return
const link = w.config.chart.link
const linkActive = !!(
link && (link.enabled || typeof link.dimension === 'function')
)
if (typeof w.config.chart.events.selection !== 'function' && !linkActive) {
return
}
const gridRectDim = this._gridRect()
if (!gridRectDim) return
const selectionRect = this.selectionRect.node.getBoundingClientRect()
const xyRatios = this.xyRatios
let minX, maxX, minY, maxY
// Convert the rect's pixel edges to data via the shared AxisMapping used by
// bar placement and selectionDrawn: pixels are measured from the plot origin
// (translateX), the data origin is minX (NOT niceMin), and barPad never
// enters the mapping.
const relLeft = this._screenXToPlotPx(selectionRect.left)
const relRight = this._screenXToPlotPx(selectionRect.right)
if (!w.axisFlags.isRangeBar) {
if (!w.globals.xAxisScale) return
minX = AxisMapping.pxToDataX(w, relLeft)
maxX = AxisMapping.pxToDataX(w, relRight)
minY =
w.globals.yAxisScale[0].niceMin +
(gridRectDim.bottom - selectionRect.bottom) * xyRatios.yRatio[0]
maxY =
w.globals.yAxisScale[0].niceMax -
(selectionRect.top - gridRectDim.top) * xyRatios.yRatio[0]
} else {
// rangeBars use y for datetime
minX =
w.globals.yAxisScale[0].niceMin + relLeft * xyRatios.invertedYRatio
maxX =
w.globals.yAxisScale[0].niceMin + relRight * xyRatios.invertedYRatio
minY = 0
maxY = 1
}
const xyAxis = {
xaxis: { min: minX, max: maxX },
yaxis: { min: minY, max: maxY },
}
if (typeof w.config.chart.events.selection === 'function') {
w.config.chart.events.selection(this.ctx, xyAxis)
}
if (
w.config.chart.brush.enabled &&
w.config.chart.events.brushScrolled !== undefined
) {
w.config.chart.events.brushScrolled(this.ctx, xyAxis)
}
// Linked Views (#4): re-crossfilter live as the persistent selection rect is
// dragged or resized.
this.ctx.linkedViews?.onSourceSelection(xyAxis.xaxis)
}
/** @param {{context: any, zoomtype: any}} opts */
selectionDrawn({ context, zoomtype }) {
const w = this.w
const me = context
const xyRatios = this.xyRatios
const toolbar = this.ctx.toolbar
// Use boundingRect for final selection area
const selRect = w.interact.zoomEnabled
? me.zoomRect.node.getBoundingClientRect()
: me.selectionRect.node.getBoundingClientRect()
const gridRectDim = me._gridRect()
if (!gridRectDim) return
// Local coords: x in the plot-origin space (shared with bar placement via
// AxisMapping), y still relative to the grid box top (no vertical padding).
const localStartX = this._screenXToPlotPx(selRect.left)
const localEndX = this._screenXToPlotPx(selRect.right)
const localStartY = selRect.top - gridRectDim.top
const localEndY = selRect.bottom - gridRectDim.top
// Convert those local coords to actual data values
let xLowestValue, xHighestValue
if (!w.axisFlags.isRangeBar) {
xLowestValue = AxisMapping.pxToDataX(w, localStartX)
xHighestValue = AxisMapping.pxToDataX(w, localEndX)
} else {
xLowestValue =
w.globals.yAxisScale[0].niceMin + localStartX * xyRatios.invertedYRatio
xHighestValue =
w.globals.yAxisScale[0].niceMin + localEndX * xyRatios.invertedYRatio
}
// For Y values, pick from the first y-axis, but handle multi-axis
/** @type {any[]} */
const yHighestValue = []
/** @type {any[]} */
const yLowestValue = []
/**
* @param {ApexYAxis} yaxe
* @param {number} index
*/
w.config.yaxis.forEach((yaxe, index) => {
// pick whichever series is mapped to this y-axis
const seriesIndex = w.globals.seriesYAxisMap[index][0]
const highestVal =
w.globals.yAxisScale[index].niceMax -
xyRatios.yRatio[seriesIndex] * localStartY
const lowestVal =
w.globals.yAxisScale[index].niceMax -
xyRatios.yRatio[seriesIndex] * localEndY
yHighestValue.push(highestVal)
yLowestValue.push(lowestVal)
})
// Only apply if user actually dragged far enough to consider it a selection
if (
me.dragged &&
(me.dragX > 10 || me.dragY > 10) &&
xLowestValue !== xHighestValue
) {
if (w.interact.zoomEnabled) {
if (!w.globals.initialConfig) return
let yaxis = Utils.clone(w.globals.initialConfig.yaxis)
let xaxis = Utils.clone(w.globals.initialConfig.xaxis)
w.interact.zoomed = true
if (w.config.xaxis.convertedCatToNumeric) {
xLowestValue = Math.floor(xLowestValue)
xHighestValue = Math.floor(xHighestValue)
if (xLowestValue < 1) {
xLowestValue = 1
xHighestValue = w.globals.dataPoints
}
if (xHighestValue - xLowestValue < 2) {
xHighestValue = xLowestValue + 1
}
}
if (zoomtype === 'xy' || zoomtype === 'x') {
xaxis = {
min: xLowestValue,
max: xHighestValue,
}
}
if (zoomtype === 'xy' || zoomtype === 'y') {
/**
* @param {ApexYAxis} yaxe
* @param {number} index
*/
yaxis.forEach((/** @type {any} */ yaxe, /** @type {any} */ index) => {
yaxis[index].min = yLowestValue[index]
yaxis[index].max = yHighestValue[index]
})
}
if (toolbar) {
const beforeZoomRange = toolbar.getBeforeZoomRange(xaxis, yaxis)
if (beforeZoomRange) {
xaxis = beforeZoomRange.xaxis ? beforeZoomRange.xaxis : xaxis
yaxis = beforeZoomRange.yaxis ? beforeZoomRange.yaxis : yaxis
}
}
/** @type {{ xaxis: any; yaxis?: any }} */
const options = {
xaxis,
}
if (!w.config.chart.group) {
// if chart in a group, prevent yaxis update here
// fix issue #650
options.yaxis = yaxis
}
me.ctx.updateHelpers._updateOptions(
options,
false,
me.w.config.chart.animations.dynamicAnimation.enabled,
)
if (typeof w.config.chart.events.zoomed === 'function') {
toolbar.zoomCallback(xaxis, yaxis)
}
} else if (w.interact.selectionEnabled) {
/** @type {any} */
let yaxis = null
let xaxis = null
xaxis = {
min: xLowestValue,
max: xHighestValue,
}
if (zoomtype === 'xy' || zoomtype === 'y') {
const yaxisCopy = /** @type {ApexYAxis[]} */ (
Utils.clone(w.config.yaxis)
)
yaxis = yaxisCopy
yaxisCopy.forEach((yaxe, index) => {
yaxisCopy[index].min = yLowestValue[index]
yaxisCopy[index].max = yHighestValue[index]
})
}
w.interact.selection = me.selection
if (typeof w.config.chart.events.selection === 'function') {
w.config.chart.events.selection(me.ctx, {
xaxis,
yaxis,
})
}
// Linked Views (#4): feed the brushed data-x range to the crossfilter
// coordinator, which dims out-of-range marks across the group. Null-safe
// no-op unless the `link` feature is bundled and chart.link.enabled.
me.ctx.linkedViews?.onSourceSelection(xaxis)
}
}
}
/** @param {{ context?: any, zoomtype?: any, xyRatios?: any }} opts */
panDragging({ context }) {
const w = this.w
const me = context
// check to make sure there is data to compare against
if (typeof w.interact.lastClientPosition.x !== 'undefined') {
// get the change from last position to this position
const deltaX = w.interact.lastClientPosition.x - me.clientX
const deltaY = (w.interact.lastClientPosition.y ?? 0) - me.clientY
// check which direction had the highest amplitude
if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 0) {
this.moveDirection = 'left'
} else if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX < 0) {
this.moveDirection = 'right'
} else if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY > 0) {
this.moveDirection = 'up'
} else if (Math.abs(deltaY) > Math.abs(deltaX) && deltaY < 0) {
this.moveDirection = 'down'
}
}
// set the new last position to the current for next time (to get the position of drag)
w.interact.lastClientPosition = {
x: me.clientX,
y: me.clientY,
}
const xLowestValue = w.axisFlags.isRangeBar
? w.globals.minY
: w.globals.minX
const xHighestValue = w.axisFlags.isRangeBar
? w.globals.maxY
: w.globals.maxX
// removed delayedPanScrolled as it doesn't seem to cause bugs anymore in convertedCatToNumeric
// if (!w.config.xaxis.convertedCatToNumeric) {
me.panScrolled(xLowestValue, xHighestValue)
// }
}
// delayedPanScrolled() {
// const w = this.w
// let newMinX = w.globals.minX
// let newMaxX = w.globals.maxX
// const centerX = (w.globals.maxX - w.globals.minX) / 2
// if (this.moveDirection === 'left') {
// newMinX = w.globals.minX + centerX
// newMaxX = w.globals.maxX + centerX
// } else if (this.moveDirection === 'right') {
// newMinX = w.globals.minX - centerX
// newMaxX = w.globals.maxX - centerX
// }
// newMinX = Math.floor(newMinX)
// newMaxX = Math.floor(newMaxX)
// this.updateScrolledChart(
// { xaxis: { min: newMinX, max: newMaxX } },
// newMinX,
// newMaxX
// )
// }
/**
* @param {number} xLowestValue
* @param {number} xHighestValue
*/
panScrolled(xLowestValue, xHighestValue) {
const w = this.w
const xyRatios = this.xyRatios
if (!w.globals.initialConfig) return
const yaxis = Utils.clone(w.globals.initialConfig.yaxis)
let xRatio = xyRatios.xRatio
let minX = w.globals.minX
let maxX = w.globals.maxX
if (w.axisFlags.isRangeBar) {
xRatio = xyRatios.invertedYRatio
minX = w.globals.minY
maxX = w.globals.maxY
}
if (this.moveDirection === 'left') {
xLowestValue = minX + (w.layout.gridWidth / 15) * xRatio
xHighestValue = maxX + (w.layout.gridWidth / 15) * xRatio
} else if (this.moveDirection === 'right') {
xLowestValue = minX - (w.layout.gridWidth / 15) * xRatio
xHighestValue = maxX - (w.layout.gridWidth / 15) * xRatio
}
if (!w.axisFlags.isRangeBar) {
const clampMin = w.globals.dataReducerRawMinX ?? w.globals.initialMinX
const clampMax = w.globals.dataReducerRawMaxX ?? w.globals.initialMaxX
if (xLowestValue < clampMin || xHighestValue > clampMax) {
xLowestValue = minX
xHighestValue = maxX
}
}
const xaxis = {
min: xLowestValue,
max: xHighestValue,
}
/** @type {{ xaxis: any; yaxis?: any }} */
const options = {
xaxis,
}
if (!w.config.chart.group) {
// if chart in a group, prevent yaxis update here
// fix issue #650
options.yaxis = yaxis
}
this.updateScrolledChart(options, xLowestValue, xHighestValue)
}
/**
* @param {object} options
* @param {number} xLowestValue
* @param {number} xHighestValue
*/
updateScrolledChart(options, xLowestValue, xHighestValue) {
const w = this.w
this.ctx.updateHelpers._updateOptions(options, false, false)
if (typeof w.config.chart.events.scrolled === 'function') {
const args = {
xaxis: {
min: xLowestValue,
max: xHighestValue,
},
}
w.config.chart.events.scrolled(this.ctx, args)
this.ctx.events.fireEvent('scrolled', args)
}
}
// ---------------------------------------------------------------------------
// Momentum: multi-touch pinch-zoom, two-finger pan and kinetic inertia.
//
// Every _updateOptions destroys and recreates this instance, and applying a
// gesture frame IS an _updateOptions, so the gesture must not depend on the
// instance surviving. All runtime state lives on w.interact.momentum (the
// interaction slice that persists across re-renders, like the crude pan's
// lastClientPosition). The instance that received touchstart keeps driving
// the gesture off the persistent state; inertia is a self-contained rAF loop
// that stops on w.globals.isDestroyed (a real destroy) rather than being
// cancelled by the per-update destroy().
// ---------------------------------------------------------------------------
_momentumEnabled() {
return this._pinchEnabled() || this._panInertiaEnabled()
}
_pinchEnabled() {
const c = this.w.config.chart
return !!(c.zoom && c.zoom.enabled && c.zoom.pinch)
}
_panInertiaEnabled() {
const c = this.w.config.chart
return !!(c.pan && c.pan.inertia)
}
/** Lazily-created, re-render-surviving gesture state on the interaction slice. */
_m() {
const it = this.w.interact
if (!it.momentum) {
it.momentum = {
busy: false,
/** @type {any} */ pinch: null,
/** @type {any} */ panState: null,
/** @type {{x:number,t:number}[]} */ samples: [],
/** @type {number|null} */ inertiaRAF: null,
}
}
return it.momentum
}
/** Current x data-window (rangeBars carry the datetime domain on y). */
_currentXWindow() {
const w = this.w
return w.axisFlags.isRangeBar
? { min: w.globals.minY, max: w.globals.maxY }
: { min: w.globals.minX, max: w.globals.maxX }
}
/** Live grid rect from the current DOM. Never cache the grid node on the
* instance: a full render replaces this whole instance, but the fast update
* path (fastUpdate/_fastAxisChromeRefresh) keeps the instance while swapping
* the grid node, and a cached node would go stale (detached nodes report an
* all-zero bounding rect, silently corrupting selection geometry). */
_gridRect() {
const baseEl = this.w.dom.baseEl
const grid = baseEl && baseEl.querySelector('.apexcharts-grid')
return grid ? grid.getBoundingClientRect() : null
}
/**
* Convert an absolute (client) x pixel to the plot-origin coordinate space
* that bar placement and the selection rect transform both use:
* `screenX - svgLeft - translateX`. This is the ONLY correct reference for the
* numeric/datetime x mapping (see AxisMapping): do NOT measure from the
* `.apexcharts-grid` box and subtract barPadForNumericAxis, because on a
* numeric bar chart that box extends barPad to the LEFT of the plot origin, so
* the two corrections are a fragile pair that only cancels while the grid box
* happens to extend exactly barPad. Anchoring on translateX (the same origin
* the bars use) is stable regardless of grid padding.
* @param {number} screenX
* @returns {number}
*/
_screenXToPlotPx(screenX) {
const baseEl = this.w.dom.baseEl
const svg = baseEl && baseEl.querySelector('.apexcharts-svg')
const svgLeft = svg ? svg.getBoundingClientRect().left : 0
return screenX - svgLeft - this.w.layout.translateX
}
/**
* Raw data bounds to clamp against. When zoom-aware downsampling is active,
* the raw stash tracks the full domain; fall back to the initial window.
* Returns null for rangeBars (no raw-x clamp available).
* @returns {{min:number, max:number}|null}
*/
_clampBounds() {
const w = this.w
if (w.axisFlags.isRangeBar) return null
return {
min: w.globals.dataReducerRawMinX ?? w.globals.initialMinX,
max: w.globals.dataReducerRawMaxX ?? w.globals.initialMaxX,
}
}
/**
* Apply an x-window immediately (no animation), mirroring panScrolled but
* pixel-accurate: clamp to the raw bounds (preserving window width so a pan
* stops flush at the edge rather than shrinking), floor for category axes,
* then route through the fast _updateOptions path.
* @param {number} newMinX @param {number} newMaxX @param {boolean} isZoom
* @returns {{minX:number, maxX:number}|false} applied window, or false if rejected
*/
_applyXRange(newMinX, newMaxX, isZoom) {
const w = this.w
if (!w.globals.initialConfig) return false
// Detect a widening (zoom-out) window up-front, before clamping/rounding can
// shrink the requested range at a boundary.
const cur = this._currentXWindow()
const zoomingOut = isZoom && newMaxX - newMinX > cur.max - cur.min
const bounds = this._clampBounds()
if (bounds) {
const range = newMaxX - newMinX
if (newMinX < bounds.min) {
newMinX = bounds.min
newMaxX = newMinX + range
}
if (newMaxX > bounds.max) {
newMaxX = bounds.max
newMinX = newMaxX - range
}
// range wider than the full domain: clamp both edges (pinch-out floor)
if (newMinX < bounds.min) newMinX = bounds.min
}
if (w.config.xaxis.convertedCatToNumeric) {
// While zooming OUT, ceil the high edge (instead of flooring it) so a
// fractional zoom-out always widens by at least one whole category.
// Flooring BOTH edges let a wheel/pinch zoom-out at the lower data
// boundary collapse to the same integer window every frame: the low edge
// is pinned at the first category and the floored high edge dropped the
// sub-integer growth, so the visible span never grew and "zoom-out
// stopped working" once only the first few points were left (the upper
// boundary was unaffected because there the low edge floors downward,
// which happens to widen the span). Zoom-IN and pan still floor both edges
// so zoom-in keeps tightening to the 3-point floor (ceiling the high edge
// there would cancel the floored low edge and stall the shrink).
newMinX = Math.floor(newMinX)
newMaxX = zoomingOut ? Math.ceil(newMaxX) : Math.floor(newMaxX)
if (newMinX < 1) newMinX = 1
if (bounds && newMaxX > bounds.max) newMaxX = Math.floor(bounds.max)
if (newMaxX - newMinX < 2) return false
}
if (!(newMaxX > newMinX)) return false
/** @type {{ xaxis: any; yaxis?: any }} */
const options = { xaxis: { min: newMinX, max: newMaxX } }
if (!w.config.chart.group) {
options.yaxis = Utils.clone(w.globals.initialConfig.yaxis)
}
if (isZoom) w.interact.zoomed = true
this.ctx.updateHelpers._updateOptions(options, false, false)
return { minX: newMinX, maxX: newMaxX }
}
_cancelInertia() {
const m = this._m()
if (m.inertiaRAF != null) {
cancelAnimationFrame(m.inertiaRAF)
m.inertiaRAF = null
}
}
_fireScrolled() {
const w = this.w
if (typeof w.config.chart.events.scrolled !== 'function') return
const { min, max } = this._currentXWindow()
const args = { xaxis: { min, max } }
w.config.chart.events.scrolled(this.ctx, args)
this.ctx.events.fireEvent('scrolled', args)
}
/** @param {number} x @param {number} t */
_pushSample(x, t) {
const s = this._m().samples
s.push({ x, t })
// keep a short trailing window for a stable release velocity
while (s.length > 6) s.shift()
}
/**
* Single passive:false handler for all touch phases. Two fingers => pinch /
* two-finger pan (zoom). One finger, in pan mode => kinetic pan with inertia.
* @param {any} e
*/
momentumTouch(e) {
const w = this.w
const m = this._m()
const type = e.type
if (type === 'touchstart') {
this._cancelInertia()
const gridRectDim = this._gridRect()
if (!gridRectDim) return
if (e.touches.length >= 2 && this._pinchEnabled()) {
e.preventDefault()
m.busy = true
m.panState = null
this._beginPinch(e, gridRectDim)
} else if (
e.touches.length === 1 &&
this._panInertiaEnabled() &&
w.interact.panEnabled
) {
m.busy = true
m.pinch = null
const t = e.touches[0]
const win = this._currentXWindow()
const gw = w.layout.gridWidth || 1
m.panState = {
startX: t.clientX,
startY: t.clientY,
axis: null, // decided on first move (rails)
minX0: win.min,
maxX0: win.max,
ratio0: (win.max - win.min) / gw,
}
m.samples = [{ x: t.clientX, t: e.timeStamp }]
}
return
}
if (type === 'touchmove') {
if (m.pinch && e.touches.length >= 2) {
e.preventDefault()
this._movePinch(e)
} else if (m.panState && e.touches.length === 1) {
this._movePan(e)
}
return
}
// touchend / touchcancel
if (m.pinch) {
if (e.touches.length < 2) this._endPinch()
} else if (m.panState) {
if (e.touches.length === 0) this._endPan()
}
if (e.touches.length === 0) {
// a passive-path touchstart may have flagged mousedown before momentum
// took over; clear it (and only release busy if inertia is not running).
w.interact.mousedown = false
this.dragged = false
if (m.inertiaRAF == null && !m.pinch && !m.panState) {
m.busy = false
}
}
}
/** @param {any} e @param {DOMRect} gridRectDim */
_beginPinch(e, gridRectDim) {
const w = this.w
const t0 = e.touches[0]
const t1 = e.touches[1]
const dist =
Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY) || 1
const cx =
(t0.clientX + t1.clientX) / 2 -
gridRectDim.left -
w.globals.barPadForNumericAxis
const { min, max } = this._currentXWindow()
this._m().pinch = {
d0: dist,
cx0: cx,
minX0: min,
maxX0: max,
gridWidth: w.layout.gridWidth || 1,
}
}
/** @param {any} e */
_movePinch(e) {
const w = this.w
const p = this._m().pinch
if (!p) return
const gridRectDim = this._gridRect()
if (!gridRectDim) return
const t0 = e.touches[0]
const t1 = e.touches[1]
const dist =
Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY) || 1
const cx =
(t0.clientX + t1.clientX) / 2 -
gridRectDim.left -
w.globals.barPadForNumericAxis
const range0 = p.maxX0 - p.minX0
// fingers spread => dist > d0 => scale < 1 => window shrinks (zoom in)
const newRange = range0 * (p.d0 / dist)
// keep the data value under the pinch centroid pinned to the current
// centroid, which folds two-finger pan into the same transform
const anchorData = p.minX0 + (p.cx0 / p.gridWidth) * range0
let newMinX = anchorData - (cx / p.gridWidth) * newRange
let newMaxX = newMinX + newRange
// floor the zoom-in window at ~2 point-spacings (same reasoning as wheel
// zoom) so it never collapses to a zero-width / NaN range
const bounds = this._clampBounds()
if (bounds) {
const minXDiff =
w.globals.minXDiff > 0 && isFinite(w.globals.minXDiff)
? w.globals.minXDiff
: 0
const minRange = Math.max(minXDiff * 2, (bounds.max - bounds.min) * 1e-6)
if (newMaxX - newMinX < minRange) {
const mid = (newMinX + newMaxX) / 2
newMinX = mid - minRange / 2
newMaxX = mid + minRange / 2
}
}
this._applyXRange(newMinX, newMaxX, true)
}
_endPinch() {
const w = this.w
const m = this._m()
m.pinch = null
const { min, max } = this._currentXWindow()
const xaxis = { min, max }
const yaxis = w.globals.initialConfig
? Utils.clone(w.globals.initialConfig.yaxis)
: []
const toolbar = this.ctx.toolbar
if (toolbar) toolbar.zoomCallback(xaxi