import {ascending} from 'd3-array'; import {scaleBand} from 'd3-scale'; import {transition} from '../core/core'; import {logger} from '../core/logger'; import {filters} from '../core/filters'; import {events} from '../core/events'; import {ColorMixin} from '../base/color-mixin'; import {MarginMixin} from '../base/margin-mixin'; import {d3compat} from '../core/config'; const DEFAULT_BORDER_RADIUS = 6.75; /** * A heat map is matrix that represents the values of two dimensions of data using colors. * @mixes ColorMixin * @mixes MarginMixin * @mixes BaseMixin */ export class HeatMap extends ColorMixin(MarginMixin) { /** * Create a Heat Map * @example * // create a heat map under #chart-container1 element using the default global chart group * var heatMap1 = new HeatMap('#chart-container1'); * // create a heat map under #chart-container2 element using chart group A * var heatMap2 = new HeatMap('#chart-container2', 'chartGroupA'); * @param {String|node|d3.selection} 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. * @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._chartBody = undefined; this._cols = undefined; this._rows = undefined; this._colOrdering = ascending; this._rowOrdering = ascending; this._colScale = scaleBand(); this._rowScale = scaleBand(); this._xBorderRadius = DEFAULT_BORDER_RADIUS; this._yBorderRadius = DEFAULT_BORDER_RADIUS; this._mandatoryAttributes(['group']); this.title(this.colorAccessor()); this._colsLabel = d => d; this._rowsLabel = d => d; this._xAxisOnClick = d => { this._filterAxis(0, d); }; this._yAxisOnClick = d => { this._filterAxis(1, d); }; this._boxOnClick = d => { const filter = d.key; events.trigger(() => { this.filter(filters.TwoDimensionalFilter(filter)); this.redrawGroup(); }); }; this.anchor(parent, chartGroup); } /** * Set or get the column label function. The chart class uses this function to render * column labels on the X axis. It is passed the column name. * @example * // the default label function just returns the name * chart.colsLabel(function(d) { return d; }); * @param {Function} [labelFunction=function(d) { return d; }] * @returns {Function|HeatMap} */ colsLabel (labelFunction) { if (!arguments.length) { return this._colsLabel; } this._colsLabel = labelFunction; return this; } /** * Set or get the row label function. The chart class uses this function to render * row labels on the Y axis. It is passed the row name. * @example * // the default label function just returns the name * chart.rowsLabel(function(d) { return d; }); * @param {Function} [labelFunction=function(d) { return d; }] * @returns {Function|HeatMap} */ rowsLabel (labelFunction) { if (!arguments.length) { return this._rowsLabel; } this._rowsLabel = labelFunction; return this; } _filterAxis (axis, value) { const cellsOnAxis = this.selectAll('.box-group').filter(d => d.key[axis] === value); const unfilteredCellsOnAxis = cellsOnAxis.filter(d => !this.hasFilter(d.key)); events.trigger(() => { const selection = unfilteredCellsOnAxis.empty() ? cellsOnAxis : unfilteredCellsOnAxis; const filtersList = selection.data().map(kv => filters.TwoDimensionalFilter(kv.key)); this.filter([filtersList]); this.redrawGroup(); }); } filter (filter) { const nonstandardFilter = f => { logger.warnOnce('heatmap.filter taking a coordinate is deprecated - please pass dc.filters.TwoDimensionalFilter instead'); return this._filter(filters.TwoDimensionalFilter(f)); }; if (!arguments.length) { return super.filter(); } if (filter !== null && filter.filterType !== 'TwoDimensionalFilter' && !(Array.isArray(filter) && Array.isArray(filter[0]) && filter[0][0].filterType === 'TwoDimensionalFilter')) { return nonstandardFilter(filter); } return super.filter(filter); } /** * Gets or sets the values used to create the rows of the heatmap, as an array. By default, all * the values will be fetched from the data using the value accessor. * @param {Array<String|Number>} [rows] * @returns {Array<String|Number>|HeatMap} */ rows (rows) { if (!arguments.length) { return this._rows; } this._rows = rows; return this; } /** * Get or set a comparator to order the rows. * Default is {@link https://github.com/d3/d3-array#ascending d3.ascending}. * @param {Function} [rowOrdering] * @returns {Function|HeatMap} */ rowOrdering (rowOrdering) { if (!arguments.length) { return this._rowOrdering; } this._rowOrdering = rowOrdering; return this; } /** * Gets or sets the keys used to create the columns of the heatmap, as an array. By default, all * the values will be fetched from the data using the key accessor. * @param {Array<String|Number>} [cols] * @returns {Array<String|Number>|HeatMap} */ cols (cols) { if (!arguments.length) { return this._cols; } this._cols = cols; return this; } /** * Get or set a comparator to order the columns. * Default is {@link https://github.com/d3/d3-array#ascending d3.ascending}. * @param {Function} [colOrdering] * @returns {Function|HeatMap} */ colOrdering (colOrdering) { if (!arguments.length) { return this._colOrdering; } this._colOrdering = colOrdering; return this; } _doRender () { this.resetSvg(); this._chartBody = this.svg() .append('g') .attr('class', 'heatmap') .attr('transform', `translate(${this.margins().left},${this.margins().top})`); return this._doRedraw(); } _doRedraw () { const data = this.data(); let rows = this.rows() || data.map(this.valueAccessor()), cols = this.cols() || data.map(this.keyAccessor()); if (this._rowOrdering) { rows = rows.sort(this._rowOrdering); } if (this._colOrdering) { cols = cols.sort(this._colOrdering); } rows = this._rowScale.domain(rows); cols = this._colScale.domain(cols); const rowCount = rows.domain().length, colCount = cols.domain().length, boxWidth = Math.floor(this.effectiveWidth() / colCount), boxHeight = Math.floor(this.effectiveHeight() / rowCount); cols.rangeRound([0, this.effectiveWidth()]); rows.rangeRound([this.effectiveHeight(), 0]); let boxes = this._chartBody.selectAll('g.box-group').data(this.data(), (d, i) => `${this.keyAccessor()(d, i)}\0${this.valueAccessor()(d, i)}`); boxes.exit().remove(); const gEnter = boxes.enter().append('g') .attr('class', 'box-group'); gEnter.append('rect') .attr('class', 'heat-box') .classed('dc-tabbable', this._keyboardAccessible) .attr('fill', 'white') .attr('x', (d, i) => cols(this.keyAccessor()(d, i))) .attr('y', (d, i) => rows(this.valueAccessor()(d, i))) .on('click', d3compat.eventHandler(this.boxOnClick())); if (this._keyboardAccessible) { this._makeKeyboardAccessible(this.boxOnClick); } boxes = gEnter.merge(boxes); if (this.renderTitle()) { gEnter.append('title'); boxes.select('title').text(this.title()); } transition(boxes.select('rect'), this.transitionDuration(), this.transitionDelay()) .attr('x', (d, i) => cols(this.keyAccessor()(d, i))) .attr('y', (d, i) => rows(this.valueAccessor()(d, i))) .attr('rx', this._xBorderRadius) .attr('ry', this._yBorderRadius) .attr('fill', this.getColor) .attr('width', boxWidth) .attr('height', boxHeight); let gCols = this._chartBody.select('g.cols'); if (gCols.empty()) { gCols = this._chartBody.append('g').attr('class', 'cols axis'); } let gColsText = gCols.selectAll('text').data(cols.domain()); gColsText.exit().remove(); gColsText = gColsText .enter() .append('text') .attr('x', d => cols(d) + boxWidth / 2) .style('text-anchor', 'middle') .attr('y', this.effectiveHeight()) .attr('dy', 12) .on('click', d3compat.eventHandler(this.xAxisOnClick())) .text(this.colsLabel()) .merge(gColsText); transition(gColsText, this.transitionDuration(), this.transitionDelay()) .text(this.colsLabel()) .attr('x', d => cols(d) + boxWidth / 2) .attr('y', this.effectiveHeight()); let gRows = this._chartBody.select('g.rows'); if (gRows.empty()) { gRows = this._chartBody.append('g').attr('class', 'rows axis'); } let gRowsText = gRows.selectAll('text').data(rows.domain()); gRowsText.exit().remove(); gRowsText = gRowsText .enter() .append('text') .style('text-anchor', 'end') .attr('x', 0) .attr('dx', -2) .attr('y', d => rows(d) + boxHeight / 2) .attr('dy', 6) .on('click', d3compat.eventHandler(this.yAxisOnClick())) .text(this.rowsLabel()) .merge(gRowsText); transition(gRowsText, this.transitionDuration(), this.transitionDelay()) .text(this.rowsLabel()) .attr('y', d => rows(d) + boxHeight / 2); if (this.hasFilter()) { const chart = this; this.selectAll('g.box-group').each(function (d) { if (chart.isSelectedNode(d)) { chart.highlightSelected(this); } else { chart.fadeDeselected(this); } }); } else { const chart = this; this.selectAll('g.box-group').each(function () { chart.resetHighlight(this); }); } return this; } /** * Gets or sets the handler that fires when an individual cell is clicked in the heatmap. * By default, filtering of the cell will be toggled. * @example * // default box on click handler * chart.boxOnClick(function (d) { * var filter = d.key; * events.trigger(function () { * _chart.filter(filter); * _chart.redrawGroup(); * }); * }); * @param {Function} [handler] * @returns {Function|HeatMap} */ boxOnClick (handler) { if (!arguments.length) { return this._boxOnClick; } this._boxOnClick = handler; return this; } /** * Gets or sets the handler that fires when a column tick is clicked in the x axis. * By default, if any cells in the column are unselected, the whole column will be selected, * otherwise the whole column will be unselected. * @param {Function} [handler] * @returns {Function|HeatMap} */ xAxisOnClick (handler) { if (!arguments.length) { return this._xAxisOnClick; } this._xAxisOnClick = handler; return this; } /** * Gets or sets the handler that fires when a row tick is clicked in the y axis. * By default, if any cells in the row are unselected, the whole row will be selected, * otherwise the whole row will be unselected. * @param {Function} [handler] * @returns {Function|HeatMap} */ yAxisOnClick (handler) { if (!arguments.length) { return this._yAxisOnClick; } this._yAxisOnClick = handler; return this; } /** * Gets or sets the X border radius. Set to 0 to get full rectangles. * @param {Number} [xBorderRadius=6.75] * @returns {Number|HeatMap} */ xBorderRadius (xBorderRadius) { if (!arguments.length) { return this._xBorderRadius; } this._xBorderRadius = xBorderRadius; return this; } /** * Gets or sets the Y border radius. Set to 0 to get full rectangles. * @param {Number} [yBorderRadius=6.75] * @returns {Number|HeatMap} */ yBorderRadius (yBorderRadius) { if (!arguments.length) { return this._yBorderRadius; } this._yBorderRadius = yBorderRadius; return this; } isSelectedNode (d) { return this.hasFilter(d.key); } } export const heatMap = (parent, chartGroup) => new HeatMap(parent, chartGroup);