UNPKG

monte

Version:

A visualization framework for D3.js and SVG. Ships with prebuilt charts and components.

1,098 lines (901 loc) 30.2 kB
import * as EV from '../const/events'; import { get as _get, set as _set, isEqual } from '../external/lodash'; import { isArray, isDefined, isFunc, isNumeric, isObject, isString } from '../tools/is'; import { InstanceGroup } from '../support/InstanceGroup'; import { MonteError } from '../support/MonteError'; import { MonteOptionError } from '../support/MonteOptionError'; import { UNDEF } from '../const/undef'; import { standard as defaultTransition } from '../util/transitionSettings'; import { getTreeSetting } from '../tools/getTreeSetting'; import { global } from '../support/MonteGlobal'; import { mergeOptions } from '../tools/mergeOptions'; import { noop } from '../tools/noop'; const CLIP_PATH_ID_BASE = 'drawPath'; export function chartClipPathId(chartId) { const num = +chartId; if (!isNumeric(num)) { throw new MonteError('Cannot get chart clipPath ID. The chart ID must be numeric.'); } return CLIP_PATH_ID_BASE + num; } export function defaultDataKey(d, i) { return (d && d.id) || i; } const DEFAULTS = { css: '', boundingWidth: 250, boundingHeight: 250, margin: { top: 0, right: 0, bottom: 0, left: 0, }, customEvents: [], extensions: [], transition: defaultTransition, resize: null, // Data key for all draw layer and feature level selections in all charts. dataKey: defaultDataKey, /************************************************************************************************* * * Misc. options * ************************************************************************************************/ // When a `clear` occurs (by direct invocation or via `data` (without an update)) the domain is // automatically reset. autoResetStyleDomains: false, // Indicates that the chart base is being used directly in a client script chart (an "on the // fly" chart). The assumption is most of the time other classes will extend and implement // required features (such as '_update') and the checks for those features should be enforced. directUse: false, developerOptions: { scales: false, // Log generated scale accessor details transitions: false, // Log transition configuration details listeners: false, // Log which event listeners are setup during developer mode }, }; /* Data Format: Single Line Format { values: [{ x: <date>, y: 300 }, { x: <date>, y: 500 }, { x: <date>, y: 600 }], css: 'fill brand-blue', } Multiple lines [<single line format>, <single line format>, ...] */ export class Chart { constructor(parentSelector, options, data) { // eslint-disable-line max-statements this._constructed = false; this._optsSet = false; this.hasRendered = false; this.layers = []; this.extensions = []; this._optionReaderCache = {}; this.__chartId = global.getNextChartId(); this.parentSelector = parentSelector; // Configure the data options. this._initOptions(options); // Setup the Public events. this._initPublicEvents( ...EV.INTERACTION_EVENTS, ...EV.CHART_SUPPORT_EVENTS, ...EV.CHART_LIFECYCLE_EVENTS, // Custom events provided by the user ...this.opts.customEvents); // Put chart in developer mode if opted into on a chart or global basis if (this.opts.developerMode || global.isDeveloperMode()) { this._initDeveloperMode(); } // Setup the core infastructure. this._initCore(); // Customize configuration this._initCustomize(); // Update the bounding box and layout basics. this._boundsUpdate(); // Bind initial extensions to this chart instance. this._bindExt(this.tryInvoke(this.opts.extensions)); // Do the various setup rendering (Axis, BG, etc...) this._initRender(); this._constructed = true; // Trigger a resize if everything is ready. if (this._resizeHandler && global.getResizeWatcher().documentReady) { this._resizeHandler(); } // First full draw cycle if (data) { this.data(data); } } getChartId() { return this.__chartId; } _getChartAttr() { return `monte-chart-${this.getChartId()}`; // ID is stored on the element as an attribute. } _initOptions(...options) { this.opts = {}; const opts = mergeOptions(...options, DEFAULTS); for (let key in opts) { if (opts.hasOwnProperty(key)) { this.option(key, opts[key]); } } this._optsSet = true; } // Intialize the vis. _initCore() { // Create SVG element and drawing area setup const parent = d3.select(this.parentSelector); // TODO: Allow parent to be a D3 Selection? if (parent.node() === null) { throw new MonteError(`Invalid selector. "${this.parentSelector}" did not match any element."`); } else if (parent.node().tagName.toLowerCase() === 'svg') { this.bound = parent; } else { this.bound = parent.append('svg'); } this.bound.attr(this._getChartAttr(), ''); // Add reference of chart to the node for flexibility of access. this.bound.node().monteChart = this; this.bound.classed(this._buildCss(['monte-chart', this.opts.css, this.opts.chartCss]), true); // SVG Defs element this.defs = this.bound.append('defs'); // Drawing area path clipping this.clip = this.defs.append('clipPath').attr('id', chartClipPathId(this.getChartId())); this.clipRect = this.clip.append('rect').attr('x', 0).attr('y', 0); this._initLayers(); const chart = this; // Setup interaction events for the overall chart. EV.INTERACTION_EVENTS.forEach((ev) => { this.bound.on(ev, function(...args) { chart.__notify(ev, this, ...args); }); }); // Bind resize function if given. const resizer = this.tryInvoke(this.opts.resize); if (resizer) { this._resizeHandler = resizer.resize.bind(resizer, this); global.getResizeWatcher().add(this._resizeHandler); } } _initLayers() { // Create a background area. this.addLayer('bg'); // Create the support area. this.addLayer('support'); // Create the selection area. this.addLayer('selection'); // Create the primary drawing area. this.addLayer('draw'); // Create the overlay area. this.addLayer('overlay'); } _initPublicEvents(...events) { this._events = events; this.dispatch = d3.dispatch(...events); } // TODO: Allow enabling/disabling of developerMode from `option`. _initDeveloperMode() { const echo = (eventName, ...args) => { let a = '(no arguments)'; if (args && args.length > 0) { a = '\n'; args.forEach((v, i) => { if (isDefined(v)) { const s = isFunc(v) ? `func: '${v.name || 'anonymous'}'` : v; a += `\t${i}: ${s}\n`; } }); } console.log(`[${this}] "${eventName}": ${a}`); // eslint-disable-line no-console }; this.developerMode = true; // Determine events to watch in developer mode. If `developerMode` is an array use the provided // events; otherwise use all registered events. const events = (isArray(this.opts.developerMode) || global.getDeveloperModeEvents()) ? (this.opts.developerMode || global.getDeveloperModeEvents()) : this._events; events.forEach((eventName) => { if (this.opts.developerOptions.listeners) { console.log(`[${this}] Adding listener for "${eventName}"`); // eslint-disable-line no-console } this.on(`${eventName}.developerMode`, echo.bind(this, eventName)); }); } _initCustomize() {} _initRender() {} _boundsUpdate(suppressNotify=false, suppressUpdate=false) { this.__notify(EV.BEFORE_BOUNDS_UPDATE); // Margin Convention and calculate drawing area size this.margin = this.opts.margin; this.width = this.opts.boundingWidth - this.margin.left - this.margin.right; this.height = this.opts.boundingHeight - this.margin.top - this.margin.bottom; // Apply margins to layers this.layers.forEach((l) => l.attr('transform', this._getLayerTranslate())); // Update sizing attributes if (this.bound) { this.bound.attr('width', this.opts.boundingWidthAttr || this.opts.boundingWidth) .attr('height', this.opts.boundingHeightAttr || this.opts.boundingHeight); } // Update drawing clip if (this.clipRect) { this.clipRect.attr('width', this.width) .attr('height', this.height); } const notify = () => { if (this._constructed) { this.__notify(EV.BOUNDS_UPDATED); } }; const update = () => { if (this.hasRendered) { this.update(); } }; if (!suppressNotify) { notify(); } if (!suppressUpdate) { update(); } return { notify, update, }; } /* * Manually invoke the resize strategy (if any). * * @Chainable */ checkSize() { if (this._resizeHandler) { this._resizeHandler(); } return this; } destroy() { this.__notify(EV.BEFORE_DESTROY); if (this._resizeHandler) { global.getResizeWatcher().remove(this._resizeHandler); } this._destroy(); // Handle case where parentSelector and bound are the same and only remove internal elements. if (this.bound.node() === d3.select(this.parentSelector).node()) { this.bound.node().innerHTML = ''; } else { this.bound.remove(); } this.__notify(EV.DESTROYED); } _destroy() {} /* * Adds a layer to the chart. The layer is the top most by default. * * @Chainable */ addLayer(layerName, elementType = 'g') { const layer = this.bound.append(elementType).attr('class', `monte-${layerName}`); this[layerName] = layer; this.layers.push(layer); return this; } /* * Makes a layer use a defined `clipPath`. * * @Chainable */ layerUseClipPath(layerName, pathId) { if (!isDefined(pathId)) { pathId = chartClipPathId(this.getChartId()); } this[layerName].attr('clip-path', `url(#${pathId})`); return this; } _getLayerTranslate() { return `translate(${this.margin.left}, ${this.margin.top})`; } /** * Sets the external dimensions on the SVG element. * * @Chainable */ boundingRect(width, height) { if (arguments.length === 0) { return [this.opts.boundingWidth, this.opts.boundingHeight]; } if (arguments.length >= 1 && isDefined(width)) { const minWidth = this.option('margin.left') + this.option('margin.right'); if (width < minWidth) { width = minWidth; } this.opts.boundingWidth = width; } if (arguments.length === 2 && isDefined(height)) { const minHeight = this.option('margin.top') + this.option('margin.bottom'); if (height < minHeight) { height = minHeight; } this.opts.boundingHeight = height; } this._boundsUpdate(); this.update(); return this; } /** * Binds an event to a given `callback`. If no `callback` is provided it returns the callback. * Passing null removes the callback. * * See https://github.com/d3/d3-dispatch#dispatch_on * * @Chainable <setter> */ on(typenames, callback) { if (arguments.length < 2) { return this.dispatch.on(typenames); } this.dispatch.on(typenames, callback); return this; } /** * Binds an extension event (filtered by `eventLead`) to a given `callback`. The `eventLead` is a * string that matches the *beginning* of the extention specific event name including prefix. * * For example, to match all SelectionRect events (including updated, rendered, etc...) use * 'selectionrect:'; to match all selection events to match only selection events (including * selectionStart, selectionMove, etc...) use 'selectionrect:selection'; to match the changed * event use 'selectionrect:selectionChanged'. * * @Chainable <setter> */ onExt(eventLead, callback) { if (arguments.length < 2) { return this.on(eventLead); } const typename = `extension.${eventLead}`; if (callback === null) { this.on(typename, null); } else { this.on(typename, function(ev, ...args) { if (ev.indexOf(eventLead) === 0) { callback.call(this, ev, ...args); } }); } return this; } /** * Force the triggering of an event with the given arguments. The `on` callbacks are invoked in * the context of the chart. * * Uses: * + Trigger event for listeners as needed such as force an extension to update. * * @Chainable */ emit(eventName, ...args) { if (!eventName) { return; } else if (!this.dispatch._[eventName]) { // Check that dispatch has a registered event const msg = `Unknown event ${eventName}. Double check the spelling or register the event. Custom events must registered at chart creation.`; throw new MonteError(msg); } this.__notify(eventName, ...args); return this; } /** * Get or set a chart option. * * NOTE: Does not invoke the "Update cycle" except for margin changes. To apply option changes * call `update`. * * @Chainable */ option(key, value) { const current = _get(this.opts, key); if (value === UNDEF) { return current; } if (this._optsSet) { this.__notify(EV.BEFORE_OPTION_CHANGE, key); } _set(this.opts, key, value); const updateBounds = this.__handleMarginOptions(key, value, current); if (this._optsSet) { // Margins affect the drawing area size so various updates are required. if (updateBounds) { this._boundsUpdate(); } this.__notify(EV.OPTION_CHANGED, key); } return this; } /** * Provides extra checks for margin related options and checks if they should modify the bounds * calculations of the chart. */ __handleMarginOptions(key, value, current) { let updateBounds = false; // Margins cause changes to the internal sizes. // If the new margin values match the old margin values do not update bounds. This will help // prevent an infinite loop if the margin is adjusted in the update cycle. if (key === 'margin') { if (!isObject(value)) { const newVal = { top: value, left: value, right: value, bottom: value }; if (!isEqual(current, newVal)) { this.opts.margin = newVal; updateBounds = true; } } else if (!isEqual(current, value)) { updateBounds = true; } } // Check if key is a 'deep' margin value (ex. 'margin.left') else if (/^margin\./.test(key) && current !== value) { updateBounds = true; } return updateBounds; } /** * Generates a function (or uses and existing one from cache) for a given option property. The * generated function attempts to access the property (uses `tryInvoke`). If the property is a * function it invokes the function with all parameters passed at the time on invocation. * * Generally this is good allowing D3 Selection chain methods (`attr`, `style`, etc...) to * directly read chart options. * * For example: * `.attr('fill', (d, i, nodes) => this.tryInvoke(this.opts.fillScaleAccessor, d, i, nodes))` * is equivalent to * `.attr('fill', this.optionReaderFunc('fillScaleAccessor')')` */ optionReaderFunc(optionKey) { if (!isString(optionKey)) { throw MonteError.InvalidArgumentType('optionReaderFunc', 'optionKey', 'string', optionKey); } if (!this._optionReaderCache[optionKey]) { this._optionReaderCache[optionKey] = (...args) => this.tryInvoke(this.opts[optionKey], ...args); } return this._optionReaderCache[optionKey]; } /** * Invoke a `value` (generally from the chart options) with the given arguments. Static values * are returned directly. */ tryInvoke(value, ...args) { if (value === null) { return null; } else if (value === UNDEF) { throw new MonteOptionError('Value not initialized.'); } 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); return null; } } /** * Invoke a function (`fn`) (generally from the chart options) with the given arguments. If the * `fn` is a non-function value (static values including undefined and null) then null is returned. */ fnInvoke(fn, ...args) { if (fn == null || !isFunc(fn)) { return null; } try { return fn.call(this, ...args); } catch (e) { this.__notify(EV.SUPPRESSED_ERROR, e); return null; } } /** * Gets the object key bound to the property of a datum. */ getPropKey(propShortName) { const propFullName = `${propShortName}Prop`; if (this.opts[propFullName]) { return this.opts[propFullName]; } else if (this.opts[propShortName]) { const propIndex = propShortName.indexOf('Prop'); if (propIndex > -1) { const expected = propShortName.substring(0, propIndex); throw new MonteError(`Property options should be accessed using short names without the "Prop" suffix. Given ${propShortName}, but expected ${expected}.`); } } return null; } /** * Reads a property from a datum and returns the raw (unscaled) value. */ getProp(propShortName, d, defaultValue=null) { const dataPropName = this.getPropKey(propShortName); if (dataPropName) { return d[dataPropName]; } return defaultValue; } /** * Reads a scale bound property from a datum and returns the scaled value. * * Examples: * `this.getScaledProp('x', d);` which is equivilent to `this.getScaledProp('x', 'x', d);` * `this.getScaledProp('x', 'xInner', d);` * `this.getScaledProp('y', 'y2', d)`; * * @param {string} scaleName The scale used for scaling * @param {string} [propPrefix=<scaleName>] The property to be scaled. Defaults to the scale's property. * @param {any} datum The data to scale. */ getScaledProp(scaleName, propPrefix, datum) { let val; let propPre; let d; if (arguments.length === 2) { propPre = scaleName; d = propPrefix; } else if (arguments.length === 3) { propPre = propPrefix; d = datum; } else { throw new MonteError(`Incorrect number of arguments. Expected 2 or 3 recieved ${arguments.length}`); } const scale = _get(this, scaleName); if (!scale) { throw new MonteError(`Scale "${scaleName}" is not defined.`); } else if (scale === noop) { // A noop function means no possible return value. return UNDEF; } else if (!isFunc(scale)) { // Treat scale like a static value (likely string or number) and return early. return scale; } else if (isObject(d)) { // Assume `d` is a datum related to the chart data. val = d[this.opts[`${propPre}Prop`]]; } else { // Assume `d` is a value the scale can process. val = d; } return scale(val); } /** * Remove the data, remove the data elements, and clear the CSS domains. * * @Chainable */ clear() { this.__notify(EV.BEFORE_CLEAR); this.displayData = null; this._clearDataElements(); if (this.opts.autoResetStyleDomains) { this.resetStyleDomains(); } this.__notify(EV.CLEARED); return this; } /** * Internal implementation of the `clear` method. */ _clearDataElements() {} /** * Resets domains related to CSS scales. * * @Chainable */ resetStyleDomains() { this.__notify(EV.BEFORE_STYLE_DOMAINS_RESET); this._resetStyleDomains(); this.__notify(EV.STYLE_DOMAINS_RESET); return this; } /** * Internal implementation of the `resetStyleDomains` method. */ _resetStyleDomains() {} /** * Builds a string of class names to insert into a `class` attribute on a DOM element (typically * SVG). The strings are inidividual class names and *not* selectors (no `.` or compound class * names). * * @param {array} cssSources The sources (strings or functions) for inidividual class names. * @param {object} d The datum to pass to function sources. * @param {object} i The node index to pass to function sources. * @param {array} nodes The node list to pass to function sources. */ _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(' ').replace(/\s+/, ' '); } /** * Apply the transition settings (duration, delay, and ease). Attempt to match specfic settings * based on the provided levels. * * For example given the levels `['line', 'update']` the transition settings will first be read: * + `transitionSettings.line.update.<property>` then * + `transitionSettings.update.<property>` then * + `transitionSettings.<property>` then * + `<propertDefaultValue>` * * @param {...string} levels The transition depths to load settings for. */ _transitionSetup(...levels) { return (transition, ...args) => { const { duration, delay, ease } = this._transitionSettings(...levels); this._transitionConfigureDuration(transition, duration, ...args); this._transitionConfigureDelay(transition, delay, ...args); transition.ease(ease); }; } _transitionConfigureDuration(transition, duration, ...args) { let durationWrap = null; if (isFunc(duration) && args && args.length) { durationWrap = function(d, i, nodes) { return duration(d, i, nodes, ...args); }; } transition.duration(durationWrap || duration); } _transitionConfigureDelay(transition, delay, ...args) { let delayWrap = null; if (isFunc(delay) && args && args.length) { delayWrap = function(d, i, nodes) { return delay(d, i, nodes, ...args); }; } transition.delay(delayWrap || delay); } _transitionConfigure(transition, transitionSettings, d, i, nodes, ...args) { const { duration, delay, ease } = transitionSettings; transition.duration(this.tryInvoke(duration, d, i, nodes, ...args)) .delay(this.tryInvoke(delay, d, i, nodes, ...args)) .ease(ease); } _transitionSettings(...levels) { const transitionSettings = this.tryInvoke(this.opts.transition); const duration = getTreeSetting(transitionSettings, levels, 'duration', defaultTransition.duration); const delay = getTreeSetting(transitionSettings, levels, 'delay', defaultTransition.delay); const ease = getTreeSetting(transitionSettings, levels, 'ease', defaultTransition.ease); if (this.developerMode && this.opts.developerOptions.transitions) { console.log('Transition: ' + levels.join('.')); // eslint-disable-line no-console console.log({ levels: levels, duration, delay, ease }); // eslint-disable-line no-console } return { duration, delay, ease }; } /** * Get / set an attribute of the bounding element. * * A convenience method that is roughly equivalent to `<chart>.bound.attr(<name>, <value>)`, * but returns the chart instead of the `<chart>.bound` selection. * * @Chainable <setter> */ attr(name, value) { if (value === UNDEF) { return this.bound.attr(name); } this.bound.attr(name, value); return this; } /** * Get / set a style of the bounding element. * * A convenience method that is roughly equivalent to `<chart>.bound.style(<name>, <value>)`, * but returns the chart instead of the `<chart>.bound` selection. * * @Chainable <setter> */ style(name, value) { if (value === UNDEF) { return this.bound.style(name); } this.bound.style(name, value); return this; } /** * Set the CSS classes on the SVG element. * * A convenience method that is roughly equivalent to `<chart>.bound.classed(<names>, <value>)`, * but returns the chart instead of the `<chart>.bound` selection. * * @Chainable */ classed(...args) { this.bound.classed(...args); return this; } /** * Invokes a function in the context of the chart with the given arguments. * * @Chainable */ call(f, ...args) { f.call(this, ...args); return this; } /** * Update the existing data of the chart to display and trigger the "Update cycle". * * @Chainable */ updateData(data) { this.data(data, true); return this; } /** * Set the data for the chart to display and trigger the "Update cycle". * * @Chainable <setter> */ data(data, isUpdate=false, suppressUpdate=false) { if (data === UNDEF) { // No data to assign return the current data. return this.displayData; } if (!isUpdate) { this.clear(); } this._data(data); if (!suppressUpdate) { this.update(); } return this; } /** * Internal method to manage assignment of data. */ _data(data) { this.displayData = data; } /** * Gets the original data if the chart modified the structure; otherwise returns the same as * `<chart>.data()`; */ getRawData() { // TODO: When assigning data make sure new data is a deep copy. if (this.rawData) { return this.rawData; } return this.displayData; } /** * Add an extension instance to the chart instance. * * @Chainable */ addExt(...exts) { this._bindExt(exts); return this; } /** * Binds a given extension instance to the chart instance. */ _bindExt(exts) { exts.forEach((ext) => { if (ext.opts.binding) { ext.setChart(this); this.extensions.push(ext); } else { this.__notify(EV.SUPPRESSED_ERROR, 'Extensions must have the `binding` option specified.'); } }); } /** * Invokes all extensions "Update Cycle" if bound to the given event (binding) name. */ __updateExt(bindingName, ...extArgs) { this.extensions.forEach((ext) => { if (ext.opts.binding.indexOf(bindingName) > -1) { ext.fire(bindingName, ...extArgs); } }); } /** * Replaces one scale with another. The new scale `range` and `domain` are set to match the * previous scale. * * For example: changing between a linear and logarithmic scale to allow users to identify trends. * * @Chainable */ replaceScale(scaleName, newScaleConstructor) { const scale = newScaleConstructor(); scale.range(this[scaleName].range()) .domain(this[scaleName].domain()); this[scaleName] = scale; this.update(); return this; } /** * (Re)renders the chart by invoking the "Update cycle" which is consistent with the D3 * "Enter-Update-Exit" pattern. * * @Chainable */ update() { if (!this.data()) { return this; } // Don't allow update if data has not been set. if (!this.hasRendered) { this.__notify(EV.BEFORE_RENDER); this._render(); this.hasRendered = true; this.__notify(EV.RENDERED); } this.__notify(EV.BEFORE_UPDATE); this._update(); this.__notify(EV.UPDATED); return this; } /** * A specific chart's one-time only setup drawing pass. */ _render() {} /** * A specific chart's implementation of the "Update cycle" */ _update() { if (!this.opts.directUse) { throw MonteError.UnimplementedMethod('Update', '_update'); } } /** * Generates a function to bind the "common" element events to an event handler. */ __bindCommonEvents(lead) { const chart = this; return function(sel) { EV.INTERACTION_EVENTS.forEach((ev) => sel.on(ev, (d, i, nodes) => chart.__elemEvent(ev, `${lead}:${ev}`, d, i, nodes))); }; } /** * Notify all listeners, extensions and those bound through `on`, of an event. * Using notify ensures that extensions are notified before outside listeners are. */ __notify(eventName, ...args) { this.__updateExt(eventName, ...args); this.dispatch.call(eventName, this, ...args); } /** * Handles an event generated through element interaction (i.e. click, mouseover, etc...). */ __elemEvent(eventType, eventNameFull, d, i, nodes) { const node = nodes[i]; const cssAction = EV.INTERACTION_EVENT_CSS_MAP[eventType]; if (cssAction) { if (cssAction.action === 'add') { d3.select(node).classed(cssAction.css, true); } else if (cssAction.action === 'remove') { d3.select(node).classed(cssAction.css, false); } } this.__notify(eventNameFull, d, i, nodes); } /** * Give the chart type name as the identifier. */ toString() { return this.constructor.name; } static generateScaleAccessor(scaleName, propPrefix) { if (propPrefix) { return function(d) { const scalePath = `opts.${scaleName}`; const v = this.getScaledProp(scalePath, propPrefix, d); if (this.developerMode && this.opts.developerOptions.scales && v !== UNDEF) { console.log(scalePath, v); // eslint-disable-line no-console } return v; }; } // If the `propPrefix` is blank than default to index counting. return function(d, i) { const v = _get(this, `opts.${scaleName}`)(i); if (this.developerMode) { console.log(v); // eslint-disable-line no-console } return v; }; } static createInstanceGroup(charts, ...additionalMethodsToProxy) { return new InstanceGroup(charts, GROUP_PROXY_METHODS, additionalMethodsToProxy); } } // The public methods from the base chart available for use in `ChartGroup`. export const GROUP_PROXY_METHODS = [ 'addExt', 'addLayer', 'boundingRect', 'call', 'checkSize', 'classed', 'clear', 'data', 'emit', 'layerUseClipPath', 'on', 'option', 'replaceScale', 'resetStyleDomains', 'update', 'updateData', ];