apexcharts
Version:
A JavaScript Chart Library
340 lines (315 loc) • 12.8 kB
JavaScript
// @ts-check
/**
* Real-time streaming scroll support.
*
* A streaming line/area chart is fed either by appendData() (growing array
* under a fixed `xaxis.range`) or by updateSeries() with a rolling window
* (fixed-length array, shifted by one each tick). Both are "windowed
* continuations" of the previous render: most points survive, the x-domain
* slides forward at constant width.
*
* The default data-change animation morphs the previous rendered path into the
* new one command-by-command. For a shifted same-length window that pairs
* command i with a DIFFERENT datum before/after: both at the same pixel-x -
* so only y interpolates and the line "warps in place" instead of scrolling.
*
* The fix implemented here: when an update is detected as a windowed
* continuation, the morph's pathFrom is not the captured previous path but the
* NEW path re-projected into the PREVIOUS frame's pixel space. Both axis
* mappings are affine in pixel space (linear, datetime, log: the same value
* transform applies on both sides), so the re-projection is a per-axis affine
* map derived exactly from two anchor points that were rendered in both
* frames. The per-command morph then becomes the pure axis slide: every
* surviving point translates, dropped points exit left, appended points enter
* from the right (clipped by the grid mask). Combined with a linear easing
* (see Animations.morphSVG) the window scrolls at constant velocity.
*
* Also hosts the `chart.streaming` memory bound: appendData() trims each
* series to `maxPoints`, or to the `xaxis.range` window (plus a small
* off-screen runway so exiting segments slide out instead of popping).
*
* @module modules/animations/StreamScroll
*/
/**
* @typedef {Object} StreamScrollTransform
* @property {number} ax - x-pixel scale (≈1 for a pure slide)
* @property {number} bx - x-pixel offset (the slide distance)
* @property {number} ay - y-pixel scale
* @property {number} by - y-pixel offset
*/
/**
* Snapshot the parsed data rows and rendered pixel positions of the current
* (about-to-be-replaced) frame. Called from Series.getPreviousPaths(), i.e.
* before parseData() rebuilds the rows for the incoming update. Row arrays are
* captured by reference: every parse/render builds fresh arrays, the old ones
* are never mutated in place (same lifetime contract as `previousPaths`).
*
* @param {import('../../types/internal').ChartStateW} w
*/
export function captureStreamFrame(w) {
const gl = w.globals
if (
!gl.axisCharts ||
!w.seriesData ||
!Array.isArray(w.seriesData.series) ||
w.seriesData.series.length === 0
) {
gl.prevStreamFrame = null
return
}
// Marker radii of the outgoing render, keyed [realIndex][j]. Bubble z
// updates change radius, not just position, so the marker ride scales
// size too. Circle markers carry r; path-shaped markers (bubbles) bake
// the radius into the path data but expose it as default-marker-size.
// The DOM read is skipped in SSR (no updates happen there anyway).
/** @type {number[][]} */
const rPixels = []
if (w.dom?.baseEl?.querySelectorAll) {
w.dom.baseEl
.querySelectorAll('.apexcharts-marker')
.forEach((/** @type {Element} */ node) => {
const ri = parseInt(node.getAttribute('index') ?? '', 10)
const j = parseInt(
node.getAttribute('j') ?? node.getAttribute('rel') ?? '',
10,
)
const r = parseFloat(
node.getAttribute('r') ??
node.getAttribute('default-marker-size') ??
'',
)
if (isFinite(ri) && isFinite(j) && isFinite(r)) {
;(rPixels[ri] = rPixels[ri] || [])[j] = r
}
})
}
// Captured for every axis chart (not just numeric x): the same frame also
// feeds the variable-length transition layer (LengthTransition /
// PathReconcile), which joins old and new datums by category label on
// category axes (where seriesX is empty). detectStreamScroll() still
// requires numeric x rows itself.
gl.prevStreamFrame = {
seriesX: (w.seriesData.seriesX || []).slice(),
seriesY: w.seriesData.series.slice(),
xPixels: (gl.seriesXvalues || []).slice(),
yPixels: (gl.seriesYvalues || []).slice(),
rPixels,
labels: (gl.labels || []).slice(),
isXNumeric: !!w.axisFlags?.isXNumeric,
}
}
/**
* Decide whether the current data-change render is a windowed continuation of
* the captured previous frame for series `realIndex`, and if so return the
* previous←current pixel-space affine transform.
*
* Detection requires, per series:
* - the first new x value exists in the old x row at some shift k ≥ 0;
* - every overlapping (x, y) pair matches exactly (a shifted window or a
* plain append: never a coincidental replace);
* - the window actually moved: points were dropped (k > 0) or appended;
* - the x scale is preserved (|ax−1| small: a slide, not a squeeze; plain
* unbounded appends stretch the domain and keep the default morph) and the
* slide is non-zero (fixed min/max frames keep the default morph);
* - a third mid-window point confirms the affine fit (guards stacked series
* whose baseline changed non-uniformly, and any other non-affine case).
*
* @param {import('../../types/internal').ChartStateW} w
* @param {number} realIndex
* @param {(number|null)[]} newXPixels - pixel x of each new datum (xArrj)
* @param {(number|null)[]} newYPixels - pixel y of each new datum (yArrj)
* @returns {StreamScrollTransform | null}
*/
export function detectStreamScroll(w, realIndex, newXPixels, newYPixels) {
const gl = w.globals
const frame = gl.prevStreamFrame
if (!frame || !gl.dataChanged || !w.axisFlags?.isXNumeric) return null
const oldX = frame.seriesX[realIndex]
const oldY = frame.seriesY[realIndex]
const newX = w.seriesData.seriesX[realIndex]
const newY = w.seriesData.series[realIndex]
if (!oldX || !oldY || !newX || !newY) return null
if (oldX.length < 3 || newX.length < 3) return null
// Locate the shift: where does the new window start inside the old one?
let k = -1
for (let i = 0; i < oldX.length; i++) {
if (oldX[i] === newX[0]) {
k = i
break
}
}
if (k === -1) return null
const overlap = Math.min(oldX.length - k, newX.length)
if (overlap < 2) return null
// Continuation must move the window: dropped points and/or an appended tail.
const appended = newX.length - overlap
if (k === 0 && appended === 0) return null
// Every surviving point must be identical in x and y.
for (let i = 0; i < overlap; i++) {
if (oldX[k + i] !== newX[i]) return null
const oy = oldY[k + i]
const ny = newY[i]
if (oy !== ny && !(oy == null && ny == null)) return null
}
const oldXP = frame.xPixels[realIndex]
const oldYP = frame.yPixels[realIndex]
if (!oldXP || !oldYP) return null
// Anchor points: overlap indices rendered (non-null) in both frames.
let a = -1
let b = -1
for (let i = 0; i < overlap; i++) {
if (
oldXP[k + i] == null ||
oldYP[k + i] == null ||
newXPixels[i] == null ||
newYPixels[i] == null
) {
continue
}
if (a === -1) a = i
b = i
}
if (a === -1 || b <= a) return null
const nxA = /** @type {number} */ (newXPixels[a])
const nxB = /** @type {number} */ (newXPixels[b])
const oxA = /** @type {number} */ (oldXP[k + a])
const oxB = /** @type {number} */ (oldXP[k + b])
if (Math.abs(nxB - nxA) < 1e-6) return null
const ax = (oxB - oxA) / (nxB - nxA)
const bx = oxA - ax * nxA
// A scroll preserves the x scale. Plain appends without a range stretch the
// domain (ax > 1) and keep today's morph; a truly static frame (bx ≈ 0)
// has nothing to slide.
if (!isFinite(ax) || !isFinite(bx)) return null
if (Math.abs(ax - 1) > 0.02) return null
if (Math.abs(bx) < 0.5) return null
// y anchors: the pair with the largest pixel separation for a stable fit.
let yLo = a
let yHi = a
for (let i = a; i <= b; i++) {
const ny = newYPixels[i]
if (ny == null || oldYP[k + i] == null) continue
if (ny < /** @type {number} */ (newYPixels[yLo])) yLo = i
if (ny > /** @type {number} */ (newYPixels[yHi])) yHi = i
}
let ay = 1
let by = 0
const nyLo = /** @type {number} */ (newYPixels[yLo])
const nyHi = /** @type {number} */ (newYPixels[yHi])
if (Math.abs(nyHi - nyLo) > 1e-6) {
ay =
(/** @type {number} */ (oldYP[k + yHi]) -
/** @type {number} */ (oldYP[k + yLo])) /
(nyHi - nyLo)
by = /** @type {number} */ (oldYP[k + yLo]) - ay * nyLo
} else {
by = /** @type {number} */ (oldYP[k + yLo]) - nyLo
}
if (!isFinite(ay) || !isFinite(by) || ay < 0.2 || ay > 5) return null
// Verify the fit on a third, mid-window point: if the old↔new pixel relation
// is not affine for this series, morphing through it would misplace points.
const m = Math.floor((a + b) / 2)
if (m !== a && m !== b && newXPixels[m] != null && oldXP[k + m] != null) {
const predX = ax * /** @type {number} */ (newXPixels[m]) + bx
if (Math.abs(predX - /** @type {number} */ (oldXP[k + m])) > 1.5) {
return null
}
if (newYPixels[m] != null && oldYP[k + m] != null) {
const predY = ay * /** @type {number} */ (newYPixels[m]) + by
if (Math.abs(predY - /** @type {number} */ (oldYP[k + m])) > 1.5) {
return null
}
}
}
return { ax, bx, ay, by }
}
const NUM_RE = /[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/gi
/**
* Apply the previous←current affine transform to a rendered path string,
* producing the morph's pathFrom. Affine maps are exact for every command the
* series builders emit (bezier curves are affine-invariant): M/L/T points,
* C/S/Q control points, H (x only), V (y only). Arcs keep their radii: the
* scroll transform is ≈identity in scale: and only move their endpoint.
*
* @param {string} d
* @param {StreamScrollTransform} t
* @returns {string}
*/
export function projectPathToPrevFrame(d, t) {
const { ax, bx, ay, by } = t
const out = []
const re = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g
let match
while ((match = re.exec(d)) !== null) {
const cmd = match[1].toUpperCase()
const nums = (match[2].match(NUM_RE) || []).map(parseFloat)
if (cmd === 'Z') {
out.push('z')
continue
}
if (cmd === 'H') {
for (const x of nums) out.push(`H ${ax * x + bx}`)
continue
}
if (cmd === 'V') {
for (const y of nums) out.push(`V ${ay * y + by}`)
continue
}
if (cmd === 'A') {
for (let i = 0; i + 6 < nums.length; i += 7) {
out.push(
`A ${nums[i]} ${nums[i + 1]} ${nums[i + 2]} ${nums[i + 3]} ${nums[i + 4]} ${ax * nums[i + 5] + bx} ${ay * nums[i + 6] + by}`,
)
}
continue
}
// M, L, T, C, S, Q: pure (x, y) coordinate lists.
const coords = []
for (let i = 0; i + 1 < nums.length; i += 2) {
coords.push(`${ax * nums[i] + bx} ${ay * nums[i + 1] + by}`)
}
if (coords.length) out.push(`${cmd} ${coords.join(' ')}`)
}
return out.join(' ')
}
/**
* `chart.streaming` memory bound, applied by appendData() after pushing the
* new points. Trims each series in place to:
* - the most recent `maxPoints` points when set, else
* - the `xaxis.range` window plus a two-point off-screen runway (so the
* exiting segment slides off the left edge instead of popping), when the
* x values are numeric/timestamps.
*
* @param {any[]} newSeries - config-shaped series array (mutated)
* @param {import('../../types/internal').ChartStateW} w
*/
export function trimStreamingSeries(newSeries, w) {
const cfg = w.config.chart.streaming
if (!cfg || !cfg.enabled) return
const maxPoints = cfg.maxPoints
const range = w.config.xaxis.range
/** @param {any} p @returns {number | null} */
const xOf = (p) => {
if (p == null) return null
if (Array.isArray(p)) return typeof p[0] === 'number' ? p[0] : null
if (typeof p === 'object') return typeof p.x === 'number' ? p.x : null
return null
}
newSeries.forEach((s) => {
const data = s?.data
if (!Array.isArray(data) || data.length < 2) return
if (typeof maxPoints === 'number' && maxPoints > 0) {
if (data.length > maxPoints) s.data = data.slice(data.length - maxPoints)
return
}
if (!range) return
const lastX = xOf(data[data.length - 1])
const firstX = xOf(data[0])
if (lastX == null || firstX == null || lastX <= firstX) return
const avgSpacing = (lastX - firstX) / (data.length - 1)
const cutoff = lastX - range - 2 * avgSpacing
let idx = 0
while (idx < data.length && (xOf(data[idx]) ?? cutoff) < cutoff) idx++
if (idx > 0) s.data = data.slice(idx)
})
}