UNPKG

apexcharts

Version:

A JavaScript Chart Library

906 lines (838 loc) 28.8 kB
// @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 = [] } }