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);