import {select} from 'd3-selection'; import {dispatch} from 'd3-dispatch'; import {ascending} from 'd3-array'; import {pluck, utils} from '../core/utils'; import {instanceOfChart} from '../core/core'; import {deregisterChart, redrawAll, registerChart, renderAll} from '../core/chart-registry'; import {constants} from '../core/constants'; import {events} from '../core/events'; import {logger} from '../core/logger'; import {printers} from '../core/printers'; import {InvalidStateException} from '../core/invalid-state-exception'; import {BadArgumentException} from '../core/bad-argument-exception'; import {d3compat} from '../core/config'; const _defaultFilterHandler = (dimension, filters) => { if (filters.length === 0) { dimension.filter(null); } else if (filters.length === 1 && !filters[0].isFiltered) { // single value and not a function-based filter dimension.filterExact(filters[0]); } else if (filters.length === 1 && filters[0].filterType === 'RangedFilter') { // single range-based filter dimension.filterRange(filters[0]); } else { dimension.filterFunction(d => { for (let i = 0; i < filters.length; i++) { const filter = filters[i]; if (filter.isFiltered) { if(filter.isFiltered(d)) { return true; } } else if (filter <= d && filter >= d) { return true; } } return false; }); } return filters; }; const _defaultHasFilterHandler = (filters, filter) => { if (filter === null || typeof (filter) === 'undefined') { return filters.length > 0; } return filters.some(f => filter <= f && filter >= f); }; const _defaultRemoveFilterHandler = (filters, filter) => { for (let i = 0; i < filters.length; i++) { if (filters[i] <= filter && filters[i] >= filter) { filters.splice(i, 1); break; } } return filters; }; const _defaultAddFilterHandler = (filters, filter) => { filters.push(filter); return filters; }; const _defaultResetFilterHandler = filters => []; /** * `BaseMixin` is an abstract functional object representing a basic `dc` chart object * for all chart and widget implementations. Methods from the {@link #BaseMixin BaseMixin} are inherited * and available on all chart implementations in the `dc` library. * @mixin BaseMixin */ export class BaseMixin { constructor () { this.__dcFlag__ = utils.uniqueId(); this._svgDescription = null this._keyboardAccessible = false; this._dimension = undefined; this._group = undefined; this._anchor = undefined; this._root = undefined; this._svg = undefined; this._isChild = undefined; this._minWidth = 200; this._defaultWidthCalc = element => { const width = element && element.getBoundingClientRect && element.getBoundingClientRect().width; return (width && width > this._minWidth) ? width : this._minWidth; }; this._widthCalc = this._defaultWidthCalc; this._minHeight = 200; this._defaultHeightCalc = element => { const height = element && element.getBoundingClientRect && element.getBoundingClientRect().height; return (height && height > this._minHeight) ? height : this._minHeight; }; this._heightCalc = this._defaultHeightCalc; this._width = undefined; this._height = undefined; this._useViewBoxResizing = false; this._keyAccessor = pluck('key'); this._valueAccessor = pluck('value'); this._label = pluck('key'); this._ordering = pluck('key'); this._renderLabel = false; this._title = d => `${this.keyAccessor()(d)}: ${this.valueAccessor()(d)}`; this._renderTitle = true; this._controlsUseVisibility = false; this._transitionDuration = 750; this._transitionDelay = 0; this._filterPrinter = printers.filters; this._mandatoryAttributesList = ['dimension', 'group']; this._chartGroup = constants.DEFAULT_CHART_GROUP; this._listeners = dispatch( 'preRender', 'postRender', 'preRedraw', 'postRedraw', 'filtered', 'zoomed', 'renderlet', 'pretransition'); this._legend = undefined; this._commitHandler = undefined; this._defaultData = group => group.all(); this._data = this._defaultData; this._filters = []; this._filterHandler = _defaultFilterHandler; this._hasFilterHandler = _defaultHasFilterHandler; this._removeFilterHandler = _defaultRemoveFilterHandler; this._addFilterHandler = _defaultAddFilterHandler; this._resetFilterHandler = _defaultResetFilterHandler; } /** * Set or get the height attribute of a chart. The height is applied to the SVGElement generated by * the chart when rendered (or re-rendered). If a value is given, then it will be used to calculate * the new height and the chart returned for method chaining. The value can either be a numeric, a * function, or falsy. If no value is specified then the value of the current height attribute will * be returned. * * By default, without an explicit height being given, the chart will select the width of its * anchor element. If that isn't possible it defaults to 200 (provided by the * {@link BaseMixin#minHeight minHeight} property). Setting the value falsy will return * the chart to the default behavior. * @see {@link BaseMixin#minHeight minHeight} * @example * // Default height * chart.height(function (element) { * var height = element && element.getBoundingClientRect && element.getBoundingClientRect().height; * return (height && height > chart.minHeight()) ? height : chart.minHeight(); * }); * * chart.height(250); // Set the chart's height to 250px; * chart.height(function(anchor) { return doSomethingWith(anchor); }); // set the chart's height with a function * chart.height(null); // reset the height to the default auto calculation * @param {Number|Function} [height] * @returns {Number|BaseMixin} */ height (height) { if (!arguments.length) { if (!utils.isNumber(this._height)) { // only calculate once this._height = this._heightCalc(this._root.node()); } return this._height; } this._heightCalc = height ? (typeof height === 'function' ? height : utils.constant(height)) : this._defaultHeightCalc; this._height = undefined; return this; } /** * Set or get the width attribute of a chart. * @see {@link BaseMixin#height height} * @see {@link BaseMixin#minWidth minWidth} * @example * // Default width * chart.width(function (element) { * var width = element && element.getBoundingClientRect && element.getBoundingClientRect().width; * return (width && width > chart.minWidth()) ? width : chart.minWidth(); * }); * @param {Number|Function} [width] * @returns {Number|BaseMixin} */ width (width) { if (!arguments.length) { if (!utils.isNumber(this._width)) { // only calculate once this._width = this._widthCalc(this._root.node()); } return this._width; } this._widthCalc = width ? (typeof width === 'function' ? width : utils.constant(width)) : this._defaultWidthCalc; this._width = undefined; return this; } /** * Set or get the minimum width attribute of a chart. This only has effect when used with the default * {@link BaseMixin#width width} function. * @see {@link BaseMixin#width width} * @param {Number} [minWidth=200] * @returns {Number|BaseMixin} */ minWidth (minWidth) { if (!arguments.length) { return this._minWidth; } this._minWidth = minWidth; return this; } /** * Set or get the minimum height attribute of a chart. This only has effect when used with the default * {@link BaseMixin#height height} function. * @see {@link BaseMixin#height height} * @param {Number} [minHeight=200] * @returns {Number|BaseMixin} */ minHeight (minHeight) { if (!arguments.length) { return this._minHeight; } this._minHeight = minHeight; return this; } /** * Turn on/off using the SVG * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox `viewBox` attribute}. * When enabled, `viewBox` will be set on the svg root element instead of `width` and `height`. * Requires that the chart aspect ratio be defined using chart.width(w) and chart.height(h). * * This will maintain the aspect ratio while enabling the chart to resize responsively to the * space given to the chart using CSS. For example, the chart can use `width: 100%; height: * 100%` or absolute positioning to resize to its parent div. * * Since the text will be sized as if the chart is drawn according to the width and height, and * will be resized if the chart is any other size, you need to set the chart width and height so * that the text looks good. In practice, 600x400 seems to work pretty well for most charts. * * You can see examples of this resizing strategy in the [Chart Resizing * Examples](http://dc-js.github.io/dc.js/resizing/); just add `?resize=viewbox` to any of the * one-chart examples to enable `useViewBoxResizing`. * @param {Boolean} [useViewBoxResizing=false] * @returns {Boolean|BaseMixin} */ useViewBoxResizing (useViewBoxResizing) { if (!arguments.length) { return this._useViewBoxResizing; } this._useViewBoxResizing = useViewBoxResizing; return this; } /** * **mandatory** * * Set or get the dimension attribute of a chart. In `dc`, a dimension can be any valid * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension crossfilter dimension} * * If a value is given, then it will be used as the new dimension. If no value is specified then * the current dimension will be returned. * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension crossfilter.dimension} * @example * var index = crossfilter([]); * var dimension = index.dimension(pluck('key')); * chart.dimension(dimension); * @param {crossfilter.dimension} [dimension] * @returns {crossfilter.dimension|BaseMixin} */ dimension (dimension) { if (!arguments.length) { return this._dimension; } this._dimension = dimension; this.expireCache(); return this; } /** * Set the data callback or retrieve the chart's data set. The data callback is passed the chart's * group and by default will return * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group_all group.all}. * This behavior may be modified to, for instance, return only the top 5 groups. * @example * // Default data function * chart.data(function (group) { return group.all(); }); * * chart.data(function (group) { return group.top(5); }); * @param {Function} [callback] * @returns {*|BaseMixin} */ data (callback) { if (!arguments.length) { return this._data(this._group); } this._data = typeof callback === 'function' ? callback : utils.constant(callback); this.expireCache(); return this; } /** * **mandatory** * * Set or get the group attribute of a chart. In `dc` a group is a * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter group}. * Usually the group should be created from the particular dimension associated with the same chart. If a value is * given, then it will be used as the new group. * * If no value specified then the current group will be returned. * If `name` is specified then it will be used to generate legend label. * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#group-map-reduce crossfilter.group} * @example * var index = crossfilter([]); * var dimension = index.dimension(pluck('key')); * chart.dimension(dimension); * chart.group(dimension.group().reduceSum()); * @param {crossfilter.group} [group] * @param {String} [name] * @returns {crossfilter.group|BaseMixin} */ group (group, name) { if (!arguments.length) { return this._group; } this._group = group; this._groupName = name; this.expireCache(); return this; } /** * Get or set an accessor to order ordinal dimensions. The chart uses * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort Array.sort} * to sort elements; this accessor returns the value to order on. * @example * // Default ordering accessor * _chart.ordering(pluck('key')); * @param {Function} [orderFunction] * @returns {Function|BaseMixin} */ ordering (orderFunction) { if (!arguments.length) { return this._ordering; } this._ordering = orderFunction; this.expireCache(); return this; } _computeOrderedGroups (data) { // clone the array before sorting, otherwise Array.sort sorts in-place return Array.from(data).sort((a, b) => ascending(this._ordering(a), this._ordering(b))); } /** * Clear all filters associated with this chart. The same effect can be achieved by calling * {@link BaseMixin#filter chart.filter(null)}. * @returns {BaseMixin} */ filterAll () { return this.filter(null); } /** * Execute d3 single selection in the chart's scope using the given selector and return the d3 * selection. * * This function is **not chainable** since it does not return a chart instance; however the d3 * selection result can be chained to d3 function calls. * @see {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3.select} * @example * // Has the same effect as d3.select('#chart-id').select(selector) * chart.select(selector) * @param {String} sel CSS selector string * @returns {d3.selection} */ select (sel) { return this._root.select(sel); } /** * Execute in scope d3 selectAll using the given selector and return d3 selection result. * * This function is **not chainable** since it does not return a chart instance; however the d3 * selection result can be chained to d3 function calls. * @see {@link https://github.com/d3/d3-selection/blob/master/README.md#selectAll d3.selectAll} * @example * // Has the same effect as d3.select('#chart-id').selectAll(selector) * chart.selectAll(selector) * @param {String} sel CSS selector string * @returns {d3.selection} */ selectAll (sel) { return this._root ? this._root.selectAll(sel) : null; } /** * Set the root SVGElement to either be an existing chart's root; or any valid [d3 single * selector](https://github.com/d3/d3-selection/blob/master/README.md#selecting-elements) specifying a dom * block element such as a div; or a dom element or d3 selection. Optionally registers the chart * within the chartGroup. This class is called internally on chart initialization, but be called * again to relocate the chart. However, it will orphan any previously created SVGElements. * @param {anchorChart|anchorSelector|anchorNode} [parent] * @param {String} [chartGroup] * @returns {String|node|d3.selection|BaseMixin} */ anchor (parent, chartGroup) { if (!arguments.length) { return this._anchor; } if (instanceOfChart(parent)) { this._anchor = parent.anchor(); if (this._anchor.children) { // is _anchor a div? this._anchor = `#${parent.anchorName()}`; } this._root = parent.root(); this._isChild = true; } else if (parent) { if (parent.select && parent.classed) { // detect d3 selection this._anchor = parent.node(); } else { this._anchor = parent; } this._root = select(this._anchor); this._root.classed(constants.CHART_CLASS, true); registerChart(this, chartGroup); this._isChild = false; } else { throw new BadArgumentException('parent must be defined'); } this._chartGroup = chartGroup; return this; } /** * Returns the DOM id for the chart's anchored location. * @returns {String} */ anchorName () { const a = this.anchor(); if (a && a.id) { return a.id; } if (a && a.replace) { return a.replace('#', ''); } return `dc-chart${this.chartID()}`; } /** * Returns the root element where a chart resides. Usually it will be the parent div element where * the SVGElement was created. You can also pass in a new root element however this is usually handled by * dc internally. Resetting the root element on a chart outside of dc internals may have * unexpected consequences. * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement HTMLElement} * @param {HTMLElement} [rootElement] * @returns {HTMLElement|BaseMixin} */ root (rootElement) { if (!arguments.length) { return this._root; } this._root = rootElement; return this; } /** * Returns the top SVGElement for this specific chart. You can also pass in a new SVGElement, * however this is usually handled by dc internally. Resetting the SVGElement on a chart outside * of dc internals may have unexpected consequences. * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement} * @param {SVGElement|d3.selection} [svgElement] * @returns {SVGElement|d3.selection|BaseMixin} */ svg (svgElement) { if (!arguments.length) { return this._svg; } this._svg = svgElement; return this; } /** * Remove the chart's SVGElements from the dom and recreate the container SVGElement. * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/SVGElement SVGElement} * @returns {SVGElement} */ resetSvg () { this.select('svg').remove(); return this.generateSvg(); } sizeSvg () { if (this._svg) { if (!this._useViewBoxResizing) { this._svg .attr('width', this.width()) .attr('height', this.height()); } else if (!this._svg.attr('viewBox')) { this._svg .attr('viewBox', `0 0 ${this.width()} ${this.height()}`); } } } generateSvg () { this._svg = this.root().append('svg'); if (this._svgDescription || this._keyboardAccessible) { this._svg.append('desc') .attr('id', `desc-id-${this.__dcFlag__}`) .html(`${this.svgDescription()}`); this._svg .attr('tabindex', '0') .attr('role', 'img') .attr('aria-labelledby', `desc-id-${this.__dcFlag__}`); } this.sizeSvg(); return this._svg; } /** * Set or get description text for the entire SVG graphic. If set, will create a `<desc>` element as the first * child of the SVG with the description text and also make the SVG focusable from keyboard. * @param {String} [description] * @returns {String|BaseMixin} */ svgDescription (description) { if (!arguments.length) { return this._svgDescription || this.constructor.name; } this._svgDescription = description; return this; } /** * If set, interactive chart elements like individual bars in a bar chart or symbols in a scatter plot * will be focusable from keyboard and on pressing Enter or Space will behave as if clicked on. * * If `svgDescription` has not been explicitly set, will also set SVG description text to the class * constructor name, like BarChart or HeatMap, and make the entire SVG focusable. * @param {Boolean} [keyboardAccessible=false] * @returns {Boolean|BarChart} */ keyboardAccessible (keyboardAccessible) { if (!arguments.length) { return this._keyboardAccessible; } this._keyboardAccessible = keyboardAccessible; return this; } /** * Set or get the filter printer function. The filter printer function is used to generate human * friendly text for filter value(s) associated with the chart instance. The text will get shown * in the `.filter element; see {@link BaseMixin#turnOnControls turnOnControls}. * * By default dc charts use a default filter printer {@link printers.filters printers.filters} * that provides simple printing support for both single value and ranged filters. * @example * // for a chart with an ordinal brush, print the filters in upper case * chart.filterPrinter(function(filters) { * return filters.map(function(f) { return f.toUpperCase(); }).join(', '); * }); * // for a chart with a range brush, print the filter as start and extent * chart.filterPrinter(function(filters) { * return 'start ' + utils.printSingleValue(filters[0][0]) + * ' extent ' + utils.printSingleValue(filters[0][1] - filters[0][0]); * }); * @param {Function} [filterPrinterFunction=printers.filters] * @returns {Function|BaseMixin} */ filterPrinter (filterPrinterFunction) { if (!arguments.length) { return this._filterPrinter; } this._filterPrinter = filterPrinterFunction; return this; } /** * If set, use the `visibility` attribute instead of the `display` attribute for showing/hiding * chart reset and filter controls, for less disruption to the layout. * @param {Boolean} [controlsUseVisibility=false] * @returns {Boolean|BaseMixin} */ controlsUseVisibility (controlsUseVisibility) { if (!arguments.length) { return this._controlsUseVisibility; } this._controlsUseVisibility = controlsUseVisibility; return this; } /** * Turn on optional control elements within the root element. dc currently supports the * following html control elements. * * root.selectAll('.reset') - elements are turned on if the chart has an active filter. This type * of control element is usually used to store a reset link to allow user to reset filter on a * certain chart. This element will be turned off automatically if the filter is cleared. * * root.selectAll('.filter') elements are turned on if the chart has an active filter. The text * content of this element is then replaced with the current filter value using the filter printer * function. This type of element will be turned off automatically if the filter is cleared. * @returns {BaseMixin} */ turnOnControls () { if (this._root) { const attribute = this.controlsUseVisibility() ? 'visibility' : 'display'; this.selectAll('.reset').style(attribute, null); this.selectAll('.filter').text(this._filterPrinter(this.filters())).style(attribute, null); } return this; } /** * Turn off optional control elements within the root element. * @see {@link BaseMixin#turnOnControls turnOnControls} * @returns {BaseMixin} */ turnOffControls () { if (this._root) { const attribute = this.controlsUseVisibility() ? 'visibility' : 'display'; const value = this.controlsUseVisibility() ? 'hidden' : 'none'; this.selectAll('.reset').style(attribute, value); this.selectAll('.filter').style(attribute, value).text(this.filter()); } return this; } /** * Set or get the animation transition duration (in milliseconds) for this chart instance. * @param {Number} [duration=750] * @returns {Number|BaseMixin} */ transitionDuration (duration) { if (!arguments.length) { return this._transitionDuration; } this._transitionDuration = duration; return this; } /** * Set or get the animation transition delay (in milliseconds) for this chart instance. * @param {Number} [delay=0] * @returns {Number|BaseMixin} */ transitionDelay (delay) { if (!arguments.length) { return this._transitionDelay; } this._transitionDelay = delay; return this; } _mandatoryAttributes (_) { if (!arguments.length) { return this._mandatoryAttributesList; } this._mandatoryAttributesList = _; return this; } checkForMandatoryAttributes (a) { if (!this[a] || !this[a]()) { throw new InvalidStateException(`Mandatory attribute chart.${a} is missing on chart[#${this.anchorName()}]`); } } /** * Invoking this method will force the chart to re-render everything from scratch. Generally it * should only be used to render the chart for the first time on the page or if you want to make * sure everything is redrawn from scratch instead of relying on the default incremental redrawing * behaviour. * @returns {BaseMixin} */ render () { this._height = this._width = undefined; // force recalculate this._listeners.call('preRender', this, this); if (this._mandatoryAttributesList) { this._mandatoryAttributesList.forEach(e => this.checkForMandatoryAttributes(e)); } const result = this._doRender(); if (this._legend) { this._legend.render(); } this._activateRenderlets('postRender'); return result; } _makeKeyboardAccessible (onClickFunction, ...onClickArgs) { // called from each chart module's render and redraw methods const tabElements = this._svg .selectAll('.dc-tabbable') .attr('tabindex', 0); if (onClickFunction) { tabElements.on('keydown', d3compat.eventHandler((d, event) => { // trigger only if d is an object undestood by KeyAccessor() if (event.keyCode === 13 && typeof d === 'object') { onClickFunction.call(this, d, ...onClickArgs) } // special case for space key press - prevent scrolling if (event.keyCode === 32 && typeof d === 'object') { onClickFunction.call(this, d, ...onClickArgs) event.preventDefault(); } })); } } _activateRenderlets (event) { this._listeners.call('pretransition', this, this); if (this.transitionDuration() > 0 && this._svg) { this._svg.transition().duration(this.transitionDuration()).delay(this.transitionDelay()) .on('end', () => { this._listeners.call('renderlet', this, this); if (event) { this._listeners.call(event, this, this); } }); } else { this._listeners.call('renderlet', this, this); if (event) { this._listeners.call(event, this, this); } } } /** * Calling redraw will cause the chart to re-render data changes incrementally. If there is no * change in the underlying data dimension then calling this method will have no effect on the * chart. Most chart interaction in dc will automatically trigger this method through internal * events (in particular {@link redrawAll redrawAll}); therefore, you only need to * manually invoke this function if data is manipulated outside of dc's control (for example if * data is loaded in the background using * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#crossfilter_add crossfilter.add}). * @returns {BaseMixin} */ redraw () { this.sizeSvg(); this._listeners.call('preRedraw', this, this); const result = this._doRedraw(); if (this._legend) { this._legend.render(); } this._activateRenderlets('postRedraw'); return result; } /** * Gets/sets the commit handler. If the chart has a commit handler, the handler will be called when * the chart's filters have changed, in order to send the filter data asynchronously to a server. * * Unlike other functions in dc.js, the commit handler is asynchronous. It takes two arguments: * a flag indicating whether this is a render (true) or a redraw (false), and a callback to be * triggered once the commit is done. The callback has the standard node.js continuation signature * with error first and result second. * @param {Function} commitHandler * @returns {BaseMixin} */ commitHandler (commitHandler) { if (!arguments.length) { return this._commitHandler; } this._commitHandler = commitHandler; return this; } /** * Redraws all charts in the same group as this chart, typically in reaction to a filter * change. If the chart has a {@link BaseMixin.commitFilter commitHandler}, it will * be executed and waited for. * @returns {BaseMixin} */ redrawGroup () { if (this._commitHandler) { this._commitHandler(false, (error, result) => { if (error) { console.log(error); } else { redrawAll(this.chartGroup()); } }); } else { redrawAll(this.chartGroup()); } return this; } /** * Renders all charts in the same group as this chart. If the chart has a * {@link BaseMixin.commitFilter commitHandler}, it will be executed and waited for * @returns {BaseMixin} */ renderGroup () { if (this._commitHandler) { this._commitHandler(false, (error, result) => { if (error) { console.log(error); } else { renderAll(this.chartGroup()); } }); } else { renderAll(this.chartGroup()); } return this; } _invokeFilteredListener (f) { if (f !== undefined) { this._listeners.call('filtered', this, this, f); } } _invokeZoomedListener () { this._listeners.call('zoomed', this, this); } /** * Set or get the has-filter handler. The has-filter handler is a function that checks to see if * the chart's current filters (first argument) include a specific filter (second argument). Using a custom has-filter handler allows * you to change the way filters are checked for and replaced. * @example * // default has-filter handler * chart.hasFilterHandler(function (filters, filter) { * if (filter === null || typeof(filter) === 'undefined') { * return filters.length > 0; * } * return filters.some(function (f) { * return filter <= f && filter >= f; * }); * }); * * // custom filter handler (no-op) * chart.hasFilterHandler(function(filters, filter) { * return false; * }); * @param {Function} [hasFilterHandler] * @returns {Function|BaseMixin} */ hasFilterHandler (hasFilterHandler) { if (!arguments.length) { return this._hasFilterHandler; } this._hasFilterHandler = hasFilterHandler; return this; } /** * Check whether any active filter or a specific filter is associated with particular chart instance. * This function is **not chainable**. * @see {@link BaseMixin#hasFilterHandler hasFilterHandler} * @param {*} [filter] * @returns {Boolean} */ hasFilter (filter) { return this._hasFilterHandler(this._filters, filter); } /** * Set or get the remove filter handler. The remove filter handler is a function that removes a * filter from the chart's current filters. Using a custom remove filter handler allows you to * change how filters are removed or perform additional work when removing a filter, e.g. when * using a filter server other than crossfilter. * * The handler should return a new or modified array as the result. * @example * // default remove filter handler * chart.removeFilterHandler(function (filters, filter) { * for (var i = 0; i < filters.length; i++) { * if (filters[i] <= filter && filters[i] >= filter) { * filters.splice(i, 1); * break; * } * } * return filters; * }); * * // custom filter handler (no-op) * chart.removeFilterHandler(function(filters, filter) { * return filters; * }); * @param {Function} [removeFilterHandler] * @returns {Function|BaseMixin} */ removeFilterHandler (removeFilterHandler) { if (!arguments.length) { return this._removeFilterHandler; } this._removeFilterHandler = removeFilterHandler; return this; } /** * Set or get the add filter handler. The add filter handler is a function that adds a filter to * the chart's filter list. Using a custom add filter handler allows you to change the way filters * are added or perform additional work when adding a filter, e.g. when using a filter server other * than crossfilter. * * The handler should return a new or modified array as the result. * @example * // default add filter handler * chart.addFilterHandler(function (filters, filter) { * filters.push(filter); * return filters; * }); * * // custom filter handler (no-op) * chart.addFilterHandler(function(filters, filter) { * return filters; * }); * @param {Function} [addFilterHandler] * @returns {Function|BaseMixin} */ addFilterHandler (addFilterHandler) { if (!arguments.length) { return this._addFilterHandler; } this._addFilterHandler = addFilterHandler; return this; } /** * Set or get the reset filter handler. The reset filter handler is a function that resets the * chart's filter list by returning a new list. Using a custom reset filter handler allows you to * change the way filters are reset, or perform additional work when resetting the filters, * e.g. when using a filter server other than crossfilter. * * The handler should return a new or modified array as the result. * @example * // default remove filter handler * function (filters) { * return []; * } * * // custom filter handler (no-op) * chart.resetFilterHandler(function(filters) { * return filters; * }); * @param {Function} [resetFilterHandler] * @returns {BaseMixin} */ resetFilterHandler (resetFilterHandler) { if (!arguments.length) { return this._resetFilterHandler; } this._resetFilterHandler = resetFilterHandler; return this; } applyFilters (filters) { if (this.dimension() && this.dimension().filter) { const fs = this._filterHandler(this.dimension(), filters); if (fs) { filters = fs; } } return filters; } /** * Replace the chart filter. This is equivalent to calling `chart.filter(null).filter(filter)` * but more efficient because the filter is only applied once. * * @param {*} [filter] * @returns {BaseMixin} */ replaceFilter (filter) { this._filters = this._resetFilterHandler(this._filters); this.filter(filter); return this; } /** * Filter the chart by the given parameter, or return the current filter if no input parameter * is given. * * The filter parameter can take one of these forms: * * A single value: the value will be toggled (added if it is not present in the current * filters, removed if it is present) * * An array containing a single array of values (`[[value,value,value]]`): each value is * toggled * * When appropriate for the chart, a {@link filters dc filter object} such as * * {@link filters.RangedFilter `filters.RangedFilter`} for the * {@link CoordinateGridMixin CoordinateGridMixin} charts * * {@link filters.TwoDimensionalFilter `filters.TwoDimensionalFilter`} for the * {@link HeatMap heat map} * * {@link filters.RangedTwoDimensionalFilter `filters.RangedTwoDimensionalFilter`} * for the {@link ScatterPlot scatter plot} * * `null`: the filter will be reset using the * {@link BaseMixin#resetFilterHandler resetFilterHandler} * * Note that this is always a toggle (even when it doesn't make sense for the filter type). If * you wish to replace the current filter, either call `chart.filter(null)` first - or it's more * efficient to call {@link BaseMixin#replaceFilter `chart.replaceFilter(filter)`} instead. * * Each toggle is executed by checking if the value is already present using the * {@link BaseMixin#hasFilterHandler hasFilterHandler}; if it is not present, it is added * using the {@link BaseMixin#addFilterHandler addFilterHandler}; if it is already present, * it is removed using the {@link BaseMixin#removeFilterHandler removeFilterHandler}. * * Once the filters array has been updated, the filters are applied to the * crossfilter dimension, using the {@link BaseMixin#filterHandler filterHandler}. * * Once you have set the filters, call {@link BaseMixin#redrawGroup `chart.redrawGroup()`} * (or {@link redrawAll `redrawAll()`}) to redraw the chart's group. * @see {@link BaseMixin#addFilterHandler addFilterHandler} * @see {@link BaseMixin#removeFilterHandler removeFilterHandler} * @see {@link BaseMixin#resetFilterHandler resetFilterHandler} * @see {@link BaseMixin#filterHandler filterHandler} * @example * // filter by a single string * chart.filter('Sunday'); * // filter by a single age * chart.filter(18); * // filter by a set of states * chart.filter([['MA', 'TX', 'ND', 'WA']]); * // filter by range -- note the use of filters.RangedFilter, which is different * // from the syntax for filtering a crossfilter dimension directly, dimension.filter([15,20]) * chart.filter(filters.RangedFilter(15,20)); * @param {*} [filter] * @returns {BaseMixin} */ filter (filter) { if (!arguments.length) { return this._filters.length > 0 ? this._filters[0] : null; } let filters = this._filters; if (filter instanceof Array && filter[0] instanceof Array && !filter.isFiltered) { // toggle each filter filter[0].forEach(f => { if (this._hasFilterHandler(filters, f)) { filters = this._removeFilterHandler(filters, f); } else { filters = this._addFilterHandler(filters, f); } }); } else if (filter === null) { filters = this._resetFilterHandler(filters); } else { if (this._hasFilterHandler(filters, filter)) { filters = this._removeFilterHandler(filters, filter); } else { filters = this._addFilterHandler(filters, filter); } } this._filters = this.applyFilters(filters); this._invokeFilteredListener(filter); if (this._root !== null && this.hasFilter()) { this.turnOnControls(); } else { this.turnOffControls(); } return this; } /** * Returns all current filters. This method does not perform defensive cloning of the internal * filter array before returning, therefore any modification of the returned array will effect the * chart's internal filter storage. * @returns {Array<*>} */ filters () { return this._filters; } highlightSelected (e) { select(e).classed(constants.SELECTED_CLASS, true); select(e).classed(constants.DESELECTED_CLASS, false); } fadeDeselected (e) { select(e).classed(constants.SELECTED_CLASS, false); select(e).classed(constants.DESELECTED_CLASS, true); } resetHighlight (e) { select(e).classed(constants.SELECTED_CLASS, false); select(e).classed(constants.DESELECTED_CLASS, false); } /** * This function is passed to d3 as the onClick handler for each chart. The default behavior is to * filter on the clicked datum (passed to the callback) and redraw the chart group. * * This function can be replaced in order to change the click behavior (but first look at * @example * var oldHandler = chart.onClick; * chart.onClick = function(datum) { * // use datum. * @param {*} datum * @return {undefined} */ onClick (datum) { const filter = this.keyAccessor()(datum); events.trigger(() => { this.filter(filter); this.redrawGroup(); }); } /** * Set or get the filter handler. The filter handler is a function that performs the filter action * on a specific dimension. Using a custom filter handler allows you to perform additional logic * before or after filtering. * @see {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#dimension_filter crossfilter.dimension.filter} * @example * // the default filter handler handles all possible cases for the charts in dc.js * // you can replace it with something more specialized for your own chart * chart.filterHandler(function (dimension, filters) { * if (filters.length === 0) { * // the empty case (no filtering) * dimension.filter(null); * } else if (filters.length === 1 && !filters[0].isFiltered) { * // single value and not a function-based filter * dimension.filterExact(filters[0]); * } else if (filters.length === 1 && filters[0].filterType === 'RangedFilter') { * // single range-based filter * dimension.filterRange(filters[0]); * } else { * // an array of values, or an array of filter objects * dimension.filterFunction(function (d) { * for (var i = 0; i < filters.length; i++) { * var filter = filters[i]; * if (filter.isFiltered && filter.isFiltered(d)) { * return true; * } else if (filter <= d && filter >= d) { * return true; * } * } * return false; * }); * } * return filters; * }); * * // custom filter handler * chart.filterHandler(function(dimension, filter){ * var newFilter = filter + 10; * dimension.filter(newFilter); * return newFilter; // set the actual filter value to the new value * }); * @param {Function} [filterHandler] * @returns {Function|BaseMixin} */ filterHandler (filterHandler) { if (!arguments.length) { return this._filterHandler; } this._filterHandler = filterHandler; return this; } // abstract function stub _doRender () { // do nothing in base, should be overridden by sub-function return this; } _doRedraw () { // do nothing in base, should be overridden by sub-function return this; } legendables () { // do nothing in base, should be overridden by sub-function return []; } legendHighlight () { // do nothing in base, should be overridden by sub-function } legendReset () { // do nothing in base, should be overridden by sub-function } legendToggle () { // do nothing in base, should be overriden by sub-function } isLegendableHidden () { // do nothing in base, should be overridden by sub-function return false; } /** * Set or get the key accessor function. The key accessor function is used to retrieve the key * value from the crossfilter group. Key values are used differently in different charts, for * example keys correspond to slices in a pie chart and x axis positions in a grid coordinate chart. * @example * // default key accessor * chart.keyAccessor(function(d) { return d.key; }); * // custom key accessor for a multi-value crossfilter reduction * chart.keyAccessor(function(p) { return p.value.absGain; }); * @param {Function} [keyAccessor] * @returns {Function|BaseMixin} */ keyAccessor (keyAccessor) { if (!arguments.length) { return this._keyAccessor; } this._keyAccessor = keyAccessor; return this; } /** * Set or get the value accessor function. The value accessor function is used to retrieve the * value from the crossfilter group. Group values are used differently in different charts, for * example values correspond to slice sizes in a pie chart and y axis positions in a grid * coordinate chart. * @example * // default value accessor * chart.valueAccessor(function(d) { return d.value; }); * // custom value accessor for a multi-value crossfilter reduction * chart.valueAccessor(function(p) { return p.value.percentageGain; }); * @param {Function} [valueAccessor] * @returns {Function|BaseMixin} */ valueAccessor (valueAccessor) { if (!arguments.length) { return this._valueAccessor; } this._valueAccessor = valueAccessor; return this; } /** * Set or get the label function. The chart class will use this function to render labels for each * child element in the chart, e.g. slices in a pie chart or bubbles in a bubble chart. Not every * chart supports the label function, for example line chart does not use this function * at all. By default, enables labels; pass false for the second parameter if this is not desired. * @example * // default label function just return the key * chart.label(function(d) { return d.key; }); * // label function has access to the standard d3 data binding and can get quite complicated * chart.label(function(d) { return d.data.key + '(' + Math.floor(d.data.value / all.value() * 100) + '%)'; }); * @param {Function} [labelFunction] * @param {Boolean} [enableLabels=true] * @returns {Function|BaseMixin} */ label (labelFunction, enableLabels) { if (!arguments.length) { return this._label; } this._label = labelFunction; if ((enableLabels === undefined) || enableLabels) { this._renderLabel = true; } return this; } /** * Turn on/off label rendering * @param {Boolean} [renderLabel=false] * @returns {Boolean|BaseMixin} */ renderLabel (renderLabel) { if (!arguments.length) { return this._renderLabel; } this._renderLabel = renderLabel; return this; } /** * Set or get the title function. The chart class will use this function to render the SVGElement title * (usually interpreted by browser as tooltips) for each child element in the chart, e.g. a slice * in a pie chart or a bubble in a bubble chart. Almost every chart supports the title function; * however in grid coordinate charts you need to turn off the brush in order to see titles, because * otherwise the brush layer will block tooltip triggering. * @example * // default title function shows "key: value" * chart.title(function(d) { return d.key + ': ' + d.value; }); * // title function has access to the standard d3 data binding and can get quite complicated * chart.title(function(p) { * return p.key.getFullYear() * + '\n' * + 'Index Gain: ' + numberFormat(p.value.absGain) + '\n' * + 'Index Gain in Percentage: ' + numberFormat(p.value.percentageGain) + '%\n' * + 'Fluctuation / Index Ratio: ' + numberFormat(p.value.fluctuationPercentage) + '%'; * }); * @param {Function} [titleFunction] * @returns {Function|BaseMixin} */ title (titleFunction) { if (!arguments.length) { return this._title; } this._title = titleFunction; return this; } /** * Turn on/off title rendering, or return the state of the render title flag if no arguments are * given. * @param {Boolean} [renderTitle=true] * @returns {Boolean|BaseMixin} */ renderTitle (renderTitle) { if (!arguments.length) { return this._renderTitle; } this._renderTitle = renderTitle; return this; } /** * Get or set the chart group to which this chart belongs. Chart groups are rendered or redrawn * together since it is expected they share the same underlying crossfilter data set. * @param {String} [chartGroup] * @returns {String|BaseMixin} */ chartGroup (chartGroup) { if (!arguments.length) { return this._chartGroup; } if (!this._isChild) { deregisterChart(this, this._chartGroup); } this._chartGroup = chartGroup; if (!this._isChild) { registerChart(this, this._chartGroup); } return this; } /** * Expire the internal chart cache. dc charts cache some data internally on a per chart basis to * speed up rendering and avoid unnecessary calculation; however it might be useful to clear the * cache if you have changed state which will affect rendering. For example, if you invoke * {@link https://github.com/crossfilter/crossfilter/wiki/API-Reference#crossfilter_add crossfilter.add} * function or reset group or dimension after rendering, it is a good idea to * clear the cache to make sure charts are rendered properly. * @returns {BaseMixin} */ expireCache () { // do nothing in base, should be overridden by sub-function return this; } /** * Attach a Legend widget to this chart. The legend widget will automatically draw legend labels * based on the color setting and names associated with each group. * @example * chart.legend(new Legend().x(400).y(10).itemHeight(13).gap(5)) * @param {Legend} [legend] * @returns {Legend|BaseMixin} */ legend (legend) { if (!arguments.length) { return this._legend; } this._legend = legend; this._legend.parent(this); return this; } /** * Returns the internal numeric ID of the chart. * @returns {String} */ chartID () { return this.__dcFlag__; } /** * Set chart options using a configuration object. Each key in the object will cause the method of * the same name to be called with the value to set that attribute for the chart. * @example * chart.options({dimension: myDimension, group: myGroup}); * @param {{}} opts * @returns {BaseMixin} */ options (opts) { const applyOptions = [ 'anchor', 'group', 'xAxisLabel', 'yAxisLabel', 'stack', 'title', 'point', 'getColor', 'overlayGeoJson' ]; for (const o in opts) { if (typeof (this[o]) === 'function') { if (opts[o] instanceof Array && applyOptions.indexOf(o) !== -1) { this[o].apply(this, opts[o]); } else { this[o].call(this, opts[o]); } } else { logger.debug(`Not a valid option setter name: ${o}`); } } return this; } /** * All dc chart instance supports the following listeners. * Supports the following events: * * `renderlet` - This listener function will be invoked after transitions after redraw and render. Replaces the * deprecated {@link BaseMixin#renderlet renderlet} method. * * `pretransition` - Like `.on('renderlet', ...)` but the event is fired before transitions start. * * `preRender` - This listener function will be invoked before chart rendering. * * `postRender` - This listener function will be invoked after chart finish rendering including * all renderlets' logic. * * `preRedraw` - This listener function will be invoked before chart redrawing. * * `postRedraw` - This listener function will be invoked after chart finish redrawing * including all renderlets' logic. * * `filtered` - This listener function will be invoked after a filter is applied, added or removed. * * `zoomed` - This listener function will be invoked after a zoom is triggered. * @see {@link https://github.com/d3/d3-dispatch/blob/master/README.md#dispatch_on d3.dispatch.on} * @example * .on('renderlet', function(chart, filter){...}) * .on('pretransition', function(chart, filter){...}) * .on('preRender', function(chart){...}) * .on('postRender', function(chart){...}) * .on('preRedraw', function(chart){...}) * .on('postRedraw', function(chart){...}) * .on('filtered', function(chart, filter){...}) * .on('zoomed', function(chart, filter){...}) * @param {String} event * @param {Function} listener * @returns {BaseMixin} */ on (event, listener) { this._listeners.on(event, listener); return this; } /** * A renderlet is similar to an event listener on rendering event. Multiple renderlets can be added * to an individual chart. Each time a chart is rerendered or redrawn the renderlets are invoked * right after the chart finishes its transitions, giving you a way to modify the SVGElements. * Renderlet functions take the chart instance as the only input parameter and you can * use the dc API or use raw d3 to achieve pretty much any effect. * * Use {@link BaseMixin#on on} with a 'renderlet' prefix. * Generates a random key for the renderlet, which makes it hard to remove. * @deprecated chart.renderlet has been deprecated. Please use chart.on("renderlet.<renderletKey>", renderletFunction) * @example * // do this instead of .renderlet(function(chart) { ... }) * chart.on("renderlet", function(chart){ * // mix of dc API and d3 manipulation * chart.select('g.y').style('display', 'none'); * // its a closure so you can also access other chart variable available in the closure scope * moveChart.filter(chart.filter()); * }); * @param {Function} renderletFunction * @returns {BaseMixin} */ renderlet (renderletFunction) { logger.warnOnce('chart.renderlet has been deprecated. Please use chart.on("renderlet.<renderletKey>", renderletFunction)'); this.on(`renderlet.${utils.uniqueId()}`, renderletFunction); return this; } } export const baseMixin = () => new BaseMixin();