import { area, curveBasis, curveBasisClosed, curveBasisOpen, curveBundle, curveCardinal, curveCardinalClosed, curveCardinalOpen, curveLinear, curveLinearClosed, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, line } from 'd3-shape'; import {select} from 'd3-selection'; import {logger} from '../core/logger'; import {pluck, utils} from '../core/utils'; import {StackMixin} from '../base/stack-mixin'; import {transition} from '../core/core'; const DEFAULT_DOT_RADIUS = 5; const TOOLTIP_G_CLASS = 'dc-tooltip'; const DOT_CIRCLE_CLASS = 'dot'; const Y_AXIS_REF_LINE_CLASS = 'yRef'; const X_AXIS_REF_LINE_CLASS = 'xRef'; const DEFAULT_DOT_OPACITY = 1e-6; const LABEL_PADDING = 3; /** * Concrete line/area chart 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 * @mixes CoordinateGridMixin */ export class LineChart extends StackMixin { /** * Create a Line Chart. * @example * // create a line chart under #chart-container1 element using the default global chart group * var chart1 = new LineChart('#chart-container1'); * // create a line chart under #chart-container2 element using chart group A * var chart2 = new LineChart('#chart-container2', 'chartGroupA'); * // create a sub-chart under a composite parent chart * var chart3 = new LineChart(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 line * 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._renderArea = false; this._dotRadius = DEFAULT_DOT_RADIUS; this._dataPointRadius = null; this._dataPointFillOpacity = DEFAULT_DOT_OPACITY; this._dataPointStrokeOpacity = DEFAULT_DOT_OPACITY; this._curve = null; this._interpolate = null; // d3.curveLinear; // deprecated in 3.0 this._tension = null; // deprecated in 3.0 this._defined = undefined; this._dashStyle = undefined; this._xyTipsOn = true; this.transitionDuration(500); this.transitionDelay(0); this._rangeBandPadding(1); this.label(d => utils.printSingleValue(d.y0 + d.y), false); this.anchor(parent, chartGroup); } plotData () { const chartBody = this.chartBodyG(); let layersList = chartBody.select('g.stack-list'); if (layersList.empty()) { layersList = chartBody.append('g').attr('class', 'stack-list'); } let layers = layersList.selectAll('g.stack').data(this.data()); const layersEnter = layers .enter() .append('g') .attr('class', (d, i) => `stack _${i}`); layers = layersEnter.merge(layers); this._drawLine(layersEnter, layers); this._drawArea(layersEnter, layers); this._drawDots(chartBody, layers); if (this.renderLabel()) { this._drawLabels(layers); } } /** * Gets or sets the curve factory to use for lines and areas drawn, allowing e.g. step * functions, splines, and cubic interpolation. Typically you would use one of the interpolator functions * provided by {@link https://github.com/d3/d3-shape/blob/master/README.md#curves d3 curves}. * * Replaces the use of {@link LineChart#interpolate} and {@link LineChart#tension} * in dc.js < 3.0 * * This is passed to * {@link https://github.com/d3/d3-shape/blob/master/README.md#line_curve line.curve} and * {@link https://github.com/d3/d3-shape/blob/master/README.md#area_curve area.curve}. * @example * // default * chart * .curve(d3.curveLinear); * // Add tension to curves that support it * chart * .curve(d3.curveCardinal.tension(0.5)); * // You can use some specialized variation like * // https://en.wikipedia.org/wiki/Centripetal_Catmull%E2%80%93Rom_spline * chart * .curve(d3.curveCatmullRom.alpha(0.5)); * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#line_curve line.curve} * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#area_curve area.curve} * @param {d3.curve} [curve=d3.curveLinear] * @returns {d3.curve|LineChart} */ curve (curve) { if (!arguments.length) { return this._curve; } this._curve = curve; return this; } /** * Gets or sets the interpolator to use for lines drawn, by string name, allowing e.g. step * functions, splines, and cubic interpolation. * * Possible values are: 'linear', 'linear-closed', 'step', 'step-before', 'step-after', 'basis', * 'basis-open', 'basis-closed', 'bundle', 'cardinal', 'cardinal-open', 'cardinal-closed', and * 'monotone'. * * This function exists for backward compatibility. Use {@link LineChart#curve} * which is generic and provides more options. * Value set through `.curve` takes precedence over `.interpolate` and `.tension`. * @deprecated since version 3.0 use {@link LineChart#curve} instead * @see {@link LineChart#curve} * @param {d3.curve} [interpolate=d3.curveLinear] * @returns {d3.curve|LineChart} */ interpolate (interpolate) { logger.warnOnce('dc.lineChart.interpolate has been deprecated since version 3.0 use dc.lineChart.curve instead'); if (!arguments.length) { return this._interpolate; } this._interpolate = interpolate; return this; } /** * Gets or sets the tension to use for lines drawn, in the range 0 to 1. * * Passed to the {@link https://github.com/d3/d3-shape/blob/master/README.md#curves d3 curve function} * if it provides a `.tension` function. Example: * {@link https://github.com/d3/d3-shape/blob/master/README.md#curveCardinal_tension curveCardinal.tension}. * * This function exists for backward compatibility. Use {@link LineChart#curve} * which is generic and provides more options. * Value set through `.curve` takes precedence over `.interpolate` and `.tension`. * @deprecated since version 3.0 use {@link LineChart#curve} instead * @see {@link LineChart#curve} * @param {Number} [tension=0] * @returns {Number|LineChart} */ tension (tension) { logger.warnOnce('dc.lineChart.tension has been deprecated since version 3.0 use dc.lineChart.curve instead'); if (!arguments.length) { return this._tension; } this._tension = tension; return this; } /** * Gets or sets a function that will determine discontinuities in the line which should be * skipped: the path will be broken into separate subpaths if some points are undefined. * This function is passed to * {@link https://github.com/d3/d3-shape/blob/master/README.md#line_defined line.defined} * * Note: crossfilter will sometimes coerce nulls to 0, so you may need to carefully write * custom reduce functions to get this to work, depending on your data. See * {@link https://github.com/dc-js/dc.js/issues/615#issuecomment-49089248 this GitHub comment} * for more details and an example. * @see {@link https://github.com/d3/d3-shape/blob/master/README.md#line_defined line.defined} * @param {Function} [defined] * @returns {Function|LineChart} */ defined (defined) { if (!arguments.length) { return this._defined; } this._defined = defined; return this; } /** * Set the line's d3 dashstyle. This value becomes the 'stroke-dasharray' of line. Defaults to empty * array (solid line). * @see {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray stroke-dasharray} * @example * // create a Dash Dot Dot Dot * chart.dashStyle([3,1,1,1]); * @param {Array<Number>} [dashStyle=[]] * @returns {Array<Number>|LineChart} */ dashStyle (dashStyle) { if (!arguments.length) { return this._dashStyle; } this._dashStyle = dashStyle; return this; } /** * Get or set render area flag. If the flag is set to true then the chart will render the area * beneath each line and the line chart effectively becomes an area chart. * @param {Boolean} [renderArea=false] * @returns {Boolean|LineChart} */ renderArea (renderArea) { if (!arguments.length) { return this._renderArea; } this._renderArea = renderArea; return this; } _getColor (d, i) { return this.getColor.call(d, d.values, i); } // To keep it backward compatible, this covers multiple cases // See https://github.com/dc-js/dc.js/issues/1376 // It will be removed when interpolate and tension are removed. _getCurveFactory () { let curve = null; // _curve takes precedence if (this._curve) { return this._curve; } // Approximate the D3v3 behavior if (typeof this._interpolate === 'function') { curve = this._interpolate; } else { // If _interpolate is string const mapping = { 'linear': curveLinear, 'linear-closed': curveLinearClosed, 'step': curveStep, 'step-before': curveStepBefore, 'step-after': curveStepAfter, 'basis': curveBasis, 'basis-open': curveBasisOpen, 'basis-closed': curveBasisClosed, 'bundle': curveBundle, 'cardinal': curveCardinal, 'cardinal-open': curveCardinalOpen, 'cardinal-closed': curveCardinalClosed, 'monotone': curveMonotoneX }; curve = mapping[this._interpolate]; } // Default value if (!curve) { curve = curveLinear; } if (this._tension !== null) { if (typeof curve.tension !== 'function') { logger.warn('tension was specified but the curve/interpolate does not support it.'); } else { curve = curve.tension(this._tension); } } return curve; } _drawLine (layersEnter, layers) { const _line = line() .x(d => this.x()(d.x)) .y(d => this.y()(d.y + d.y0)) .curve(this._getCurveFactory()); if (this._defined) { _line.defined(this._defined); } const path = layersEnter.append('path') .attr('class', 'line') .attr('stroke', (d, i) => this._getColor(d, i)); if (this._dashStyle) { path.attr('stroke-dasharray', this._dashStyle); } transition(layers.select('path.line'), this.transitionDuration(), this.transitionDelay()) //.ease('linear') .attr('stroke', (d, i) => this._getColor(d, i)) .attr('d', d => this._safeD(_line(d.values))); } _drawArea (layersEnter, layers) { if (this._renderArea) { const _area = area() .x(d => this.x()(d.x)) .y1(d => this.y()(d.y + d.y0)) .y0(d => this.y()(d.y0)) .curve(this._getCurveFactory()); if (this._defined) { _area.defined(this._defined); } layersEnter.append('path') .attr('class', 'area') .attr('fill', (d, i) => this._getColor(d, i)) .attr('d', d => this._safeD(_area(d.values))); transition(layers.select('path.area'), this.transitionDuration(), this.transitionDelay()) //.ease('linear') .attr('fill', (d, i) => this._getColor(d, i)) .attr('d', d => this._safeD(_area(d.values))); } } _safeD (d) { return (!d || d.indexOf('NaN') >= 0) ? 'M0,0' : d; } _drawDots (chartBody, layers) { if (this.xyTipsOn() === 'always' || (!(this.brushOn() || this.parentBrushOn()) && this.xyTipsOn())) { const tooltipListClass = `${TOOLTIP_G_CLASS}-list`; let tooltips = chartBody.select(`g.${tooltipListClass}`); if (tooltips.empty()) { tooltips = chartBody.append('g').attr('class', tooltipListClass); } layers.each((data, layerIndex) => { let points = data.values; if (this._defined) { points = points.filter(this._defined); } let g = tooltips.select(`g.${TOOLTIP_G_CLASS}._${layerIndex}`); if (g.empty()) { g = tooltips.append('g').attr('class', `${TOOLTIP_G_CLASS} _${layerIndex}`); } this._createRefLines(g); const dots = g.selectAll(`circle.${DOT_CIRCLE_CLASS}`) .data(points, pluck('x')); const chart = this; const dotsEnterModify = dots .enter() .append('circle') .attr('class', DOT_CIRCLE_CLASS) .classed('dc-tabbable', this._keyboardAccessible) .attr('cx', d => utils.safeNumber(this.x()(d.x))) .attr('cy', d => utils.safeNumber(this.y()(d.y + d.y0))) .attr('r', this._getDotRadius()) .style('fill-opacity', this._dataPointFillOpacity) .style('stroke-opacity', this._dataPointStrokeOpacity) .attr('fill', this.getColor) .attr('stroke', this.getColor) .on('mousemove', function () { const dot = select(this); chart._showDot(dot); chart._showRefLines(dot, g); }) .on('mouseout', function () { const dot = select(this); chart._hideDot(dot); chart._hideRefLines(g); }) .merge(dots); // special case for on-focus for line chart and its dots if (this._keyboardAccessible) { this._svg.selectAll('.dc-tabbable') .attr('tabindex', 0) .on('focus', function () { const dot = select(this); chart._showDot(dot); chart._showRefLines(dot, g); }) .on('blur', function () { const dot = select(this); chart._hideDot(dot); chart._hideRefLines(g); }); } dotsEnterModify.call(dot => this._doRenderTitle(dot, data)); transition(dotsEnterModify, this.transitionDuration()) .attr('cx', d => utils.safeNumber(this.x()(d.x))) .attr('cy', d => utils.safeNumber(this.y()(d.y + d.y0))) .attr('fill', this.getColor); dots.exit().remove(); }); } } _drawLabels (layers) { const chart = this; layers.each(function (data, layerIndex) { const layer = select(this); const labels = layer.selectAll('text.lineLabel') .data(data.values, pluck('x')); const labelsEnterModify = labels .enter() .append('text') .attr('class', 'lineLabel') .attr('text-anchor', 'middle') .merge(labels); transition(labelsEnterModify, chart.transitionDuration()) .attr('x', d => utils.safeNumber(chart.x()(d.x))) .attr('y', d => { const y = chart.y()(d.y + d.y0) - LABEL_PADDING; return utils.safeNumber(y); }) .text(d => chart.label()(d)); transition(labels.exit(), chart.transitionDuration()) .attr('height', 0) .remove(); }); } _createRefLines (g) { const yRefLine = g.select(`path.${Y_AXIS_REF_LINE_CLASS}`).empty() ? g.append('path').attr('class', Y_AXIS_REF_LINE_CLASS) : g.select(`path.${Y_AXIS_REF_LINE_CLASS}`); yRefLine.style('display', 'none').attr('stroke-dasharray', '5,5'); const xRefLine = g.select(`path.${X_AXIS_REF_LINE_CLASS}`).empty() ? g.append('path').attr('class', X_AXIS_REF_LINE_CLASS) : g.select(`path.${X_AXIS_REF_LINE_CLASS}`); xRefLine.style('display', 'none').attr('stroke-dasharray', '5,5'); } _showDot (dot) { dot.style('fill-opacity', 0.8); dot.style('stroke-opacity', 0.8); dot.attr('r', this._dotRadius); return dot; } _showRefLines (dot, g) { const x = dot.attr('cx'); const y = dot.attr('cy'); const yAxisX = (this._yAxisX() - this.margins().left); const yAxisRefPathD = `M${yAxisX} ${y}L${x} ${y}`; const xAxisRefPathD = `M${x} ${this.yAxisHeight()}L${x} ${y}`; g.select(`path.${Y_AXIS_REF_LINE_CLASS}`).style('display', '').attr('d', yAxisRefPathD); g.select(`path.${X_AXIS_REF_LINE_CLASS}`).style('display', '').attr('d', xAxisRefPathD); } _getDotRadius () { return this._dataPointRadius || this._dotRadius; } _hideDot (dot) { dot.style('fill-opacity', this._dataPointFillOpacity) .style('stroke-opacity', this._dataPointStrokeOpacity) .attr('r', this._getDotRadius()); } _hideRefLines (g) { g.select(`path.${Y_AXIS_REF_LINE_CLASS}`).style('display', 'none'); g.select(`path.${X_AXIS_REF_LINE_CLASS}`).style('display', 'none'); } _doRenderTitle (dot, d) { if (this.renderTitle()) { dot.select('title').remove(); dot.append('title').text(pluck('data', this.title(d.name))); } } /** * Turn on/off the mouseover behavior of an individual data point which renders a circle and x/y axis * dashed lines back to each respective axis. This is ignored if the chart * {@link CoordinateGridMixin#brushOn brush} is on * @param {Boolean} [xyTipsOn=false] * @returns {Boolean|LineChart} */ xyTipsOn (xyTipsOn) { if (!arguments.length) { return this._xyTipsOn; } this._xyTipsOn = xyTipsOn; return this; } /** * Get or set the radius (in px) for dots displayed on the data points. * @param {Number} [dotRadius=5] * @returns {Number|LineChart} */ dotRadius (dotRadius) { if (!arguments.length) { return this._dotRadius; } this._dotRadius = dotRadius; return this; } /** * Always show individual dots for each datapoint. * * If `options` is falsy, it disables data point rendering. If no `options` are provided, the * current `options` values are instead returned. * @example * chart.renderDataPoints({radius: 2, fillOpacity: 0.8, strokeOpacity: 0.0}) * @param {{fillOpacity: Number, strokeOpacity: Number, radius: Number}} [options={fillOpacity: 0.8, strokeOpacity: 0.0, radius: 2}] * @returns {{fillOpacity: Number, strokeOpacity: Number, radius: Number}|LineChart} */ renderDataPoints (options) { if (!arguments.length) { return { fillOpacity: this._dataPointFillOpacity, strokeOpacity: this._dataPointStrokeOpacity, radius: this._dataPointRadius }; } else if (!options) { this._dataPointFillOpacity = DEFAULT_DOT_OPACITY; this._dataPointStrokeOpacity = DEFAULT_DOT_OPACITY; this._dataPointRadius = null; } else { this._dataPointFillOpacity = options.fillOpacity || 0.8; this._dataPointStrokeOpacity = options.strokeOpacity || 0.0; this._dataPointRadius = options.radius || 2; } return this; } _colorFilter (color, dashstyle, inv) { return function () { const item = select(this); const match = (item.attr('stroke') === color && item.attr('stroke-dasharray') === ((dashstyle instanceof Array) ? dashstyle.join(',') : null)) || item.attr('fill') === color; return inv ? !match : match; }; } legendHighlight (d) { if (!this.isLegendableHidden(d)) { this.g().selectAll('path.line, path.area') .classed('highlight', this._colorFilter(d.color, d.dashstyle)) .classed('fadeout', this._colorFilter(d.color, d.dashstyle, true)); } } legendReset () { this.g().selectAll('path.line, path.area') .classed('highlight', false) .classed('fadeout', false); } legendables () { const legendables = super.legendables(); if (!this._dashStyle) { return legendables; } return legendables.map(l => { l.dashstyle = this._dashStyle; return l; }); } } export const lineChart = (parent, chartGroup) => new LineChart(parent, chartGroup);