import {pluck, utils} from '../core/utils'; import {d3compat} from '../core/config'; import {constants} from '../core/constants'; const LABEL_GAP = 2; /** * Legend is a attachable widget that can be added to other dc charts to render horizontal legend * labels. * * 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} * @example * chart.legend(new Legend().x(400).y(10).itemHeight(13).gap(5)) * @returns {Legend} */ export class Legend { constructor () { this._parent = undefined; this._x = 0; this._y = 0; this._itemHeight = 12; this._gap = 5; this._horizontal = false; this._legendWidth = 560; this._itemWidth = 70; this._autoItemWidth = false; this._legendText = pluck('name'); this._maxItems = undefined; this._highlightSelected = false; this._keyboardAccessible = false; this._g = undefined; } parent (p) { if (!arguments.length) { return this._parent; } this._parent = p; return this; } /** * Set or get x coordinate for legend widget. * @param {Number} [x=0] * @returns {Number|Legend} */ x (x) { if (!arguments.length) { return this._x; } this._x = x; return this; } /** * Set or get y coordinate for legend widget. * @param {Number} [y=0] * @returns {Number|Legend} */ y (y) { if (!arguments.length) { return this._y; } this._y = y; return this; } /** * Set or get gap between legend items. * @param {Number} [gap=5] * @returns {Number|Legend} */ gap (gap) { if (!arguments.length) { return this._gap; } this._gap = gap; return this; } /** * This can be optionally used to enable highlighting legends for the selections/filters for the * chart. * @param {String} [highlightSelected] * @return {String|dc.legend} **/ highlightSelected (highlightSelected) { if (!arguments.length) { return this._highlightSelected; } this._highlightSelected = highlightSelected; return this; } /** * Set or get legend item height. * @param {Number} [itemHeight=12] * @returns {Number|Legend} */ itemHeight (itemHeight) { if (!arguments.length) { return this._itemHeight; } this._itemHeight = itemHeight; return this; } /** * Position legend horizontally instead of vertically. * @param {Boolean} [horizontal=false] * @returns {Boolean|Legend} */ horizontal (horizontal) { if (!arguments.length) { return this._horizontal; } this._horizontal = horizontal; return this; } /** * Maximum width for horizontal legend. * @param {Number} [legendWidth=500] * @returns {Number|Legend} */ legendWidth (legendWidth) { if (!arguments.length) { return this._legendWidth; } this._legendWidth = legendWidth; return this; } /** * Legend item width for horizontal legend. * @param {Number} [itemWidth=70] * @returns {Number|Legend} */ itemWidth (itemWidth) { if (!arguments.length) { return this._itemWidth; } this._itemWidth = itemWidth; return this; } /** * Turn automatic width for legend items on or off. If true, {@link Legend#itemWidth itemWidth} is ignored. * This setting takes into account the {@link Legend#gap gap}. * @param {Boolean} [autoItemWidth=false] * @returns {Boolean|Legend} */ autoItemWidth (autoItemWidth) { if (!arguments.length) { return this._autoItemWidth; } this._autoItemWidth = autoItemWidth; return this; } /** * Set or get the legend text function. The legend widget uses this function to render the legend * text for each item. If no function is specified the legend widget will display the names * associated with each group. * @param {Function} [legendText] * @returns {Function|Legend} * @example * // default legendText * legend.legendText(pluck('name')) * * // create numbered legend items * chart.legend(new Legend().legendText(function(d, i) { return i + '. ' + d.name; })) * * // create legend displaying group counts * chart.legend(new Legend().legendText(function(d) { return d.name + ': ' d.data; })) */ legendText (legendText) { if (!arguments.length) { return this._legendText; } this._legendText = legendText; return this; } /** * Maximum number of legend items to display * @param {Number} [maxItems] * @return {Legend} */ maxItems (maxItems) { if (!arguments.length) { return this._maxItems; } this._maxItems = utils.isNumber(maxItems) ? maxItems : undefined; return this; } /** * If set, individual legend items will be focusable from keyboard and on pressing Enter or Space * will behave as if clicked on. * * If `svgDescription` on the parent chart has not been explicitly set, will also set the default * SVG description text to the class constructor name, like BarChart or HeatMap, and make the entire * SVG focusable. * @param {Boolean} [keyboardAccessible=false] * @returns {Boolean|Legend} */ keyboardAccessible (keyboardAccessible) { if (!arguments.length) { return this._keyboardAccessible; } this._keyboardAccessible = keyboardAccessible; return this; } // Implementation methods _legendItemHeight () { return this._gap + this._itemHeight; } _makeLegendKeyboardAccessible () { if (!this._parent._svgDescription) { this._parent.svg().append('desc') .attr('id', `desc-id-${this._parent.__dcFlag__}`) .html(`${this._parent.svgDescription()}`); this._parent.svg() .attr('tabindex', '0') .attr('role', 'img') .attr('aria-labelledby', `desc-id-${this._parent.__dcFlag__}`); } const tabElements = this._parent.svg() .selectAll('.dc-legend .dc-tabbable') .attr('tabindex', 0); tabElements .on('keydown', d3compat.eventHandler((d, event) => { // trigger only if d is an object if (event.keyCode === 13 && typeof d === 'object') { d.chart.legendToggle(d) } // special case for space key press - prevent scrolling if (event.keyCode === 32 && typeof d === 'object') { d.chart.legendToggle(d) event.preventDefault(); } })) .on('focus', d3compat.eventHandler(d => { this._parent.legendHighlight(d); })) .on('blur', d3compat.eventHandler(d => { this._parent.legendReset(d); })); } render () { this._parent.svg().select('g.dc-legend').remove(); this._g = this._parent.svg().append('g') .attr('class', 'dc-legend') .attr('transform', `translate(${this._x},${this._y})`); let legendables = this._parent.legendables(); const filters = this._parent.filters(); if (this._maxItems !== undefined) { legendables = legendables.slice(0, this._maxItems); } const itemEnter = this._g.selectAll('g.dc-legend-item') .data(legendables) .enter() .append('g') .attr('class', 'dc-legend-item') .on('mouseover', d3compat.eventHandler(d => { this._parent.legendHighlight(d); })) .on('mouseout', d3compat.eventHandler(d => { this._parent.legendReset(d); })) .on('click', d3compat.eventHandler(d => { d.chart.legendToggle(d); })); if (this._highlightSelected) { itemEnter.classed(constants.SELECTED_CLASS, d => filters.indexOf(d.name) !== -1); } this._g.selectAll('g.dc-legend-item') .classed('fadeout', d => d.chart.isLegendableHidden(d)); if (legendables.some(pluck('dashstyle'))) { itemEnter .append('line') .attr('x1', 0) .attr('y1', this._itemHeight / 2) .attr('x2', this._itemHeight) .attr('y2', this._itemHeight / 2) .attr('stroke-width', 2) .attr('stroke-dasharray', pluck('dashstyle')) .attr('stroke', pluck('color')); } else { itemEnter .append('rect') .attr('width', this._itemHeight) .attr('height', this._itemHeight) .attr('fill', d => d ? d.color : 'blue'); } { const self = this; itemEnter.append('text') .text(self._legendText) .classed('dc-tabbable', this._keyboardAccessible) .attr('x', self._itemHeight + LABEL_GAP) .attr('y', function () { return self._itemHeight / 2 + (this.clientHeight ? this.clientHeight : 13) / 2 - 2; }); if (this._keyboardAccessible) { this._makeLegendKeyboardAccessible(); } } let cumulativeLegendTextWidth = 0; let row = 0; { const self = this; itemEnter.attr('transform', function (d, i) { if (self._horizontal) { const itemWidth = self._autoItemWidth === true ? this.getBBox().width + self._gap : self._itemWidth; if ((cumulativeLegendTextWidth + itemWidth) > self._legendWidth && cumulativeLegendTextWidth > 0) { ++row; cumulativeLegendTextWidth = 0; } const translateBy = `translate(${cumulativeLegendTextWidth},${row * self._legendItemHeight()})`; cumulativeLegendTextWidth += itemWidth; return translateBy; } else { return `translate(0,${i * self._legendItemHeight()})`; } }); } } } export const legend = () => new Legend();