import {select} from 'd3-selection'; import {StackMixin} from '../base/stack-mixin'; import {transition} from '../core/core'; import {constants} from '../core/constants'; import {logger} from '../core/logger'; import {pluck, utils} from '../core/utils'; import {d3compat} from '../core/config'; const MIN_BAR_WIDTH = 1; const DEFAULT_GAP_BETWEEN_BARS = 2; const LABEL_PADDING = 3; /** * Concrete bar chart/histogram implementation. * * Examples: * - {@link http://dc-js.github.com/dc.js/ Nasdaq 100 Index} * - {@link http://dc-js.github.com/dc.js/crime/index.html Canadian City Crime Stats} * @mixes StackMixin */ export class BarChart extends StackMixin { /** * Create a Bar Chart * @example * // create a bar chart under #chart-container1 element using the default global chart group * var chart1 = new BarChart('#chart-container1'); * // create a bar chart under #chart-container2 element using chart group A * var chart2 = new BarChart('#chart-container2', 'chartGroupA'); * // create a sub-chart under a composite parent chart * var chart3 = new BarChart(compositeChart); * @param {String|node|d3.selection|CompositeChart} parent - Any valid * {@link https://github.com/d3/d3-selection/blob/master/README.md#select d3 single selector} * specifying a dom block element such as a div; or a dom element or d3 selection. If the bar * chart is a sub-chart in a {@link CompositeChart Composite Chart} then pass in the parent * composite chart instance instead. * @param {String} [chartGroup] - The name of the chart group this chart instance should be placed in. * Interaction with a chart will only trigger events and redraws within the chart's group. */ constructor (parent, chartGroup) { super(); this._gap = DEFAULT_GAP_BETWEEN_BARS; this._centerBar = false; this._alwaysUseRounding = false; this._barWidth = undefined; this.label(d => utils.printSingleValue(d.y0 + d.y), false); this.anchor(parent, chartGroup); } /** * Get or set the outer padding on an ordinal bar chart. This setting has no effect on non-ordinal charts. * Will pad the width by `padding * barWidth` on each side of the chart. * @param {Number} [padding=0.5] * @returns {Number|BarChart} */ outerPadding (padding) { if (!arguments.length) { return this._outerRangeBandPadding(); } return this._outerRangeBandPadding(padding); } rescale () { super.rescale(); this._barWidth = undefined; return this; } render () { if (this.round() && this._centerBar && !this._alwaysUseRounding) { logger.warn('By default, brush rounding is disabled if bars are centered. ' + 'See dc.js bar chart API documentation for details.'); } return super.render(); } plotData () { let layers = this.chartBodyG().selectAll('g.stack') .data(this.data()); this._calculateBarWidth(); layers = layers .enter() .append('g') .attr('class', (d, i) => `stack _${i}`) .merge(layers); const last = layers.size() - 1; { const chart = this; layers.each(function (d, i) { const layer = select(this); chart._renderBars(layer, i, d); if (chart.renderLabel() && last === i) { chart._renderLabels(layer, i, d); } }); } } _barHeight (d) { return utils.safeNumber(Math.abs(this.y()(d.y + d.y0) - this.y()(d.y0))); } _labelXPos (d) { let x = this.x()(d.x); if (!this._centerBar) { x += this._barWidth / 2; } if (this.isOrdinal() && this._gap !== undefined) { x += this._gap / 2; } return utils.safeNumber(x); } _labelYPos (d) { let y = this.y()(d.y + d.y0); if (d.y < 0) { y -= this._barHeight(d); } return utils.safeNumber(y - LABEL_PADDING); } _renderLabels (layer, layerIndex, data) { const labels = layer.selectAll('text.barLabel') .data(data.values, pluck('x')); const labelsEnterUpdate = labels .enter() .append('text') .attr('class', 'barLabel') .attr('text-anchor', 'middle') .attr('x', d => this._labelXPos(d)) .attr('y', d => this._labelYPos(d)) .merge(labels); if (this.isOrdinal()) { labelsEnterUpdate.on('click', d3compat.eventHandler(d => this.onClick(d))); labelsEnterUpdate.attr('cursor', 'pointer'); } transition(labelsEnterUpdate, this.transitionDuration(), this.transitionDelay()) .attr('x', d => this._labelXPos(d)) .attr('y', d => this._labelYPos(d)) .text(d => this.label()(d)); transition(labels.exit(), this.transitionDuration(), this.transitionDelay()) .attr('height', 0) .remove(); } _barXPos (d) { let x = this.x()(d.x); if (this._centerBar) { x -= this._barWidth / 2; } if (this.isOrdinal() && this._gap !== undefined) { x += this._gap / 2; } return utils.safeNumber(x); } _renderBars (layer, layerIndex, data) { const bars = layer.selectAll('rect.bar') .data(data.values, pluck('x')); const enter = bars.enter() .append('rect') .attr('class', 'bar') .classed('dc-tabbable', this._keyboardAccessible) .attr('fill', pluck('data', this.getColor)) .attr('x', d => this._barXPos(d)) .attr('y', this.yAxisHeight()) .attr('height', 0); const barsEnterUpdate = enter.merge(bars); if (this.renderTitle()) { enter.append('title').text(pluck('data', this.title(data.name))); } if (this.isOrdinal()) { barsEnterUpdate.on('click', d3compat.eventHandler(d => this.onClick(d))); } if (this._keyboardAccessible) { this._makeKeyboardAccessible(this.onClick); } transition(barsEnterUpdate, this.transitionDuration(), this.transitionDelay()) .attr('x', d => this._barXPos(d)) .attr('y', d => { let y = this.y()(d.y + d.y0); if (d.y < 0) { y -= this._barHeight(d); } return utils.safeNumber(y); }) .attr('width', this._barWidth) .attr('height', d => this._barHeight(d)) .attr('fill', pluck('data', this.getColor)) .select('title').text(pluck('data', this.title(data.name))); transition(bars.exit(), this.transitionDuration(), this.transitionDelay()) .attr('x', d => this.x()(d.x)) .attr('width', this._barWidth * 0.9) .remove(); } _calculateBarWidth () { if (this._barWidth === undefined) { const numberOfBars = this.xUnitCount(); // please can't we always use rangeBands for bar charts? if (this.isOrdinal() && this._gap === undefined) { this._barWidth = Math.floor(this.x().bandwidth()); } else if (this._gap) { this._barWidth = Math.floor((this.xAxisLength() - (numberOfBars - 1) * this._gap) / numberOfBars); } else { this._barWidth = Math.floor(this.xAxisLength() / (1 + this.barPadding()) / numberOfBars); } if (this._barWidth === Infinity || isNaN(this._barWidth) || this._barWidth < MIN_BAR_WIDTH) { this._barWidth = MIN_BAR_WIDTH; } } } fadeDeselectedArea (brushSelection) { const bars = this.chartBodyG().selectAll('rect.bar'); if (this.isOrdinal()) { if (this.hasFilter()) { bars.classed(constants.SELECTED_CLASS, d => this.hasFilter(d.x)); bars.classed(constants.DESELECTED_CLASS, d => !this.hasFilter(d.x)); } else { bars.classed(constants.SELECTED_CLASS, false); bars.classed(constants.DESELECTED_CLASS, false); } } else if (this.brushOn() || this.parentBrushOn()) { if (!this.brushIsEmpty(brushSelection)) { const start = brushSelection[0]; const end = brushSelection[1]; bars.classed(constants.DESELECTED_CLASS, d => d.x < start || d.x >= end); } else { bars.classed(constants.DESELECTED_CLASS, false); } } } /** * Whether the bar chart will render each bar centered around the data position on the x-axis. * @param {Boolean} [centerBar=false] * @returns {Boolean|BarChart} */ centerBar (centerBar) { if (!arguments.length) { return this._centerBar; } this._centerBar = centerBar; return this; } onClick (d) { super.onClick(d.data); } /** * Get or set the spacing between bars as a fraction of bar size. Valid values are between 0-1. * Setting this value will also remove any previously set {@link BarChart#gap gap}. See the * {@link https://github.com/d3/d3-scale/blob/master/README.md#scaleBand d3 docs} * for a visual description of how the padding is applied. * @param {Number} [barPadding=0] * @returns {Number|BarChart} */ barPadding (barPadding) { if (!arguments.length) { return this._rangeBandPadding(); } this._rangeBandPadding(barPadding); this._gap = undefined; return this; } _useOuterPadding () { return this._gap === undefined; } /** * Manually set fixed gap (in px) between bars instead of relying on the default auto-generated * gap. By default the bar chart implementation will calculate and set the gap automatically * based on the number of data points and the length of the x axis. * @param {Number} [gap=2] * @returns {Number|BarChart} */ gap (gap) { if (!arguments.length) { return this._gap; } this._gap = gap; return this; } extendBrush (brushSelection) { if (brushSelection && this.round() && (!this._centerBar || this._alwaysUseRounding)) { brushSelection[0] = this.round()(brushSelection[0]); brushSelection[1] = this.round()(brushSelection[1]); } return brushSelection; } /** * Set or get whether rounding is enabled when bars are centered. If false, using * rounding with centered bars will result in a warning and rounding will be ignored. This flag * has no effect if bars are not {@link BarChart#centerBar centered}. * When using standard d3.js rounding methods, the brush often doesn't align correctly with * centered bars since the bars are offset. The rounding function must add an offset to * compensate, such as in the following example. * @example * chart.round(function(n) { return Math.floor(n) + 0.5; }); * @param {Boolean} [alwaysUseRounding=false] * @returns {Boolean|BarChart} */ alwaysUseRounding (alwaysUseRounding) { if (!arguments.length) { return this._alwaysUseRounding; } this._alwaysUseRounding = alwaysUseRounding; return this; } legendHighlight (d) { const colorFilter = (color, inv) => function () { const item = select(this); const match = item.attr('fill') === color; return inv ? !match : match; }; if (!this.isLegendableHidden(d)) { this.g().selectAll('rect.bar') .classed('highlight', colorFilter(d.color)) .classed('fadeout', colorFilter(d.color, true)); } } legendReset () { this.g().selectAll('rect.bar') .classed('highlight', false) .classed('fadeout', false); } xAxisMax () { let max = super.xAxisMax(); if ('resolution' in this.xUnits()) { const res = this.xUnits().resolution; max += res; } return max; } } export const barChart = (parent, chartGroup) => new BarChart(parent, chartGroup);