import {schemeCategory10} from 'd3-scale-chromatic'; import {timeDay} from 'd3-time'; import {max, min} from 'd3-array'; import {scaleBand, scaleLinear, scaleOrdinal} from 'd3-scale'; import {axisTop, axisBottom, axisLeft, axisRight} from 'd3-axis'; import {zoom, zoomIdentity} from 'd3-zoom'; import {brushX} from 'd3-brush'; import {ColorMixin} from './color-mixin'; import {MarginMixin} from './margin-mixin'; import {optionalTransition, transition} from '../core/core'; import {units} from '../core/units'; import {constants} from '../core/constants'; import {utils} from '../core/utils'; import {d3compat} from '../core/config'; import {logger} from '../core/logger'; import {filters} from '../core/filters'; import {events} from '../core/events'; const GRID_LINE_CLASS = 'grid-line'; const HORIZONTAL_CLASS = 'horizontal'; const VERTICAL_CLASS = 'vertical'; const Y_AXIS_LABEL_CLASS = 'y-axis-label'; const X_AXIS_LABEL_CLASS = 'x-axis-label'; const CUSTOM_BRUSH_HANDLE_CLASS = 'custom-brush-handle'; const DEFAULT_AXIS_LABEL_PADDING = 12; /** * Coordinate Grid is an abstract base chart designed to support a number of coordinate grid based * concrete chart types, e.g. bar chart, line chart, and bubble chart. * @mixin CoordinateGridMixin * @mixes ColorMixin * @mixes MarginMixin */ export class CoordinateGridMixin extends ColorMixin(MarginMixin) { constructor () { super(); this.colors(scaleOrdinal(schemeCategory10)); this._mandatoryAttributes().push('x'); this._parent = undefined; this._g = undefined; this._chartBodyG = undefined; this._x = undefined; this._origX = undefined; // Will hold original scale in case of zoom this._xOriginalDomain = undefined; this._xAxis = null; this._xUnits = units.integers; this._xAxisPadding = 0; this._xAxisPaddingUnit = timeDay; this._xElasticity = false; this._xAxisLabel = undefined; this._xAxisLabelPadding = 0; this._lastXDomain = undefined; this._y = undefined; this._yAxis = null; this._yAxisPadding = 0; this._yElasticity = false; this._yAxisLabel = undefined; this._yAxisLabelPadding = 0; this._brush = brushX(); this._gBrush = undefined; this._brushOn = true; this._parentBrushOn = false; this._round = undefined; this._ignoreBrushEvents = false; // ignore when carrying out programmatic brush operations this._renderHorizontalGridLine = false; this._renderVerticalGridLine = false; this._resizing = false; this._unitCount = undefined; this._zoomScale = [1, Infinity]; this._zoomOutRestrict = true; this._zoom = zoom().on('zoom', d3compat.eventHandler((d, evt) => this._onZoom(evt))); this._nullZoom = zoom().on('zoom', null); this._hasBeenMouseZoomable = false; this._ignoreZoomEvents = false; // ignore when carrying out programmatic zoom operations this._rangeChart = undefined; this._focusChart = undefined; this._mouseZoomable = false; this._clipPadding = 0; this._fOuterRangeBandPadding = 0.5; this._fRangeBandPadding = 0; this._useRightYAxis = false; this._useTopXAxis = false; } /** * When changing the domain of the x or y scale, it is necessary to tell the chart to recalculate * and redraw the axes. (`.rescale()` is called automatically when the x or y scale is replaced * with {@link CoordinateGridMixin+x .x()} or {@link CoordinateGridMixin#y .y()}, and has * no effect on elastic scales.) * @returns {CoordinateGridMixin} */ rescale () { this._unitCount = undefined; this._resizing = true; return this; } resizing (resizing) { if (!arguments.length) { return this._resizing; } this._resizing = resizing; return this; } /** * Get or set the range selection chart associated with this instance. Setting the range selection * chart using this function will automatically update its selection brush when the current chart * zooms in. In return the given range chart will also automatically attach this chart as its focus * chart hence zoom in when range brush updates. * * Usually the range and focus charts will share a dimension. The range chart will set the zoom * boundaries for the focus chart, so its dimension values must be compatible with the domain of * the focus chart. * * See the [Nasdaq 100 Index](http://dc-js.github.com/dc.js/) example for this effect in action. * @param {CoordinateGridMixin} [rangeChart] * @returns {CoordinateGridMixin} */ rangeChart (rangeChart) { if (!arguments.length) { return this._rangeChart; } this._rangeChart = rangeChart; this._rangeChart.focusChart(this); return this; } /** * Get or set the scale extent for mouse zooms. * @param {Array<Number|Date>} [extent=[1, Infinity]] * @returns {Array<Number|Date>|CoordinateGridMixin} */ zoomScale (extent) { if (!arguments.length) { return this._zoomScale; } this._zoomScale = extent; return this; } /** * Get or set the zoom restriction for the chart. If true limits the zoom to origional domain of the chart. * @param {Boolean} [zoomOutRestrict=true] * @returns {Boolean|CoordinateGridMixin} */ zoomOutRestrict (zoomOutRestrict) { if (!arguments.length) { return this._zoomOutRestrict; } this._zoomOutRestrict = zoomOutRestrict; return this; } _generateG (parent) { if (parent === undefined) { this._parent = this.svg(); } else { this._parent = parent; } const href = window.location.href.split('#')[0]; this._g = this._parent.append('g'); this._chartBodyG = this._g.append('g').attr('class', 'chart-body') .attr('transform', `translate(${this.margins().left}, ${this.margins().top})`) .attr('clip-path', `url(${href}#${this._getClipPathId()})`); return this._g; } /** * Get or set the root g element. This method is usually used to retrieve the g element in order to * overlay custom svg drawing programatically. **Caution**: The root g element is usually generated * by dc.js internals, and resetting it might produce unpredictable result. * @param {SVGElement} [gElement] * @returns {SVGElement|CoordinateGridMixin} */ g (gElement) { if (!arguments.length) { return this._g; } this._g = gElement; return this; } /** * Set or get mouse zoom capability flag (default: false). When turned on the chart will be * zoomable using the mouse wheel. If the range selector chart is attached zooming will also update * the range selection brush on the associated range selector chart. * @param {Boolean} [mouseZoomable=false] * @returns {Boolean|CoordinateGridMixin} */ mouseZoomable (mouseZoomable) { if (!arguments.length) { return this._mouseZoomable; } this._mouseZoomable = mouseZoomable; return this; } /** * Retrieve the svg group for the chart body. * @param {SVGElement} [chartBodyG] * @returns {SVGElement} */ chartBodyG (chartBodyG) { if (!arguments.length) { return this._chartBodyG; } this._chartBodyG = chartBodyG; return this; } /** * **mandatory** * * Get or set the x scale. The x scale can be any d3 * {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} or * {@link https://github.com/d3/d3-scale/blob/master/README.md#ordinal-scales ordinal scale} * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @example * // set x to a linear scale * chart.x(d3.scaleLinear().domain([-2500, 2500])) * // set x to a time scale to generate histogram * chart.x(d3.scaleTime().domain([new Date(1985, 0, 1), new Date(2012, 11, 31)])) * @param {d3.scale} [xScale] * @returns {d3.scale|CoordinateGridMixin} */ x (xScale) { if (!arguments.length) { return this._x; } this._x = xScale; this._xOriginalDomain = this._x.domain(); this.rescale(); return this; } xOriginalDomain () { return this._xOriginalDomain; } /** * Set or get the xUnits function. The coordinate grid chart uses the xUnits function to calculate * the number of data projections on the x axis such as the number of bars for a bar chart or the * number of dots for a line chart. * * This function is expected to return a Javascript array of all data points on the x axis, or * the number of points on the axis. d3 time range functions [d3.timeDays, d3.timeMonths, and * d3.timeYears](https://github.com/d3/d3-time/blob/master/README.md#intervals) are all valid * xUnits functions. * * dc.js also provides a few units function, see the {@link units Units Namespace} for * a list of built-in units functions. * * Note that as of dc.js 3.0, `units.ordinal` is not a real function, because it is not * possible to define this function compliant with the d3 range functions. It was already a * magic value which caused charts to behave differently, and now it is completely so. * @example * // set x units to count days * chart.xUnits(d3.timeDays); * // set x units to count months * chart.xUnits(d3.timeMonths); * * // A custom xUnits function can be used as long as it follows the following interface: * // units in integer * function(start, end) { * // simply calculates how many integers in the domain * return Math.abs(end - start); * } * * // fixed units * function(start, end) { * // be aware using fixed units will disable the focus/zoom ability on the chart * return 1000; * } * @param {Function} [xUnits=units.integers] * @returns {Function|CoordinateGridMixin} */ xUnits (xUnits) { if (!arguments.length) { return this._xUnits; } this._xUnits = xUnits; return this; } /** * Set or get the x axis used by a particular coordinate grid chart instance. This function is most * useful when x axis customization is required. The x axis in dc.js is an instance of a * {@link https://github.com/d3/d3-axis/blob/master/README.md#axisBottom d3 bottom axis object}; * therefore it supports any valid d3 axisBottom manipulation. * * **Caution**: The x axis is usually generated internally by dc; resetting it may cause * unexpected results. Note also that when used as a getter, this function is not chainable: * it returns the axis, not the chart, * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis * so attempting to call chart functions after calling `.xAxis()` will fail}. * @see {@link https://github.com/d3/d3-axis/blob/master/README.md#axisBottom d3.axisBottom} * @example * // customize x axis tick format * chart.xAxis().tickFormat(function(v) {return v + '%';}); * // customize x axis tick values * chart.xAxis().tickValues([0, 100, 200, 300]); * @param {d3.axis} [xAxis=d3.axisBottom()] * @returns {d3.axis|CoordinateGridMixin} */ xAxis (xAxis) { if (!arguments.length) { if (!this._xAxis) { this._xAxis = this._createXAxis(); } return this._xAxis; } this._xAxis = xAxis; return this; } /** * Turn on/off elastic x axis behavior. If x axis elasticity is turned on, then the grid chart will * attempt to recalculate the x axis range whenever a redraw event is triggered. * @param {Boolean} [elasticX=false] * @returns {Boolean|CoordinateGridMixin} */ elasticX (elasticX) { if (!arguments.length) { return this._xElasticity; } this._xElasticity = elasticX; return this; } /** * Set or get x axis padding for the elastic x axis. The padding will be added to both end of the x * axis if elasticX is turned on; otherwise it is ignored. * * Padding can be an integer or percentage in string (e.g. '10%'). Padding can be applied to * number or date x axes. When padding a date axis, an integer represents number of units being padded * and a percentage string will be treated the same as an integer. The unit will be determined by the * xAxisPaddingUnit variable. * @param {Number|String} [padding=0] * @returns {Number|String|CoordinateGridMixin} */ xAxisPadding (padding) { if (!arguments.length) { return this._xAxisPadding; } this._xAxisPadding = padding; return this; } /** * Set or get x axis padding unit for the elastic x axis. The padding unit will determine which unit to * use when applying xAxis padding if elasticX is turned on and if x-axis uses a time dimension; * otherwise it is ignored. * * The padding unit should be a * [d3 time interval](https://github.com/d3/d3-time/blob/master/README.md#self._interval). * For backward compatibility with dc.js 2.0, it can also be the name of a d3 time interval * ('day', 'hour', etc). Available arguments are the * [d3 time intervals](https://github.com/d3/d3-time/blob/master/README.md#intervals d3.timeInterval). * @param {String} [unit=d3.timeDay] * @returns {String|CoordinateGridMixin} */ xAxisPaddingUnit (unit) { if (!arguments.length) { return this._xAxisPaddingUnit; } this._xAxisPaddingUnit = unit; return this; } /** * Returns the number of units displayed on the x axis. If the x axis is ordinal (`xUnits` is * `units.ordinal`), this is the number of items in the domain of the x scale. Otherwise, the * x unit count is calculated using the {@link CoordinateGridMixin#xUnits xUnits} function. * @returns {Number} */ xUnitCount () { if (this._unitCount === undefined) { if (this.isOrdinal()) { // In this case it number of items in domain this._unitCount = this.x().domain().length; } else { this._unitCount = this.xUnits()(this.x().domain()[0], this.x().domain()[1]); // Sometimes xUnits() may return an array while sometimes directly the count if (this._unitCount instanceof Array) { this._unitCount = this._unitCount.length; } } } return this._unitCount; } /** * Gets or sets whether the chart should be drawn with a right axis instead of a left axis. When * used with a chart in a composite chart, allows both left and right Y axes to be shown on a * chart. * @param {Boolean} [useRightYAxis=false] * @returns {Boolean|CoordinateGridMixin} */ useRightYAxis (useRightYAxis) { if (!arguments.length) { return this._useRightYAxis; } // We need to warn if value is changing after self._yAxis was created if (this._useRightYAxis !== useRightYAxis && this._yAxis) { logger.warn('Value of useRightYAxis has been altered, after yAxis was created. ' + 'You might get unexpected yAxis behavior. ' + 'Make calls to useRightYAxis sooner in your chart creation process.'); } this._useRightYAxis = useRightYAxis; return this; } /** * Gets or sets whether the chart should be drawn with a top axis instead of a bottom axis. When * used with a chart in a composite chart, allows both top and bottom X axes to be shown on a * chart. * @param {Boolean} [useTopXAxis=false] * @returns {Boolean|CoordinateGridMixin} */ useTopXAxis (useTopXAxis) { if (!arguments.length) { return this._useTopXAxis; } // We need to warn if value is changing after self._yAxis was created if (this._useTopXAxis !== useTopXAxis && this._xAxis) { logger.warn('Value of useTopXAxis has been altered, after xAxis was created. ' + 'You might get unexpected yAxis behavior. ' + 'Make calls to useTopXAxis sooner in your chart creation process.'); } this._useTopXAxis = useTopXAxis; return this; } /** * Returns true if the chart is using ordinal xUnits ({@link units.ordinal units.ordinal}, or false * otherwise. Most charts behave differently with ordinal data and use the result of this method to * trigger the appropriate logic. * @returns {Boolean} */ isOrdinal () { return this.xUnits() === units.ordinal; } _useOuterPadding () { return true; } _ordinalXDomain () { const groups = this._computeOrderedGroups(this.data()); return groups.map(this.keyAccessor()); } _createXAxis () { return this._useTopXAxis ? axisTop() : axisBottom(); } // eslint-disable-next-line complexity _prepareXAxis (g, render) { if (!this.isOrdinal()) { if (this.elasticX()) { this._x.domain([this.xAxisMin(), this.xAxisMax()]); } } else { // self._chart.isOrdinal() // D3v4 - Ordinal charts would need scaleBand // bandwidth is a method in scaleBand // (https://github.com/d3/d3-scale/blob/master/README.md#scaleBand) if (!this._x.bandwidth) { // If self._x is not a scaleBand create a new scale and // copy the original domain to the new scale logger.warn('For compatibility with d3v4+, dc.js d3.0 ordinal bar/line/bubble charts need ' + 'd3.scaleBand() for the x scale, instead of d3.scaleOrdinal(). ' + 'Replacing .x() with a d3.scaleBand with the same domain - ' + 'make the same change in your code to avoid this warning!'); this._x = scaleBand().domain(this._x.domain()); } if (this.elasticX() || this._x.domain().length === 0) { this._x.domain(this._ordinalXDomain()); } } // has the domain changed? const xdom = this._x.domain(); if (render || !utils.arraysEqual(this._lastXDomain, xdom)) { this.rescale(); } this._lastXDomain = xdom; // please can't we always use rangeBands for bar charts? if (this.isOrdinal()) { this._x.range([0, this.xAxisLength()]) .paddingInner(this._fRangeBandPadding) .paddingOuter(this._useOuterPadding() ? this._fOuterRangeBandPadding : 0); } else { this._x.range([0, this.xAxisLength()]); } if (!this._xAxis) { this._xAxis = this._createXAxis() } this._xAxis = this._xAxis.scale(this.x()); this._renderVerticalGridLines(g); } renderXAxis (g) { let axisXG = g.select('g.x'); if (axisXG.empty()) { axisXG = g.append('g') .attr('class', 'axis x') .attr('transform', `translate(${this.margins().left},${this._xAxisY()})`); } let axisXLab = g.select(`text.${X_AXIS_LABEL_CLASS}`); const axisXLabY = this._useTopXAxis ? this._xAxisLabelPadding : (this.height() - this._xAxisLabelPadding); if (axisXLab.empty() && this.xAxisLabel()) { axisXLab = g.append('text') .attr('class', X_AXIS_LABEL_CLASS) .attr('transform', `translate(${this.margins().left + this.xAxisLength() / 2},${axisXLabY})`) .attr('text-anchor', 'middle'); } if (this.xAxisLabel() && axisXLab.text() !== this.xAxisLabel()) { axisXLab.text(this.xAxisLabel()); } transition(axisXG, this.transitionDuration(), this.transitionDelay()) .attr('transform', `translate(${this.margins().left},${this._xAxisY()})`) .call(this._xAxis); transition(axisXLab, this.transitionDuration(), this.transitionDelay()) .attr('transform', `translate(${this.margins().left + this.xAxisLength() / 2},${axisXLabY})`); } _renderVerticalGridLines (g) { let gridLineG = g.select(`g.${VERTICAL_CLASS}`); if (this._renderVerticalGridLine) { if (gridLineG.empty()) { gridLineG = g.insert('g', ':first-child') .attr('class', `${GRID_LINE_CLASS} ${VERTICAL_CLASS}`) .attr('transform', `translate(${this.margins().left},${this.margins().top})`); } const ticks = this._xAxis.tickValues() ? this._xAxis.tickValues() : (typeof this._x.ticks === 'function' ? this._x.ticks.apply(this._x, this._xAxis.tickArguments()) : this._x.domain()); const lines = gridLineG.selectAll('line') .data(ticks); // enter const linesGEnter = lines.enter() .append('line') .attr('x1', d => this._x(d)) .attr('y1', this._xAxisY() - this.margins().top) .attr('x2', d => this._x(d)) .attr('y2', 0) .attr('opacity', 0); transition(linesGEnter, this.transitionDuration(), this.transitionDelay()) .attr('opacity', 0.5); // update transition(lines, this.transitionDuration(), this.transitionDelay()) .attr('x1', d => this._x(d)) .attr('y1', this._xAxisY() - this.margins().top) .attr('x2', d => this._x(d)) .attr('y2', 0); // exit lines.exit().remove(); } else { gridLineG.selectAll('line').remove(); } } _xAxisY () { return this._useTopXAxis ? this.margins().top : this.height() - this.margins().bottom; } xAxisLength () { return this.effectiveWidth(); } /** * Set or get the x axis label. If setting the label, you may optionally include additional padding to * the margin to make room for the label. By default the padded is set to 12 to accomodate the text height. * @param {String} [labelText] * @param {Number} [padding=12] * @returns {String} */ xAxisLabel (labelText, padding) { if (!arguments.length) { return this._xAxisLabel; } this._xAxisLabel = labelText; this.margins().bottom -= this._xAxisLabelPadding; this._xAxisLabelPadding = (padding === undefined) ? DEFAULT_AXIS_LABEL_PADDING : padding; this.margins().bottom += this._xAxisLabelPadding; return this; } _createYAxis () { return this._useRightYAxis ? axisRight() : axisLeft(); } _prepareYAxis (g) { if (this._y === undefined || this.elasticY()) { if (this._y === undefined) { this._y = scaleLinear(); } const _min = this.yAxisMin() || 0; const _max = this.yAxisMax() || 0; this._y.domain([_min, _max]).rangeRound([this.yAxisHeight(), 0]); } this._y.range([this.yAxisHeight(), 0]); if (!this._yAxis) { this._yAxis = this._createYAxis(); } this._yAxis.scale(this._y); this._renderHorizontalGridLinesForAxis(g, this._y, this._yAxis); } renderYAxisLabel (axisClass, text, rotation, labelXPosition) { labelXPosition = labelXPosition || this._yAxisLabelPadding; let axisYLab = this.g().select(`text.${Y_AXIS_LABEL_CLASS}.${axisClass}-label`); const labelYPosition = (this.margins().top + this.yAxisHeight() / 2); if (axisYLab.empty() && text) { axisYLab = this.g().append('text') .attr('transform', `translate(${labelXPosition},${labelYPosition}),rotate(${rotation})`) .attr('class', `${Y_AXIS_LABEL_CLASS} ${axisClass}-label`) .attr('text-anchor', 'middle') .text(text); } if (text && axisYLab.text() !== text) { axisYLab.text(text); } transition(axisYLab, this.transitionDuration(), this.transitionDelay()) .attr('transform', `translate(${labelXPosition},${labelYPosition}),rotate(${rotation})`); } renderYAxisAt (axisClass, axis, position) { let axisYG = this.g().select(`g.${axisClass}`); if (axisYG.empty()) { axisYG = this.g().append('g') .attr('class', `axis ${axisClass}`) .attr('transform', `translate(${position},${this.margins().top})`); } transition(axisYG, this.transitionDuration(), this.transitionDelay()) .attr('transform', `translate(${position},${this.margins().top})`) .call(axis); } renderYAxis () { const axisPosition = this._useRightYAxis ? (this.width() - this.margins().right) : this._yAxisX(); this.renderYAxisAt('y', this._yAxis, axisPosition); const labelPosition = this._useRightYAxis ? (this.width() - this._yAxisLabelPadding) : this._yAxisLabelPadding; const rotation = this._useRightYAxis ? 90 : -90; this.renderYAxisLabel('y', this.yAxisLabel(), rotation, labelPosition); } _renderHorizontalGridLinesForAxis (g, scale, axis) { let gridLineG = g.select(`g.${HORIZONTAL_CLASS}`); if (this._renderHorizontalGridLine) { // see https://github.com/d3/d3-axis/blob/master/src/axis.js#L48 const ticks = axis.tickValues() ? axis.tickValues() : (scale.ticks ? scale.ticks.apply(scale, axis.tickArguments()) : scale.domain()); if (gridLineG.empty()) { gridLineG = g.insert('g', ':first-child') .attr('class', `${GRID_LINE_CLASS} ${HORIZONTAL_CLASS}`) .attr('transform', `translate(${this.margins().left},${this.margins().top})`); } const lines = gridLineG.selectAll('line') .data(ticks); // enter const linesGEnter = lines.enter() .append('line') .attr('x1', 1) .attr('y1', d => scale(d)) .attr('x2', this.xAxisLength()) .attr('y2', d => scale(d)) .attr('opacity', 0); transition(linesGEnter, this.transitionDuration(), this.transitionDelay()) .attr('opacity', 0.5); // update transition(lines, this.transitionDuration(), this.transitionDelay()) .attr('x1', 1) .attr('y1', d => scale(d)) .attr('x2', this.xAxisLength()) .attr('y2', d => scale(d)); // exit lines.exit().remove(); } else { gridLineG.selectAll('line').remove(); } } _yAxisX () { return this.useRightYAxis() ? this.width() - this.margins().right : this.margins().left; } /** * Set or get the y axis label. If setting the label, you may optionally include additional padding * to the margin to make room for the label. By default the padding is set to 12 to accommodate the * text height. * @param {String} [labelText] * @param {Number} [padding=12] * @returns {String|CoordinateGridMixin} */ yAxisLabel (labelText, padding) { if (!arguments.length) { return this._yAxisLabel; } this._yAxisLabel = labelText; this.margins().left -= this._yAxisLabelPadding; this._yAxisLabelPadding = (padding === undefined) ? DEFAULT_AXIS_LABEL_PADDING : padding; this.margins().left += this._yAxisLabelPadding; return this; } /** * Get or set the y scale. The y scale is typically automatically determined by the chart implementation. * @see {@link https://github.com/d3/d3-scale/blob/master/README.md d3.scale} * @param {d3.scale} [yScale] * @returns {d3.scale|CoordinateGridMixin} */ y (yScale) { if (!arguments.length) { return this._y; } this._y = yScale; this.rescale(); return this; } /** * Set or get the y axis used by the coordinate grid chart instance. This function is most useful * when y axis customization is required. Depending on `useRightYAxis` the y axis in dc.js is an instance of * either [d3.axisLeft](https://github.com/d3/d3-axis/blob/master/README.md#axisLeft) or * [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight); therefore it supports any * valid d3 axis manipulation. * * **Caution**: The y axis is usually generated internally by dc; resetting it may cause * unexpected results. Note also that when used as a getter, this function is not chainable: it * returns the axis, not the chart, * {@link https://github.com/dc-js/dc.js/wiki/FAQ#why-does-everything-break-after-a-call-to-xaxis-or-yaxis * so attempting to call chart functions after calling `.yAxis()` will fail}. * In addition, depending on whether you are going to use the axis on left or right * you need to appropriately pass [d3.axisLeft](https://github.com/d3/d3-axis/blob/master/README.md#axisLeft) * or [d3.axisRight](https://github.com/d3/d3-axis/blob/master/README.md#axisRight) * @see {@link https://github.com/d3/d3-axis/blob/master/README.md d3.axis} * @example * // customize y axis tick format * chart.yAxis().tickFormat(function(v) {return v + '%';}); * // customize y axis tick values * chart.yAxis().tickValues([0, 100, 200, 300]); * @param {d3.axisLeft|d3.axisRight} [yAxis] * @returns {d3.axisLeft|d3.axisRight|CoordinateGridMixin} */ yAxis (yAxis) { if (!arguments.length) { if (!this._yAxis) { this._yAxis = this._createYAxis(); } return this._yAxis; } this._yAxis = yAxis; return this; } /** * Turn on/off elastic y axis behavior. If y axis elasticity is turned on, then the grid chart will * attempt to recalculate the y axis range whenever a redraw event is triggered. * @param {Boolean} [elasticY=false] * @returns {Boolean|CoordinateGridMixin} */ elasticY (elasticY) { if (!arguments.length) { return this._yElasticity; } this._yElasticity = elasticY; return this; } /** * Turn on/off horizontal grid lines. * @param {Boolean} [renderHorizontalGridLines=false] * @returns {Boolean|CoordinateGridMixin} */ renderHorizontalGridLines (renderHorizontalGridLines) { if (!arguments.length) { return this._renderHorizontalGridLine; } this._renderHorizontalGridLine = renderHorizontalGridLines; return this; } /** * Turn on/off vertical grid lines. * @param {Boolean} [renderVerticalGridLines=false] * @returns {Boolean|CoordinateGridMixin} */ renderVerticalGridLines (renderVerticalGridLines) { if (!arguments.length) { return this._renderVerticalGridLine; } this._renderVerticalGridLine = renderVerticalGridLines; return this; } /** * Calculates the minimum x value to display in the chart. Includes xAxisPadding if set. * @returns {*} */ xAxisMin () { const m = min(this.data(), e => this.keyAccessor()(e)); return utils.subtract(m, this._xAxisPadding, this._xAxisPaddingUnit); } /** * Calculates the maximum x value to display in the chart. Includes xAxisPadding if set. * @returns {*} */ xAxisMax () { const m = max(this.data(), e => this.keyAccessor()(e)); return utils.add(m, this._xAxisPadding, this._xAxisPaddingUnit); } /** * Calculates the minimum y value to display in the chart. Includes yAxisPadding if set. * @returns {*} */ yAxisMin () { const m = min(this.data(), e => this.valueAccessor()(e)); return utils.subtract(m, this._yAxisPadding); } /** * Calculates the maximum y value to display in the chart. Includes yAxisPadding if set. * @returns {*} */ yAxisMax () { const m = max(this.data(), e => this.valueAccessor()(e)); return utils.add(m, this._yAxisPadding); } /** * Set or get y axis padding for the elastic y axis. The padding will be added to the top and * bottom of the y axis if elasticY is turned on; otherwise it is ignored. * * Padding can be an integer or percentage in string (e.g. '10%'). Padding can be applied to * number or date axes. When padding a date axis, an integer represents number of days being padded * and a percentage string will be treated the same as an integer. * @param {Number|String} [padding=0] * @returns {Number|CoordinateGridMixin} */ yAxisPadding (padding) { if (!arguments.length) { return this._yAxisPadding; } this._yAxisPadding = padding; return this; } yAxisHeight () { return this.effectiveHeight(); } /** * Set or get the rounding function used to quantize the selection when brushing is enabled. * @example * // set x unit round to by month, this will make sure range selection brush will * // select whole months * chart.round(d3.timeMonth.round); * @param {Function} [round] * @returns {Function|CoordinateGridMixin} */ round (round) { if (!arguments.length) { return this._round; } this._round = round; return this; } _rangeBandPadding (_) { if (!arguments.length) { return this._fRangeBandPadding; } this._fRangeBandPadding = _; return this; } _outerRangeBandPadding (_) { if (!arguments.length) { return this._fOuterRangeBandPadding; } this._fOuterRangeBandPadding = _; return this; } filter (_) { if (!arguments.length) { return super.filter(); } super.filter(_); this.redrawBrush(_, false); return this; } /** * Get or set the brush. Brush must be an instance of d3 brushes * https://github.com/d3/d3-brush/blob/master/README.md * You will use this only if you are writing a new chart type that supports brushing. * * **Caution**: dc creates and manages brushes internally. Go through and understand the source code * if you want to pass a new brush object. Even if you are only using the getter, * the brush object may not behave the way you expect. * * @param {d3.brush} [_] * @returns {d3.brush|CoordinateGridMixin} */ brush (_) { if (!arguments.length) { return this._brush; } this._brush = _; return this; } renderBrush (g, doTransition) { if (this._brushOn) { this._brush.on('start brush end', d3compat.eventHandler((d, evt) => this._brushing(evt))); // To retrieve selection we need self._gBrush this._gBrush = g.append('g') .attr('class', 'brush') .attr('transform', `translate(${this.margins().left},${this.margins().top})`); this.setBrushExtents(); this.createBrushHandlePaths(this._gBrush, doTransition); this.redrawBrush(this.filter(), doTransition); } } createBrushHandlePaths (gBrush) { let brushHandles = gBrush.selectAll(`path.${CUSTOM_BRUSH_HANDLE_CLASS}`).data([{type: 'w'}, {type: 'e'}]); brushHandles = brushHandles .enter() .append('path') .attr('class', CUSTOM_BRUSH_HANDLE_CLASS) .merge(brushHandles); brushHandles .attr('d', d => this.resizeHandlePath(d)); } extendBrush (brushSelection) { if (brushSelection && this.round()) { brushSelection[0] = this.round()(brushSelection[0]); brushSelection[1] = this.round()(brushSelection[1]); } return brushSelection; } brushIsEmpty (brushSelection) { return !brushSelection || brushSelection[1] <= brushSelection[0]; } _brushing (evt) { if (this._ignoreBrushEvents) { return; } let brushSelection = evt.selection; if (brushSelection) { brushSelection = brushSelection.map(this.x().invert); } brushSelection = this.extendBrush(brushSelection); this.redrawBrush(brushSelection, false); const rangedFilter = this.brushIsEmpty(brushSelection) ? null : filters.RangedFilter(brushSelection[0], brushSelection[1]); events.trigger(() => { this.applyBrushSelection(rangedFilter); }, constants.EVENT_DELAY); } // This can be overridden in a derived chart. For example Composite chart overrides it applyBrushSelection (rangedFilter) { this.replaceFilter(rangedFilter); this.redrawGroup(); } _withoutBrushEvents (closure) { const oldValue = this._ignoreBrushEvents; this._ignoreBrushEvents = true; try { closure(); } finally { this._ignoreBrushEvents = oldValue; } } setBrushExtents (doTransition) { this._withoutBrushEvents(() => { // Set boundaries of the brush, must set it before applying to self._gBrush this._brush.extent([[0, 0], [this.effectiveWidth(), this.effectiveHeight()]]); }); this._gBrush .call(this._brush); } redrawBrush (brushSelection, doTransition) { if (this._brushOn && this._gBrush) { if (this._resizing) { this.setBrushExtents(doTransition); } if (!brushSelection) { this._withoutBrushEvents(() => { this._gBrush .call(this._brush.move, null); }) this._gBrush.selectAll(`path.${CUSTOM_BRUSH_HANDLE_CLASS}`) .attr('display', 'none'); } else { const scaledSelection = [this._x(brushSelection[0]), this._x(brushSelection[1])]; const gBrush = optionalTransition(doTransition, this.transitionDuration(), this.transitionDelay())(this._gBrush); this._withoutBrushEvents(() => { gBrush .call(this._brush.move, scaledSelection); }); gBrush.selectAll(`path.${CUSTOM_BRUSH_HANDLE_CLASS}`) .attr('display', null) .attr('transform', (d, i) => `translate(${this._x(brushSelection[i])}, 0)`) .attr('d', d => this.resizeHandlePath(d)); } } this.fadeDeselectedArea(brushSelection); } fadeDeselectedArea (brushSelection) { // do nothing, sub-chart should override this function } // borrowed from Crossfilter example resizeHandlePath (d) { d = d.type; const e = +(d === 'e'), x = e ? 1 : -1, y = this.effectiveHeight() / 3; return `M${0.5 * x},${y }A6,6 0 0 ${e} ${6.5 * x},${y + 6 }V${2 * y - 6 }A6,6 0 0 ${e} ${0.5 * x},${2 * y }Z` + `M${2.5 * x},${y + 8 }V${2 * y - 8 }M${4.5 * x},${y + 8 }V${2 * y - 8}`; } _getClipPathId () { return `${this.anchorName().replace(/[ .#=\[\]"]/g, '-')}-clip`; } /** * Get or set the padding in pixels for the clip path. Once set padding will be applied evenly to * the top, left, right, and bottom when the clip path is generated. If set to zero, the clip area * will be exactly the chart body area minus the margins. * @param {Number} [padding=5] * @returns {Number|CoordinateGridMixin} */ clipPadding (padding) { if (!arguments.length) { return this._clipPadding; } this._clipPadding = padding; return this; } _generateClipPath () { const defs = utils.appendOrSelect(this._parent, 'defs'); // cannot select <clippath> elements; bug in WebKit, must select by id // https://groups.google.com/forum/#!topic/d3-js/6EpAzQ2gU9I const id = this._getClipPathId(); const chartBodyClip = utils.appendOrSelect(defs, `#${id}`, 'clipPath').attr('id', id); const padding = this._clipPadding * 2; utils.appendOrSelect(chartBodyClip, 'rect') .attr('width', this.xAxisLength() + padding) .attr('height', this.yAxisHeight() + padding) .attr('transform', `translate(-${this._clipPadding}, -${this._clipPadding})`); } _preprocessData () { } _doRender () { this.resetSvg(); this._preprocessData(); this._generateG(); this._generateClipPath(); this._drawChart(true); this._configureMouseZoom(); return this; } _doRedraw () { this._preprocessData(); this._drawChart(false); this._generateClipPath(); return this; } _drawChart (render) { if (this.isOrdinal()) { this._brushOn = false; } this._prepareXAxis(this.g(), render); this._prepareYAxis(this.g()); this.plotData(); if (this.elasticX() || this._resizing || render) { this.renderXAxis(this.g()); } if (this.elasticY() || this._resizing || render) { this.renderYAxis(this.g()); } if (render) { this.renderBrush(this.g(), false); } else { // Animate the brush only while resizing this.redrawBrush(this.filter(), this._resizing); } this.fadeDeselectedArea(this.filter()); this.resizing(false); } _configureMouseZoom () { // Save a copy of original x scale this._origX = this._x.copy(); if (this._mouseZoomable) { this._enableMouseZoom(); } else if (this._hasBeenMouseZoomable) { this._disableMouseZoom(); } } _enableMouseZoom () { this._hasBeenMouseZoomable = true; const extent = [[0, 0], [this.effectiveWidth(), this.effectiveHeight()]]; this._zoom .scaleExtent(this._zoomScale) .extent(extent) .duration(this.transitionDuration()); if (this._zoomOutRestrict) { // Ensure minimum zoomScale is at least 1 const zoomScaleMin = Math.max(this._zoomScale[0], 1); this._zoom .translateExtent(extent) .scaleExtent([zoomScaleMin, this._zoomScale[1]]); } this.root().call(this._zoom); // Tell D3 zoom our current zoom/pan status this._updateD3zoomTransform(); } _disableMouseZoom () { this.root().call(this._nullZoom); } _zoomHandler (newDomain, noRaiseEvents) { let domFilter; if (this._hasRangeSelected(newDomain)) { this.x().domain(newDomain); domFilter = filters.RangedFilter(newDomain[0], newDomain[1]); } else { this.x().domain(this._xOriginalDomain); domFilter = null; } this.replaceFilter(domFilter); this.rescale(); this.redraw(); if (!noRaiseEvents) { if (this._rangeChart && !utils.arraysEqual(this.filter(), this._rangeChart.filter())) { events.trigger(() => { this._rangeChart.replaceFilter(domFilter); this._rangeChart.redraw(); }); } this._invokeZoomedListener(); events.trigger(() => { this.redrawGroup(); }, constants.EVENT_DELAY); } } // event.transform.rescaleX(self._origX).domain() should give back newDomain _domainToZoomTransform (newDomain, origDomain, xScale) { const k = (origDomain[1] - origDomain[0]) / (newDomain[1] - newDomain[0]); const xt = -1 * xScale(newDomain[0]); return zoomIdentity.scale(k).translate(xt, 0); } // If we changing zoom status (for example by calling focus), tell D3 zoom about it _updateD3zoomTransform () { if (this._zoom) { this._withoutZoomEvents(() => { this._zoom.transform(this.root(), this._domainToZoomTransform(this.x().domain(), this._xOriginalDomain, this._origX)); }); } } _withoutZoomEvents (closure) { const oldValue = this._ignoreZoomEvents; this._ignoreZoomEvents = true; try { closure(); } finally { this._ignoreZoomEvents = oldValue; } } _onZoom (evt) { // ignore zoom events if it was caused by a programmatic change if (this._ignoreZoomEvents) { return; } const newDomain = evt.transform.rescaleX(this._origX).domain(); this.focus(newDomain, false); } _checkExtents (ext, outerLimits) { if (!ext || ext.length !== 2 || !outerLimits || outerLimits.length !== 2) { return ext; } if (ext[0] > outerLimits[1] || ext[1] < outerLimits[0]) { console.warn('Could not intersect extents, will reset'); } // Math.max does not work (as the values may be dates as well) return [ext[0] > outerLimits[0] ? ext[0] : outerLimits[0], ext[1] < outerLimits[1] ? ext[1] : outerLimits[1]]; } /** * Zoom this chart to focus on the given range. The given range should be an array containing only * 2 elements (`[start, end]`) defining a range in the x domain. If the range is not given or set * to null, then the zoom will be reset. _For focus to work elasticX has to be turned off; * otherwise focus will be ignored. * * To avoid ping-pong volley of events between a pair of range and focus charts please set * `noRaiseEvents` to `true`. In that case it will update this chart but will not fire `zoom` event * and not try to update back the associated range chart. * If you are calling it manually - typically you will leave it to `false` (the default). * * @example * chart.on('renderlet', function(chart) { * // smooth the rendering through event throttling * events.trigger(function(){ * // focus some other chart to the range selected by user on this chart * someOtherChart.focus(chart.filter()); * }); * }) * @param {Array<Number>} [range] * @param {Boolean} [noRaiseEvents = false] * @return {undefined} */ focus (range, noRaiseEvents) { if (this._zoomOutRestrict) { // ensure range is within self._xOriginalDomain range = this._checkExtents(range, this._xOriginalDomain); // If it has an associated range chart ensure range is within domain of that rangeChart if (this._rangeChart) { range = this._checkExtents(range, this._rangeChart.x().domain()); } } this._zoomHandler(range, noRaiseEvents); this._updateD3zoomTransform(); } refocused () { return !utils.arraysEqual(this.x().domain(), this._xOriginalDomain); } focusChart (c) { if (!arguments.length) { return this._focusChart; } this._focusChart = c; this.on('filtered.dcjs-range-chart', chart => { if (!chart.filter()) { events.trigger(() => { this._focusChart.x().domain(this._focusChart.xOriginalDomain(), true); }); } else if (!utils.arraysEqual(chart.filter(), this._focusChart.filter())) { events.trigger(() => { this._focusChart.focus(chart.filter(), true); }); } }); return this; } /** * Turn on/off the brush-based range filter. When brushing is on then user can drag the mouse * across a chart with a quantitative scale to perform range filtering based on the extent of the * brush, or click on the bars of an ordinal bar chart or slices of a pie chart to filter and * un-filter them. However turning on the brush filter will disable other interactive elements on * the chart such as highlighting, tool tips, and reference lines. Zooming will still be possible * if enabled, but only via scrolling (panning will be disabled.) * @param {Boolean} [brushOn=true] * @returns {Boolean|CoordinateGridMixin} */ brushOn (brushOn) { if (!arguments.length) { return this._brushOn; } this._brushOn = brushOn; return this; } /** * This will be internally used by composite chart onto children. Please go not invoke directly. * * @protected * @param {Boolean} [brushOn=false] * @returns {Boolean|CoordinateGridMixin} */ parentBrushOn (brushOn) { if (!arguments.length) { return this._parentBrushOn; } this._parentBrushOn = brushOn; return this; } // Get the SVG rendered brush gBrush () { return this._gBrush; } _hasRangeSelected (range) { return range instanceof Array && range.length > 1; } }