monte
Version:
A visualization framework for D3.js and SVG. Ships with prebuilt charts and components.
352 lines (286 loc) • 9.1 kB
JavaScript
import * as EV from '../const/events';
import { isDefined, isFunc } from '../tools/is';
import { MonteError } from '../support/MonteError';
import { MonteOptionError } from '../support/MonteOptionError';
import { UNDEF } from '../const/undef';
import { global } from '../support/MonteGlobal';
import { mergeOptions } from '../tools/mergeOptions';
import { pascalCase } from '../tools/string';
// TODO: Allow setting presets that are automatically merged with settings to create "subtypes"?
// For example a "Horizontal" preset for ReferenceLine that makes assumptions about x1 being 0
// and x2 being `this.chart.width`. This could apply to charts to create standardized transistion
// effects.
const DEFAULTS = {
// The layer for drawing operations
layer: 'bg',
// The chart events to listen for.
binding: [EV.BEFORE_DESTROY, EV.RENDERED, EV.UPDATED, EV.BOUNDS_UPDATED],
// Flag for global updates for any option change.
// Subclasses can override `_shouldOptionUpdate` for nuanced behavior.
optionsTriggerUpdate: false,
// Special prefix for all events originating *from* the extension.
eventPrefix: null,
customEvents: [],
};
export class Extension {
constructor(options) {
this.__extId = null; // Not set until a chart is bound
// Configure the data options.
this._initOptions(options);
this._initPublicEvents(
...EV.EXTENSION_LIFECYCLE_EVENTS,
// Custom events provided by the user
...this.opts.customEvents);
this.lastUpdateEvent = '';
}
_initOptions(...options) {
this.opts = mergeOptions(...options, DEFAULTS);
if (!this.opts.eventPrefix) {
throw MonteOptionError.RequiredOption('eventPrefix');
}
// Always require the "destroying" event to ensure extension clean up.
if (this.opts.binding.indexOf(EV.BEFORE_DESTROY) === -1) {
this.opts.binding.push(EV.BEFORE_DESTROY);
}
}
_initPublicEvents(...events) {
this._events = events;
}
/**
* Associate a chart instance with the extension. The chart instance will be acted upon by the
* extension instance.
*
* @Chainable
*/
setChart(chart) {
// Prevent setting chart more than once.
if (this.chart && this.chart !== chart) {
throw new MonteError('An extension should only have the chart set once.');
}
this.chart = chart;
const layerName = this.tryInvoke(this.opts.layer);
if (layerName) { this.layer = this.chart[layerName]; }
// Get and store extension ID from MonteGlobal
this.__extId = global.getNextExtensionId();
return this;
}
option(prop, value) {
if (value === UNDEF) {
return this.opts[prop];
}
this.opts[prop] = value;
if (this._shouldOptionUpdate(prop) && this.cachedChart) {
this.fire(EV.OPTION_CHANGED, prop);
}
return this;
}
// Indicates whether an option change should cause an update to occur.
_shouldOptionUpdate(prop) { // eslint-disable-line no-unused-vars
return this.opts.optionsTriggerUpdate;
}
/**
* Binds an event to a given `callback`. If no `callback` is provided it returns the callback.
*
* @Chainable <setter>
*/
on(typenames, callback) {
if (!this.dispatch) { // Lazy construct the dispatcher.
this.dispatch = d3.dispatch(...this.events);
}
if (callback) {
this.dispatch.on(typenames, callback);
return this;
}
return this.dispatch.on(typenames);
}
/**
* Force the triggering of an event with the given arguments. The event is notified through the
* extension's dispatcher and the parent chart's dispatcher. The `on` callbacks are invoked in
* the context of the extension for the extension's dispatcher and in the context of the chart
* for the chart's dispatcher.
*
* Uses:
* + Trigger event for listeners as needed such as force an extension to update.
*
* @Chainable
*/
__notify(eventName, ...args) {
if (this.dispatch) {
this.dispatch.call(eventName, this, ...args);
}
this.chart.emit('extension', `${this.opts.eventPrefix}:${eventName}`, this, ...args);
}
emit(eventName, ...args) {
this.__notify(eventName, ...args);
}
/**
* Remove the data elements.
*
* @Chainable
*/
clear() {
this.__notify(EV.BEFORE_CLEAR);
this._clearDataElements();
this.__notify(EV.CLEARED);
return this;
}
_clearDataElements() {
// Clear elements based on extension ID.
this._extCreateSelection.remove();
}
/**
* Invokes a lifecycle event ('destroying', 'boundsUpdated', 'rendered', 'optionChanged') with
* all other events resulting in an 'updated' event and invoking the update-cycle.
*
* The 'boundsUpdated' and 'rendered' chart events result in update-cycle invocation if the
* extension does not override the event-bound methods (`_boundsUpdate`, `_render`).
*
* The 'optionChanged' extension event results in `_option<>`
*/
fire(event, ...args) {
if (!this.chart) {
throw new MonteError('A chart must be associated with the extension prior to use.');
}
try {
switch (event) {
case EV.BEFORE_DESTROY:
this.destroy();
break;
case EV.BOUNDS_UPDATED:
this.updateBounds(...args);
break;
case EV.RENDERED:
this.render(...args);
break;
case EV.OPTION_CHANGED:
this._optionChanged(...args);
break;
default:
this.lastUpdateEvent = event;
this.update(...args);
}
}
catch (e) {
if (console && console.error) { console.error(e); } // eslint-disable-line no-console
this.__notify(EV.SUPPRESSED_ERROR, e, e.stack || 'No stack available.');
}
}
/**
* Invoke the extension update-cycle.
*/
update(...args) {
this.__notify(EV.BEFORE_UPDATE);
this._update(...args);
this.__notify(EV.UPDATED);
}
_update(binding, chart) { // eslint-disable-line no-unused-vars
throw MonteError.UnimplementedMethod('Update', '_update', 'extension');
}
/**
* Invoke `_render` if defined.
*/
render(...args) {
if (this._render) {
this.__notify(EV.BEFORE_RENDER);
this._render(...args);
this.__notify(EV.RENDERED);
}
this.rendered = true;
}
/**
* Invoke `_boundsUpdate` if defined.
*/
updateBounds(...args) {
if (this._boundsUpdate) {
this.__notify(EV.BEFORE_BOUNDS_UPDATE);
this._boundsUpdate(...args);
this.__notify(EV.BOUNDS_UPDATED);
}
}
/**
* Invoke the method related directly to the option (`_option<OptionName>`) if defined; otherwise
* invoke the extension update-cycle.
*/
_optionChanged(...args) {
const prop = args[0];
const optionMethodName = `_option${pascalCase(prop)}`;
this.__notify(EV.BEFORE_OPTION_CHANGE, prop);
if (this[optionMethodName]) {
this[optionMethodName]();
}
else {
this.update(...args);
}
this.__notify(EV.OPTION_CHANGED, prop);
}
// Access an option that may need to be invoked as a function or that may be a literal value.
tryInvoke(value, ...args) {
try {
return isFunc(value) ? value.call(this, ...args) : value;
}
catch (e) {
if (console && console.error) { console.error(e); } // eslint-disable-line no-console
this.__notify(EV.SUPPRESSED_ERROR, e, e.stack || 'No stack available.');
return null;
}
}
/**
* Reads a property from a datum and returns the raw (unscaled) value.
*/
getProp(propShortName, d, defaultValue=null) {
const propFullName = `${propShortName}Prop`;
const dataPropName = this.opts[propFullName];
if (dataPropName) {
return d[dataPropName];
}
return defaultValue;
}
// Build a string of CSS classes that may include invokable options.
_buildCss(cssSources, d, i, nodes) {
const cssClasses = [];
const sources = Array.isArray(cssSources) ? cssSources : [cssSources];
sources.forEach((source) => {
if (isDefined(source)) {
cssClasses.push(this.tryInvoke(source, d, i, nodes));
}
});
return cssClasses.join(' ');
}
getExtId() {
return this.__extId;
}
_getExtAttr() {
return `monte-ext-${this.getExtId()}`; // ID is stored on the element as an attribute.
}
// Sets the extension ID selector ('the ID attribute') on the element.
_setExtAttrs(selection) {
selection.attr(this._getExtAttr(), '');
}
_extCreateSelection(cssClass) {
const extAttr = this._getExtAttr();
let selector = `[${extAttr}]`;
if (cssClass) {
selector += `.${cssClass}`;
}
return this.layer.selectAll(selector);
}
/**
* Invoke `_destroy`.
*/
destroy() {
this.__notify(EV.BEFORE_DESTROY);
this._destroy();
this.__notify(EV.DESTROYED);
}
// Clean up if necessary.
_destroy() {}
toString() {
return this.constructor.name;
}
static featureEventName(featurePrefix, eventName) {
if (featurePrefix === 'chart') {
return eventName;
}
return `${featurePrefix}:${eventName}`;
}
}