apexcharts
Version:
A JavaScript Chart Library
906 lines (838 loc) • 28.8 kB
JavaScript
// @ts-check
import Graphics from '../Graphics'
import { Environment } from '../../utils/Environment.js'
/**
* Measure ruler (#18): the interactive measure / delta ruler.
*
* Eager, opt-in module (`ctx.measure`, requires the `measure` feature). Enabled
* via `chart.measure.enabled`. Hold the measure key (default 'm') and drag A->B
* on the plot, or call chart.startMeasure(), to draw a live ruler that reads
* dx / dy / %change / slope in data space. On release (when `pinOnRelease`) the
* ruler is pinned as a data-anchored overlay that re-projects on
* zoom / pan / resize, and a `measured` event fires.
*
* Gesture isolation: while armed, a transparent "glass pane" rect is laid over
* the plot so ZoomPanSelection's passive svg listeners never see the drag; the
* pane is removed when disarmed, so zoom/pan/hover are untouched otherwise.
*
* Pixel<->data uses the grid client rect fraction (scale-independent) x the
* axis ranges, so it is axis-type-agnostic and round-trips: a point captured at
* fraction f re-projects to f*gridWidth. The pinned rulers live on the instance
* (eager module, not recreated on update) and are redrawn on 'mounted'/
* 'updated' from their stored data coordinates.
*/
export default class Measure {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx
this.graphics = new Graphics(w)
/** @type {Array<{xa:number,ya:number,xb:number,yb:number,mode?:string}>} pinned rulers in data space */
this.pins = []
/** live drag state, or null */
this.drag = null
this.armed = false
this.persistent = false
/** @type {any} the glass-pane capture rect element */
this.pane = null
/** True while a "measure from here" seed is following the cursor. */
this._seedActive = false
this._onKeyDown = this._onKeyDown.bind(this)
this._onKeyUp = this._onKeyUp.bind(this)
this._onDown = this._onDown.bind(this)
this._onMove = this._onMove.bind(this)
this._onUp = this._onUp.bind(this)
this._onSeedDown = this._onSeedDown.bind(this)
this._onSeedKey = this._onSeedKey.bind(this)
this._afterRender = this._afterRender.bind(this)
ctx.addEventListener('mounted', this._afterRender)
ctx.addEventListener('updated', this._afterRender)
this._bindKeys()
}
_cfg() {
return this.w.config.chart.measure || {}
}
_enabled() {
return this.w.globals.axisCharts && this._cfg().enabled === true
}
_mode() {
return this._cfg().mode === 'free' ? 'free' : 'span'
}
/**
* Nearest first-series data point to a data x (used to snap span endpoints
* onto the series line).
* @param {number} dataX
* @returns {{x:number,y:number}|null}
*/
_snapToSeries(dataX) {
const s0 = /** @type {any} */ ((this.w.config.series || [])[0])
if (!s0) return null
const pts = this._points(s0.data || [])
if (!pts.length) return null
let best = pts[0]
let bd = Math.abs(pts[0].x - dataX)
for (let i = 1; i < pts.length; i++) {
const d = Math.abs(pts[i].x - dataX)
if (d < bd) {
bd = d
best = pts[i]
}
}
return best
}
/**
* Resolve a raw projected point to the endpoint that is actually drawn: span
* mode snaps x to the nearest first-series data point (y follows the line);
* free mode keeps the raw point.
* @param {{x:number,y:number,gx:number,gy:number}} raw
*/
_resolve(raw) {
if (this._mode() === 'free') return raw
const snapped = this._snapToSeries(raw.x)
if (!snapped) return raw
const g = this._dataToGrid(snapped.x, snapped.y)
return { x: snapped.x, y: snapped.y, gx: g.gx, gy: g.gy }
}
_doc() {
return this.w.dom.baseEl && this.w.dom.baseEl.ownerDocument
}
/** Bind the measure-key listeners once on the owner document. */
_bindKeys() {
if (this._keysBound) return
const doc = this._doc()
if (!doc) return
doc.addEventListener('keydown', this._onKeyDown)
doc.addEventListener('keyup', this._onKeyUp)
this._keysBound = true
}
/** @param {any} e */
_onKeyDown(e) {
if (!this._enabled() || this.persistent) return
const key = this._cfg().key || 'm'
if (e.key && e.key.toLowerCase() === String(key).toLowerCase()) {
this._arm()
} else if (e.key === 'Escape') {
this._cancelDrag()
}
}
/** @param {any} e */
_onKeyUp(e) {
if (this.persistent) return
const key = this._cfg().key || 'm'
if (e.key && e.key.toLowerCase() === String(key).toLowerCase()) {
if (!this.drag) this._disarm()
}
}
/** Public: arm a sticky measure mode (survives key release) until stopped. */
startMeasure() {
if (!this._enabled()) return
this.persistent = true
this._arm()
}
/** Public: leave measure mode. */
stopMeasure() {
this.persistent = false
this._cancelDrag()
this._disarm()
}
/**
* Public: begin a measurement anchored at a client-space point (used by the
* context menu's "Measure from here"). Endpoint A is fixed at (cx,cy), the
* ruler follows the cursor, and the next pointer press sets B and pins it.
* Escape cancels. No-op unless the measure tool is enabled.
* @param {number} cx @param {number} cy
*/
seedFromClient(cx, cy) {
if (!this._enabled()) return
this._cancelDrag()
this._endSeed(false)
const a = this._project(cx, cy)
this.drag = { a, b: a }
this._seedActive = true
const doc = this._doc()
if (doc) {
doc.addEventListener('mousemove', this._onMove)
doc.addEventListener('touchmove', this._onMove, { passive: false })
// Capture-phase so the finishing press is caught before other handlers,
// and 'mousedown' (not the current click's mouseup) so the menu-item
// release that armed this does not immediately finish it.
doc.addEventListener('mousedown', this._onSeedDown, true)
doc.addEventListener('touchstart', this._onSeedDown, true)
doc.addEventListener('keydown', this._onSeedKey, true)
}
this._renderLive()
}
/** @param {any} e */
_onSeedDown(e) {
if (!this._seedActive || !this.drag) return
e.preventDefault()
e.stopPropagation()
const { cx, cy } = this._clientXY(e)
this.drag.b = this._project(cx, cy)
this._endSeed(true)
}
/** @param {any} e */
_onSeedKey(e) {
if (e.key === 'Escape') this._endSeed(false)
}
/**
* Finish (commit) or cancel a "measure from here" seed and detach listeners.
* @param {boolean} commit
*/
_endSeed(commit) {
if (!this._seedActive) return
this._seedActive = false
const doc = this._doc()
if (doc) {
doc.removeEventListener('mousemove', this._onMove)
doc.removeEventListener('touchmove', this._onMove)
doc.removeEventListener('mousedown', this._onSeedDown, true)
doc.removeEventListener('touchstart', this._onSeedDown, true)
doc.removeEventListener('keydown', this._onSeedKey, true)
}
const d = this.drag
this.drag = null
this._clearLive()
if (!commit || !d) return
const a = this._resolve(d.a)
const b = this._resolve(d.b)
// ignore a zero-length finish (pressed without moving)
if (Math.abs(a.gx - b.gx) < 2 && Math.abs(a.gy - b.gy) < 2) return
const mode = this._mode()
if (this._cfg().pinOnRelease !== false) {
this.pins.push({ xa: a.x, ya: a.y, xb: b.x, yb: b.y, mode })
this._renderPins()
this.ctx.history?.snapshot?.('measure')
}
this._fireMeasured(a, b)
}
/** Public: remove all pinned rulers. */
clearMeasures() {
this.pins = []
this._renderPins()
// Targeted redraw fires no 'updated' event, so record the undo step here.
this.ctx.history?.snapshot?.('clear measures')
}
/**
* Snapshot of the pinned rulers as JSON-safe plain data. This is the piece of
* state ViewState / Perspectives (shareable URL) / Rewind (undo) persist:
* pins already live in data space, so they round-trip and re-project.
* Returns a deep copy so callers cannot mutate ours.
* @returns {Array<{xa:number,ya:number,xb:number,yb:number,mode:string}>}
*/
getPins() {
return this.pins.map((p) => ({
xa: p.xa,
ya: p.ya,
xb: p.xb,
yb: p.yb,
mode: p.mode === 'free' ? 'free' : 'span',
}))
}
/**
* Replace the pinned rulers with a restored set and redraw. Accepts the shape
* getPins() returns; a nullish / non-array value (or an old token without
* measure state) clears all pins. Non-finite entries are dropped. Does NOT
* record a Rewind step (the restore itself is the caller's undo boundary).
* @param {any} pins
*/
setPins(pins) {
this.pins = Array.isArray(pins)
? pins
.filter(
(p) =>
p &&
isFinite(p.xa) &&
isFinite(p.ya) &&
isFinite(p.xb) &&
isFinite(p.yb),
)
.map((p) => ({
xa: +p.xa,
ya: +p.ya,
xb: +p.xb,
yb: +p.yb,
mode: p.mode === 'free' ? 'free' : 'span',
}))
: []
this._renderPins()
}
/**
* Normalize a series `data` array into {x,y} points (numeric/datetime x or
* category index). Non-finite / non-scalar (range/candle) points drop out.
* @param {any[]} data
* @returns {Array<{x:number,y:number}>}
*/
_points(data) {
const out = []
for (let i = 0; i < data.length; i++) {
const p = data[i]
let x
let y
if (Array.isArray(p)) {
x = +p[0]
y = +p[1]
} else if (p && typeof p === 'object') {
x = +p.x
y = +p.y
} else {
x = i
y = +p
}
if (isFinite(x) && isFinite(y)) out.push({ x, y })
}
return out
}
/** Lay the transparent capture pane over the plot so our pointer handlers own
* the drag (ZoomPanSelection never sees it). */
_arm() {
if (this.armed || !this._enabled()) return
const w = this.w
const parent = w.dom.elGraphical
if (!parent) return
this.armed = true
const pane = this.graphics.drawRect(0, 0, w.layout.gridWidth, w.layout.gridHeight)
pane.node.setAttribute('class', 'apexcharts-measure-capture')
pane.node.setAttribute('fill', 'transparent')
pane.node.style.cursor = 'crosshair'
pane.node.style.pointerEvents = 'all'
pane.node.addEventListener('mousedown', this._onDown)
pane.node.addEventListener('touchstart', this._onDown, { passive: false })
parent.add(pane)
this.pane = pane
}
_disarm() {
this.armed = false
if (this.pane) {
this.pane.node.removeEventListener('mousedown', this._onDown)
this.pane.node.removeEventListener('touchstart', this._onDown)
const p = this.pane.node
if (p.parentNode) p.parentNode.removeChild(p)
this.pane = null
}
}
/** @param {any} e @returns {{cx:number,cy:number}} */
_clientXY(e) {
const t = e.touches && e.touches[0] ? e.touches[0] : e
return { cx: t.clientX, cy: t.clientY }
}
_gridRect() {
const g = this.w.dom.baseEl.querySelector('.apexcharts-grid')
return g ? g.getBoundingClientRect() : null
}
/** [min,max] for the primary y-axis, preferring the rendered nice scale. */
_yRange() {
const g = this.w.globals
const s = g.yAxisScale && g.yAxisScale[0]
if (
s &&
isFinite(s.niceMin) &&
isFinite(s.niceMax) &&
s.niceMax !== s.niceMin
) {
return [s.niceMin, s.niceMax]
}
return [g.minY, g.maxY]
}
/**
* Client pixel -> { x, y (data), gx, gy (grid-local SVG units) }.
* @param {number} cx @param {number} cy
*/
_project(cx, cy) {
const w = this.w
const rect = this._gridRect()
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const clamp = (/** @type {number} */ v) => (v < 0 ? 0 : v > 1 ? 1 : v)
const fx = rect ? clamp((cx - rect.left) / rect.width) : 0
const fy = rect ? clamp((cy - rect.top) / rect.height) : 0
const [ymin, ymax] = this._yRange()
const x = w.globals.minX + fx * (w.globals.maxX - w.globals.minX)
const y = ymax - fy * (ymax - ymin)
return { x, y, gx: fx * gw, gy: fy * gh }
}
/**
* Data (x,y) -> grid-local SVG coords, for redrawing pinned rulers.
* @param {number} x @param {number} y
*/
_dataToGrid(x, y) {
const w = this.w
const gw = w.layout.gridWidth
const gh = w.layout.gridHeight
const xr = w.globals.maxX - w.globals.minX || 1
const [ymin, ymax] = this._yRange()
const yr = ymax - ymin || 1
return {
gx: ((x - w.globals.minX) / xr) * gw,
gy: gh - ((y - ymin) / yr) * gh,
}
}
/** @param {any} e */
_onDown(e) {
if (!this.armed) return
e.preventDefault()
e.stopPropagation()
const { cx, cy } = this._clientXY(e)
const a = this._project(cx, cy)
this.drag = { a, b: a }
const doc = this._doc()
if (doc) {
doc.addEventListener('mousemove', this._onMove)
doc.addEventListener('mouseup', this._onUp)
doc.addEventListener('touchmove', this._onMove, { passive: false })
doc.addEventListener('touchend', this._onUp)
}
this._renderLive()
}
/** @param {any} e */
_onMove(e) {
if (!this.drag) return
if (e.cancelable) e.preventDefault()
const { cx, cy } = this._clientXY(e)
this.drag.b = this._project(cx, cy)
this._renderLive()
}
/** @param {any} _e */
_onUp(_e) {
const doc = this._doc()
if (doc) {
doc.removeEventListener('mousemove', this._onMove)
doc.removeEventListener('mouseup', this._onUp)
doc.removeEventListener('touchmove', this._onMove)
doc.removeEventListener('touchend', this._onUp)
}
if (!this.drag) return
const rawA = this.drag.a
const rawB = this.drag.b
this.drag = null
this._clearLive()
// ignore a zero-length click (on the raw pixels)
if (Math.abs(rawA.gx - rawB.gx) < 2 && Math.abs(rawA.gy - rawB.gy) < 2) {
if (!this.persistent) this._disarm()
return
}
const mode = this._mode()
const a = this._resolve(rawA)
const b = this._resolve(rawB)
if (this._cfg().pinOnRelease !== false) {
this.pins.push({ xa: a.x, ya: a.y, xb: b.x, yb: b.y, mode })
this._renderPins()
// Targeted redraw fires no 'updated' event, so record the undo step here.
this.ctx.history?.snapshot?.('measure')
}
this._fireMeasured(a, b)
if (!this.persistent) this._disarm()
}
_cancelDrag() {
const doc = this._doc()
if (doc) {
doc.removeEventListener('mousemove', this._onMove)
doc.removeEventListener('mouseup', this._onUp)
doc.removeEventListener('touchmove', this._onMove)
doc.removeEventListener('touchend', this._onUp)
}
this.drag = null
this._clearLive()
}
/**
* @param {{x:number,y:number}} a @param {{x:number,y:number}} b
* @returns {{dx:number,dy:number,pct:number,slope:number}}
*/
_stats(a, b) {
const dx = b.x - a.x
const dy = b.y - a.y
return {
dx,
dy,
pct: a.y !== 0 ? (dy / Math.abs(a.y)) * 100 : NaN,
slope: dx !== 0 ? dy / dx : NaN,
}
}
/** @param {number} v */
_fmt(v) {
if (!isFinite(v)) return 'n/a'
const a = Math.abs(v)
if (a !== 0 && (a < 0.01 || a >= 1e6)) return v.toExponential(2)
return String(Math.round(v * 100) / 100)
}
/** getComputedStyle of the graphical layer (browser only), for CSS vars. */
_computedStyle() {
if (!Environment.isBrowser()) return null
const node = this.w.dom.elGraphical && this.w.dom.elGraphical.node
if (!node || typeof getComputedStyle !== 'function') return null
try {
return getComputedStyle(node)
} catch (e) {
return null
}
}
/**
* Resolve a color: explicit config value, else a `--apx-measure-*` CSS custom
* property, else the built-in default.
* @param {any} cfgVal @param {string} varName @param {string} fallback
* @param {CSSStyleDeclaration|null} [cs]
*/
_resolveColor(cfgVal, varName, fallback, cs) {
if (cfgVal) return cfgVal
const style = cs !== undefined ? cs : this._computedStyle()
const v = style ? style.getPropertyValue(varName).trim() : ''
return v || fallback
}
/** Resolved semantic colors (config -> CSS var -> built-in default). */
_colors() {
const c = this._cfg().colors || {}
const cs = this._computedStyle()
return {
up: this._resolveColor(c.up, '--apx-measure-up', '#16a34a', cs),
down: this._resolveColor(c.down, '--apx-measure-down', '#dc2626', cs),
neutral: this._resolveColor(c.neutral, '--apx-measure-neutral', '#64748b', cs),
guide: this._resolveColor(c.guide, '--apx-measure-guide', '#94a3b8', cs),
}
}
/** @param {number} dy */
_dirColor(dy) {
const c = this._colors()
return dy === 0 ? c.neutral : dy > 0 ? c.up : c.down
}
/** @param {number} dy */
_dirClass(dy) {
return dy === 0
? 'apexcharts-measure-flat'
: dy > 0
? 'apexcharts-measure-up'
: 'apexcharts-measure-down'
}
/**
* Format a percentage, via `measure.format.percent` when set.
* @param {number} p
*/
_fmtPct(p) {
if (!isFinite(p)) return 'n/a'
const f = this._cfg().format && this._cfg().format.percent
if (typeof f === 'function') {
try {
const s = f(p)
if (s != null) return String(s)
} catch (e) {
/* fall through */
}
}
return (p >= 0 ? '+' : '') + this._fmt(p) + '%'
}
/**
* The readout lines for a ruler: `measure.label` override, else the default
* per-mode text.
* @param {{x:number,y:number}} a @param {{x:number,y:number}} b
* @param {{dx:number,dy:number,pct:number,slope:number}} st @param {string} mode
* @returns {string[]}
*/
_label(a, b, st, mode) {
const fn = this._cfg().label
if (typeof fn === 'function') {
const out = fn({
from: { x: a.x, y: a.y },
to: { x: b.x, y: b.y },
dx: st.dx,
dy: st.dy,
percentChange: st.pct,
slope: st.slope,
mode,
})
return Array.isArray(out) ? out.map(String) : [String(out)]
}
if (mode === 'span') {
const arrow = st.dy === 0 ? '' : st.dy > 0 ? ' ↑' : ' ↓'
const pct = isFinite(st.pct) ? '(' + this._fmtPct(st.pct) + ')' : ''
return [this._fmtY(st.dy) + ' ' + pct + arrow, this._fmtX(a.x) + ' to ' + this._fmtX(b.x)]
}
return ['Δx ' + this._fmtDx(st.dx), 'Δy ' + this._fmtY(st.dy), this._fmtPct(st.pct)]
}
/**
* Format an x delta, treating datetime x as a day count.
* @param {number} dx
*/
_fmtDx(dx) {
if (this.w.config.xaxis.type === 'datetime') {
const days = dx / 86400000
return (Math.round(days * 10) / 10) + 'd'
}
return this._fmt(dx)
}
/**
* Format a y value (a delta) via the y-axis label formatter when present.
* @param {number} v
*/
_fmtY(v) {
const cf = this._cfg().format && this._cfg().format.y
if (typeof cf === 'function') {
try {
const s = cf(v)
if (s != null) return String(s)
} catch (e) {
/* fall through */
}
}
const f =
this.w.globals.yLabelFormatters && this.w.globals.yLabelFormatters[0]
if (typeof f === 'function') {
try {
const s = f(v, { seriesIndex: 0, dataPointIndex: -1, w: this.w })
if (s != null && s !== '') return String(s)
} catch (e) {
/* fall through to numeric */
}
}
return this._fmt(v)
}
/**
* Format an x value: a short date for datetime x, else the x-label formatter
* or a plain number.
* @param {number} x
*/
_fmtX(x) {
const w = this.w
const cf = this._cfg().format && this._cfg().format.x
if (typeof cf === 'function') {
try {
const s = cf(x)
if (s != null) return String(s)
} catch (e) {
/* fall through */
}
}
if (w.config.xaxis.type === 'datetime') {
const d = new Date(x)
return d.toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
const f = w.globals.xLabelFormatter
if (typeof f === 'function') {
try {
const s = f(x, { w })
if (s != null && s !== '') return String(s)
} catch (e) {
/* fall through to numeric */
}
}
return this._fmt(x)
}
/** The live overlay group, created lazily inside elGraphical. */
_liveGroup() {
const w = this.w
if (this._live && this._live.node && this._live.node.parentNode) {
return this._live
}
const g = this.graphics.group({ class: 'apexcharts-measure-live' })
g.node.style.pointerEvents = 'none'
if (w.dom.elGraphical) w.dom.elGraphical.add(g)
this._live = g
return g
}
_clearLive() {
if (this._live && this._live.node) {
const n = this._live.node
if (n.parentNode) n.parentNode.removeChild(n)
}
this._live = null
}
_renderLive() {
if (!this.drag) return
const g = this._liveGroup()
// wipe children
while (g.node.firstChild) g.node.removeChild(g.node.firstChild)
const a = this._resolve(this.drag.a)
const b = this._resolve(this.drag.b)
this._drawRuler(g, a, b, false, this._mode())
}
/** Redraw all pinned rulers into a fresh group (called after each render). */
_renderPins() {
const w = this.w
// remove any existing pinned group
const old = w.dom.baseEl && w.dom.baseEl.querySelector('.apexcharts-measure-pins')
if (old && old.parentNode) old.parentNode.removeChild(old)
if (!this.pins.length || !w.dom.elGraphical) return
const g = this.graphics.group({ class: 'apexcharts-measure-pins' })
g.node.style.pointerEvents = 'none'
w.dom.elGraphical.add(g)
this.pins.forEach((/** @type {any} */ p) => {
const a = { ...this._dataToGrid(p.xa, p.ya), x: p.xa, y: p.ya }
const b = { ...this._dataToGrid(p.xb, p.yb), x: p.xb, y: p.yb }
this._drawRuler(g, a, b, true, p.mode || 'span')
})
}
/**
* Draw one ruler into `g`, dispatching on style.
* @param {any} g
* @param {{gx:number,gy:number,x:number,y:number}} a
* @param {{gx:number,gy:number,x:number,y:number}} b
* @param {boolean} [pinned]
* @param {string} [mode] 'span' (finance-style band, default) | 'free'
*/
_drawRuler(g, a, b, pinned, mode) {
const st = this._stats(a, b)
// Per-ruler sub-group carries a direction modifier class so a stylesheet can
// theme up/down/flat independently (the config colors are the JS default).
const rg = this.graphics.group({
class:
'apexcharts-measure-ruler ' +
this._dirClass(st.dy) +
(pinned ? ' apexcharts-measure-pinned' : ''),
})
if ((mode || this._mode()) === 'free') this._drawFree(rg, a, b, st)
else this._drawSpan(rg, a, b, st)
g.add(rg)
}
/** Endpoint dots on the series line (skipped when markers:false).
* @param {any} g @param {{gx:number,gy:number}} p @param {string} color */
_dot(g, p, color) {
if (this._cfg().markers === false) return
const dot = this.graphics.drawMarker(p.gx, p.gy, {
pSize: 4,
shape: 'circle',
pointFillColor: color,
pointFillOpacity: 1,
pointStrokeColor: '#fff',
pointStrokeWidth: 2,
pointStrokeOpacity: 1,
})
g.add(dot)
}
/** Draw the readout label box + text into `g` at (bx,by).
* @param {any} g @param {string[]} lines @param {number} bx @param {number} by
* @param {number} boxW @param {number} boxH @param {string} color */
_readout(g, lines, bx, by, boxW, boxH, color) {
const box = this.graphics.drawRect(bx, by, boxW, boxH, 4)
box.node.setAttribute('class', 'apexcharts-measure-label-bg')
box.attr({ fill: '#ffffff', 'fill-opacity': 0.95, stroke: color, 'stroke-width': 1 })
g.add(box)
const label = this.graphics.drawText({
x: bx + 9,
y: by + 15,
text: lines,
textAnchor: 'start',
fontSize: '11px',
foreColor: '#1e293b',
cssClass: 'apexcharts-measure-label',
})
g.add(label)
}
/**
* Finance-style ruler: vertical guides + shaded band + endpoints on the
* series line + a top readout. Guides/band/markers are individually
* toggleable via config.
* @param {any} g
* @param {{gx:number,gy:number,x:number,y:number}} a
* @param {{gx:number,gy:number,x:number,y:number}} b
* @param {{dx:number,dy:number,pct:number,slope:number}} st
*/
_drawSpan(g, a, b, st) {
const w = this.w
const cfg = this._cfg()
const gh = w.layout.gridHeight
const color = this._dirColor(st.dy)
const lx = Math.min(a.gx, b.gx)
const rx = Math.max(a.gx, b.gx)
if (cfg.band !== false) {
const band = this.graphics.drawRect(lx, 0, Math.max(0, rx - lx), gh, 0)
band.node.setAttribute('class', 'apexcharts-measure-band')
band.attr({ fill: color, 'fill-opacity': 0.09, stroke: 'none' })
g.add(band)
}
if (cfg.guides !== false) {
;[a, b].forEach((p) => {
const vline = this.graphics.drawLine(p.gx, 0, p.gx, gh, this._colors().guide, 4, 1)
vline.node.setAttribute('class', 'apexcharts-measure-vline')
g.add(vline)
})
}
;[a, b].forEach((p) => this._dot(g, p, color))
const lines = this._label(a, b, st, 'span')
const longest = lines.reduce((m, s) => Math.max(m, s.length), 0)
const boxW = Math.max(148, longest * 6.4)
const boxH = 20 + lines.length * 15
let bx = (lx + rx) / 2 - boxW / 2
bx = Math.max(2, Math.min(bx, w.layout.gridWidth - boxW - 2))
this._readout(g, lines, bx, 4, boxW, boxH, color)
}
/**
* Free 2D ruler: a diagonal line between two arbitrary points + a readout.
* @param {any} g
* @param {{gx:number,gy:number,x:number,y:number}} a
* @param {{gx:number,gy:number,x:number,y:number}} b
* @param {{dx:number,dy:number,pct:number,slope:number}} st
*/
_drawFree(g, a, b, st) {
const color = this._dirColor(st.dy)
const line = this.graphics.drawLine(a.gx, a.gy, b.gx, b.gy, color, 0, 2)
line.node.setAttribute('class', 'apexcharts-measure-line')
g.add(line)
;[a, b].forEach((p) => this._dot(g, p, color))
const lines = this._label(a, b, st, 'free')
const longest = lines.reduce((m, s) => Math.max(m, s.length), 0)
const boxW = Math.max(72, longest * 6.2)
const boxH = 16 + lines.length * 15
let bx = (a.gx + b.gx) / 2 + 8
let by = (a.gy + b.gy) / 2 - boxH / 2
bx = Math.max(2, Math.min(bx, this.w.layout.gridWidth - boxW - 2))
by = Math.max(2, Math.min(by, this.w.layout.gridHeight - boxH - 2))
this._readout(g, lines, bx, by, boxW, boxH, color)
}
/**
* @param {{x:number,y:number}} a @param {{x:number,y:number}} b
*/
_fireMeasured(a, b) {
const st = this._stats(a, b)
const payload = {
from: { x: a.x, y: a.y },
to: { x: b.x, y: b.y },
dx: st.dx,
dy: st.dy,
percentChange: st.pct,
slope: st.slope,
}
const fn = this.w.config.chart.events.measured
if (typeof fn === 'function') fn(this.ctx, payload)
this.ctx.events.fireEvent('measured', [this.ctx, payload])
}
/** Re-project the measure pins after each render. */
_afterRender() {
// pins/arm only when the measure tool is enabled. The armed pane and live
// overlay are per-gesture; only pins persist across a render.
if (!this._enabled()) return
// Pre-selected via toolbar.autoSelected: 'measure' — enter sticky measure
// mode on mount so the ruler is ready to drag with no key held and no
// toolbar click (works even when the toolbar isn't bundled/shown).
if (this.w.interact.measureEnabled && !this.persistent) {
this.persistent = true
}
if (this.pins.length) this._renderPins()
// Re-arm into the freshly rendered graphical layer. A re-render (e.g.
// updateOptions) tears down the old capture pane with the old DOM but leaves
// this.armed/this.pane pointing at the detached node, so a plain
// `!this.pane` guard would short-circuit and never rebuild it. Disarm first
// to clear that stale state, then arm a new pane. Skip mid-drag so an
// in-flight gesture is not interrupted.
if (this.persistent && !this.drag) {
this._disarm()
this._arm()
}
}
teardown() {
this._cancelDrag()
this._endSeed(false)
this._disarm()
const doc = this._doc()
if (doc && this._keysBound) {
doc.removeEventListener('keydown', this._onKeyDown)
doc.removeEventListener('keyup', this._onKeyUp)
}
this._keysBound = false
this.pins = []
}
}