Merge pull request #3 from foretold-app/feature/1081

Lollipops charts
This commit is contained in:
Ozzie Gooen 2020-02-20 11:17:07 +00:00 committed by GitHub
commit ccf40c1639
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 270 additions and 154 deletions

View File

@ -1,11 +1,19 @@
[@bs.module "./cdfChartReact.js"] [@bs.module "./cdfChartReact.js"]
external cdfChart: ReasonReact.reactClass = "default"; external cdfChart: ReasonReact.reactClass = "default";
type primaryDistribution = { type primaryDistribution =
. option({
"xs": array(float), .
"ys": array(float), "xs": array(float),
}; "ys": array(float),
});
type discrete =
option({
.
"xs": array(float),
"ys": array(float),
});
[@react.component] [@react.component]
let make = let make =
@ -17,8 +25,10 @@ let make =
~minX=?, ~minX=?,
~onHover=(f: float) => (), ~onHover=(f: float) => (),
~primaryDistribution=?, ~primaryDistribution=?,
~discrete=?,
~scale=?, ~scale=?,
~showDistributionLines=?, ~showDistributionLines=?,
~showDistributionYAxis=?,
~showVerticalLine=?, ~showVerticalLine=?,
~timeScale=?, ~timeScale=?,
~verticalLine=?, ~verticalLine=?,
@ -35,8 +45,10 @@ let make =
~minX?, ~minX?,
~onHover, ~onHover,
~primaryDistribution?, ~primaryDistribution?,
~discrete?,
~scale?, ~scale?,
~showDistributionLines?, ~showDistributionLines?,
~showDistributionYAxis?,
~showVerticalLine?, ~showVerticalLine?,
~timeScale?, ~timeScale?,
~verticalLine?, ~verticalLine?,

View File

@ -7,11 +7,19 @@ module Styles = {
let graph = chartColor => let graph = chartColor =>
style([ style([
position(`relative), position(`relative),
selector(".axis", [fontSize(`px(9))]), selector(".x-axis", [fontSize(`px(9))]),
selector(".domain", [display(`none)]), selector(".x-axis .domain", [display(`none)]),
selector(".tick line", [display(`none)]), selector(".x-axis .tick line", [display(`none)]),
selector(".tick text", [color(`hex("bfcad4"))]), selector(".x-axis .tick text", [color(`hex("bfcad4"))]),
selector(".chart .area-path", [SVG.fill(chartColor)]), selector(".chart .area-path", [SVG.fill(chartColor)]),
selector(".lollipops-line", [SVG.stroke(`hex("bfcad4"))]),
selector(
".lollipops-circle",
[SVG.stroke(`hex("bfcad4")), SVG.fill(`hex("bfcad4"))],
),
selector(".lollipops-x-axis .domain", [display(`none)]),
selector(".lollipops-x-axis .tick line", [display(`none)]),
selector(".lollipops-x-axis .tick text", [display(`none)]),
]); ]);
}; };
@ -19,12 +27,16 @@ module Styles = {
let make = let make =
( (
~color=`hex("111"), ~color=`hex("111"),
~data, ~discrete=?,
~height=200, ~height=200,
~maxX=?, ~maxX=?,
~minX=?, ~minX=?,
~onHover: float => unit, ~onHover: float => unit,
~primaryDistribution=?,
~scale=?, ~scale=?,
~showDistributionLines=false,
~showDistributionYAxis=false,
~showVerticalLine=false,
~timeScale=?, ~timeScale=?,
) => { ) => {
<div className={Styles.graph(color)}> <div className={Styles.graph(color)}>
@ -33,13 +45,17 @@ let make =
?minX ?minX
?scale ?scale
?timeScale ?timeScale
discrete={discrete |> E.O.fmap(d => d |> Shape.Discrete.toJs)}
height height
marginBottom=50 marginBottom=50
marginTop=0 marginTop=0
onHover onHover
primaryDistribution={data |> Shape.XYShape.toJs} primaryDistribution={
showDistributionLines=false primaryDistribution |> E.O.fmap(pd => pd |> Shape.XYShape.toJs)
showVerticalLine=false }
showDistributionLines
showDistributionYAxis
showVerticalLine
/> />
</div>; </div>;
}; };

View File

@ -7,10 +7,12 @@ module Mixed = {
React.useMemo1( React.useMemo1(
() => () =>
<CdfChart__Plain <CdfChart__Plain
data={data.continuous} primaryDistribution={data.continuous}
discrete={data.discrete}
color={`hex("333")} color={`hex("333")}
timeScale timeScale
onHover={r => setX(_ => r)} onHover={r => setX(_ => r)}
showDistributionYAxis=true
/>, />,
[|data|], [|data|],
); );
@ -68,7 +70,7 @@ module Cont = {
React.useMemo1( React.useMemo1(
() => () =>
<CdfChart__Plain <CdfChart__Plain
data=continuous primaryDistribution=continuous
color={`hex("333")} color={`hex("333")}
onHover onHover
timeScale timeScale

View File

@ -20,19 +20,30 @@ export class CdfChartD3 {
scale: 'linear', scale: 'linear',
timeScale: null, timeScale: null,
showDistributionLines: true, showDistributionLines: true,
showDistributionYAxis: false,
areaColors: ['#E1E5EC', '#E1E5EC'], areaColors: ['#E1E5EC', '#E1E5EC'],
logBase: 10, logBase: 10,
verticalLine: 110, verticalLine: 110,
showVerticalLine: true, showVerticalLine: true,
data: null, data: {
primary: null,
discrete: null,
},
onHover: (e) => { onHover: (e) => {
}, },
}; };
this.hoverLine = null;
this.xScale = null; this.calc = {
this.dataPoints = null; chartLeftMargin: null,
this.mouseover = this.mouseover.bind(this); chartTopMargin: null,
this.mouseout = this.mouseout.bind(this); chartWidth: null,
chartHeight: null,
};
this.chart = null;
this.svg = null;
this._container = null;
this.formatDates = this.formatDates.bind(this); this.formatDates = this.formatDates.bind(this);
} }
@ -96,6 +107,11 @@ export class CdfChartD3 {
return this; return this;
} }
showDistributionYAxis(showDistributionYAxis) {
this.attrs.showDistributionYAxis = showDistributionYAxis;
return this;
}
verticalLine(verticalLine) { verticalLine(verticalLine) {
this.attrs.verticalLine = verticalLine; this.attrs.verticalLine = verticalLine;
return this; return this;
@ -116,69 +132,77 @@ export class CdfChartD3 {
return this; return this;
} }
/**
* @param key
* @returns {[]}
*/
getDataPoints(key) {
const dt = [];
const data = this.attrs.data[key];
const len = data.xs.length;
for (let i = 0; i < len; i++) {
dt.push({ x: data.xs[i], y: data.ys[i] });
}
return dt;
}
render() { render() {
const attrs = this.attrs; this._container = d3.select(this.attrs.container);
const container = d3.select(attrs.container); if (this._container.node() === null) {
if (container.node() === null) {
console.error('Container for D3 is not defined.'); console.error('Container for D3 is not defined.');
return; return;
} }
// Sets the width from the DOM element. // Sets the width from the DOM element.
const containerRect = container.node().getBoundingClientRect(); const containerRect = this._container.node().getBoundingClientRect();
if (containerRect.width > 0) { if (containerRect.width > 0) {
attrs.svgWidth = containerRect.width; this.attrs.svgWidth = containerRect.width;
} }
// Calculated properties. // Calculated properties.
const calc = {}; this.calc.chartLeftMargin = this.attrs.marginLeft;
calc.chartLeftMargin = attrs.marginLeft; this.calc.chartTopMargin = this.attrs.marginTop;
calc.chartTopMargin = attrs.marginTop; this.calc.chartWidth = this.attrs.svgWidth - this.attrs.marginRight - this.attrs.marginLeft;
calc.chartWidth = attrs.svgWidth - attrs.marginRight - attrs.marginLeft; this.calc.chartHeight = this.attrs.svgHeight - this.attrs.marginBottom - this.attrs.marginTop;
calc.chartHeight = attrs.svgHeight - attrs.marginBottom - attrs.marginTop;
const areaColorRange = d3.scaleOrdinal().range(attrs.areaColors); // Add svg.
this.dataPoints = [this.getDataPoints('primary')]; 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 element.
this.chart = this.svg
.createObject({ tag: 'g', selector: 'chart' })
.attr(
'transform',
'translate(' + this.calc.chartLeftMargin + ',' + this.calc.chartTopMargin + ')',
);
if(this.hasDate('primary')){
const distributionChart = this.addDistributionChart();
if(this.hasDate('discrete')) {
this.addLollipopsChart(distributionChart);
}
}
return this;
}
addDistributionChart() {
const areaColorRange = d3.scaleOrdinal().range(this.attrs.areaColors);
const dataPoints = [this.getDataPoints('primary')];
// Boundaries.
const xMin = this.attrs.minX || d3.min(this.attrs.data.primary.xs);
const xMax = this.attrs.maxX || d3.max(this.attrs.data.primary.xs);
const yMin = d3.min(this.attrs.data.primary.ys);
const yMax = d3.max(this.attrs.data.primary.ys);
// Scales. // Scales.
const xMin = d3.min(attrs.data.primary.xs); let xScale = null;
const xMax = d3.max(attrs.data.primary.xs); if (this.attrs.scale === 'linear') {
xScale = d3.scaleLinear()
if (attrs.scale === 'linear') { .domain([xMin, xMax])
this.xScale = d3.scaleLinear() .range([0, this.calc.chartWidth]);
.domain([attrs.minX || xMin, attrs.maxX || xMax])
.range([0, calc.chartWidth]);
} else { } else {
this.xScale = d3.scaleLog() xScale = d3.scaleLog()
.base(attrs.logBase) .base(this.attrs.logBase)
.domain([attrs.minX, attrs.maxX]) .domain([xMin, xMax])
.range([0, calc.chartWidth]); .range([0, this.calc.chartWidth]);
} }
const yMin = d3.min(attrs.data.primary.ys); const yScale = d3.scaleLinear()
const yMax = d3.max(attrs.data.primary.ys);
this.yScale = d3.scaleLinear()
.domain([yMin, yMax]) .domain([yMin, yMax])
.range([calc.chartHeight, 0]); .range([this.calc.chartHeight, 0]);
// Axis generator. // Axis generator.
let xAxis = null;
if (!!this.attrs.timeScale) { if (!!this.attrs.timeScale) {
const zero = _.get(this.attrs.timeScale, 'zero', moment()); const zero = _.get(this.attrs.timeScale, 'zero', moment());
const unit = _.get(this.attrs.timeScale, 'unit', 'years'); const unit = _.get(this.attrs.timeScale, 'unit', 'years');
@ -189,14 +213,14 @@ export class CdfChartD3 {
const xScaleTime = d3.scaleTime() const xScaleTime = d3.scaleTime()
.domain([left.toDate(), right.toDate()]) .domain([left.toDate(), right.toDate()])
.nice() .nice()
.range([0, calc.chartWidth]); .range([0, this.calc.chartWidth]);
this.xAxis = d3.axisBottom() xAxis = d3.axisBottom()
.scale(xScaleTime) .scale(xScaleTime)
.ticks(this.getTimeTicksByStr(unit)) .ticks(this.getTimeTicksByStr(unit))
.tickFormat(this.formatDates); .tickFormat(this.formatDates);
} else { } else {
this.xAxis = d3.axisBottom(this.xScale) xAxis = d3.axisBottom(xScale)
.ticks(3) .ticks(3)
.tickFormat(d => { .tickFormat(d => {
if (Math.abs(d) < 1) { if (Math.abs(d) < 1) {
@ -211,54 +235,46 @@ export class CdfChartD3 {
}); });
} }
const yAxis = d3.axisRight(yScale);
// Objects. // Objects.
const line = d3.line() const line = d3.line()
.x(d => this.xScale(d.x)) .x(d => xScale(d.x))
.y(d => this.yScale(d.y)); .y(d => yScale(d.y));
const area = d3.area() const area = d3.area()
.x(d => this.xScale(d.x)) .x(d => xScale(d.x))
.y1(d => this.yScale(d.y)) .y1(d => yScale(d.y))
.y0(calc.chartHeight); .y0(this.calc.chartHeight);
// Add svg.
const svg = container
.createObject({ tag: 'svg', selector: 'svg-chart-container' })
.attr('width', "100%")
.attr('height', attrs.svgHeight)
.attr('pointer-events', 'none');
// Add container g element.
this.chart = svg
.createObject({ tag: 'g', selector: 'chart' })
.attr(
'transform',
'translate(' + calc.chartLeftMargin + ',' + calc.chartTopMargin + ')',
);
// Add axis. // Add axis.
this.chart.createObject({ tag: 'g', selector: 'axis' }) this.chart.createObject({ tag: 'g', selector: 'x-axis' })
.attr('transform', 'translate(' + 0 + ',' + calc.chartHeight + ')') .attr('transform', 'translate(0,' + this.calc.chartHeight + ')')
.call(this.xAxis); .call(xAxis);
if (this.attrs.showDistributionYAxis) {
this.chart.createObject({ tag: 'g', selector: 'y-axis' })
.call(yAxis);
}
// Draw area. // Draw area.
this.chart this.chart
.createObjectsWithData({ .createObjectsWithData({
tag: 'path', tag: 'path',
selector: 'area-path', selector: 'area-path',
data: this.dataPoints, data: dataPoints,
}) })
.attr('d', area) .attr('d', area)
.attr('fill', (d, i) => areaColorRange(i)) .attr('fill', (d, i) => areaColorRange(i))
.attr('opacity', (d, i) => i === 0 ? 0.7 : 0.5); .attr('opacity', (d, i) => i === 0 ? 0.7 : 0.5);
// Draw line. // Draw line.
if (attrs.showDistributionLines) { if (this.attrs.showDistributionLines) {
this.chart this.chart
.createObjectsWithData({ .createObjectsWithData({
tag: 'path', tag: 'path',
selector: 'line-path', selector: 'line-path',
data: this.dataPoints, data: dataPoints,
}) })
.attr('d', line) .attr('d', line)
.attr('id', (d, i) => 'line-' + (i + 1)) .attr('id', (d, i) => 'line-' + (i + 1))
@ -266,74 +282,109 @@ export class CdfChartD3 {
.attr('fill', 'none'); .attr('fill', 'none');
} }
if (attrs.showVerticalLine) { if (this.attrs.showVerticalLine) {
this.chart this.chart
.createObject({ tag: 'line', selector: 'v-line' }) .createObject({ tag: 'line', selector: 'v-line' })
.attr('x1', this.xScale(attrs.verticalLine)) .attr('x1', xScale(this.attrs.verticalLine))
.attr('x2', this.xScale(attrs.verticalLine)) .attr('x2', xScale(this.attrs.verticalLine))
.attr('y1', 0) .attr('y1', 0)
.attr('y2', calc.chartHeight) .attr('y2', this.calc.chartHeight)
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
.attr('stroke-dasharray', '6 6') .attr('stroke-dasharray', '6 6')
.attr('stroke', 'steelblue'); .attr('stroke', 'steelblue');
} }
this.hoverLine = this.chart const hoverLine = this.chart
.createObject({ tag: 'line', selector: 'hover-line' }) .createObject({ tag: 'line', selector: 'hover-line' })
.attr('x1', 0) .attr('x1', 0)
.attr('x2', 0) .attr('x2', 0)
.attr('y1', 0) .attr('y1', 0)
.attr('y2', calc.chartHeight) .attr('y2', this.calc.chartHeight)
.attr('opacity', 0) .attr('opacity', 0)
.attr('stroke-width', 1.5) .attr('stroke-width', 1.5)
.attr('stroke-dasharray', '6 6') .attr('stroke-dasharray', '6 6')
.attr('stroke', '#22313F'); .attr('stroke', '#22313F');
// Add drawing rectangle. // Add drawing rectangle.
const thi$ = this; {
this.chart const context = this;
.createObject({ tag: 'rect', selector: 'mouse-rect' }) const range = [
.attr('width', calc.chartWidth) xScale(dataPoints[dataPoints.length - 1][0].x),
.attr('height', calc.chartHeight) xScale(
.attr('fill', 'transparent') dataPoints
.attr('pointer-events', 'all') [dataPoints.length - 1]
.on('mouseover', function () { [dataPoints[dataPoints.length - 1].length - 1].x,
thi$.mouseover(this); ),
}) ];
.on('mousemove', function () {
thi$.mouseover(this);
})
.on('mouseout', this.mouseout);
return this; function mouseover() {
} const mouse = d3.mouse(this);
hoverLine.attr('opacity', 1).attr('x1', mouse[0]).attr('x2', mouse[0]);
const xValue = mouse[0] > range[0] && mouse[0] < range[1]
? xScale.invert(mouse[0]).toFixed(2)
: 0;
context.attrs.onHover(xValue);
}
mouseover(constructor) { function mouseout() {
const mouse = d3.mouse(constructor); hoverLine.attr('opacity', 0)
this.hoverLine.attr('opacity', 1) }
.attr('x1', mouse[0])
.attr('x2', mouse[0]);
const xValue = this.xScale.invert(mouse[0]).toFixed(2); this.chart
.createObject({ tag: 'rect', selector: 'mouse-rect' })
const range = [ .attr('width', this.calc.chartWidth)
this.xScale(this.dataPoints[this.dataPoints.length - 1][0].x), .attr('height', this.calc.chartHeight)
this.xScale( .attr('fill', 'transparent')
this.dataPoints .attr('pointer-events', 'all')
[this.dataPoints.length - 1] .on('mouseover', mouseover)
[this.dataPoints[this.dataPoints.length - 1].length - 1].x, .on('mousemove', mouseover)
), .on('mouseout', mouseout);
];
if (mouse[0] > range[0] && mouse[0] < range[1]) {
this.attrs.onHover(xValue);
} else {
this.attrs.onHover(0.0);
} }
return { xScale, yScale };
} }
mouseout() { addLollipopsChart(distributionChart) {
this.hoverLine.attr('opacity', 0) const data = this.getDataPoints('discrete');
const ys = data.map(item => item.y);
const yMax = d3.max(ys);
// X axis
this.chart.append("g")
.attr("class", 'lollipops-x-axis')
.attr("transform", "translate(0," + this.calc.chartHeight + ")")
.call(d3.axisBottom(distributionChart.xScale));
// Y axis
const yScale = d3.scaleLinear()
.domain([0, yMax])
.range([this.calc.chartHeight, 0]);
this.chart.append("g")
.attr("class", 'lollipops-y-axis')
.attr("transform", "translate(" + this.calc.chartWidth + ",0)")
.call(d3.axisLeft(yScale));
// Lines
this.chart.selectAll("lollipops-line")
.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")
.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");
} }
formatDates(ts) { formatDates(ts) {
@ -368,11 +419,39 @@ export class CdfChartD3 {
return d3.timeYear.every(10); return d3.timeYear.every(10);
} }
} }
/**
* @param {name} key
* @returns {{x: number[], y: number[]}}
*/
getDataPoints(key) {
const dt = [];
const emptyShape = { xs: [], ys: [] };
const data = _.get(this.attrs.data, key, emptyShape);
const len = data.xs.length;
for (let i = 0; i < len; i++) {
dt.push({ x: data.xs[i], y: data.ys[i] });
}
return dt;
}
/**
* @param {string} key
* @returns {boolean}
*/
hasDate(key) {
const data = _.get(this.attrs.data, key);
return !!data;
}
} }
/** /**
* @docs: https://github.com/d3/d3-selection * @docs: https://github.com/d3/d3-selection
* @param params * @param {object} params
* @param {string} params.selector
* @param {string} params.tag
* @returns {*} * @returns {*}
*/ */
d3.selection.prototype.createObject = function createObject(params) { d3.selection.prototype.createObject = function createObject(params) {
@ -382,16 +461,11 @@ d3.selection.prototype.createObject = function createObject(params) {
}; };
/** /**
* @example:
* This call example
* createObjectsByData({
* tag: 'path',
* selector: 'line-path',
* data: this.dataPoints,
* })
* will create a new tag "<path class="line-path">1,2,3</path>".
* @docs: https://github.com/d3/d3-selection * @docs: https://github.com/d3/d3-selection
* @param params * @param {object} params
* @param {string} params.selector
* @param {string} params.tag
* @param {*[]} params.data
* @returns {*} * @returns {*}
*/ */
d3.selection.prototype.createObjectsWithData = function createObjectsWithData(params) { d3.selection.prototype.createObjectsWithData = function createObjectsWithData(params) {

View File

@ -45,10 +45,14 @@ function CdfChartReact(props) {
.marginRight(5) .marginRight(5)
.marginTop(5) .marginTop(5)
.showDistributionLines(props.showDistributionLines) .showDistributionLines(props.showDistributionLines)
.showDistributionYAxis(props.showDistributionYAxis)
.verticalLine(props.verticalLine) .verticalLine(props.verticalLine)
.showVerticalLine(props.showVerticalLine) .showVerticalLine(props.showVerticalLine)
.container(containerRef.current) .container(containerRef.current)
.data({ primary: props.primaryDistribution }) .data({
primary: props.primaryDistribution,
discrete: props.discrete,
})
.scale(scale) .scale(scale)
.timeScale(props.timeScale) .timeScale(props.timeScale)
.render(); .render();

View File

@ -2,11 +2,19 @@ module Styles = {
open Css; open Css;
let graph = chartColor => let graph = chartColor =>
style([ style([
selector(".axis", [fontSize(`px(9))]), selector(".x-axis", [fontSize(`px(9))]),
selector(".domain", [display(`none)]), selector(".x-axis .domain", [display(`none)]),
selector(".tick line", [display(`none)]), selector(".x-axis .tick line", [display(`none)]),
selector(".tick text", [color(`hex("bfcad4"))]), selector(".x-axis .tick text", [color(`hex("bfcad4"))]),
selector(".chart .area-path", [SVG.fill(chartColor)]), selector(".chart .area-path", [SVG.fill(chartColor)]),
selector(".lollipops-line", [SVG.stroke(`hex("bfcad4"))]),
selector(
".lollipops-circle",
[SVG.stroke(`hex("bfcad4")), SVG.fill(`hex("bfcad4"))],
),
selector(".lollipops-x-axis .domain", [display(`none)]),
selector(".lollipops-x-axis .tick line", [display(`none)]),
selector(".lollipops-x-axis .tick text", [display(`none)]),
]); ]);
}; };