UNPKG

interpolated-charts

Version:

Configurable d3 v4 charts with interpolation and missing data range

459 lines (380 loc) 12.1 kB
import * as d3 from 'd3'; import { colorProvider } from '../utils/color-provider'; import getYPointFromPath from '../utils/svg-calculating'; import eventThreshold from '../utils/event-threshold'; const chartEvents = { chartMouseEnter: 'chartMouseEnter', chartMouseLeave: 'chartMouseLeave', chartMouseMove: 'chartMouseMove', chartMouseClick: 'chartMouseClick' }; function line({ width = 700, height = 500, margin = { top: 20, right: 30, bottom: 40, left: 40 }, maxTimeRangeDifferenceToDraw = 1000 * 60 * 60 * 24 * 1.5, xAxisTimeFormat, yAxisValueFormat, curve = d3.curveBasis, interpolationMaxIterationCount = 25, interpolationAccuracy = 0.005, mouseMoveTimeTreshold = 20, xAxisDateFrom, xAxisDateTo } = {}) { let svg; let chartWidth, chartHeight; let xAxis, yAxis; let xScale, yScale; let chartData; 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(); drawAxis(); drawGridLines(); drawLines(); runDrawingAnimation(); subscribeEvents(); }); } function initializeChartData(data) { chartData = data.map(d => { d.color = d.color || colors.next().value; d.segregatedData = getSegregatedData(d.data); d.chartDiapasons = getDiapasonsWithData(d.data); return d; }); } function createScales() { const chartPoints = [].concat(...chartData.map(c => c.data)); const chartDates = chartPoints.map(p => p.date); const chartValues = chartPoints.map(p => p.value); const xMin = xAxisDateFrom || d3.min(chartDates); const xMax = xAxisDateTo || d3.max(chartDates); const yMin = d3.min(chartValues); const yMax = d3.max(chartValues); xScale = d3 .scaleTime() .domain([xMin, xMax]) .range([0, chartWidth]); // add some free space at Y scale borders const yScaleIndentation = (yMax - yMin) * 0.05; yScale = d3 .scaleLinear() .domain([yMin - yScaleIndentation, yMax + yScaleIndentation]) .range([chartHeight, 0]); } function createAxis() { xAxis = d3 .axisBottom(xScale); if (xAxisTimeFormat) { xAxis.tickFormat(xAxisTimeFormat); } yAxis = d3 .axisLeft(yScale); if (yAxisValueFormat) { yAxis.tickFormat(yAxisValueFormat); } } function drawAxis() { svg .select('.x-axis-container') .append('g') .classed('x-axis', true) .attr('transform', `translate(0, ${chartHeight})`) .call(xAxis); svg .select('.y-axis-container') .append('g') .classed('y-axis', true) .call(yAxis) } function drawGridLines() { svg .select('.grid-container') .selectAll('.horizontal-grid-line') .data(yScale.ticks()) .enter() .append('line') .classed('horizontal-grid-line', true) .attr('x1', 0) .attr('x2', chartWidth) .attr('y1', d => yScale(d)) .attr('y2', d => yScale(d)); svg .select('.grid-container') .selectAll('.vertical-grid-line') .data(xScale.ticks()) .enter() .append('line') .classed('vertical-grid-line', true) .attr('x1', d => xScale(d)) .attr('x2', d => xScale(d)) .attr('y1', 0) .attr('y2', chartHeight); } function drawLines() { const valueLine = d3 .line() .curve(curve) // end line segment if current point falsy (null) .defined(d => d) .x(d => xScale(d.date)) .y(d => yScale(d.value)); const lines = svg .select('.data-lines-container') .selectAll('.line') // reverse order - first path should be drawn above last .data(chartData.reverse()); lines .enter() .append('path') .classed('line', true) .style('stroke-width', 2) .style('fill', 'none') .merge(lines) .style('stroke', d => d.color) .attr('d', d => valueLine(d.segregatedData)); lines .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('line-chart', 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('grid-container', true); container .append('g') .classed('x-axis-container', true); container .append('g') .classed('y-axis-container', true); container .append('g') .classed('data-lines-container', true); container .append('g') .classed('metadata-container', 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 getMouseEventOptions(x, y) { const moveNumToRange = (num, min, max) => Math.min(Math.max(num, min), max); // 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 }; } const previousClosestPathes = {}; function getClosestData(x) { const closestData = []; const selectedDate = xScale.invert(x); svg .selectAll('.line') .each(function(data) { const closesPoint = data.data.reduce((prev, curr) => { return !curr || Math.abs(prev.date - selectedDate) < Math.abs(curr.date - selectedDate) ? prev : curr; }); // closest point does not fall into path drawing range if (!data.chartDiapasons.some(d => d.from <= selectedDate && selectedDate <= d.to)) { return; } const { y, pathLength } = getYPointFromPath(this, x, interpolationMaxIterationCount, interpolationAccuracy, previousClosestPathes[data.name]); closesPoint.x = xScale(closesPoint.date); closesPoint.y = yScale(closesPoint.value); closesPoint.interpolatedX = x; closesPoint.interpolatedY = y; closesPoint.interpolatedDate = xScale.invert(x); closesPoint.interpolatedValue = yScale.invert(y); closesPoint.name = data.name; closesPoint.color = data.color; closestData.push(closesPoint); previousClosestPathes[data.name] = pathLength; }); // reverse to move last elements on top of svg to array start return closestData.reverse(); } function getDiapasonsWithData(data) { if (!Array.isArray(data) || !data.length) { return []; } const chartDiapasons = []; data.reduce((prev, curr) => { if (curr.date - prev.date < maxTimeRangeDifferenceToDraw) { // TODO: don't add neighbours, change existing point chartDiapasons.push({ from: prev.date, to: curr.date }); } return curr; }); return chartDiapasons; } function getSegregatedData(data) { const segregatedData = []; data.forEach((d, i, arr) => { if (i === 0 || i === arr.length - 1) { segregatedData.push(arr[i]); return; } const isBreakBetween = (p1, p2) => p1.date - p2.date > maxTimeRangeDifferenceToDraw; if (isBreakBetween(arr[i], arr[i - 1])) { segregatedData.push(null); } segregatedData.push(arr[i]); if (isBreakBetween(arr[i + 1], arr[i])) { segregatedData.push(null); } }); return segregatedData; } 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.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.yAxisValueFormat = function(_yAxisValueFormat) { if (!arguments.length) { return yAxisValueFormat; } yAxisValueFormat = _yAxisValueFormat; return this; }; exports.curve = function(_curve) { if (!arguments.length) { return curve; } curve = _curve; return this; }; exports.interpolationMaxIterationCount = function(_interpolationMaxIterationCount) { if (!arguments.length) { return interpolationMaxIterationCount; } interpolationMaxIterationCount = _interpolationMaxIterationCount; return this; }; exports.interpolationAccuracy = function(_interpolationAccuracy) { if (!arguments.length) { return interpolationAccuracy; } interpolationAccuracy = _interpolationAccuracy; 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; }; return exports; } export { line, chartEvents };