apexcharts
Version:
A JavaScript Chart Library
382 lines (350 loc) • 14.1 kB
JavaScript
// @ts-check
import { Environment } from '../../utils/Environment.js'
import { prefersReducedMotion } from '../Animations'
import { enforceLicense } from '../license/LicenseEnforcer'
/**
* Storyboard: scroll-driven chart choreography (scrollytelling).
*
* The page's prose is split into "beats". As the reader scrolls a beat's
* element across a trigger line in the viewport, the chart morphs to that
* beat's view; scrolling back up reverses to the previous beat. A beat's view
* is a Perspectives token (object or encoded string), so a beat describes the
* WHOLE view (zoom window, collapsed series, annotations, theme): applying it
* is idempotent, which is what makes scrubbing in both directions safe.
*
* Driven by IntersectionObserver (works in every modern browser and inside
* any scroll container), not by scroll events: no per-frame work, no scroll
* jank. Under prefers-reduced-motion the beat still applies, without
* animation (a cut instead of a morph).
*
* Eager module (like perspectives): constructed once in InitCtxVariables so
* bindings survive update()/re-render (the observed elements are page prose,
* outside the chart's DOM) and are dropped on a full destroy().
*
* Public API (namespaced under chart.storyboard):
* chart.storyboard.bind(opts) -> number of beats bound
* opts.beats: [{ el | selector, view?, options?, announce?, onEnter?, key? }]
* view: a bare ViewState object (hand-authored shorthand),
* a full Perspective token, or an encoded token string,
* applied via chart.perspectives.apply.
* options: an updateOptions payload merged into the SAME
* render as the view, so a beat can restyle or swap
* chart.type: one render keeps the transition animated and
* lets a cross-type morph (morph feature) play out. Options
* are not part of the view contract: like the view, a beat
* should carry every option it depends on, since
* updateOptions merges. onEnter(chart, info) is the escape
* hatch for arbitrary per-beat work.
* (no beats): auto-discovers [data-apex-beat] elements in document
* order; data-apex-view carries an encoded token and
* data-apex-announce a screen-reader announcement.
* opts.scroller: element or selector of a custom scroll container
* (default: the viewport).
* opts.offset: 0..1 fraction of the viewport height where the trigger
* line sits (default 0.5, the vertical midline).
* opts.animate: animate beat transitions (default true; forced off by
* prefers-reduced-motion).
* chart.storyboard.goTo(indexOrKey, { animate }) -> apply a beat directly
* chart.storyboard.current() -> { index, key } | null
* chart.storyboard.unbind()
*
* Fires the `beatChange` chart event with
* { index, key, el, direction: 'up' | 'down' } on every beat activation.
*
* @module Storyboard
*/
/**
* @typedef {Object} StoryboardBeat
* @property {Element} el the prose element that triggers the beat
* @property {string | null} key author key (data-apex-beat value or beats[].key)
* @property {any} [view] ViewState object, Perspective token or encoded string
* @property {any} [options] updateOptions payload merged into the view's render
* @property {string} [announce] aria-live announcement text
* @property {((chart: any, info: StoryboardBeatInfo) => void) | undefined} [onEnter]
*/
/**
* @typedef {Object} StoryboardBeatInfo
* @property {number} index
* @property {string | null} key
* @property {Element} el
* @property {'up' | 'down'} direction
*/
export default class Storyboard {
/**
* @param {import('../../types/internal').ChartStateW} w
* @param {import('../../types/internal').ChartContext} ctx
*/
constructor(w, ctx) {
this.w = w
this.ctx = ctx
/** @type {StoryboardBeat[]} */
this._beats = []
/** @type {IntersectionObserver | null} */
this._observer = null
this._activeIndex = -1
this._animate = true
this._warnedNoPerspectives = false
// Premium "in use" signal: true once bind() has bound beats (API-driven,
// there is no chart.storyboard config flag). Read by the license enforcer.
this._used = false
}
/**
* Bind beats to scroll position. Rebinding replaces the previous binding.
* @param {{
* beats?: Array<{ el?: Element, selector?: string, key?: string, view?: any, announce?: string, onEnter?: (chart: any, info: StoryboardBeatInfo) => void }>,
* scroller?: Element | string,
* offset?: number,
* animate?: boolean,
* }} [opts]
* @returns {number} the number of beats bound
*/
bind(opts = {}) {
this.unbind()
if (!Environment.isBrowser()) return 0
const doc = this.ctx.el && this.ctx.el.ownerDocument
if (!doc || typeof IntersectionObserver === 'undefined') return 0
/** @type {Element | null} */
let root = null
if (opts.scroller) {
root =
typeof opts.scroller === 'string'
? doc.querySelector(opts.scroller)
: opts.scroller
}
this._beats = this._resolveBeats(doc, root, opts.beats)
if (!this._beats.length) return 0
this._animate = opts.animate !== false
// Shrink the observation root to a zero-height band at `offset` of the
// viewport (or custom scroller). A beat is active while it spans that
// trigger line; entering it going down or leaving it going up drives the
// step logic in _onIntersect.
const offset = Math.min(Math.max(opts.offset ?? 0.5, 0), 1)
const top = +(offset * 100).toFixed(3)
const bottom = +(100 - offset * 100).toFixed(3)
this._observer = new IntersectionObserver(
(entries) => this._onIntersect(entries),
{ root, rootMargin: `-${top}% 0px -${bottom}%`, threshold: 0 },
)
this._beats.forEach((b) => this._observer?.observe(b.el))
// Storyboard is now in use (beats bound): re-evaluate the trial watermark.
this._used = true
enforceLicense(this.w, this.ctx)
return this._beats.length
}
/**
* Normalize the beats option, or auto-discover [data-apex-beat] elements in
* document order when no explicit list is given.
* @param {Document} doc
* @param {Element | null} root
* @param {Array<any>} [beatsOpt]
* @returns {StoryboardBeat[]}
* @private
*/
_resolveBeats(doc, root, beatsOpt) {
/** @type {StoryboardBeat[]} */
const beats = []
if (Array.isArray(beatsOpt)) {
beatsOpt.forEach((b, i) => {
if (!b) return
const el =
b.el && typeof b.el === 'object'
? b.el
: b.selector
? doc.querySelector(b.selector)
: null
if (!el) {
console.warn(
`apexcharts: storyboard beat ${i} has no resolvable element; skipped.`,
)
return
}
beats.push({
el,
key: b.key ?? el.getAttribute('data-apex-beat') ?? String(i),
view: b.view,
options: b.options,
announce: b.announce,
onEnter: typeof b.onEnter === 'function' ? b.onEnter : undefined,
})
})
return beats
}
const scope = root || doc
scope.querySelectorAll('[data-apex-beat]').forEach((el, i) => {
beats.push({
el,
key: el.getAttribute('data-apex-beat') || String(i),
view: el.getAttribute('data-apex-view') || undefined,
options: undefined,
announce: el.getAttribute('data-apex-announce') || undefined,
onEnter: undefined,
})
})
return beats
}
/**
* @param {IntersectionObserverEntry[]} entries
* @private
*/
_onIntersect(entries) {
entries.forEach((entry) => {
const idx = this._beats.findIndex((b) => b.el === entry.target)
if (idx < 0) return
if (entry.isIntersecting) {
// The beat's element reached the trigger line: activate it. Covers
// both scroll directions and the initial position on bind().
this._activate(idx)
} else if (idx === this._activeIndex && entry.rootBounds) {
// The ACTIVE beat left the line. If its box now sits below the line
// the reader scrolled up past its top edge: step back one beat.
// (Leaving upward means the next beat is about to intersect and will
// activate itself, so only the upward-scroll case is handled here.)
if (
entry.boundingClientRect.top >= entry.rootBounds.bottom &&
idx > 0
) {
this._activate(idx - 1)
}
}
})
}
/**
* Activate a beat: apply its view token, run its callback, announce it and
* fire `beatChange`. Idempotent per beat (re-activating the current beat is
* a no-op), so IO chatter never re-applies a view.
* @param {number} idx
* @param {{ animate?: boolean }} [opts]
* @private
*/
_activate(idx, opts = {}) {
if (idx === this._activeIndex) return
const beat = this._beats[idx]
if (!beat) return
/** @type {'up' | 'down'} */
const direction = idx > this._activeIndex ? 'down' : 'up'
this._activeIndex = idx
const animate =
(opts.animate !== undefined ? opts.animate : this._animate) &&
!prefersReducedMotion()
if (beat.view != null || beat.options) {
if (this.ctx.perspectives) {
// A beat's view may be an encoded string, a full Perspective token
// ({ view: ... }), or, the hand-authored shorthand, a bare ViewState
// object: wrap and normalize the latter so authors write
// view: { window: ... } and a beat describes the WHOLE target state.
const v = beat.view ?? {}
const token =
typeof v === 'string' || v.view ? v : { view: this._normalizeView(v) }
// The beat's option overrides ride the SAME render as the view: a
// separate follow-up updateOptions would kill the view's animation
// mid-flight, and merging lets a chart.type change morph (morph
// feature) inside the one re-render.
this.ctx.perspectives.apply(token, {
animate,
mergeOptions: beat.options,
})
} else if (!this._warnedNoPerspectives) {
this._warnedNoPerspectives = true
console.warn(
'apexcharts: storyboard beats carry views but the perspectives feature is not bundled. import "apexcharts/features/storyboard" (which includes it) or drive beats via onEnter.',
)
}
}
/** @type {StoryboardBeatInfo} */
const info = { index: idx, key: beat.key, el: beat.el, direction }
if (beat.onEnter) beat.onEnter(this.ctx, info)
if (beat.announce) this._announce(beat.announce)
if (typeof this.w.config.chart.events.beatChange === 'function') {
this.w.config.chart.events.beatChange(this.ctx, info)
}
// fireEvent applies an args ARRAY to addEventListener handlers, so pass
// [chart, info] like the other feature events (filterChange etc.).
this.ctx.events?.fireEvent('beatChange', [this.ctx, info])
}
/**
* Fill in the parts of a hand-authored (bare) ViewState that would
* otherwise LEAK between beats. updateOptions merges objects, so a beat
* listing only xaxis annotations would keep a previous beat's point
* annotations; padding every annotation kind with an empty array makes
* each beat fully describe its own state, which is what allows scrubbing
* in both directions. Full tokens (from capture()/encode()) already carry
* the complete set and never pass through here.
* @param {any} view
* @returns {any}
* @private
*/
_normalizeView(view) {
const provided = (view.annotations && view.annotations.static) || {}
return {
...view,
annotations: {
static: {
points: [],
xaxis: [],
yaxis: [],
texts: [],
images: [],
...provided,
},
dynamic: (view.annotations && view.annotations.dynamic) || [],
},
}
}
/**
* Programmatically jump to a beat by index or author key (also usable
* without scrolling, e.g. from next/prev buttons).
* @param {number | string} indexOrKey
* @param {{ animate?: boolean }} [opts]
*/
goTo(indexOrKey, opts = {}) {
const idx =
typeof indexOrKey === 'number'
? indexOrKey
: this._beats.findIndex((b) => b.key === indexOrKey)
if (idx >= 0 && idx < this._beats.length) this._activate(idx, opts)
}
/**
* @returns {{ index: number, key: string | null } | null} the active beat
*/
current() {
if (this._activeIndex < 0) return null
const beat = this._beats[this._activeIndex]
return { index: this._activeIndex, key: beat ? beat.key : null }
}
/** Disconnect the observer and drop the beat list. */
unbind() {
if (this._observer) {
this._observer.disconnect()
this._observer = null
}
this._beats = []
this._activeIndex = -1
// No longer in use: drop the trial watermark if nothing else needs it.
this._used = false
enforceLicense(this.w, this.ctx)
}
/** Full-destroy cleanup (called from Destroy). */
teardown() {
this.unbind()
}
/**
* Push a beat's announcement to the chart's visually-hidden aria-live
* status region so screen-reader users follow the story too. No-op when
* announcements are disabled or the region is absent.
* @param {string} message
* @private
*/
_announce(message) {
const w = this.w
if (!w.config.chart.accessibility.announcements.enabled) return
const baseEl = w.dom.baseEl
if (!baseEl) return
const region = baseEl.querySelector('.apexcharts-sr-status')
if (!region) return
// Clear-then-set so a repeated string is still re-announced.
region.textContent = ''
setTimeout(() => {
region.textContent = message
}, 0)
}
}