squiggle/src/components/charts/DistributionPlot/distPlotD3.js

509 lines
13 KiB
JavaScript
Raw Normal View History

2020-02-19 07:36:11 +00:00
const _ = require('lodash');
2020-02-18 11:29:51 +00:00
const d3 = require('d3');
const moment = require('moment');
2020-02-19 07:36:11 +00:00
export class CdfChartD3 {
2020-02-18 11:29:51 +00:00
constructor() {
this.attrs = {
svgWidth: 400,
svgHeight: 400,
marginTop: 5,
marginBottom: 5,
marginRight: 50,
marginLeft: 5,
2020-02-19 07:36:11 +00:00
container: null,
2020-02-18 11:29:51 +00:00
minX: false,
maxX: false,
scale: 'linear',
2020-02-19 07:36:11 +00:00
timeScale: null,
2020-02-18 11:29:51 +00:00
showDistributionLines: true,
2020-02-20 11:08:51 +00:00
showDistributionYAxis: false,
2020-02-18 11:29:51 +00:00
areaColors: ['#E1E5EC', '#E1E5EC'],
logBase: 10,
verticalLine: 110,
showVerticalLine: true,
2020-02-20 11:08:51 +00:00
data: {
2020-02-20 13:59:29 +00:00
continuous: null,
2020-02-20 11:08:51 +00:00
discrete: null,
},
2020-02-18 11:29:51 +00:00
onHover: (e) => {
},
};
2020-02-20 10:15:48 +00:00
2020-02-20 07:02:39 +00:00
this.calc = {
chartLeftMargin: null,
chartTopMargin: null,
chartWidth: null,
chartHeight: null,
};
2020-02-20 06:11:01 +00:00
this.chart = null;
this.svg = null;
this._container = null;
2020-02-18 11:58:34 +00:00
this.formatDates = this.formatDates.bind(this);
2020-02-18 11:29:51 +00:00
}
svgWidth(svgWidth) {
this.attrs.svgWidth = svgWidth;
return this;
}
svgHeight(height) {
this.attrs.svgHeight = height;
return this;
}
maxX(maxX) {
this.attrs.maxX = maxX;
return this;
}
minX(minX) {
this.attrs.minX = minX;
return this;
}
2020-02-18 12:11:22 +00:00
scale(scale) {
this.attrs.scale = scale;
return this;
}
2020-02-19 07:36:11 +00:00
timeScale(timeScale) {
this.attrs.timeScale = timeScale;
return this;
}
2020-02-18 11:29:51 +00:00
onHover(onHover) {
this.attrs.onHover = onHover;
return this;
}
marginBottom(marginBottom) {
this.attrs.marginBottom = marginBottom;
return this;
}
marginLeft(marginLeft) {
this.attrs.marginLeft = marginLeft;
return this;
}
marginRight(marginRight) {
this.attrs.marginRight = marginRight;
return this;
}
marginTop(marginTop) {
this.attrs.marginTop = marginTop;
return this;
}
showDistributionLines(showDistributionLines) {
this.attrs.showDistributionLines = showDistributionLines;
return this;
}
2020-02-20 11:08:51 +00:00
showDistributionYAxis(showDistributionYAxis) {
this.attrs.showDistributionYAxis = showDistributionYAxis;
return this;
}
2020-02-18 11:29:51 +00:00
verticalLine(verticalLine) {
this.attrs.verticalLine = verticalLine;
return this;
}
showVerticalLine(showVerticalLine) {
this.attrs.showVerticalLine = showVerticalLine;
return this;
}
container(container) {
this.attrs.container = container;
return this;
}
data(data) {
this.attrs.data = data;
this.attrs.data.continuous = data.continuous || {
xs: [],
ys: [],
};
this.attrs.data.discrete = data.discrete || {
xs: [],
ys: [],
};
2020-02-18 11:29:51 +00:00
return this;
}
2020-02-18 11:29:51 +00:00
render() {
2020-02-20 06:11:01 +00:00
this._container = d3.select(this.attrs.container);
if (this._container.node() === null) {
2020-02-19 08:42:54 +00:00
console.error('Container for D3 is not defined.');
return;
}
2020-02-19 07:36:11 +00:00
// Sets the width from the DOM element.
2020-02-20 06:11:01 +00:00
const containerRect = this._container.node().getBoundingClientRect();
if (containerRect.width > 0) {
2020-02-20 06:11:01 +00:00
this.attrs.svgWidth = containerRect.width;
}
// Calculated properties.
2020-02-20 06:11:01 +00:00
this.calc.chartLeftMargin = this.attrs.marginLeft;
this.calc.chartTopMargin = this.attrs.marginTop;
this.calc.chartWidth = this.attrs.svgWidth
- this.attrs.marginRight
- this.attrs.marginLeft;
this.calc.chartHeight = this.attrs.svgHeight
- this.attrs.marginBottom
- this.attrs.marginTop;
2020-02-20 06:11:01 +00:00
// Add svg.
this.svg = this._container
.createObject({ tag: 'svg', selector: 'svg-chart-container' })
.attr('width', "100%")
.attr('height', this.attrs.svgHeight)
.attr('pointer-events', 'none');
// Add container "g" (empty) element.
2020-02-20 06:11:01 +00:00
this.chart = this.svg
.createObject({ tag: 'g', selector: 'chart' })
.attr(
'transform',
`translate(${this.calc.chartLeftMargin}, ${this.calc.chartTopMargin})`,
2020-02-20 06:11:01 +00:00
);
if (this.hasDate('continuous')) {
2020-02-20 10:43:39 +00:00
const distributionChart = this.addDistributionChart();
if (this.hasDate('discrete')) {
2020-02-20 10:43:39 +00:00
this.addLollipopsChart(distributionChart);
}
}
2020-02-20 09:34:34 +00:00
return this;
}
2020-02-20 09:34:34 +00:00
addDistributionChart() {
2020-02-20 06:11:01 +00:00
const areaColorRange = d3.scaleOrdinal().range(this.attrs.areaColors);
2020-02-20 13:59:29 +00:00
const dataPoints = [this.getDataPoints('continuous')];
2020-02-20 10:15:48 +00:00
// Boundaries.
2020-02-21 05:05:41 +00:00
const xMin = this.attrs.minX || d3.min(this.attrs.data.continuous.xs) || d3.min(this.attrs.data.discrete.xs);
const xMax = this.attrs.maxX || d3.max(this.attrs.data.continuous.xs) || d3.max(this.attrs.data.discrete.xs);
2020-02-20 13:59:29 +00:00
const yMin = d3.min(this.attrs.data.continuous.ys);
const yMax = d3.max(this.attrs.data.continuous.ys);
// X-domains.
const xMinDomain = xMin;
const xMaxDomain = xMax;
// X-scale.
let xScale = this.attrs.scale === 'linear'
? d3.scaleLinear()
.domain([xMinDomain, xMaxDomain])
.range([0, this.calc.chartWidth])
: d3.scaleLog()
2020-02-20 06:11:01 +00:00
.base(this.attrs.logBase)
.domain([xMinDomain, xMaxDomain])
2020-02-20 06:11:01 +00:00
.range([0, this.calc.chartWidth]);
// Y-scale.
2020-02-20 10:33:21 +00:00
const yScale = d3.scaleLinear()
2020-02-18 09:31:47 +00:00
.domain([yMin, yMax])
2020-02-20 06:11:01 +00:00
.range([this.calc.chartHeight, 0]);
// X-axis.
2020-02-20 10:15:48 +00:00
let xAxis = null;
2020-02-19 07:36:11 +00:00
if (!!this.attrs.timeScale) {
// Calculates the projection on X-axis.
const zero = _.get(this.attrs, 'timeScale.zero', moment());
const unit = _.get(this.attrs, 'timeScale.unit', 'years');
2020-02-19 12:06:41 +00:00
const diff = Math.abs(xMax - xMin);
const left = zero.clone().add(xMin, unit);
const right = left.clone().add(diff, unit);
2020-02-19 07:36:11 +00:00
// X-time-scale.
2020-02-19 08:33:50 +00:00
const xScaleTime = d3.scaleTime()
2020-02-19 12:06:41 +00:00
.domain([left.toDate(), right.toDate()])
2020-02-19 08:33:50 +00:00
.nice()
2020-02-20 06:11:01 +00:00
.range([0, this.calc.chartWidth]);
2020-02-19 07:36:11 +00:00
2020-02-20 10:15:48 +00:00
xAxis = d3.axisBottom()
2020-02-19 08:33:50 +00:00
.scale(xScaleTime)
2020-02-19 12:06:41 +00:00
.ticks(this.getTimeTicksByStr(unit))
2020-02-18 11:58:34 +00:00
.tickFormat(this.formatDates);
} else {
2020-02-20 10:33:21 +00:00
xAxis = d3.axisBottom(xScale)
2020-02-18 11:58:34 +00:00
.ticks(3)
.tickFormat(d => {
if (Math.abs(d) < 1) {
return d3.format(".2")(d);
} else if (xMin > 1000 && xMax < 3000) {
// Condition which identifies years; 2019, 2020, 2021.
return d3.format(".0")(d);
} else {
const prefix = d3.formatPrefix(".0", d);
return prefix(d).replace("G", "B");
}
});
}
// Y-axis.
2020-02-20 10:33:21 +00:00
const yAxis = d3.axisRight(yScale);
2020-02-20 09:31:10 +00:00
2020-02-19 08:39:57 +00:00
// Objects.
2020-02-18 09:31:47 +00:00
const line = d3.line()
2020-02-20 10:33:21 +00:00
.x(d => xScale(d.x))
.y(d => yScale(d.y));
2020-02-18 09:31:47 +00:00
const area = d3.area()
2020-02-20 10:33:21 +00:00
.x(d => xScale(d.x))
.y1(d => yScale(d.y))
2020-02-20 06:11:01 +00:00
.y0(this.calc.chartHeight);
// Add axis.
this.chart
.createObject({ tag: 'g', selector: 'x-axis' })
.attr('transform', `translate(0, ${this.calc.chartHeight})`)
2020-02-20 10:15:48 +00:00
.call(xAxis);
2020-02-20 11:08:51 +00:00
if (this.attrs.showDistributionYAxis) {
this.chart
.createObject({ tag: 'g', selector: 'y-axis' })
2020-02-20 11:08:51 +00:00
.call(yAxis);
}
2020-02-20 09:31:10 +00:00
// Draw area.
2020-02-18 11:29:51 +00:00
this.chart
2020-02-19 09:53:43 +00:00
.createObjectsWithData({
tag: 'path',
selector: 'area-path',
2020-02-20 10:15:48 +00:00
data: dataPoints,
})
.attr('d', area)
2020-02-19 08:39:57 +00:00
.attr('fill', (d, i) => areaColorRange(i))
2020-02-18 09:31:47 +00:00
.attr('opacity', (d, i) => i === 0 ? 0.7 : 0.5);
// Draw line.
2020-02-20 06:11:01 +00:00
if (this.attrs.showDistributionLines) {
2020-02-18 11:29:51 +00:00
this.chart
2020-02-19 09:53:43 +00:00
.createObjectsWithData({
tag: 'path',
selector: 'line-path',
2020-02-20 10:15:48 +00:00
data: dataPoints,
})
.attr('d', line)
.attr('id', (d, i) => 'line-' + (i + 1))
2020-02-18 11:58:34 +00:00
.attr('opacity', (d, i) => i === 0 ? 0.7 : 1)
.attr('fill', 'none');
}
2020-02-20 06:11:01 +00:00
if (this.attrs.showVerticalLine) {
2020-02-18 11:29:51 +00:00
this.chart
2020-02-19 09:24:38 +00:00
.createObject({ tag: 'line', selector: 'v-line' })
2020-02-20 10:33:21 +00:00
.attr('x1', xScale(this.attrs.verticalLine))
.attr('x2', xScale(this.attrs.verticalLine))
.attr('y1', 0)
2020-02-20 06:11:01 +00:00
.attr('y2', this.calc.chartHeight)
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', '6 6')
.attr('stroke', 'steelblue');
}
2020-02-20 10:15:48 +00:00
const hoverLine = this.chart
2020-02-19 09:24:38 +00:00
.createObject({ tag: 'line', selector: 'hover-line' })
.attr('x1', 0)
.attr('x2', 0)
.attr('y1', 0)
2020-02-20 06:11:01 +00:00
.attr('y2', this.calc.chartHeight)
.attr('opacity', 0)
.attr('stroke-width', 1.5)
.attr('stroke-dasharray', '6 6')
.attr('stroke', '#22313F');
// Add drawing rectangle.
2020-02-20 10:15:48 +00:00
{
const context = this;
function mouseover() {
const mouse = d3.mouse(this);
hoverLine
.attr('opacity', 1)
.attr('x1', mouse[0])
.attr('x2', mouse[0]);
const xValue = xScale.invert(mouse[0]);
2020-02-20 10:15:48 +00:00
context.attrs.onHover(xValue);
}
function mouseout() {
hoverLine.attr('opacity', 0);
2020-02-20 10:15:48 +00:00
}
this.chart
.createObject({ tag: 'rect', selector: 'mouse-rect' })
.attr('width', this.calc.chartWidth)
.attr('height', this.calc.chartHeight)
.attr('fill', 'transparent')
.attr('pointer-events', 'all')
.on('mouseover', mouseover)
.on('mousemove', mouseover)
.on('mouseout', mouseout);
}
2020-02-20 10:33:21 +00:00
return { xScale, yScale };
2020-02-20 09:34:34 +00:00
}
/**
* @param {object} distributionChart
* @param {object} distributionChart.xScale
* @param {object} distributionChart.yScale
*/
2020-02-20 10:33:21 +00:00
addLollipopsChart(distributionChart) {
const data = this.getDataPoints('discrete');
2020-02-20 09:34:34 +00:00
const _yMin = d3.min(this.attrs.data.discrete.ys);
const yMax = d3.max(this.attrs.data.discrete.ys);
// X axis.
this.chart.append('g')
.attr('class', 'lollipops-x-axis')
.attr('transform', `translate(0, ${this.calc.chartHeight})`)
2020-02-20 10:33:21 +00:00
.call(d3.axisBottom(distributionChart.xScale));
2020-02-20 09:34:34 +00:00
// Y-domain.
const yMinDomain = 0;
const yMaxDomain = yMax * 2;
// Y-scale.
2020-02-20 10:33:43 +00:00
const yScale = d3.scaleLinear()
.domain([yMinDomain, yMaxDomain])
2020-02-20 09:34:34 +00:00
.range([this.calc.chartHeight, 0]);
// Adds "g" for an y-axis.
this.chart.append('g')
.attr('class', 'lollipops-y-axis')
.attr('transform', `translate(${this.calc.chartWidth}, 0)`)
2020-02-20 10:33:43 +00:00
.call(d3.axisLeft(yScale));
2020-02-20 09:34:34 +00:00
// Lines.
this.chart.selectAll('lollipops-line')
2020-02-20 09:34:34 +00:00
.data(data)
.enter()
.append('line')
.attr('class', 'lollipops-line')
.attr('x1', d => distributionChart.xScale(d.x))
.attr('x2', d => distributionChart.xScale(d.x))
.attr('y1', d => yScale(d.y))
.attr('y2', yScale(0));
// Circles.
this.chart.selectAll('lollipops-circle')
2020-02-20 09:34:34 +00:00
.data(data)
.enter()
.append('circle')
.attr('class', 'lollipops-circle')
.attr('cx', d => distributionChart.xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', '4');
2020-02-18 11:29:51 +00:00
}
/**
* @param ts
* @returns {string}
*/
2020-02-18 11:58:34 +00:00
formatDates(ts) {
2020-02-19 08:33:50 +00:00
return moment(ts).format("MMMM Do YYYY");
}
/**
2020-02-19 12:06:41 +00:00
* @param {string} unit
2020-02-19 08:33:50 +00:00
* @returns {*}
*/
2020-02-19 12:06:41 +00:00
getTimeTicksByStr(unit) {
switch (unit) {
2020-02-19 08:33:50 +00:00
case "months":
2020-02-20 10:57:01 +00:00
return d3.timeMonth.every(4);
2020-02-19 08:33:50 +00:00
case "quarters":
return d3.timeMonth.every(3);
case "hours":
2020-02-20 10:57:01 +00:00
return d3.timeHour.every(10);
2020-02-19 08:33:50 +00:00
case "days":
2020-02-20 10:57:01 +00:00
return d3.timeDay.every(7);
2020-02-19 08:33:50 +00:00
case "seconds":
2020-02-20 10:57:01 +00:00
return d3.timeSecond.every(10);
2020-02-19 08:33:50 +00:00
case "years":
2020-02-20 10:57:01 +00:00
return d3.timeYear.every(10);
2020-02-19 08:33:50 +00:00
case "minutes":
2020-02-20 10:57:01 +00:00
return d3.timeMinute.every(10);
2020-02-19 08:33:50 +00:00
case "weeks":
2020-02-20 10:57:01 +00:00
return d3.timeWeek.every(10);
2020-02-19 08:33:50 +00:00
case "milliseconds":
2020-02-20 10:57:01 +00:00
return d3.timeMillisecond.every(10);
2020-02-19 08:33:50 +00:00
default:
2020-02-20 10:57:01 +00:00
return d3.timeYear.every(10);
2020-02-19 08:33:50 +00:00
}
2020-02-18 11:58:34 +00:00
}
2020-02-20 06:11:01 +00:00
/**
2020-02-20 10:43:39 +00:00
* @param {name} key
* @returns {{x: number[], y: number[]}}
2020-02-20 06:11:01 +00:00
*/
getDataPoints(key) {
const dt = [];
2020-02-20 10:33:21 +00:00
const emptyShape = { xs: [], ys: [] };
const data = _.get(this.attrs.data, key, emptyShape);
2020-02-20 06:11:01 +00:00
const len = data.xs.length;
for (let i = 0; i < len; i++) {
dt.push({ x: data.xs[i], y: data.ys[i] });
}
return dt;
}
2020-02-20 10:43:39 +00:00
/**
* @param {string} key
* @returns {boolean}
*/
hasDate(key) {
const xs = _.get(this.attrs, ['data', key, 'xs']);
return !!_.size(xs);
2020-02-20 10:43:39 +00:00
}
2020-02-18 11:29:51 +00:00
}
2020-02-19 08:39:57 +00:00
/**
2020-02-19 09:24:38 +00:00
* @docs: https://github.com/d3/d3-selection
2020-02-20 10:43:39 +00:00
* @param {object} params
* @param {string} params.selector
* @param {string} params.tag
2020-02-19 08:39:57 +00:00
* @returns {*}
*/
2020-02-19 09:24:38 +00:00
d3.selection.prototype.createObject = function createObject(params) {
2020-02-19 05:20:54 +00:00
const selector = params.selector;
2020-02-19 09:53:43 +00:00
const tag = params.tag;
return this.insert(tag).attr('class', selector);
2020-02-19 09:24:38 +00:00
};
2020-02-19 05:20:54 +00:00
2020-02-19 09:24:38 +00:00
/**
* @docs: https://github.com/d3/d3-selection
2020-02-20 10:43:39 +00:00
* @param {object} params
* @param {string} params.selector
* @param {string} params.tag
* @param {*[]} params.data
2020-02-19 09:24:38 +00:00
* @returns {*}
*/
2020-02-19 09:53:43 +00:00
d3.selection.prototype.createObjectsWithData = function createObjectsWithData(params) {
2020-02-19 09:24:38 +00:00
const selector = params.selector;
2020-02-19 09:53:43 +00:00
const tag = params.tag;
2020-02-19 09:24:38 +00:00
const data = params.data;
2020-02-19 05:20:54 +00:00
2020-02-19 09:24:38 +00:00
return this.selectAll('.' + selector)
.data(data)
2020-02-19 05:20:54 +00:00
.enter()
2020-02-19 09:53:43 +00:00
.insert(tag)
2020-02-19 05:20:54 +00:00
.attr('class', selector);
};