apexcharts
Version:
A JavaScript Chart Library
388 lines (354 loc) • 14.9 kB
JavaScript
// @ts-check
import Utils from '../../utils/Utils'
/**
* Shared view-state core.
*
* `captureViewState` / `applyViewState` are the single contract that both
* Perspectives (#10, serializable/shareable view state) and the future Rewind
* (#3, undo/redo history) build on. Keeping the capture/apply logic here: as a
* pure module with no feature registration: means the two features stay in
* lock-step and there is exactly one place that knows where each piece of view
* state lives in `w`.
*
* A `ViewState` is JSON-SERIALIZABLE: every field reads from a config/globals/
* interact source that holds data, not callbacks. One honest caveat: captured
* annotation params are `Utils.clone`d, which passes any function values (label
* formatters, event handlers) through BY REFERENCE so in-memory consumers
* (Rewind) restore them intact; JSON serialisation (Perspectives URL tokens)
* drops those functions, so annotations restored from a token render with
* default formatting. Rewind additionally pairs this view with a separate
* function-preserving `Utils.clone(w.config)` snapshot (that layer is Rewind's,
* not here).
*
* Sources (v5.16.0):
* - window.xaxis/yaxis ← w.config.xaxis/yaxis min/max (the requested window
* that drives the redraw; NOT ephemeral globals.minX)
* - zoomed ← w.interact.zoomed
* - collapsed ← w.globals.collapsedSeriesIndices (persistent)
* - ancillaryCollapsed ← w.globals.ancillaryCollapsedSeriesIndices
* - selectedDataPoints ← w.interact.selectedDataPoints
* - theme ← w.config.theme.mode / .palette
* - locale ← w.config.chart.defaultLocale
* - annotations.static ← w.config.annotations (declarative set)
* - annotations.dynamic ← w.globals.memory.methodsToExec (add*Annotation calls)
* - drill ← ctx.drilldown.path (informational)
* - measure ← ctx.measure.getPins() (pinned rulers, data space)
*
* @module ViewState
*/
/** Schema version for the ViewState shape. Bump on breaking changes. */
export const VIEWSTATE_VERSION = 1
/**
* Build an axis window, using null (not undefined) for absent bounds so the
* value survives a JSON round-trip. Returns null when neither bound is set.
* @param {any} min
* @param {any} max
* @returns {{ min: any, max: any } | null}
*/
function axisWindow(min, max) {
const hasMin = min !== undefined && min !== null
const hasMax = max !== undefined && max !== null
if (!hasMin && !hasMax) return null
return { min: hasMin ? min : null, max: hasMax ? max : null }
}
/**
* Deep-copy a number[][] selection without sharing references.
* @param {any} sel
* @returns {number[][]}
*/
function cloneSelection(sel) {
if (!Array.isArray(sel)) return []
return sel.map((a) => (Array.isArray(a) ? a.slice() : a))
}
/**
* Identify which add*Annotation method a memory entry was recorded with, by
* reference-comparing the stored method against the instance methods. Both are
* the same prototype function, so equality holds. Returns null for anything
* that is not a recognised dynamic annotation.
* @param {Function} method
* @param {any} ctx
* @returns {'xaxis'|'yaxis'|'point'|null}
*/
function annotationKind(method, ctx) {
if (typeof method !== 'function' || !ctx) return null
if (method === ctx.addXaxisAnnotation) return 'xaxis'
if (method === ctx.addYaxisAnnotation) return 'yaxis'
if (method === ctx.addPointAnnotation) return 'point'
return null
}
/**
* Map a dynamic-annotation kind to the public add method name.
* @param {string} kind
* @returns {'addXaxisAnnotation'|'addYaxisAnnotation'|'addPointAnnotation'|null}
*/
function addMethodName(kind) {
switch (kind) {
case 'xaxis':
return 'addXaxisAnnotation'
case 'yaxis':
return 'addYaxisAnnotation'
case 'point':
return 'addPointAnnotation'
default:
return null
}
}
/**
* Capture annotations from both sources: the declarative config set (static)
* and the dynamically-added set replayed from the memory queue (dynamic).
* Dynamic annotations do NOT live in w.config.annotations (they are drawn
* directly and replayed on mount), so both must be recorded.
* @param {any} w
* @param {any} ctx
*/
function captureAnnotations(w, ctx) {
const staticAnno = w.config.annotations
? Utils.clone(w.config.annotations)
: null
/** @type {{ kind: string, params: any }[]} */
const dynamic = []
const mem = (w.globals.memory && w.globals.memory.methodsToExec) || []
for (const entry of mem) {
if (!entry || entry.label !== 'addAnnotation') continue
const kind = annotationKind(entry.method, ctx)
if (!kind) continue
// Utils.clone preserves functions (label formatters etc.) for in-memory
// consumers; JSON.stringify later drops them for serialised tokens.
dynamic.push({ kind, params: Utils.clone(entry.params) })
}
return { static: staticAnno, dynamic }
}
/**
* Capture the pinned measure rulers (Measure #18) as JSON-safe data. Returns
* null when the feature is absent or nothing is pinned, so a pin-free view
* keeps a lean token. Pins are already in data space, so they re-project on
* restore at whatever zoom the target view lands in.
* @param {any} ctx
* @returns {{ pins: any[] } | null}
*/
function captureMeasure(ctx) {
const m = ctx && ctx.measure
if (!m || typeof m.getPins !== 'function') return null
const pins = m.getPins()
return Array.isArray(pins) && pins.length ? { pins } : null
}
/**
* Read the current view state off `w`. Pure: no DOM, no mutation, Node-safe.
* @param {any} w chart state object
* @param {any} ctx chart instance (for method-reference and drilldown reads)
* @returns {any} a function-free, JSON-safe ViewState
*/
export function captureViewState(w, ctx) {
const cfgX = w.config.xaxis || {}
const cfgYArr = Array.isArray(w.config.yaxis)
? w.config.yaxis
: w.config.yaxis
? [w.config.yaxis]
: []
const yWindows = cfgYArr.map((/** @type {any} */ y) =>
axisWindow(y && y.min, y && y.max),
)
const anyY = yWindows.some((/** @type {any} */ yw) => yw !== null)
const theme = w.config.theme
const drilldown = ctx && ctx.drilldown
return {
v: VIEWSTATE_VERSION,
window: {
xaxis: axisWindow(cfgX.min, cfgX.max),
yaxis: anyY ? yWindows : null,
},
zoomed: !!w.interact.zoomed,
collapsed: (w.globals.collapsedSeriesIndices || []).slice(),
ancillaryCollapsed: (w.globals.ancillaryCollapsedSeriesIndices || []).slice(),
selectedDataPoints: cloneSelection(w.interact.selectedDataPoints),
theme: theme
? { mode: theme.mode ?? null, palette: theme.palette ?? null }
: null,
locale: (w.config.chart && w.config.chart.defaultLocale) || null,
annotations: captureAnnotations(w, ctx),
drill:
drilldown && drilldown.depth > 0 ? { path: drilldown.path.slice() } : null,
measure: captureMeasure(ctx),
}
}
/**
* Reconcile the collapsed-series set to a target by diffing against the current
* set and toggling only the delta through the public show/hide API (which maps
* a realIndex → series name → the legend toggle). Series not present in both
* sets are left untouched.
*
* Note: showSeries/hideSeries route through the legend helper, so an unbundled
* legend feature makes this a no-op: acceptable, as the default bundle ships
* the legend.
*
* @param {any} ctx
* @param {number[]} targetCollapsed
* @param {number[]} targetAncillary
*/
export function applyCollapsedSet(ctx, targetCollapsed, targetAncillary) {
const w = ctx.w
// A hand-authored view (e.g. a storyboard beat, a bare ViewState) that does
// NOT mention the collapsed set leaves it untouched, so a user's legend toggle
// persists as they scrub between beats. A captured token always carries arrays
// (possibly empty), so an explicit empty set still un-hides everything.
if (targetCollapsed == null && targetAncillary == null) return
const names = w.globals.seriesNames || []
const target = new Set([
...(targetCollapsed || []),
...(targetAncillary || []),
])
const current = new Set([
...(w.globals.collapsedSeriesIndices || []),
...(w.globals.ancillaryCollapsedSeriesIndices || []),
])
for (let realIndex = 0; realIndex < names.length; realIndex++) {
const name = names[realIndex]
if (name == null) continue
const shouldCollapse = target.has(realIndex)
const isCollapsed = current.has(realIndex)
if (shouldCollapse && !isCollapsed) {
ctx.hideSeries(name)
} else if (!shouldCollapse && isCollapsed) {
ctx.showSeries(name)
}
}
}
/**
* Restore the selected-data-point set. There is no public "set selection" API
* (toggleDataPointSelection flips one point and simulates a click), so v1 sets
* the interact state directly. The active-state styling of restored points is a
* best-effort follow-up (see spec Open Decision §10.2); the state itself is
* restored so getState()/programmatic reads are correct.
*
* @param {any} ctx
* @param {number[][]} selectedDataPoints
*/
export function restoreSelection(ctx, selectedDataPoints) {
if (!Array.isArray(selectedDataPoints)) return
ctx.w.interact.selectedDataPoints = cloneSelection(selectedDataPoints)
}
/**
* Deterministically restore a captured view. Composes with resetSeries and
* drilldown by never re-capturing the initialConfig baseline
* (overwriteInitialConfig:false on the merge).
*
* When Cadence (#6) is present, callers wrap the whole call in
* document.startViewTransition(() => applyViewState(...)) for a morphing
* restore; that is the caller's concern, not this module's.
*
* @param {any} ctx
* @param {any} view a ViewState produced by captureViewState
* @param {{ animate?: boolean, mergeOptions?: Record<string, any> }} [opts]
* mergeOptions: extra updateOptions payload applied IN THE SAME render as
* the view (the view's own fields win on collision). One render instead of
* two matters: a second immediate updateOptions would kill the first one's
* animation mid-flight, and a chart.type change merged here lets the
* cross-type morph play out inside the single re-render.
*/
export function applyViewState(
ctx,
view,
{ animate = true, mergeOptions } = {},
) {
if (!ctx || !view) return
// 0. Version gate: a token minted by a NEWER schema may carry fields this
// build does not understand. Apply best-effort, but say so, instead of
// silently rendering a half-restored view.
if (typeof view.v === 'number' && view.v > VIEWSTATE_VERSION) {
console.warn(
`[apexcharts] ViewState v${view.v} is newer than this build understands (v${VIEWSTATE_VERSION}); applying best-effort.`,
)
}
// 1. Purge current dynamic annotations BEFORE the re-render so the mount-time
// replay of memory.methodsToExec cannot resurrect stale ones (resolves the
// static/dynamic double-render, spec §10.1). clearAnnotations() drops both
// the DOM nodes and the 'addAnnotation'/'addText' memory entries.
ctx.clearAnnotations()
// 2. Window + theme + static annotations (+ caller extras) in ONE merge.
// overwriteInitialConfig is false so the reset/drilldown baseline is
// preserved. Cloned because updateOptions mutates its payload.
/** @type {Record<string, any>} */
const options = mergeOptions ? Utils.clone(mergeOptions) : {}
const xw = view.window && view.window.xaxis
// An explicit {min,max} restores a zoom; {min:undefined,max:undefined} clears
// it back to the full range when the captured view was not zoomed. Merged
// over any caller-supplied xaxis so its other keys (ticks, labels) survive.
options.xaxis = Object.assign(
{},
options.xaxis,
xw
? { min: xw.min ?? undefined, max: xw.max ?? undefined }
: { min: undefined, max: undefined },
)
const yw = view.window && view.window.yaxis
if (Array.isArray(yw)) {
options.yaxis = yw.map((y) =>
y ? { min: y.min ?? undefined, max: y.max ?? undefined } : {},
)
}
if (view.theme) {
/** @type {Record<string, any>} */
const theme = {}
if (view.theme.mode != null) theme.mode = view.theme.mode
if (view.theme.palette != null) theme.palette = view.theme.palette
if (Object.keys(theme).length) {
options.theme = Object.assign({}, options.theme, theme)
}
}
if (view.annotations && view.annotations.static) {
options.annotations = Utils.clone(view.annotations.static)
}
ctx.updateOptions(
options,
false /* redraw */,
animate,
false /* updateSyncedCharts */,
false /* overwriteInitialConfig */,
)
// 3. Everything that does not live in w.config (zoom flag, collapsed set,
// dynamic annotations, selection, locale).
applyViewInteraction(ctx, view)
}
/**
* Restore the parts of a view that do NOT live in `w.config`: the zoom flag,
* the collapsed-series set, dynamic annotations, the selection, and the locale.
*
* Assumes the config-level state (zoom window, theme, static annotations, and,
* for Rewind, series/title) has ALREADY been applied by the caller's own
* updateOptions, and that dynamic annotations were cleared before that
* re-render (so the mount replay did not resurrect stale ones). Shared by
* Perspectives.applyViewState (partial config) and History._restore (full
* config) so there is exactly one non-config restore path.
*
* @param {any} ctx
* @param {any} view a ViewState produced by captureViewState
*/
export function applyViewInteraction(ctx, view) {
if (!ctx || !view) return
const w = ctx.w
// Zoom flag: Series.resetSeries clears it; set it explicitly here.
w.interact.zoomed = !!view.zoomed
// Collapsed set: toggle the delta via public show/hide.
applyCollapsedSet(ctx, view.collapsed, view.ancillaryCollapsed)
// Dynamic annotations: replay the captured data-level params. pushToMemory
// is true so they persist across subsequent re-renders like a real add.
if (view.annotations && Array.isArray(view.annotations.dynamic)) {
view.annotations.dynamic.forEach((/** @type {any} */ a) => {
const method = addMethodName(a.kind)
if (method && typeof ctx[method] === 'function') {
ctx[method](a.params, true)
}
})
}
// Selection (best-effort styling; state restored faithfully).
restoreSelection(ctx, view.selectedDataPoints)
// Locale.
if (view.locale && view.locale !== w.config.chart.defaultLocale) {
ctx.setLocale(view.locale)
}
// Measure rulers (#18): restore the pinned set; an absent/empty measure
// state clears any current pins so the restored view matches exactly.
if (ctx.measure && typeof ctx.measure.setPins === 'function') {
ctx.measure.setPins((view.measure && view.measure.pins) || [])
}
}