UNPKG

cl-react-graph

Version:
412 lines (374 loc) 11.6 kB
import { extent } from 'd3-array'; import { axisBottom, axisLeft } from 'd3-axis'; import { easeCubic } from 'd3-ease'; import { scaleLinear, scaleTime } from 'd3-scale'; import { select } from 'd3-selection'; import { area, curveCatmullRom, line } from 'd3-shape'; import { timeFormat, timeParse } from 'd3-time-format'; import merge from 'deepmerge'; import * as get from 'lodash.get'; import attrs from './d3/attrs'; import { drawGrid, gridHeight, gridWidth, makeXGridlines, makeYGridlines, xAxisHeight as getXAxisHeight, yAxisWidth as getYAxisWidth, } from './grid'; import { IChartAdaptor, IHistogramDataSet } from './Histogram'; import { IChartPoint, ILineChartDataSet, ILineChartProps, ISVGPoint } from './LineChart'; import tips, { makeTip } from './tip'; export const lineChartD3 = ((): IChartAdaptor => { let svg; let tipContainer; let xParseTime; let xFormatTime; let tipContent; const y = scaleLinear(); let x: any = scaleLinear(); const lineProps = { curveType: curveCatmullRom, fill: false, show: true, stroke: '#005870', strokeDashArray: '5 5', strokeDashOffset: 0, }; const pointProps: ISVGPoint = { fill: 'rgba(255, 255, 255, 0)', radius: 4, stroke: '#005870', }; const defaultProps: ILineChartProps = { axis: { x: { height: 20, scale: 'LINEAR', style: { 'fill': 'none', 'shape-rendering': 'crispEdges', 'stroke': '#666', 'stroke-opacity': 1, 'stroke-width': 1, }, text: { style: { fill: '#666', }, }, }, y: { scale: 'LINEAR', style: { 'fill': 'none', 'shape-rendering': 'crispEdges', 'stroke': '#666', 'stroke-opacity': 1, 'stroke-width': 1, }, text: { style: { fill: '#666', }, }, ticks: 10, width: 25, }, }, className: 'line-chart-d3', fx: easeCubic, grid: { x: { style: { 'fill': 'none', 'stroke': '#bbb', 'stroke-opacity': 0.7, 'stroke-width': 1, }, ticks: 5, visible: true, }, y: { style: { 'fill': 'none', 'stroke': '#bbb', 'stroke-opacity': 0.7, 'stroke-width': 1, }, ticks: 5, visible: true, }, }, height: 250, line: lineProps, margin: { left: 5, top: 5, }, point: pointProps, tip: tips, tipContainer: 'body', tipContentFn: (info, i, d) => { switch (typeof info[i].x) { case 'object': // date return xFormatTime(info[i].x) + ', ' + info[i].y; default: return Number(info[i].x).toFixed(3) + ', ' + info[i].y; } }, width: 200, }; const datumProps = { line: lineProps, point: pointProps, }; const LineChartD3 = { /** * Initialization * @param {Node} el Target DOM node * @param {Object} props Chart properties */ create(el: Node, props: Object = {}) { this.props = merge(defaultProps, props); this._makeSvg(el); this.container = svg .append('g') .attr('class', 'linechart-container'); this.makeGrid(); this.lineContainer = svg.append('g').attr('class', 'line-container'); this.makeScales(); this.update(el, this.props); }, /** * Make the SVG container element * Recreate if it previously existed * @param {Dom} el Dom container node */ _makeSvg(el) { if (svg) { svg.selectAll('svg > *').remove(); svg.remove(); const childNodes = el.getElementsByTagName('svg'); if (childNodes.length > 0) { el.removeChild(childNodes[0]); } } const { margin, width, height, className } = this.props; // Reference to svg element containing chart svg = select(el).append('svg') .attr('class', className) .attr('width', width) .attr('height', height) .append('g') .attr('transform', `translate(${margin.left},${margin.top})`); const r = makeTip(this.props.tipContainer, tipContainer); tipContent = r.tipContent; tipContainer = r.tipContainer; }, /** * Iterate over the dataset drawing points for sets marked as * requiring points. * @param {Array} data LineChartDataSet */ _drawDataPointSet(data: ILineChartDataSet[]) { const { axis } = this.props; const yAxisWidth = getYAxisWidth(axis); const pointContainer = this.container.selectAll('g').data(data); const point = pointContainer.enter() .append('g') .attr('class', (d, i) => 'point-container' + i) .merge(pointContainer) .selectAll('circle') .data((d) => { return d.data.map((dx) => { return { ...dx, point: d.point, }; }); }); // UPDATE - Update old elements as needed. point.attr('class', 'update'); // ENTER + UPDATE // After merging the entered elements with the update selection, // apply operations to both. point.enter().append('circle') .attr('class', 'enter') .merge(point) .attr('class', 'point') .attr('cy', (d) => y(d.y)) .attr('r', (d, i) => 0) .attr('fill', (d) => d.point.fill) .attr('stroke', (d) => d.point.stroke) .attr('cx', (d) => { return x(d.x) + yAxisWidth; }) .transition() .duration(400) .attr('r', (d) => d.point.radius) .delay(50); // EXIT - Remove old elements as needed. pointContainer.exit().remove(); point.exit().remove(); }, makeScales() { this.xAxis = svg.append('g').attr('class', 'x-axis'); this.yAxis = svg.append('g').attr('class', 'y-axis'); this.xAxisLabel = svg.append('g'); this.yAxisLabel = svg.append('g'); }, /** * Draw the chart scales * @param {Array} data LineChartDataSet */ _drawScales(data: ILineChartDataSet[]) { const { axis, margin, height } = this.props; const w = gridWidth(this.props); let yDomain; let xDomain; const ys: any[] = []; const xs: any[] = []; const yAxis = axisLeft(y).ticks(axis.y.ticks); const xAxis = axisBottom(x); // .ticks(axis.x.ticks); const xAxisHeight = getXAxisHeight(axis); const yAxisWidth = getYAxisWidth(axis); data.forEach((datum: ILineChartDataSet) => { datum.data.forEach((d: IChartPoint) => { ys.push((d.y)); xs.push((d.x)); }); }); yDomain = extent(ys); xDomain = extent(xs); x .domain(xDomain) .rangeRound([0, w]); y.domain(yDomain) .range([height - xAxisHeight, 0]); this.yAxis .attr('transform', `translate(${yAxisWidth}, 0)`) .transition() .call(yAxis); this.xAxis .attr('transform', `translate(${yAxisWidth},${(height - xAxisHeight)})`) .call(xAxis); attrs(svg.selectAll('.y-axis .domain, .y-axis .tick line'), axis.y.style); attrs(svg.selectAll('.y-axis .tick text'), axis.y.text.style); attrs(svg.selectAll('.x-axis .domain, .x-axis .tick line'), axis.x.style); attrs(svg.selectAll('.x-axis .tick text'), axis.x.text.style); }, /** * Iterate over data and draw lines * @param {Array} data Chart data objects */ _drawLines(data: ILineChartDataSet[]) { const { axis } = this.props; const yAxisWidth = getYAxisWidth(axis); const xAxisHeight = getXAxisHeight(axis); const curve = (curveType) => line() .curve(curveType) .x((d: any) => x(d.x) + yAxisWidth) .y((d: any) => { return y(d.y); }); const path = this.lineContainer.selectAll('g').data(data); // TODO update data is adding in new lines - was hoping the transition would simply update the 'd' attr. path.enter().append('path') .attr('d', (d) => curve(d.line.curveType)(d.data)) .attr('class', (d, i) => 'path' + i) .attr('fill', 'none') .attr('stroke-dashoffset', (d, i) => d.line.strokeDashOffset) .attr('stroke-dasharray', (d) => d.line.strokeDashArray) .attr('stroke', (d) => d.line.stroke); path.transition() .duration(500) .attr('d', (d) => curve(d.data)) .delay(50); this.lineContainer.exit().remove(); path.exit().remove(); }, drawAreas(data: ILineChartDataSet[]) { const { axis, height } = this.props; const yAxisWidth = getYAxisWidth(axis); const xAxisHeight = getXAxisHeight(axis); const thisArea = (curveType) => area() .curve(curveType) .x((d: any) => x(d.x) + yAxisWidth) .y0((d) => height - xAxisHeight) .y1((d: any) => y(d.y)); const dx = data.filter((d) => get(d, 'line.show') === true && get(d, 'line.fill.show') === true); const path = this.lineContainer.selectAll('g.areas').data(dx); // TODO update data is adding in new lines - was hoping the transition would simply update the 'd' attr. path.enter().append('path') .attr('d', (d) => thisArea(d.line.curveType)(d.data)) .attr('class', (d, i) => 'curve-area path' + i) .attr('fill', (d) => d.line.fill.fill); path.transition() .duration(500) .attr('d', (d) => thisArea(d.data)) .delay(50); path.exit().remove(); }, /** * Get a max count of values in each data set * @param {Object} counts Histogram data set values * @return {Number} count */ valuesCount(data: IHistogramDataSet[]): number { return data.reduce((a: number, b: IHistogramDataSet): number => { return b.data.length > a ? b.data.length : a; }, 0); }, makeGrid() { this.gridX = svg.append('g') .attr('class', 'grid gridX'); this.gridY = svg.append('g') .attr('class', 'grid gridY'); }, /** * Update chart */ update(el: Element, props) { if (!props.data) { return; } this.props = merge(defaultProps, props); switch (props.axis.x.scale) { case 'TIME': x = scaleTime(); break; default: x = scaleLinear(); break; } let data = props.data; xParseTime = timeParse(props.axis.x.dateFormat); xFormatTime = timeFormat(props.axis.x.dateFormat); data = data.map((datum: ILineChartDataSet) => { if (props.axis.x.scale === 'TIME') { datum.data = datum.data.map((d) => { const newd = { ...d, x: typeof d.x === 'object' ? d.x : xParseTime(d.x.toString()), }; return newd; }); } return Object.assign({}, datumProps, datum); }); this._drawScales(data); this._drawLines(data); this.drawAreas(data); drawGrid(x, y, this.gridX, this.gridY, this.props, this.valuesCount(data)); this._drawDataPointSet(data); }, /** * Any necessary clean up * @param {Element} el To remove */ destroy(el: Element) { svg.selectAll('svg > *').remove(); }, }; return LineChartD3; });