interpolated-charts
Version:
Configurable d3 v4 charts with interpolation and missing data range
439 lines (360 loc) • 11.5 kB
JavaScript
import * as d3 from 'd3';
import { colorProvider } from '../utils/color-provider';
import { getDatePlusTime, getAverageDate } from '../utils/helpers';
import eventThreshold from '../utils/event-threshold';
const chartEvents = {
chartMouseEnter: 'chartMouseEnter',
chartMouseLeave: 'chartMouseLeave',
chartMouseMove: 'chartMouseMove',
chartMouseClick: 'chartMouseClick'
};
function stackBar({
width = 700, height = 120,
margin = { top: 20, right: 30, bottom: 40, left: 40 },
marginBetweenStacks = 0,
backgroundColor = '#CCCCCC',
maxTimeRangeDifferenceToDraw = 1000 * 60 * 60 * 24 * 1.5,
xAxisTimeFormat,
mouseMoveTimeTreshold = 20,
xAxisDateFrom, xAxisDateTo
} = {}) {
let svg;
let chartWidth, chartHeight;
let stackHeight;
let xAxis;
let xScale;
let diapasons;
const colors = colorProvider();
let events = eventThreshold(mouseMoveTimeTreshold);
const dispatcher = d3.dispatch(chartEvents.chartMouseEnter,
chartEvents.chartMouseLeave, chartEvents.chartMouseMove,
chartEvents.chartMouseClick);
function exports(selection) {
selection.each(function(data) {
chartWidth = exports.chartWidth();
chartHeight = exports.chartHeight();
buildSvg(this);
initializeChartData(data);
createScales();
createAxis();
drawBackground();
drawAxis();
drawRectangles();
runDrawingAnimation();
subscribeEvents();
});
}
function initializeChartData(data) {
const numberOfStacks = data.length;
stackHeight = (chartHeight - (numberOfStacks - 1) * marginBetweenStacks) / numberOfStacks;
diapasons = data.map(d => getStackDiapasons(d));
}
function createScales() {
const dateRanges = [].concat.apply([],
diapasons
.map(d => d.chartDiapasons)
.filter(d => d))
.map(d => [d.from, d.to]);
const allChartDates = [].concat.apply([], dateRanges);
const xMin = xAxisDateFrom || d3.min(allChartDates);
const xMax = xAxisDateTo || d3.max(allChartDates);
xScale = d3
.scaleTime()
.domain([xMin, xMax])
.range([0, chartWidth]);
}
function createAxis() {
xAxis = d3
.axisBottom(xScale);
if (xAxisTimeFormat) {
xAxis.tickFormat(xAxisTimeFormat);
}
}
function drawAxis() {
svg
.select('.x-axis-container')
.append('g')
.classed('x-axis', true)
.attr('transform', `translate(0, ${chartHeight})`)
.call(xAxis);
}
function drawRectangles() {
const stacks = svg
.select('.data-stacks-container')
.selectAll('.stack-holder')
// reverse order - first path should be drawn above last
.data(diapasons);
const computeXPosition = date =>
moveNumToRange(Math.trunc(xScale(date)), 0, chartWidth);
const stackHolders = stacks
.enter()
.append('g')
.classed('stack-holder', true)
.merge(stacks)
.attr('transform', (d, i) => `translate(0, ${i * (stackHeight + marginBetweenStacks)})`)
.attr('width', chartWidth)
.attr('height', stackHeight);
stackHolders.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', chartWidth)
.attr('height', stackHeight)
.style('fill', d => d.backgroundColor)
stackHolders.selectAll('.stack-holder')
.data(d => d.chartDiapasons)
.enter()
.append('rect')
.classed('stack-diapason', true)
.attr('x', d => computeXPosition(d.from))
.attr('y', () => 0)
.style('fill', (d) => d.color)
.attr('width', d => moveNumToRange(
Math.ceil(xScale(d.to) - xScale(d.from)),
0, chartWidth - computeXPosition(d.from)))
.attr('height', stackHeight);
stacks
.exit()
.remove();
}
function runDrawingAnimation() {
const maskingRectangle = svg.append('rect')
.style('fill', 'white')
.attr('width', chartWidth)
.attr('height', chartHeight)
.attr('transform', `translate(${margin.left}, ${margin.top})`)
.attr('x', 0)
.attr('y', 0);
maskingRectangle.transition()
.duration(1000)
.ease(d3.easeQuadInOut)
.attr('x', chartWidth)
.on('end', () => maskingRectangle.remove());
}
function buildSvg(container) {
if (!svg) {
svg = d3
.select(container)
.append('svg')
.classed('stack-bar', true);
buildContainerGroups();
}
svg
.attr('width', width)
.attr('height', height);
}
function buildContainerGroups() {
const container = svg
.append('g')
.classed('container-group', true)
.attr('transform', `translate(${margin.left}, ${margin.top})`);
container
.append('g')
.classed('background-container', true);
container
.append('g')
.classed('x-axis-container', true);
container
.append('g')
.classed('data-stacks-container', true);
container
.append('g')
.classed('metadata-container', true);
}
function drawBackground() {
const background = svg
.select('.background-container');
background
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', chartWidth)
.attr('height', chartHeight)
.attr('fill', backgroundColor)
.classed('background', true);
}
function subscribeEvents() {
svg.on('mousemove', mouseMove);
svg.on('mouseenter', mouseEnter);
svg.on('mouseleave', mouseLeave);
svg.on('click', mouseClick);
}
function mouseMove() {
const mouse = d3.mouse(this);
events.call(dispatchMouseMoveEvent.bind(this, mouse));
}
function dispatchMouseMoveEvent(mouse) {
const options = getMouseEventOptions(...mouse);
dispatcher.call(chartEvents.chartMouseMove, this, options);
}
function mouseClick() {
const options = getMouseEventOptions(...d3.mouse(this));
dispatcher.call(chartEvents.chartMouseClick, this, options);
}
function mouseEnter() {
dispatcher.call(chartEvents.chartMouseEnter, ...d3.mouse(this));
}
function mouseLeave() {
events.clear();
dispatcher.call(chartEvents.chartMouseLeave, ...d3.mouse(this));
}
function moveNumToRange(num, min, max) {
return Math.min(Math.max(num, min), max);
}
function getMouseEventOptions(x, y) {
// coords inside of chart
x = moveNumToRange(x - margin.left, 0, chartWidth);
y = moveNumToRange(y - margin.top, 0, chartHeight);
const data = getClosestData(x);
const selectedDate = xScale.invert(x);
return { x, y, selectedDate, data };
}
function getClosestData(x) {
const selectedDate = xScale.invert(x);
const closestData = [];
diapasons.forEach(d => {
const selectedDiapason = {
name: d.name,
interpolatedDate: selectedDate
};
const closestDiapason = d.chartDiapasons
.find(d => d.from <= selectedDate && selectedDate <= d.to)
if (closestDiapason) {
Object.assign(selectedDiapason, closestDiapason);
}
closestData.push(selectedDiapason);
});
return closestData;
}
function getStackDiapasons({ data, backgroundColor, name }) {
if (!Array.isArray(data) || !data.length) {
return { chartDiapasons: [], backgroundColor, name };
}
const buildDiapason = (data, from, to) => ({
color: data.color || colors.next(`${data.name}-${data.value}`).value,
value: data.value,
from: from || data.date,
to: to || data.date
});
const sortedData = data.sort((d1, d2) => d1.date - d2.date);
const chartDiapasons = [];
const leftDiapason = buildDiapason(
sortedData[0],
getDatePlusTime(sortedData[0].date, -maxTimeRangeDifferenceToDraw),
sortedData[0].date
);
chartDiapasons.push(leftDiapason);
if (sortedData.length > 1) {
sortedData
.reduce((prev, curr) => {
const avgDate = getAverageDate(prev.date, curr.date);
const leftDate = d3.min([
getDatePlusTime(prev.date, maxTimeRangeDifferenceToDraw),
avgDate
]);
const rightDate = d3.max([
getDatePlusTime(curr.date, -maxTimeRangeDifferenceToDraw),
avgDate
]);
const leftDiapason = buildDiapason(prev, prev.date, leftDate);
const rightDiapason = buildDiapason(curr, rightDate, curr.date);
chartDiapasons.push(leftDiapason, rightDiapason);
return curr;
});
}
const lastData = sortedData[sortedData.length - 1];
const rightDiapason = buildDiapason(
lastData,
lastData.date,
getDatePlusTime(lastData.date, maxTimeRangeDifferenceToDraw)
);
chartDiapasons.push(rightDiapason);
return { chartDiapasons, backgroundColor, name };
}
exports.width = function(_width) {
if (!arguments.length) {
return width;
}
width = _width;
return this;
};
exports.height = function(_height) {
if (!arguments.length) {
return height;
}
height = _height;
return this;
};
exports.margin = function(_margin) {
if (!arguments.length) {
return margin;
}
margin = _margin;
return this;
};
exports.marginBetweenStacks = function(_marginBetweenStacks) {
if (!arguments.length) {
return marginBetweenStacks;
}
marginBetweenStacks = _marginBetweenStacks;
return this;
};
exports.backgroundColor = function(_backgroundColor) {
if (!arguments.length) {
return backgroundColor;
}
backgroundColor = _backgroundColor;
return this;
}
exports.on = function() {
dispatcher.on.apply(dispatcher, arguments);
return this;
};
exports.maxTimeRangeDifferenceToDraw = function(_maxTimeRangeDifferenceToDraw) {
if (!arguments.length) {
return maxTimeRangeDifferenceToDraw;
}
maxTimeRangeDifferenceToDraw = _maxTimeRangeDifferenceToDraw;
return this;
};
exports.xAxisTimeFormat = function(_xAxisTimeFormat) {
if (!arguments.length) {
return xAxisTimeFormat;
}
xAxisTimeFormat = _xAxisTimeFormat;
return this;
};
exports.mouseMoveTimeTreshold = function(_mouseMoveTimeTreshold) {
if (!arguments.length) {
return mouseMoveTimeTreshold;
}
mouseMoveTimeTreshold = _mouseMoveTimeTreshold;
events.clear();
events = eventThreshold(mouseMoveTimeTreshold);
return this;
};
exports.chartHeight = function() {
return height - margin.top - margin.bottom;
};
exports.chartWidth = function() {
return width - margin.left - margin.right;
};
exports.xAxisDateFrom = function(_xAxisDateFrom) {
if (!arguments.length) {
return xAxisDateFrom;
}
xAxisDateFrom = _xAxisDateFrom;
return this;
}
exports.xAxisDateTo = function(_xAxisDateTo) {
if (!arguments.length) {
return xAxisDateTo;
}
xAxisDateTo = _xAxisDateTo;
return this;
}
return exports;
}
export {
stackBar,
chartEvents
};