apexcharts
Version:
A JavaScript Chart Library
326 lines (299 loc) • 10.6 kB
JavaScript
// @ts-check
import Utils from '../../utils/Utils'
import { Environment } from '../../utils/Environment.js'
import { captureViewState, applyViewState } from '../state/ViewState'
import { enforceLicense } from '../license/LicenseEnforcer'
/**
* Perspectives (#10): serializable, shareable view state.
*
* "Capture the chart as I'm looking at it" as a compact token: zoom window,
* hidden series, selected points, theme, locale and annotations. Encode it to a
* base64url string / URL hash; a colleague opening the link restores the exact
* view (functions such as formatters are supplied by their own page config, not
* carried in the token: see encode()).
*
* Eager module (like drilldown): constructed once in InitCtxVariables so the
* in-memory saved-views registry survives update() and is dropped on destroy().
*
* Public API (namespaced under chart.perspectives):
* chart.perspectives.capture() -> Perspective { v, view, options? }
* chart.perspectives.encode(token?) -> base64url string
* chart.perspectives.decode(str) -> Perspective | null
* chart.perspectives.toURL() -> href with #apex=<token> (browser)
* chart.perspectives.apply(tokenOrStr, { animate })
* chart.perspectives.save(name) -> id
* chart.perspectives.list() -> [{ id, name, token }]
* chart.perspectives.delete(id)
* ApexCharts.perspectives.decode(str) (static, pure)
* ApexCharts.perspectives.fromURL(href) (static, pure)
*
* @module Perspectives
*/
/** Perspective token schema version. */
const PERSPECTIVE_VERSION = 1
/** URL-hash key that carries an encoded perspective. */
const HASH_KEY = 'apex'
// ── base64url helpers (UTF-8 safe, Node + browser) ──────────────────────────
/**
* @param {string} str
* @returns {string} standard base64
*/
function toBase64(str) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(str, 'utf-8').toString('base64')
}
// Browser: TextEncoder → binary string → btoa (handles non-Latin1 correctly).
const bytes = new TextEncoder().encode(str)
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
return btoa(bin)
}
/**
* @param {string} b64 standard base64
* @returns {string}
*/
function fromBase64(b64) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(b64, 'base64').toString('utf-8')
}
const bin = atob(b64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return new TextDecoder().decode(bytes)
}
/**
* @param {string} str
* @returns {string} base64url (no padding)
*/
function base64urlEncode(str) {
return toBase64(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
/**
* @param {string} b64url
* @returns {string}
*/
function base64urlDecode(b64url) {
let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/')
while (b64.length % 4) b64 += '='
return fromBase64(b64)
}
/**
* Deep copy of data only: JSON.stringify silently drops functions and
* undefined, which is exactly what we want for a serialisable option delta.
* @param {any} obj
* @returns {any}
*/
function stripFunctions(obj) {
try {
return JSON.parse(JSON.stringify(obj))
} catch (e) {
return undefined
}
}
export default class Perspectives {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx
/** @type {{ id: string, name: string, token: any }[]} in-memory registry */
this._saved = []
this._counter = 0
// Premium "in use" signal: true once apply()/save() runs (API-driven).
// Read by the license enforcer. The static decode/fromURL path sets a
// separate process-global signal in the enforcer.
this._used = false
}
/**
* Capture the current chart view as a Perspective token.
* @returns {{ v: number, view: object, options?: Record<string, any> }}
*/
capture() {
const view = captureViewState(this.w, this.ctx)
const token = /** @type {any} */ ({ v: PERSPECTIVE_VERSION, view })
const options = this._serializableDelta()
if (options && Object.keys(options).length) token.options = options
return token
}
/**
* Build the whitelisted, function-free option override recorded in the token.
* @returns {Record<string, any>}
* @private
*/
_serializableDelta() {
const cfg = this.w.config
// Config lives under chart.perspectives (see Options.js); fall back to the
// documented default whitelist if it was stripped from the resolved config.
const whitelist =
(cfg.chart &&
cfg.chart.perspectives &&
cfg.chart.perspectives.serializeOptions) ||
['theme', 'xaxis', 'yaxis', 'title', 'subtitle']
/** @type {Record<string, any>} */
const delta = {}
whitelist.forEach((/** @type {string} */ path) => {
if (cfg[path] !== undefined) {
const stripped = stripFunctions(cfg[path])
if (stripped !== undefined) delta[path] = stripped
}
})
return delta
}
/**
* Encode a token (or the current capture) to a compact base64url string.
* JSON.stringify drops any functions embedded in annotation params / option
* overrides by construction: a shared link carries data; the opening page
* supplies its own functions from config.
* @param {any} [token]
* @returns {string}
*/
encode(token) {
const t = token || this.capture()
return base64urlEncode(JSON.stringify(t))
}
/**
* Decode a base64url token string. Never throws: returns null on any error
* or version mismatch (with a console warning).
* @param {string} str
* @returns {any | null}
*/
decode(str) {
return Perspectives.decode(str)
}
/**
* Encode the current capture into a `#apex=<token>` URL hash fragment on the
* current location. Browser-only; returns '' under SSR.
* @returns {string}
*/
toURL() {
if (!Environment.isBrowser()) return ''
const encoded = this.encode(this.capture())
const url = new URL(window.location.href)
url.hash = `${HASH_KEY}=${encoded}`
return url.toString()
}
/**
* Restore a perspective. Accepts a token object or an encoded string. When
* the chart is grouped, applies to every synced chart.
*
* The token's option overrides and `opts.mergeOptions` are folded into the
* view restore's ONE updateOptions call (mergeOptions wins over
* token.options; the view's own fields win over both). A single render is
* deliberate: a second immediate updateOptions would kill the first one's
* animation mid-flight, and a chart.type change applied this way morphs
* (morph feature) inside the same re-render instead of being re-rendered
* over. Consumed by Storyboard for per-beat option payloads.
*
* @param {any} tokenOrString
* @param {{ animate?: boolean, mergeOptions?: Record<string, any> }} [opts]
*/
apply(tokenOrString, opts = {}) {
const token =
typeof tokenOrString === 'string'
? Perspectives.decode(tokenOrString)
: tokenOrString
if (!token || !token.view) return
// Applying a view is premium perspectives usage: mark before the re-render
// so the render's license check sees it, and enforce directly in case the
// apply is a no-op re-render (identical view memoized away).
this._used = true
enforceLicense(this.w, this.ctx)
const animate = opts.animate !== undefined ? opts.animate : true
const combined = Utils.extend(
token.options ? Utils.clone(token.options) : {},
opts.mergeOptions || {},
)
const mergeOptions = Object.keys(combined).length ? combined : undefined
const targets = this.w.config.chart.group
? this.ctx.getSyncedCharts()
: [this.ctx]
targets.forEach((/** @type {any} */ chart) => {
applyViewState(chart, token.view, { animate, mergeOptions })
})
}
/**
* Save the current view under a name in the in-memory registry.
* @param {string} name
* @returns {string} generated id
*/
save(name) {
const id = `perspective-${++this._counter}`
this._saved.push({ id, name: name || id, token: this.capture() })
// Saving a view is premium perspectives usage (no re-render on its own).
this._used = true
enforceLicense(this.w, this.ctx)
return id
}
/**
* List saved perspectives.
* @returns {{ id: string, name: string, token: any }[]}
*/
list() {
return this._saved.map((s) => ({ id: s.id, name: s.name, token: s.token }))
}
/**
* Delete a saved perspective by id.
* @param {string} id
*/
delete(id) {
const i = this._saved.findIndex((s) => s.id === id)
if (i > -1) this._saved.splice(i, 1)
}
/** Drop the saved-views registry (called on full destroy). */
teardown() {
this._saved = []
this._counter = 0
}
// ── static, pure helpers (available once the feature is imported) ─────────
/**
* @param {string} str base64url token
* @returns {any | null}
*/
static decode(str) {
if (typeof str !== 'string' || !str) return null
try {
const token = JSON.parse(base64urlDecode(str))
if (!token || typeof token !== 'object') return null
if (token.v !== PERSPECTIVE_VERSION) {
// No migrations defined yet: warn and bail rather than throw.
console.warn(
`apexcharts: unsupported perspective version ${token.v} (expected ${PERSPECTIVE_VERSION}).`,
)
return null
}
return token
} catch (e) {
console.warn('apexcharts: failed to decode perspective token.', e)
return null
}
}
/**
* Parse a `#apex=<token>` fragment out of an href (or the current location in
* a browser). Pure and Node-safe when given an explicit href.
* @param {string} [href]
* @returns {any | null}
*/
static fromURL(href) {
try {
const target =
href || (Environment.isBrowser() ? window.location.href : '')
if (!target) return null
const url = new URL(target)
const hash = url.hash.replace(/^#/, '')
if (!hash) return null
const pair = hash
.split('&')
.map((p) => p.split('='))
.find((p) => p[0] === HASH_KEY)
if (!pair || pair[1] == null) return null
return Perspectives.decode(decodeURIComponent(pair[1]))
} catch (e) {
return null
}
}
}